Go · GMP 协程调度模型

Runtime 调度器 — G(协程) / P(逻辑处理器) / M(内核线程) 三层抽象: work stealing · syscall hand-off · 信号抢占

新建 G 优先入 P 本地队列, 满则转入 GRQ 取 G Go Runtime — 用户态调度器 (每个 Go 进程内嵌) G · Goroutine — 用户态协程 · 初始栈 ~2KB · 单机百万级 G1 G2 G3 G4 G5 G6 G7 G8 G9 go func(){}() → newproc 入队 全局运行队列 GRQ — 所有 P 可见 local runq 满 → 整批转移一半到此 · 每 61 次调度强制检查 (公平性) GOMAXPROCS = P 数量 = NumCPU P0 逻辑处理器 · 调度上下文 runnext local runq (≤256) G G G G 本地访问无锁 · mcache/timers P1 逻辑处理器 · 调度上下文 runnext local runq (≤256) G G G G 空闲时可偷取 ⇄ (steal 一半) P2 逻辑处理器 · 调度上下文 runnext local runq (≤256) G G G G P 是执行 G 的必要凭证 M · Machine — OS 内核线程 (需绑定 P 才能执行 G · 数量可至万级) M0 · 内核线程 绑定 P0 · 正在执行 G7 pthread · 由内核调度 M1 · 内核线程 绑定 P1 · 正在执行 G3 阻塞时交还 P (hand-off) M2 · 内核线程 绑定 P2 · 正在执行 G9 M : P ≈ 万级 : GOMAXPROCS syscall 阻塞 → hand-off M1 陷入 read() 阻塞 runtime 将 M1 与 P1 解绑 P1 交给唤醒的 M3 续跑 本地队列 G 无需等待 P 永不陪绑阻塞 网络 IO → G 挂起 G 调 conn.Read → 注册 Netpoller, G → waiting M 立即执行下一个 G fd 就绪 → G 回 runnext epoll / kqueue / IOCP 异步抢占 (Go 1.14+) sysmon: G 运行超 10ms 向 M 发送 SIGURG 信号 G 强制让出 → 重新入队 解决定长循环饿死调度 此前: 仅函数调用点协作让出 M : P : G ≈ 万级 : GOMAXPROCS : 百万级 — 三层解耦是 Go 高并发的根 findRunnable — M 找 G 的顺序 1 runnext — 刚唤醒的 G (无锁直达) 2 本 P 的 local runq 3 全局队列 GRQ (每 61 tick 强制) 4 work stealing — 偷其他 P 一半 5 netpoll 非阻塞查询 6 全空 → M 休眠 (park) 唤醒优先级: 被窃取 > 定时器 > 网络 为什么 goroutine 极轻 栈 2KB 起步 · 按需增长收缩 — 线程固定 ~MB 级 切换纯用户态 ns 级 — 线程需陷入内核 μs 级 创建廉价 — go 关键字一条指令级开销 channel 通信 + 调度器接管阻塞 = CSP 模型 调度核心机制 Work stealing 空闲 P 随机选受害者, 偷走其本地队列一半的 G Syscall hand-off 阻塞的 M 立即交出 P — P 是稀缺资源 信号抢占 sysmon + SIGURG 强制让出, 不依赖调用点 runnext + 61 tick 唤醒延迟最小化, 兼顾全局公平 Legend G · 协程 P · 逻辑处理器 M · 内核线程 全局队列 GRQ 调度场景 抢占 / 约束 work stealing ⇄ Runtime 边界

G — 用户态协程

  • • 只存在于 runtime 队列中的执行单元: 2KB 可增长栈 + PC + 状态
  • • 创建与切换全在用户态完成, 成本比线程低 2~3 个数量级
  • • 每个阻塞操作 (channel / IO / lock) 都由调度器接管, G 让出 M
  • • 对程序员暴露的只是 go 关键字与 channel

