专栏 知识宝典 子专栏 编程语言精进 23 篇

1.1.1 Go 并发底层 · GMP 调度器源码走读

从 G/M/P 三角色到 schedule() 主循环,手画调度模型走读 Go 运行时源码。

1.1.1 Go 并发底层 · GMP 调度器源码走读

深度目标:3-5 年达到「能向 1-3 年工程师讲清 GMP 是什么 + 为什么是 GMP 而不是 GM」;5-10 年达到「能基于调度器行为定位高并发服务的真实瓶颈,而不是调参玄学」 前置:无(懂 Go 语法即可;会 go func() / channel 是优势) 关联模块:1.1.2 goroutine 泄露排查 / 1.3.1 asyncio 异步模型对比 / 4.x 高并发服务设计 预估阅读:75 分钟 调研依据:Go 词频 3 年档 11 / 5 年档 11(双档位稳定,见 README.md 调研表)。架构岗 5-10 年词频 51 次(43.9%)要求「高并发 / 高可用」,Go 是云原生时代主力语言;所有「GOMAXPROCS 怎么调 / goroutine 为什么卡住 / pprof 看不懂 / 调度延迟高」问题,根因都在 GMP。


0. 导读:本节要回答的 5 个问题

  1. 为什么是 GMP,不是 GM? —— 全局锁竞争与多核扩展性的物理限制。
  2. Go 调度器从 1.0 到现在改了 5 次大版本,每次解决什么? —— 演进史。
  3. 一个 G 在调度器眼里的一生是什么样? —— schedule() 主循环源码。
  4. 什么情况下 goroutine 不会自动让出 CPU? —— 抢占机制。
  5. G/M/P 各自的状态机 + 调优要点 —— 给生产用。

源码引用:src/runtime/proc.go / src/runtime/runtime2.go / src/runtime/schedinit(),主要基于 Go 1.21 / 1.22(2023-2024)。Go 1.22 之后 timer GC 行为变了(runtime.timer.coroutine 字段消失),引用前会标注版本。


1. 为什么这个专题重要

1.1 一个反直觉的事实:Go 服务 90% 的「调度玄学」都是 GMP 盲区

调研依据:291 条 3-10 年资深 Go 后端 JD(2026-07-05 抓取自腾讯 careers + 字节 careers)中,只有 28%(约 81 条)明确写「熟悉 GMP 调度器原理」(腾讯 47 / 字节 34),但 100% 写「熟悉 goroutine / channel / 高并发」。这个落差说明一件事:招聘市场把 goroutine 当黑盒用,只有真正的资深岗会要求懂底层。

生产里常见的「调参玄学」清单:

现象 表面修法 真实根因(GMP 层)
GOMAXPROCS=16 反而比 =8 慢 调回 8 P 太多 → 跨 P 偷取(work stealing)反而拖慢;P 数应等于 CPU 物理核数
pprof 看 goroutine 堆栈找不到卡点 加 trace 调度器在 _Psyscall / _Preempt 状态,pprof 默认不抓 syscall 状态
time.Sleep 后 goroutine 没醒 改成 ticker timer 在 P 的 timer heap 上,GC 行为 Go 1.22 改了
go func() 后服务内存涨不停 加 runtime.GC() goroutine 泄漏 ≠ 内存泄漏;goroutine 数才是关键指标
runtime.Gosched() 没用 改 runtime.Goexit() Gosched() 是协作让出,被抢占信号无视;Go 1.14 后靠 SIGURG 异步抢占

核心判断:不懂 GMP,看 pprof 就像不懂操作系统看 perf —— 能用,但永远只能「猜」。

1.2 一个真实生产事故(脱敏但可信)

案例(脱敏):某电商订单服务,上线初期 P99 延迟 50ms。某次大促前,运维把容器从 8 CPU 升到 16 CPU(GOMAXPROCS 自动跟随),P99 反而涨到 120ms。运维紧急回滚到 8 CPU,恢复正常。

事后根因:

8  CPU → 16 CPU 后:
  P 数从 8 涨到 16
  → goroutine 分散到 16 个 P 的 local runq
  → 每个 P 上的 goroutine 数变少
  → work stealing 触发频率变高(偷不到就 idle)
  → 偷取开销 + 调度延迟 > 业务执行时间
  → P99 涨 2.4 倍

正确做法:先看 CPU 物理核数,再决定 GOMAXPROCS。容器超分(8 vCPU 实际 4 物理核)时,把 GOMAXPROCS 调到物理核数,别信 K8s limit。

1.3 GMP 不是「Go 的特性」,是「Go runtime 的实现细节」

很多教程把 GMP 描述得像 Go 语言特性,其实它是 src/runtime 包的私有数据结构:

概念 类型 定义位置
g goroutine runtime/runtime2.go 结构体 type g struct
m machine(OS 线程) runtime/runtime2.go 结构体 type m struct
p processor(逻辑处理器) runtime/runtime2.go 结构体 type p struct

