Files
cs-note/hzh/GolangStar/Go语言原理/gmp调度原理/gmp-preemption.md
T

6.7 KiB
Raw Blame History

tags, create time
tags create time
go
golang
go-principle
gmp-scheduler
preemption
2026-06-07 16:00

GMP 调度原理 — 抢占式调度

概述

前面讨论的让渡都是 G 的主动行为。但如果一个 G 执行纯计算循环,从不阻塞、不让出 CPU,整个系统就会被它拖垮。本节介绍 Go 调度器的"第三只手"——由后台 sysmon 线程发起的抢占式调度。涵盖 §5:system monitor、系统调用抢占、协作与非协作抢占。

正文

5.1 sysmon:永不休息的巡逻兵

Go 程序启动时,runtime 通过 newm(sysmon, nil, -1) 创建一个独立的 OS 线程专跑 sysmon。它全程唯一、终身运行。

func sysmon() {
    for {
        usleep(delay)           // 自适应休眠(最长 10ms)
        
        if netpollinited() && lastpoll+10ms < now {
            list := netpoll(0)   // ① 非阻塞网络轮询
            injectglist(&list)   // 将就绪 G 放回全局队列
        }
        
        retake(now)              // ② 抢占检查
        
        if t.test() && forcegc.idle != 0 {
            // ③ GC 触发检查
        }
    }
}

三次巡检职责:

功能 说明
netpoll 从 epoll 取出已完成的 IO 事件,唤醒对应 G
retake 遍历所有 P,发现超时或 syscall 过久的立即抢占
GC 检查 判断是否需要触发强制垃圾回收

5.2 系统调用抢占

当一个 G 发起 syscall 时,对应的 M 会被操作系统挂起,绑定的 P 也随之闲置。Go 的策略是:人走可以,但办公桌留下。

进入 syscall(reentersyscall):

func reentersyscall(pc, sp uintptr) {
    casgstatus(_g_, _Grunning, _Gsyscall)
    
    pp := _g_.m.p.ptr()
    pp.m = 0           // 解除 P → M
    _g_.m.p = 0        // 解除 M → P
    
    _g_.m.oldp.set(pp) // 记住原 P(弱引用)
    atomic.Store(&pp.status, _Psyscall)
}

退出 syscall 时有两条路径:

flowchart TD
    A["G 退出 syscall"] --> B{exitsyscallfast?}
    B -->|"是: oldP 仍单身"| C["快速路径<br/>复用 oldP<br/>状态→Grunning"]
    B -->|"否: oldP 被抢"| D["慢速路径<br/>mcall exitsyscall0<br/>找新 P / 入 GRQ / stopm"]
    style C fill:#e8f5e9
    style D fill:#fff3e0
func exitsyscall() {
    oldp := _g_.m.oldp.ptr()
    if exitsyscallfast(oldp) {          // 快速路径
        casgstatus(_g_, _Gsyscall, _Grunning)
        return
    }
    mcall(exitsyscall0)                // 慢速路径
}

sysmon 介入:若 P 处于 _Psyscall 超过 10ms,retake 会强制将 P 从 syscall 的 M 处夺走,分配给新的空闲 M:

// retake 中关键片段
if s == _Psyscall {
    if runqempty(_p_) && pd.syscallwhen+10ms > now {
        continue    // 刚进去不久且队列为空,暂不抢占
    }
    atomic.Cas(&_p_.status, s, _Pidle)
    handoffp(_p_)    // 抢夺 P,分配给新 M
}

5.3 运行超时抢占

对持续运行的 G(如纯计算死循环),sysmon 通过 preemptone 发起超时抢占。这分为两代实现:

5.3.1 协作式抢占(Go ≤ 1.13)

preemptone 在目标 G 上打两个标记:

func preemptone(_p_ *p) bool {
    mp := _p_.m.ptr()
    gp := mp.curg
    
    gp.preempt = true                  // 抢占标志
    gp.stackguard0 = stackPreempt      // 栈保护区特殊值
    return true
}

G 在执行函数调用时(尤其是触发栈扩容的 newstack),会检查 stackguard0:

func newstack() {
    stackguard0 := atomic.Loaduintptr(&gp.stackguard0)
    if stackguard0 == stackPreempt {
        if canPreemptM(thisg.m) {
            gopreempt_m(gp)   // 响应抢占,殊途同归 goschedImpl()
        }
    }
}

缺点:如果一个 G 一直在跑无函数调用的纯计算死循环,永远不检查 stackguard0,就不会响应抢占意图。

5.3.2 非协作式抢占(Go ≥ 1.14)

为解决上述短板,Go 1.14 引入基于 POSIX 信号的硬抢占机制:

func preemptone(_p_ *p) bool {
    // ... 上面设置协作标记的代码不变 ...
    
    if preemptMSupported && debug.asyncpreemptoff == 0 {
        preemptM(mp)     // 向目标线程发送 sigPreempt 信号
    }
    return true
}

func signalM(mp *m, sig int) {
    pthread_kill(pthread(mp.procid), uint32(sig))  // 底层 syscall
}

信号到达后,操作系统的信号处理函数 sighandler → doSigPreempt 会通过修改寄存器的"指令注入"方式强行接管执行流:

flowchart TD
    A["sigPreempt 信号到达"] --> B["sighandler 检查 safepoint"]
    B --> C["pushCall: 修改 PC + SP"]
    C --> D["下一条指令跳入 asyncPreempt"]
    D --> E["mcall gopreempt_m"]
    E --> F["goschedImpl: 状态→Grunnable → GRQ"]
    style A fill:#ffebee
    style D fill:#fff9c4
    style F fill:#e8f5e9
func doSigPreempt(gp *g, ctxt *sigctxt) {
    if wantAsyncPreempt(gp) {
        if ok, newpc := isAsyncSafePoint(...); ok {
            ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
        }
    }
}

func pushCall(targetPC, resumePC uintptr) {
    sp -= goarch.PtrSize
    *(*uintptr)(unsafe.Pointer(sp)) = resumePC  // 压入返回地址
    c.set_rsp(uint64(sp))                        // 更新栈指针
    c.set_rip(uint64(targetPC))                  // 劫持程序计数器
}

这条链路的特点是:无论 G 在做什么——不管有没有函数调用、是不是死循环——只要收到信号并被确认为安全中断点,就强制执行抢占。这是操作系统级别的强制手段,Go runtime 无法绕过。

5.4 小结对比

抢占类型 触发条件 方式 生效时机
系统调用抢占 P 处于 _Psyscall > 10ms handoffp 夺回 P sysmon 定期检查
协作式抢占 G 运行 > 10ms 设置 stackPreempt 标记 G 下次函数调用/栈检查时
非协作式抢占 G 运行 > 10ms 发送 sigPreempt 信号并注入代码 信号中断后立即执行

[!question] ❓ 为什么保留协作式抢占? 信号机制有平台限制(Windows 不支持),协作式作为兜底方案始终有效;同时对于大多数有 IO 或 channel 操作的 G,协作式抢占已经足够及时。

关联笔记