P — 调度的钥匙

  • • P 持有本地运行队列、runnext 槽、mcache 与 timers
  • • 数量 = GOMAXPROCS (默认 CPU 核数), 是并行度的硬上限
  • • M 必须绑定一个 P 才能执行 G — P 是"执行凭证"
  • • 本地队列无锁访问, 是多核扩展性的关键设计

M × 三大机制

  • • M 是内核线程, 数量可远超 P (默认上限 10000)
  • • Work stealing 均衡负载: 空闲 P 偷走忙碌 P 一半的 G
  • • Syscall hand-off: M 阻塞立即交出 P, 不让稀缺的 P 陪绑
  • • 信号抢占 (1.14+): SIGURG 强制让出, CPU 密集 G 不再饿死调度器

💡 一句话理解

GMP 把"要执行的代码 (G)"、"执行凭证 (P)"、"系统线程 (M)"解耦: goroutine 便宜到可以随手百万个, P 数量(GOMAXPROCS)决定真并行度, M 只是在 P 上干活的内核线程。三大机制 — work stealing / syscall hand-off / 信号抢占 — 保证 CPU 几乎永不空闲。

🧠 必知必会 必考 & 必会

G · Goroutine
用户态协程: 2KB 可增长栈 + 状态。创建/切换全在用户态(ns 级, 不进内核), 比线程(OS 线程栈 ~MB、切换 μs 级)轻 2~3 个数量级。
for i := 0; i < 1000000; i++ {
    go handle(reqs[i])  // 关键: 创建纯用户态 ns 级, 栈 2KB 起步
}
// → runtime.NumGoroutine() ≈ 100 万, OS 线程仍只有几十个
P · Processor
调度上下文, 持有本地运行队列(无锁访问)、runnext 槽、mcache。数量 = GOMAXPROCS = 真并行度上限, 默认 CPU 核数。
runtime.GOMAXPROCS(0)   // → 10 (只查询, 不修改)
runtime.GOMAXPROCS(2)   // 真并行度上限压到 2
// 关键: 20 个 CPU 密集 G 也最多同时占 2 个核
M · Machine
内核线程。必须绑定一个 P 才能执行 G; 数量可远大于 P(默认上限 10000), 阻塞 syscall 时与 P 解绑让 P 继续干活。
# 大量阻塞 syscall 时观察线程数:
GODEBUG=schedtrace=1000 ./app
# → SCHED ... gomaxprocs=8 idleprocs=5 threads=157 ...
# 关键: threads(=M) 157 远大于 gomaxprocs(=P) 8
runnext
P 上的单元素高优先槽: 刚被唤醒的 G(如刚收到网络数据)直接放这里, 下一个被执行 —— 降低唤醒→执行的延迟。
ch := make(chan int)
go func() { ch <- 1 }()     // 接收方唤醒后进其 P 的 runnext
v := <-ch                    // → 1, 下一调度周期立即被挑中
61 tick 公平
每调度 61 次, 强制先检查全局队列, 防止本地队列繁忙时全局队列里的 G 被饿死。
// runtime findRunnable 示意 (proc.go):
if schedtick%61 == 0 && runqsize > 0 {
    globrunqget(pp, 1)       // 关键: 每 61 次必摸全局队列
}
work stealing
本地队列空时, 随机挑一个 P 偷走其本地队列一半的 G —— 免中心化的负载均衡。
# 每个 P 各压满任务, 观察偷取是否发生:
GODEBUG=schedtrace=1000 ./app
# → SCHED ... gomaxprocs=8 idleprocs=0 ...
# 关键: 均衡全自动, 代码里不写任何分发逻辑
异步抢占
Go 1.14 前, G 只有在函数调用点才可能让出(定长 for 循环会饿死同 P 的其他 G)。1.14+ 由 sysmon 监测运行超 10ms 的 G, 向 M 发 SIGURG 信号强制抢占。
go func() { for {} }()                  // 定长死循环
go func() { fmt.Println("alive") }()   // 1.14+: → 正常打印
// 1.13-: 同 P 的第二个 G 被饿死 (无调用点不让出)
阻塞的去向
channel/锁阻塞 → G 挂到等待队列, M 换下一个 G; 网络 IO → G 挂到 netpoller; 系统 syscall → M 陷入内核, P hand-off 给别的 M。三种阻塞都不浪费 P。
v := <-ch                    // ① chan: G 入等待队列, M 换下一个 G
n, _ := conn.Read(buf)       // ② 网络: G 入 netpoller (epoll)
f.Sync()                     // ③ syscall: M 陷内核, P hand-off
// 关键: 三种阻塞都不占用稀缺的 P