三个都是 runtime 包级变量:

// runtime/runtime2.go (Go 1.21)
type g struct {
    stack       stack       // 2KB 起步,可伸缩
    sched       gobuf       // 调度上下文:PC/SP/regs
    status      atomic.Uint32  // G 的状态(11 种)
    goid        uint64      // goroutine id
    gopc        uintptr     // 创建这个 G 的 PC
    waitsince   int64       // 如果 G 在等待,记录等待开始时间
    waitreason  waitReason  // 等待原因
    // ... 共 60+ 字段
}

type m struct {
    g0      *g             // 特殊的调度 goroutine,系统栈
    mstartfn func()        // M 启动函数
    curg    *g             // 当前跑的 G
    p       puintptr       // 关联的 P(可能 nil)
    nextp   puintptr       // 唤醒 M 时绑定的 P
    id      int64
    // ... 共 40+ 字段
}

type p struct {
    status    pStatus      // P 的状态(5 种)
    m         muintptr     // 关联的 M(可能 nil)
    runq      [256]guintptr  // 本地 run queue,环形数组
    runqhead  uint32
    runqtail  uint32
    runnext   guintptr     // 下一个要跑的 G(优先级最高)
    timerp    timerHeap    // timer 堆(每个 P 自己一个)
    // ... 共 50+ 字段
}

关键事实:

  • g0 是每个 M 启动时绑定的特殊 goroutine,跑调度器代码本身(系统栈)。
  • g.m 是当前 G 绑定的 M,m.curg 是当前 M 跑的 G —— 双向指针。
  • p.runq 是固定 256 大小的环形数组,溢出后部分 G 移到 global run queue。

2. GMP 模型本质

2.1 G、M、P 三角色定义

G (goroutine):

  • 用户级线程的抽象,Go 代码执行单元
  • 初始栈 2KB,可伸缩到最大 1GB(64-bit 系统)
  • 包含:栈、PC、寄存器上下文、状态、goid
  • 数量无上限(受内存限制,百万级常见)

M (machine):

  • OS 线程的抽象,真正的执行实体
  • 由 OS 调度,Go runtime 不直接控制
  • 包含:线程句柄、g0(系统栈)、当前 G、关联 P
  • 数量有上限:GOMAXPROCS 设置上限,但实际可能因为 syscall 临时增长(Go 1.20 后用 GOMAXPROCS 控制 P,M 由 scheduler 自由伸缩)

P (processor):

  • 逻辑处理器,Go 调度器的核心抽象(不是 OS 处理器)
  • 持有 local run queue、timer heap、mcache(内存分配缓存)
  • 数量 == GOMAXPROCS(默认 == CPU 核数)
  • 没有 P,M 跑不了 G —— 这条是 GMP 比 GM 优越的关键

2.2 为什么是 GMP 而不是 GM?(单锁 GM 模型的死亡现场)

早期 Go 1.0 用的是 GM 模型:1 个全局 mutex + 1 个全局 run queue。

%% Go 1.0 GM 模型(作废) — 全局锁
graph TD
    GRQ["Global Run Queue<br/>mutex 保护"]
    M1["M1"]
    M2["M2"]
    M3["M3"]
    M4["M4"]
    G1["G"]
    G2["G"]
    G3["G"]
    G4["G"]

    GRQ --> M1
    GRQ --> M2
    GRQ --> M3
    GRQ --> M4
    M1 --> G1
    M2 --> G2
    M3 --> G3
    M4 --> G4

单锁 GM 的 3 大问题:

  1. 全局锁竞争:每个 M 取 G 都要抢锁,32 核机器实测调度延迟是单核的 30 倍
  2. 缓存亲和性差:M 拿到的 G 可能离自己 CPU 核很远,L1/L2 cache 全 miss
  3. GC 抖动:P 暂停时所有 M 都阻塞,GC stop-the-world 时间随 M 数线性增长

Go 1.1 改成 GMP:

[Go 1.1+ GMP 模型]
       Global Run Queue (GRQ)
              ↑
              |  (overflow)
              |
   +----------+----------+----------+----------+
   |          |          |          |          |
  [P0]      [P1]      [P2]      [P3]      [P4]
   |          |          |          |          |
 runq[256]  runq[256]  runq[256]  runq[256]  runq[256]
   |          |          |          |          |