🏭 生产实战 real world

场景 1 · 每请求一个 goroutine 的 HTTP 服务

net/http 默认就是"每连接两 goroutine"模型, 这是 Go 单机轻松十万级并发的根基:

http.HandleFunc("/order", func(w http.ResponseWriter, r *http.Request) {
    go audit.Log(r)          // 异步审计: 随手一个 go, 2KB 起步, 不心疼
    result := query(r)        // 阻塞 IO 期间调度器自动让 M 去服务其他请求
    json.NewEncoder(w).Encode(result)
})

场景 2 · 容器里必须显式管 GOMAXPROCS

K8s 给 Pod 限 CPU=2, 但宿主机 64 核时旧版 Go 默认 GOMAXPROCS=64: 64 个 P 争抢 2 核配额, 被 CFS throttle 后出现周期性延迟毛刺(P99 飙升)。业界标准解法(Uber 方案):

import _ "github.com/uber-go/automaxprocs"   // 自动读 cgroup quota, 设 GOMAXPROCS=配额核数
// 或 main 里显式: debug.SetGCPercent / runtime.GOMAXPROCS(2)

场景 3 · 用信号量(带缓冲 channel)限制并发扇出

批量调下游接口, 并发不能超过 50(下游限流), 用 buffered channel 当信号量:

sem := make(chan struct{}, 50)          // 容量 50 = 最多 50 个并发
wg := &sync.WaitGroup{}
for _, id := range ids {
    wg.Add(1)
    sem <- struct{}{}                      // 满了就阻塞在这里, 等价于排队
    go func(id string) {
        defer wg.Done(); defer func() { <-sem }()
        fetch(id)
    }(id)
}
wg.Wait()

场景 4 · errgroup 并行子任务 + 联动取消

并行拉多个下游、任一失败全体停止 —— errgroup 是现代标准写法:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10)                       // 内置并发上限 (1.20+)
for _, id := range ids {
    g.Go(func() error { return fetch(ctx, id) })
}
err := g.Wait()                        // 任一 error → ctx 取消 → 其余快速退出

场景 5 · goroutine 泄漏的线上排查闭环

指标单调上涨 = 泄漏; pprof 按创建栈聚合, 找卡在 chan/lock 的栈:

// 1. 暴露指标: goroutine 数趋势 (告警看斜率, 不看绝对值)
prometheus.MustRegister(collectors.NewGoCollector())
log.Info("goroutines", "n", runtime.NumGoroutine())

# 2. 线上抓现场: 按创建位置聚合, 找最多的卡点栈
curl localhost:6060/debug/pprof/goroutine?debug=1 | head -50
# 3. 常见结论: "chan send" 无接收 / "select 无 ctx.Done 分支" → 补取消

场景 6 · 定时批量聚合(双阈值 flush)

埋点上报: 累积到量或到时即刷, 停机强制 flush:

func (b *Buffer) run(ctx context.Context) {
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:                      // 到时刷
            b.flush()
        case e := <-b.in:
            b.buf = append(b.buf, e)
            if len(b.buf) >= 1000 {          // 到量提前刷
                b.flush()
            }
        case <-ctx.Done():                  // 停机: 强制刷尾批再退
            b.flush()
            return
        }
    }
}

场景 7 · 大文件并行分段处理

多 GB 文件按偏移分段, 每段一 goroutine 用 ReadAt 并发读(句柄并发安全):