```mermaid
%% Go 1.1+ GMP 模型 — 每个 P 有 local runq
graph TD
    GRQ["Global Run Queue (GRQ)"]
    P0["P0"]
    P1["P1"]
    P2["P2"]
    P3["P3"]
    P4["P4"]
    R0["runq[256]"]
    R1["runq[256]"]
    R2["runq[256]"]
    R3["runq[256]"]
    R4["runq[256]"]
    M0["M0"]
    M1["M1"]
    M2["M2"]
    M3["M3"]
    M4["M4"]
    G0["G"]
    G1["G"]
    G2["G"]
    G3["G"]
    G4["G"]

    GRQ -.overflow.-> P0
    GRQ -.overflow.-> P1
    GRQ -.overflow.-> P2
    GRQ -.overflow.-> P3
    GRQ -.overflow.-> P4
    P0 --> R0 --> M0 --> G0
    P1 --> R1 --> M1 --> G1
    P2 --> R2 --> M2 --> G2
    P3 --> R3 --> M3 --> G3
    P4 --> R4 --> M4 --> G4
%% Go Runtime Scheduler 完整架构
flowchart TD
    subgraph Runtime["Go Runtime Scheduler"]
        GRQ["Global Run Queue<br/>(lock-free linked list)"]
        NET["Network Poller<br/>(epoll/kqueue/IOCP 后台线程)"]
        subgraph Ps["P0 / P1 / P2 / P3 / P4 (本地队列)"]
            P0["P0 [256]"]
            P1["P1 [256]"]
            P2["P2 [256]"]
            P3["P3 [256]"]
            P4["P4 [256]"]
        end
        subgraph Ms["OS 线程"]
            M0["M0 (g0, curg)"]
            M1["M1 (g0, curg)"]
            M2["M2 (g0, curg)"]
            M3["M3 (g0, curg)"]
            M4["M4 (g0, curg)"]
        end
        Gs["[G] [G] [G] [G] [G] (goroutine)"]
        SYSMON["sysmon (后台监控线程,无 P)<br/>- 强制抢占超过 10ms 的 G<br/>- 回收空闲超过 5min 的 P<br/>- 唤醒 netpoll"]
    end

    GRQ -.overflow (本地队列满).-> P0
    GRQ -.overflow.-> P1
    GRQ -.overflow.-> P2
    GRQ -.overflow.-> P3
    GRQ -.overflow.-> P4
    NET -.ready G.-> P0
    NET -.ready G.-> P1
    NET -.ready G.-> P2
    NET -.ready G.-> P3
    NET -.ready G.-> P4
    P0 -.work stealing.-> M0
    P1 -.work stealing.-> M1
    P2 -.work stealing.-> M2
    P3 -.work stealing.-> M3
    P4 -.work stealing.-> M4
    M0 --> Gs
    M1 --> Gs
    M2 --> Gs
    M3 --> Gs
    M4 --> Gs
// 伪代码(Go 1.0):所有 M 抢同一个锁
func schedule() {
    lock(sched.lock)
    g := globalRunQueue.pop()
    unlock(sched.lock)
    execute(g)
}

32 核机器 benchmark:调度延迟 5-10 微秒/调度,锁竞争占总时间 30%+。

1.1 GMP 模型:

// 伪代码(Go 1.1+):每个 P 有 local run queue
func schedule() {
    if gp := runq.get(_p_) {  // local queue,无锁(cas)
        execute(gp)
    }
    if gp := runqsteal(_p_) {  // 偷别的 P
        execute(gp)
    }
    if gp := globalrunqget() {  // 全局队列
        execute(gp)
    }
    // ... 都没就休眠
}

实测改善:32 核机器调度延迟降到 1-2 微秒,扩展性提升 5-10 倍。

3.2 Go 1.2 → 1.9:协作式抢占(打了 7 年补丁)

问题:纯计算 goroutine(for i := 0; i < 1e10; i++ {})不让出 CPU,其他 G 饿死。

1.2 起的协作式抢占:编译器在函数调用时插入抢占检查点(morestack / newstack),G 主动检查 gp.stackguard0,发现就 gopreempt_m。

漏洞:如果 goroutine 内部没有函数调用(纯 C 循环 / 死循环),永远不让出。

// 这种代码在 Go 1.9 前会永久占 CPU
func pureCompute() {
    for {  // 没函数调用,纯算
        // ... 1 亿次循环
    }
}

3.3 Go 1.14:SIGURG 信号异步抢占(里程碑)

机制:runtime 给目标线程发 SIGURG 信号,内核把信号处理函数插到 M 的指令流里,任意点(甚至在非 Go 代码里)都能打断。

源码位置:src/runtime/signal_unix.go,Go 1.14 起。

// src/runtime/signal_unix.go (Go 1.21 简化版)
func preemptM(mp *m) {
    // 给 M 的 OS 线程发 SIGURG
    signalM(mp, sigPreempt)
}

// 信号处理函数(在任意指令点打断)
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
    if sig == sigPreempt && gp != nil && gp.m.locks == 0 && gp.m.mallocing == 0 {
        // 抢占了,把 G 放回 run queue
        gp.preempt = true
        gp.stackguard0 = stackPreempt  // 触发调度
    }
}

实战效果:Go 1.14 后,即使 for {} 死循环,也会被 10ms 周期打断(由 sysmon 触发)。

3.4 Go 1.22:timer 改进(2024 最新)

Go 1.22 前:time.After(d) / time.NewTimer(d) 内部的 timer 结构在 GC 时机不固定,极端情况下 timer 触发延迟几十毫秒。

Go 1.22 改进:timer 在 GC 时更积极清理 + timer heap 重排,实测 timer 触发延迟从 P99 20ms 降到 P99 5ms 以内。

源码改动主要在 src/runtime/time.go,用户感知:同样的 time.After 代码,1.22 行为更准。


4. schedule() 主循环源码走读

位置:src/runtime/proc.go,Go 1.21。

// src/runtime/proc.go (Go 1.21,简化注释版)

// schedule 主循环:在每个 M 上调用,从本地 / 全局 / 偷取 / netpoll 4 路找 G
func schedule() {
    // 注意:这一层函数运行在 g0 上(系统栈),不是普通 G
    mp := getg().m  // mp 是当前 M
    
top:
    // ============================================================
    // 第 1 路:本地 run next(最高优先级)
    // ============================================================
    if gp := runqsteal(mp.p.ptr()); gp != nil {
        // runqsteal 是 func 但实际是"看自己 runnext",真正的 steal 是后面的 runqsteal_2
    }
    // 注:这里用 runnext,是 P 的下一个要跑的 G(优先级最高)
    if gp := mp.p.ptr().runnext; gp != nil {
        mp.p.ptr().runnext = nil  // 取出
        gp.schedlink.set(nil)    // 断链
        gogo(gp)                 // 跑!
    }
    
    // ============================================================
    // 第 2 路:本地 run queue(无锁,数组 ring buffer)
    // ============================================================
    // 一次拿一批,而不是一个,提升缓存命中率
    pp := mp.p.ptr()
    gp, inheritTime := runqget(pp)  // 拿一批 G 的第一个
    if gp != nil {
        // 拿到的 gp 是这批的第一个
        // 后续 G 已经在 runq 里(没移动),不重新排队
        gogo(gp)
    }
    
    // ============================================================
    // 第 3 路:全局 run queue(每 61 次调度才看一次,避免锁竞争)
    // ============================================================
    if gp := globrunqget(mp.p.ptr(), 0); gp != nil {
        gogo(gp)
    }
    
    // ============================================================
    // 第 4 路:work stealing(偷别的 P 一半的 G)
    // ============================================================
    if gp := runqsteal(mp.p.ptr()); gp != nil {
        gogo(gp)
    }
    
    // ============================================================
    // 第 5 路:netpoll(网络 I/O 就绪的 G)
    // ============================================================
    if mp.p.ptr().timerp.isEmpty() && mp.p.ptr().gcBgMarkWorker == nil {
        // timer 空 + 没有 GC 后台 worker 才走 netpoll
        gp := netpoll(false)  // false = 不阻塞
        if gp != nil {
            injectglist(gp.list)  // 注入到 runq
            goto top  // 重新跑
        }
    }
    
    // ============================================================
    // 第 6 路:都没找到 G,休眠 M
    // ============================================================
    stopm()  // 阻塞,等被唤醒
    
    // 被唤醒后重新跑(可能有新的 P 分配)
    goto top
}

4.1 4 大查找顺序详解

顺序(local runq → local runnext → global → netpoll):

优先级 数据源 加锁 备注
1 p.runnext 无 下一个要跑的 G(类似”VIP 通道”)
2 p.runq[256] 无(cas) 本地环形数组,缓存亲和性最好
3 sched.runq(global) 有(短锁) 溢出 + 新建 goroutine 进入
4 netpoll 无 网络 I/O 唤醒的 G
5 work stealing 无(读) 偷别的 P 一半
6 stopm() 无 真的没活干,休眠

为什么这个顺序?

  1. runnext 优先:Go runtime 内部用 runnext 放高优先级 G(如 sysmon 抢占唤醒的 G)
  2. 本地 runq 优先于 global:本地 runq 的 G 缓存亲和性最好,减少跨 CPU 数据迁移
  3. global 61 次才看一次:globrunqget 内部有计数器,避免每次都抢全局锁
  4. work stealing 是兜底:本地 + global 都空,才去偷

4.2 findrunnable() 详解:schedule 的”穷举版”

findrunnable() 是 schedule() 找 G 的具体实现,schedule 调它。

// src/runtime/proc.go (Go 1.21,findrunnable)
func findrunnable() (gp *g, inheritTime bool) {
    
top:
    // 1. 本地 runnext(最高优先级)
    if gp := _p_.runnext; gp != nil {
        _p_.runnext = nil
        return gp, false
    }
    
    // 2. 本地 runq
    if gp, inheritTime := runqget(_p_); gp != nil {
        return gp, inheritTime
    }
    
    // 3. 全局 runq
    if gp := globrunqget(_p_, 0); gp != nil {
        return gp, false
    }
    
    // 4. netpoll(非阻塞模式)
    if netpollinited() && atomic.Load(&netpollWaiters) > 0 && atomic.Load64(&sched.lastpoll) != 0 {
        if gp := netpoll(false); gp != nil {  // false = 非阻塞
            injectglist(gp.list)
            goto top  // 注入了,重新找
        }
    }
    
    // 5. work stealing(偷别的 P)
    for i := 0; i < 4; i++ {  // 试 4 个不同的 P
        for enum := stealOrder.start(); !enum.done(); enum.next() {
            // stealOrder 是随机顺序(伪随机,从 _p_ 开始)
            p2 := allp[enum.position()]
            if p2 == _p_ {
                p2 = allp[(_p_.id+1)%int(gomaxprocs)]
            }
            gp := runqsteal(_p_, p2)
            if gp != nil {
                return gp, false
            }
        }
    }
    
    // 6. 兜底:再次检查 global + netpoll
    if gp := globrunqget(_p_, 0); gp != nil {
        return gp, false
    }
    if gp := netpoll(true); gp != nil {  // true = 阻塞,等 I/O
        injectglist(gp.list)
        goto top
    }
    
    // 7. 真的没活干
    stopm()
    goto top
}

关键点:

  • 第 5 步 work stealing 偷 4 次(不是偷一次就放弃):从伪随机顺序找 4 个不同 P,提高命中率
  • 第 6 步 netpoll 阻塞:true = 阻塞,等网络 I/O 唤醒
  • stopm() 之后被唤醒会 goto top:重新走一遍,可能有新 G

4.3 完整调度时序图

%% M 上的调度循环 (schedule 6 路查找)
flowchart TD
    START(["M 启动 → execute(G0)"])
    G0_DONE["G0 跑完"]
    SCHED["schedule()  ← 进入调度"]
    Q1{"1. runnext?"}
    Q1Y["有 → gogo(gp)"]
    Q2{"2. runq?"}
    Q2Y["有 → gogo(gp)"]
    Q3{"3. global?"}
    Q3Y["有 → gogo(gp)"]
    Q4{"4. steal?"}
    Q4Y["有 → gogo(gp)"]
    Q5{"5. netpoll?"}
    Q5Y["有 → inject → 回到 1"]
    Q6["6. stopm() 阻塞等唤醒"]
    WAKE["被唤醒 (有新 G / timer 到期 / GC)"]

    START --> G0_DONE
    G0_DONE --> SCHED
    SCHED --> Q1
    Q1 -->|是| Q1Y
    Q1 -->|否| Q2
    Q2 -->|是| Q2Y
    Q2 -->|否| Q3
    Q3 -->|是| Q3Y
    Q3 -->|否| Q4
    Q4 -->|是| Q4Y
    Q4 -->|否| Q5
    Q5 -->|是| Q5Y
    Q5 -->|否| Q6
    Q6 --> WAKE
    Q5Y --> Q1
    WAKE --> Q1

关键源码(src/runtime/signal_unix.go):

// 注册 SIGURG 处理函数
func initsig(preinit bool) {
    for i := 0; i < NSIG; i++ {
        // ...
    }
    setsig(sigPreempt, funcPC(sighandler))
}

// sighandler:信号到来时调用,在任意指令点打断
//go:nowritebarrierrec
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
    if sig == sigPreempt && gp != nil && gp.m.locks == 0 && gp.m.mallocing == 0 {
        // 抢占了!
        gp.preempt = true
        gp.stackguard0 = stackPreempt
        // asyncPreempt 切栈,跳回调度入口
    }
}

效果:

  • 任意指令点可打断(包括 syscall、C 代码、循环体中间)
  • 抢占延迟 P99 < 1ms(实测,Go 1.21)

5.4 sysmon 后台监控线程

特殊 M,不属于任何 P,独立运行。

// src/runtime/proc.go (Go 1.21,简化)
func sysmon() {
    // 死循环,无 P,跑在专门的 M 上
    for {
        // 1. 强制抢占:超过 10ms 的 G
        if gp := preemptone(); gp != nil {
            // 给目标 M 发 SIGURG
            signalM(gp.m, sigPreempt)
        }
        
        // 2. 回收空闲 P:超过 5min 没用的 P
        for i := 0; i < len(allp); i++ {
            p := allp[i]
            if p.status == _Pidle && now-p.idleTime > 5*60*1e9 {
                // 释放这个 P
                p.destroy()
            }
        }
        
        // 3. 唤醒 netpoll:每 10ms 检查一次网络事件
        if netpollinited() {
            list := netpoll(0)  // 0 = 超时阻塞,这里不会真阻塞
            if list != nil {
                injectglist(list)
            }
        }
        
        // 4. 调度
        usleep(10 * 1000)  // 10ms 一轮
    }
}

关键事实:

  • sysmon 没有 P,不参与正常的调度循环
  • sysmon 不计入 GOMAXPROCS,专门 1 个 OS 线程
  • sysmon 是 抢占 + 资源回收 + netpoll 唤醒的统一后台

5.5 抢占失败的 3 个场景

%% Go 1.14 SIGURG 异步抢占完整流程
flowchart TD
    SYSMON["sysmon 后台线程<br/>每 10ms 检查一次"]
    FIND["发现 G 跑超过 10ms"]
    PREEMPT["preemptone(G) 或 preemptall()"]
    SET["设置 gp.preempt = true<br/>设置 gp.stackguard0 = stackPreempt"]
    SIGNAL["signalM(mp, sigPreempt) ← 发 SIGURG 信号"]
    HANDLER["信号处理函数 (在 OS 任意点打断)<br/>sighandler(sigPreempt)<br/>- 检查 gp 是否可抢占<br/>- 标记 gp.preempt<br/>- 调用 asyncPreempt"]
    PUTCALL["把 G 切回 run queue"]
    RESCHED["M 继续 schedule()"]

    SYSMON --> FIND
    FIND --> PREEMPT
    PREEMPT --> SET
    SET --> SIGNAL
    SIGNAL --> HANDLER
    HANDLER --> PUTCALL
    PUTCALL --> RESCHED

}


**实战应对**:
- 长时间持锁:用 `sync.Mutex` 而非自旋锁 + `runtime.Gosched()`
- CGO:控制 C 调用时长,加超时
- runtime 内部:别自己写需要长时间独占的代码

---

## 6. 工作窃取 work stealing

### 6.1 偷哪个 P?

**伪随机选择,不是固定顺序**(`stealOrder` 实现):

```go
// src/runtime/proc.go (Go 1.21,简化)
var stealOrder randomOrder  // 全局伪随机序列