func parallelSum(f *os.File, size int64) uint64 {
    n := runtime.GOMAXPROCS(0)              // 分段数 = P 的 1~2 倍
    seg := size / int64(n)
    results := make(chan uint64, n)
    for i := 0; i < n; i++ {
        go func(off, length int64) {
            buf := make([]byte, length)
            f.ReadAt(buf, off)                  // ReadAt 并发安全
            results <- checksum(buf)
        }(int64(i)*seg, seg)
    }
    var total uint64
    for i := 0; i < n; i++ { total += <-results }
    return total
}

场景 8 · runtime 指标暴露 Prometheus

调度健康度三件套: goroutine 数、GC 停顿、GOMAXPROCS, 客户端库自带:

import _ "github.com/prometheus/client_golang/prometheus/auto"
# 已自动暴露: go_goroutines / go_gc_duration_seconds / go_memstats_*
# 告警规则示例 (PromQL):
#   deriv(go_goroutines[10m]) > 0.5   → goroutine 持续增长疑似泄漏
#   rate(go_gc_duration_seconds_sum[5m]) > 0.1  → GC 吃掉 10% 时间

场景 9 · 长连接推送服务模型

每连接两 goroutine(读+写), done chan 双向退出 —— 单机十万连接的常规架构:

func (s *Server) serveConn(conn net.Conn) {
    done := make(chan struct{})
    outbox := make(chan []byte, 64)
    go s.writeLoop(conn, outbox, done)      // 唯一写者: 不交错
    go s.heartbeat(conn, outbox, done)      // 定期 ping 保活
    s.readLoop(conn, outbox)                // 阻塞读, 出错即返回
    close(done)                            // 广播退出: 写/心跳循环感知并收尾
}

场景 10 · CPU 亲和与极限低延迟

行情/风控类服务把 P 与核绑定, 减少跨核迁移的缓存失效:

// 容器/K8s: cpuset 固定 2-4 号核, Go 自动感知 GOMAXPROCS=核数
docker run --cpuset-cpus=2-4 app
# 裸机 systemd: CPUAffinity=2 3 4
# 验证: 服务内打点 runtime.GOMAXPROCS(0) 应等于绑定的核数
# 效果: P 不跨核迁移 → 缓存/TLB 命中率升 → P99 尾部更稳

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 无上限地 go — 突发流量下瞬时百万 goroutine, 内存/fd 打爆。正解: 信号量/worker pool 限制在途数量(见场景 3)。
// 错: for _, r := range reqs { go handle(r) }  → 瞬时 10 万 G
sem := make(chan struct{}, 100)     // 对: 在途上限 100
for _, r := range reqs {
    sem <- struct{}{}                 // 满了排队, 不打爆
    go func(r Req) { defer func() { <-sem }(); handle(r) }(r)
}
坑 2 · goroutine 泄漏 — 向永远没人接收的 channel 发送、或等待永不返回的锁, goroutine 卡死常驻。正解: 一律配 context 超时取消; 上线后盯 pprof 的 goroutine 数是否单调上涨。
// 错: ch <- result (无人接收, G 永久卡在 send)
select {                          // 对: 一律带超时/取消
case ch <- result:
case <-ctx.Done():                // → context canceled, G 正常退出
}
坑 3 · 容器 GOMAXPROCS 默认值 — 见场景 2, CPU limit 与 GOMAXPROCS 不匹配是 Go 服务 P99 毛刺的头号来源。
# 错: CPU limit=2 但宿主机 64 核 → GOMAXPROCS=64, 被 CFS throttle
# 对: 自动对齐 cgroup 配额
import _ "github.com/uber-go/automaxprocs"  # → GOMAXPROCS=2
坑 4 · 循环变量捕获(1.22 前) — for _, v := range 的 v 是复用变量, 闭包捕获的是同一个地址, 并发下全拿到最后一个值。Go 1.22 起每轮迭代新变量, 已修复; 老代码仍要 v := v。
for _, v := range items {
    go func() { use(v) }()        // 错(1.21-): 并发下全拿到最后一个 v
    go func(v int) { use(v) }(v)  // 对: 按值传参 (或每轮 v := v)
}
坑 5 · 用 time.Sleep 做同步 — 依赖调度时序的 sleep 是竞态温床。正解: channel / WaitGroup / errgroup 明确表达"等什么"。
// 错: go do(); time.Sleep(time.Second)  → 时序碰运气
done := make(chan struct{})        // 对: 等"什么"就等"什么"
go func() { do(); close(done) }()
<-done                            // → 精确同步, 零竞态
坑 6 · 子 goroutine panic 直接打死进程 — 任何一个 goroutine panic 且未 recover, 整个进程崩溃(主 goroutine 的 recover 救不了它)。正解: 每个 goroutine 入口顶层 defer func(){ if r := recover(); r != nil { log } }(), 或统一封装 go safe(fn)。
go func() { panic("boom") }()   // 错: 裸 go, 整个进程被打死
// 对: 统一封装, 入口顶层 recover
func safe(fn func() error) {
    defer func() { if r := recover(); r != nil { log.Error(r) } }()
    fn()
}
坑 7 · defer 写在循环里 — 资源要到函数返回才释放, 万次循环攒万把锁/句柄。正解: 循环体抽成函数, defer 随函数归还; 或手动 Close。
for _, f := range files {
    fh, _ := os.Open(f)
    defer fh.Close()               // 错: 万个句柄攒到函数返回才释放
}
for _, f := range files { process(f) }  // 对: defer 进 process 内随轮归还
坑 8 · WaitGroup 计数错误 — Add 在 goroutine 内部执行, Wait 先到直接返回; Add 传负数直接 panic。正解: Add 严格写在 go 语句前; Done 用 defer。
for _, t := range tasks {
    go func() { wg.Add(1); t() }()   // 错: Wait 抢先返回, 少算
    wg.Add(1)                          // 对: Add 在 go 语句之前
}
坑 9 · 无缓冲 channel 自锁 — 单 goroutine 里先 send 后 receive, send 永远阻塞 → 经典死锁 fatal error: all goroutines are asleep。正解: 无缓冲 chan 的 send/recv 必须分居两方; 单流程用有缓冲或变量直传。
ch := make(chan int)       // 错: 无缓冲
ch <- 1; fmt.Println(<-ch)   // → fatal error: all goroutines are asleep
ch2 := make(chan int, 1)   // 对: 有缓冲 (或 send 放进另一个 G)
ch2 <- 1; fmt.Println(<-ch2)  // → 1
坑 10 · for select 忘写退出条件 — 循环体没有 ctx.Done()/done chan 分支, goroutine 永远转下去。正解: 每个 for select 的第一分支写退出条件, 作为模板习惯。
for {
    select {
    case <-ctx.Done():              // 对: 第一分支固定写退出
        return
    case job := <-jobs:            // 只有此分支 = 错形态, 永转不停
        run(job)
    }
}
坑 11 · 手动 runtime.Gosched 抢占 — 1.14 前老代码靠 Gosched 让出, 现在异步抢占已内置, 残留调用徒增开销。正解: 升级后删除; 真正的长任务分片下沉 worker。
for i := 0; i < 1e9; i++ {
    runtime.Gosched()             // 错(过时): 1.14+ 异步抢占已内置
    work(i)
}
for i := 0; i < 1e9; i++ { work(i) }  // 对: 直接算; 超长任务下沉 worker
坑 12 · goroutine 里丢了请求上下文 — 异步任务里日志没有 traceId/uid, 排查断链。正解: 把 ctx(或提取出的 trace 字段)作为第一个参数传进 goroutine, 日志中间件统一注入。
// 错: go func() { audit(order) }()  → 日志无 traceId, 排查断链
go func(ctx context.Context, o Order) {  // 对: ctx 作为第一个参数
    log.From(ctx).Info("audit")          // → traceId 自动注入
    audit(ctx, o)
}(ctx, order)
坑 13 · goroutine 数量裸奔无监控 — 泄漏是慢性病, 上线三天才 OOM。正解: go_goroutines 接告警(环比/斜率), 发布看板里必有这条曲线。
# 错: 无监控, 上线三天 OOM 才发现泄漏
# 对: go_goroutines 告警看斜率, 不看绝对值
deriv(go_goroutines[10m]) > 0.5   # → 持续增长即疑似泄漏
坑 14 · 用共享 flag + sleep 做同步 — for !done { time.Sleep(...) } 是竞态+浪费的双重反模式。正解: done chan / WaitGroup / errgroup, 让"等待"语义显式。
for !done { time.Sleep(100 * time.Millisecond) }  // 错: 竞态+空转
done := make(chan struct{})                // 对: 事件驱动
close(done); <-done                             // → 精确唤醒
坑 15 · main 返回不等后台任务 — main 一退, 所有 goroutine 立即终止, 半写的文件/未 ack 的消息丢失。正解: main 退出前 WaitGroup.Wait() + 显式 flush; 依赖消息确认的任务做幂等重投。
func main() {
    go flusher()                    // 错: main 一退 flusher 立即死
    serve()                        // → 尾批数据/未 ack 消息丢失
    wg.Wait(); flush()            // 对: 退出前 Wait + 强制 flush
}
坑 16 · 在 goroutine 里直接用外部变量名捕获 — 参数零的闭包全靠捕获, 大对象生命周期被闭包拉长、并发读写同一变量。正解: 需要的值一律按值传参进 goroutine。
big := make([]byte, 64<<20)     // 64MB
go func() { use(big) }()          // 错: 64MB 被 G 拉满整个生命周期
go func(n int) { calc(n) }(len(big))  // 对: 只带需要的值
坑 17 · runtime.Goexit 与 return 混淆 — Goexit 只终止当前 goroutine 但会执行 defer; 在测试里误用会静默跳过断言。正解: 测试中断用 t.Fatal/FailNow, Goexit 留给测试框架内部。
defer log.Println("defer 仍执行")   // Goexit 与 panic 一样跑 defer
runtime.Goexit()               // 只终止当前 G, 调用方无感知
// 错: 测试里用 Goexit 跳过断言 → 静默假绿
// 对: 断言失败直接 t.Fatal("bad state")
坑 18 · 嵌套 goroutine 的取消不传递 — 外层收到 ctx 取消, 内层又起了不带 ctx 的 goroutine, 泄漏点转移。正解: 派生一律用传入的 ctx; 起 goroutine 的函数签名里必须有 ctx。
go func() {                    // 错: 内层新 G 不带 ctx
    go cleanup()                // 外层取消它也不知道, 泄漏转移
}()
go func() { go cleanup(ctx) }()  // 对: 派生一律用传入的 ctx
坑 19 · 本地多核压测外推到生产 — 本机 16 核跑分漂亮, 容器限 2 核后调度行为完全不同(P 多核排队被 throttle)。正解: 压测环境与生产同限核同 GOMAXPROCS。
# 错: 本机 16 核压测 P99=10ms, 直接外推生产
# 对: 与生产同限核同参数再压
GOMAXPROCS=2 ./bench                      # 模拟容器 CPU limit=2
docker run --cpus=2 --memory=4g app        # → 压出真实容量
坑 20 · 把调度器当任务队列用 — go 出去就不管, 既无并发上限也无生命周期, 等于把排队策略交给运气。正解: 扇出必配限流(信号量/池) + 生命周期(ctx/WaitGroup), 三件套缺一不可。
for _, job := range jobs {
    go handle(job)                  // 错: 无上限无生命周期, 排队靠运气
}
g, ctx := errgroup.WithContext(ctx)          // 对: 限流+取消+等待
g.SetLimit(50)                              // → 上限 50, 可取消, 可等待
for _, job := range jobs { g.Go(func() error { return handle(ctx, job) }) }
g.Wait()