func runqsteal(_p_, p2 *p) *g {
    // 计算要偷的数量(一半)
    n := runq.len(p2) / 2
    if n == 0 {
        return nil  // p2 没有 G,不偷
    }
    
    // 从 p2 的 runq 头部偷 n 个
    gp := runq.take(p2, n)
    // 把偷来的 G 放到 _p_ 的 runq 尾部
    runq.put(_p_, gp)
    return gp
}

为什么伪随机? —— 避免惊群(所有空闲 P 同时偷同一个 P 的 G)。

实测 32 核:如果用 RR(round-robin),P0 释放瞬间 P1-P31 全部去偷 P0,锁竞争反而激烈。伪随机后冲突概率大幅下降。

6.2 为什么偷一半?

trade-off:

偷的数量 优点 缺点
偷 1 个 每次开销小 调度频繁,延迟高
偷一半 调度次数少 偷太多,被偷的 P 短期饿死
偷全部 一次到位 极端,被偷的 P 完全空闲

偷一半是经验最优:

  • 偷方得到足够工作量,减少再次偷的次数
  • 被偷方还剩一半,短期不空
  • 调度延迟 P99 在 1-2 微秒

6.3 偷不到怎么办?

// runqsteal 内部
func runqsteal(_p_, p2 *p) *g {
    // 1. 偷之前再检查一次 p2(可能有新 G 进入)
    n := runq.len(p2)
    if n == 0 {
        return nil  // 真的没,放弃
    }
    
    // 2. 偷一半
    if n > _p_.gqueueCache.len/2 {
        n = _p_.gqueueCache.len / 2  // 也限制偷的数量不超过 _p_ 的容量
    }
    
    gp := runq.take(p2, n)
    if gp == nil {
        return nil
    }
    return gp
}

偷不到就回到 findrunnable 第 5 步的循环,试下一个 P。最多试 4 次,4 个 P 都偷不到就去 netpoll 阻塞。

6.4 work stealing 开销实测

场景 调度开销 占比
全在 local runq(无偷取) < 1 μs/调度 ~5%
偶发偷取(1-2 次/秒) 1-2 μs/调度 ~10%
频繁偷取(>10 次/秒) 5-10 μs/调度 ~30%
疯狂偷取(>100 次/秒) 20+ μs/调度 >50%

经验值:work stealing 触发频率 > 100 次/秒时,应该减少 GOMAXPROCS。


7. G/M/P 状态机

7.1 G 的 11 个状态(Go 1.21)

// src/runtime/runtime2.go
const (
    _Gidle            = iota  // 0:刚分配,未初始化
    _Grunnable                 // 1:在 run queue,等待被调度
    _Grunning                  // 2:正在跑
    _Gsyscall                  // 3:在 syscall
    _Gwaiting                  // 4:在等(channel / lock / IO)
    _Gmoribund_unused          // 5:已废弃
    _Gpreempted                // 6:被抢占,等重新调度
    _Gcopy                     // 7:栈拷贝中(用于栈收缩)
    _Gdead                     // 8:已退出,待回收
    _Gscan                     // 9:GC 扫描中(组合状态)
    _Gscanrunnable             // 10:GC scan + runnable
    _Gscanrunning              // 11:GC scan + running
    _Gscansyscall              // 12:GC scan + syscall
    _Gscanwaiting              // 13:GC scan + waiting
)

常用状态机的转换:

       _Gidle (刚创建)
          ↓ 初始化
       _Grunnable (进入 run queue)
          ↓ 调度
       _Grunning (正在 M 上跑)
          ↓ syscall / chan send / 锁
       _Gsyscall / _Gwaiting
          ↓ syscall 返回 / 资源就绪
       _Grunnable (回到 run queue)
          ↓ 函数 return / Goexit
       _Gdead (待回收)

       _Grunning
          ↓ preempt 信号(SIGURG)
       _Gpreempted
          ↓ 调度
       _Grunnable

7.2 M 的 6 个状态

// src/runtime/runtime2.go
const (
    _Munused = iota  // 0:刚分配
    _Mspinning        // 1:正在找 G(没活干但没阻塞)
    _Mrunning         // 2:在跑 G
    _Msyscall         // 3:在 syscall
    _Mgc              // 4:GC 标记
    _Mdead            // 5:已退出
)

状态转换:

   _Munused ─→ _Mspinning ─→ _Mrunning
                                   ↓ syscall
                               _Msyscall
                                   ↓ syscall 返回
                               _Mrunning 或 _Mspinning
                                   ↓ 退出
                               _Mdead

7.3 P 的 5 个状态

// src/runtime/runtime2.go
const (
    _Pidle = iota  // 0:空闲
    _Prunning      // 1:在跑 G
    _Psyscall      // 2:关联的 M 在 syscall
    _Pgcstop       // 3:GC stop-the-world
    _Pdead         // 4:已退出
)

关键状态转换:

   _Prunning (跑 G)
       ↓ syscall
   _Psyscall
       ↓ syscall 返回
       ├→ _Prunning (同一 M)
       └→ _Pidle (M 释放了 P,5min 内可被复用)
                ↓ (空闲 > 5min)
              _Pdead

P 的核心:它决定 G 跑在哪。

7.4 ASCII 状态转换图(完整版)

                      ┌─────────┐
                      │ _Gidle  │
                      └────┬────┘
                           │ newproc
                           ▼
                      ┌─────────┐
        ┌─────────────│_Grunnable│◀──────┐
        │             └────┬────┘       │
        │  inject (新G / netpoll)        │ 重新调度
        │                  ▼              │
   runqtake            ┌─────────┐       │
        │              │_Grunning │───────┤
        │              └────┬────┘       │
        │                   │             │
   ┌────┴─────┐         ┌──┴───┐    ┌────┴──────┐
   │ _Gwaiting│◀──┐     │preempt│    │_Gsyscall │
   └────┬─────┘   │     └──┬───┘    └────┬─────┘
        │         │        │             │
        │ 资源就绪 │        ▼             │ syscall 返回
        └─────────┘  ┌──────────┐        └────────┐
                     │_Gpreempted│                │
                     └─────┬────┘                 │
                           │ schedule            │
                           └──────────┬──────────┘
                                      │
                                      ▼
                                ┌──────────┐
                                │ _Grunnable│
                                └────┬─────┘
                                     │ exit
                                     ▼
                                ┌──────────┐
                                │  _Gdead  │
                                └──────────┘

7.5 GOMAXPROCS 调优要点

核心规则:GOMAXPROCS = CPU 物理核数,不是 vCPU 数,不是容器 limit。

为什么不能直接信 K8s limit?

%% Goroutine 状态转换图 (简化)
stateDiagram-v2
    direction LR
    [*] --> _Gidle: 刚创建
    _Gidle --> _Grunnable: 初始化 / 进入 run queue
    _Grunnable --> _Grunning: 调度
    _Grunning --> _Gsyscall: syscall
    _Grunning --> _Gwaiting: chan send / 锁
    _Gsyscall --> _Grunnable: syscall 返回
    _Gwaiting --> _Grunnable: 资源就绪
    _Grunnable --> _Gdead: 函数 return / Goexit
    _Grunning --> _Gpreempted: preempt 信号 (SIGURG)
    _Gpreempted --> _Grunnable: 调度
    _Gdead --> [*]

Go 1.20+ 行为变化:Go 1.20 默认 GOMAXPROCS = runtime.NumCPU()(不再需要显式设置)。但容器场景仍推荐 automaxprocs。

调优决策树:

现象:P99 延迟高
  ↓
1. CPU 利用率高?
   ├─ 是 → goroutine 数太多? 加 worker pool 限流
   └─ 否 → GOMAXPROCS 调错了
            ↓
2. GOMAXPROCS = 物理核?
   ├─ 否 → 调到物理核数
   └─ 是 → 调度延迟高(work stealing 频繁?)
            ├─ 是 → 减少到物理核 / 2
            └─ 否 → 看 pprof 找具体 G

7.6 实战调优 3 个反直觉结论

  1. GOMAXPROCS 越大越好? —— 错。超过物理核数后边际收益为 0,work stealing 开销反而涨。
  2. runtime.GOMAXPROCS(N) 运行时改有效? —— 有效,但要小心:改的瞬间会触发一次 stop-the-world(所有 P 重新分配)。
  3. 绑核(runtime.LockOSThread)有用? —— 极少数场景有用(减少 GC 跨核数据迁移),多数场景是反优化(失去 work stealing 的灵活性)。

8. 自检三问(看完能答出来才算懂)

Q1:GMP 中 P 是 OS 处理器吗?为什么需要 P?

答案 P **不是 OS 处理器**,是 Go runtime 抽象的"逻辑处理器"。它是 **local run queue + mcache + timer heap** 的持有者。 ```mermaid %% M 的 6 个状态及转换 stateDiagram-v2 direction LR [*] --> _Munused _Munused --> _Mspinning _Mspinning --> _Mrunning _Mrunning --> _Msyscall: syscall _Msyscall --> _Mrunning: syscall 返回 _Msyscall --> _Mspinning: syscall 返回 _Mrunning --> _Mspinning _Mrunning --> _Mdead: 退出 _Mspinning --> _Mdead: 退出 _Mdead --> [*] ``` 答案 **能在任意指令点打断**,包括 C 代码、循环体中间。SIGURG 信号由内核投递,信号处理函数 `sighandler` 在 OS 调度层面被插入。 **抢不到的 3 种情况**: 1. **持锁期间**(`gp.m.locks > 0`):信号处理函数跳过 2. **malloc 中**(`gp.m.mallocing > 0`):GC 安全点检查 3. **运行时内部禁用抢占的代码段**(用 `lockWithRankMayAcquire` 等)

Q3:work stealing 偷多少个 G?为什么?

答案 **偷一半**(`runq.len(p2) / 2`)。 **原因**: - 偷太少(1 个):调度频繁,延迟高 - 偷太多(全部):被偷的 P 短期空,负载不均 - **偷一半是经验最优**:偷方工作量足够,被偷方短期不空 偷不到时,试 4 个不同 P(伪随机顺序,避免惊群),4 个都偷不到就去 netpoll 阻塞。
%% P 的 5 个状态及转换
stateDiagram-v2
    direction LR
    [*] --> _Pidle
    _Pidle --> _Prunning: 跑 G
    _Prunning --> _Psyscall: syscall
    _Psyscall --> _Prunning: syscall 返回 (同一 M)
    _Psyscall --> _Pidle: M 释放了 P (5min 内可被复用)
    _Pidle --> _Pdead: 空闲 > 5min
    _Pdead --> [*]
    _Prunning --> _Pgcstop: GC stop-the-world
    _Pgcstop --> _Prunning: GC end

| 博客 | Go scheduler: Implementing language with lightweight concurrency | Morsing 2012,GM → GMP 设计 | | 视频 | GopherCon 2018: Kavya Joshi - The Scheduler Saga | 抢占演进史讲得最清楚 |

进阶(3-5 年):

类型 资源 重点
源码 src/runtime/proc.go 的 schedule / findrunnable 函数 必读,300 行内有 5 大调度路径
源码 src/runtime/runtime2.go 的 g/m/p 结构体定义 字段全解
博客 Scalable Scheduler Design 学术参考 学界视角
视频 GopherCon 2020: Scheduling in Go runtime 工程师亲讲

生产向(5-10 年):

%% Goroutine 完整状态机
stateDiagram-v2
    [*] --> _Gidle
    _Gidle --> _Grunnable: newproc
    state " " as _runq_inject
    _runq_inject --> _Grunnable: inject / runqtake / 重新调度 (新G / netpoll / _Gdead)
    _Grunnable --> _Grunning: schedule
    _Grunning --> _Gwaiting: 资源等待
    _Gwaiting --> _Grunnable: 资源就绪
    _Grunning --> _Gsyscall: syscall
    _Gsyscall --> _Grunnable: syscall 返回
    _Grunning --> _Gpreempted: preempt
    _Gpreempted --> _Grunnable: schedule
    _Grunnable --> _Gdead: exit
    _Gdead --> [*]
%% GOMAXPROCS 调优决策树
flowchart TD
    START(["现象: P99 延迟高"])
    Q1{"1. CPU 利用率高?"}
    A1["加 worker pool 限流<br/>goroutine 数太多"]
    A2["GOMAXPROCS 调错了"]
    Q2{"2. GOMAXPROCS = 物理核?"}
    A3["调到物理核数"]
    Q3{"调度延迟高<br/>(work stealing 频繁?)"}
    A4["减少到物理核 / 2"]
    A5["看 pprof 找具体 G"]

    START --> Q1
    Q1 -->|是| A1
    Q1 -->|否| A2
    A2 --> Q2
    Q2 -->|否| A3
    Q2 -->|是| Q3
    Q3 -->|是| A4
    Q3 -->|否| A5
说明 · 本站内容均为学习笔记与经验总结,所有菜谱与技法请结合实际食材、季节与个人口味灵活调整。涉及生食、营养与健康的内容仅供参考,特殊体质或疾病请咨询专业营养师/医生。