编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • Go 专栏博客
      • 内存模型与栈堆布局
      • 指针与逃逸分析
      • 结构体内存布局对齐
      • 字符串与切片底层
      • 接口与类型系统
      • map哈希表底层实现
      • 零值初始化设计哲学
      • GMP协程调度器机制
      • 通道channel源码剖析
      • sync同步原语剖析
      • map并发安全与哈希
      • Go内存模型一致性
      • 加权信号量与限流
      • errgroup并行控制
      • 协程泄漏排查与修复
      • 并发设计模式详解
      • GC三色标记与屏障
      • 内存分配器深挖
      • defer延迟执行机制
      • 定时器四叉堆实现
      • 抢占式调度器原理
        • 1. 案例引入
          • 1.1 一段崩在哪
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 协作式 vs 抢占式全景
          • 2.2 为什么需要抢占
        • 3. 协作式调度时代
          • 3.1 stackguard0 检查机制
          • 3.2 协作式抢占的触发点
          • 3.3 为什么协作式不够
        • 4. Go 1.14 基于信号的抢占
          • 4.1 SIGURG 信号机制
          • 4.2 preemptM 流程
          • 4.3 信号处理与 goroutine 视角
        • 5. Safe-Point 安全点
          • 5.1 GC 安全点的控制收缩
          • 5.2 编译器插入的异步安全点
          • 5.3 不安全点的处理
        • 6. 系统调用中的抢占
          • 6.1 M 与 P 的解绑机制
          • 6.2 sysmon 监控线程的职责
          • 6.3 系统调用返回后的重调度
        • 7. 异步抢占的汇编实现
          • 7.1 信号栈与 gsignal
          • 7.2 asyncPreempt 的汇编代码
          • 7.3 保存与恢复寄存器上下文
        • 8. 与 Linux CFS 的对比
          • 8.1 抢占机制对比
          • 8.2 时间片与调度策略
        • 9. 实战观测与调试
          • 9.1 GODEBUG asyncpreemptoff
          • 9.2 schedtrace 与抢占
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一次抢占的完整旅程
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • 协程栈扩容与缩容
      • 上下文取消与传播
      • 泛型与类型约束
      • 反射机制与unsafe
      • 迭代器与rangefunc
      • 错误处理与panic
      • 网络轮询器netpoller
      • HTTP服务端源码分析
      • JSON序列化与编解码
      • 数据库SQL连接池
      • 文件IO与零拷贝
      • 结构化日志与配置
      • 单元测试与基准
      • cgo与系统调用切换
      • 编译链接与PGO优化
      • 写作模板
    • 开发技巧

  • JavaScript入门

  • CodeX
  • Go入门到精通
  • 专栏博客
杨充
2026-06-12
目录

抢占式调度器原理

# 21.抢占式调度器原理

卷三第二十一篇——Go 的调度器不是天生就是抢占式的。在 1.14 之前,一个 for {} 的 goroutine 可以让整个 P 上的其他 goroutine 永远得不到执行——因为抢占只能在函数调用时发生。Go 1.14 引入基于信号的异步抢占——SIGURG 信号打断正在执行的 goroutine,让调度器有权力在任何安全点夺走 CPU。读完本篇,你能回答:为什么 Go 不用 timer 中断做抢占(像 Linux CFS 那样)?SIGURG 信号怎么找到目标 M 的?抢占怎么处理 GC 安全点——在不安全的位置被抢占会发生什么?关键词:异步抢占、SIGURG、safe-point、协作式调度、CFS 对比、M 与 P 解绑。

# 目录介绍

  • 1. 案例引入
    • 1.1 一段崩在哪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 协作式 vs 抢占式全景
    • 2.2 为什么需要抢占
  • 3. 协作式调度时代
    • 3.1 stackguard0 检查机制
    • 3.2 协作式抢占的触发点
    • 3.3 为什么协作式不够
  • 4. Go 1.14 基于信号的抢占
    • 4.1 SIGURG 信号机制
    • 4.2 preemptM 流程
    • 4.3 信号处理与 goroutine 视角
  • 5. Safe-Point 安全点
    • 5.1 GC 安全点的控制收缩
    • 5.2 编译器插入的异步安全点
    • 5.3 不安全点的处理
  • 6. 系统调用中的抢占
    • 6.1 M 与 P 的解绑机制
    • 6.2 sysmon 监控线程的职责
    • 6.3 系统调用返回后的重调度
  • 7. 异步抢占的汇编实现
    • 7.1 信号栈与 gsignal
    • 7.2 asyncPreempt 的汇编代码
    • 7.3 保存与恢复寄存器上下文
  • 8. 与 Linux CFS 的对比
    • 8.1 抢占机制对比
    • 8.2 时间片与调度策略
  • 9. 实战观测与调试
    • 9.1 GODEBUG asyncpreemptoff
    • 9.2 schedtrace 与抢占
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次抢占的完整旅程
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 1. 案例引入

# 1.1 一段崩在哪

看一个日志索引服务——它有一个 goroutine 对大量 JSON 做正则匹配(没有函数调用、纯循环),Go 1.13 时代的生产环境出现了诡异的延迟毛刺——定期有请求超时 5 秒以上,但 CPU 并不高:

// regex_worker.go —— 正则匹配 Worker
package main

import (
    "regexp"
    "runtime"
    "time"
)

func main() {
    runtime.GOMAXPROCS(2)

    // goroutine A: 死循环做正则匹配——没有函数调用
    go func() {
        re := regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
        for {
            // ❌ Go 1.13: 纯 CPU 循环——没有栈检查、没有函数调用
            // → 永远不会被抢占 → 独占 P 直到 G 自愿放弃
            re.MatchString("2024-01-15 some text here")
        }
    }()

    // goroutine B: 定时任务——需要准时执行
    go func() {
        for {
            time.Sleep(time.Second)
            log.Println("心跳: 我还在") // ← Go 1.13 可能延迟数秒才打印!
        }
    }()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

Go 1.13 现象:

  • goroutine A 是纯 CPU 循环——正则匹配内部没有函数调用时的栈检查
  • goroutine A 独占 P0 无限运行——永远不会被调度器抢走
  • goroutine B 在 P1 上——但如果 GOMAXPROCS=1(单 P)→ goroutine B 永远得不到执行
  • 外部请求的 goroutine 在 runq 中等待——P 被 A 独占→排队延迟数秒

Go 1.14 现象:同一段代码——goroutine A 最多运行 10ms 就被 SIGURG 信号抢占——goroutine B 的定时任务准时执行。

核心变化——Go 1.14+:调度器有权力在任何安全点把 goroutine 从 CPU 上踢下来。

# 1.2 顺藤摸到根因

追查过程:

第一步:GODEBUG schedtrace——Go 1.13 下的输出:

$ GODEBUG=schedtrace=1000 ./app
SCHED 1000ms: gomaxprocs=2 idleprocs=1 threads=3 ...
# idleprocs=1 → 一个 P 空闲——但 goroutine A 在另一个 P 上不放手
1
2
3

第二步:pprof goroutine——goroutine B 的状态是 runnable(可运行但得不到 CPU),不是 sleeping:

$ curl localhost:6060/debug/pprof/goroutine?debug=2 | grep "regex_worker"
goroutine 7 [running]:
main.main.func1()
    /app/main.go:15  # ← 第 15 行: re.MatchString(...)
# goroutine 7 在 Go 1.13 中从不变为 runnable → 它在 running 和 runnable 之间不切换
1
2
3
4
5

第三步:对比 Go 1.14——同一场景下,schedtrace 显示 goroutine A 每 10ms 被换出一次——idleprocs=0 意味着两个 P 都在工作。

这个事故藏着 7 个原理点:

① 协作式抢占为什么只在函数调用时触发——背后的 stackguard0 机制?  → 第 3 章
② Go 1.14 怎么用信号实现异步抢占——SIGURG 的发送和接收?          → 第 4 章
③ 抢占时怎么保证 GC 安全——safe-point 的编译器插入策略?           → 第 5 章
④ 系统调用中的 goroutine 怎么被抢占——M 与 P 的解绑?              → 第 6 章
⑤ sysmon 监控线程怎么检测"运行过久"的 goroutine?                  → 第 6.2
⑥ asyncPreempt 汇编怎么保存和恢复寄存器上下文?                    → 第 7 章
⑦ Go 的抢占策略和 Linux CFS 有什么区别——为什么不用 timer 中断?    → 第 8 章
1
2
3
4
5
6
7

# 1.3 我们要回答什么

这个正则匹配案例贯穿全篇。我们从协作式调度的 stackguard0 机制出发,深入到基于信号的异步抢占实现、safe-point 的编译器策略、系统调用中的抢占——最后对比 Linux CFS 的设计差异。

本篇路线:

架构总图 (第 2 章) ── 协作式 vs 抢占式 + 为什么需要抢占
   ↓
协作式时代 (第 3 章) ── stackguard0 + 触发点 + 不足
   ↓
信号式抢占 (第 4 章) ── SIGURG + preemptM + 信号处理
   ↓
Safe-Point (第 5 章) ── GC安全点 + 编译器插入
   ↓
系统调用抢占 (第 6 章) ── M/P 解绑 + sysmon
   ↓
汇编实现 (第 7 章) ── asyncPreempt + 寄存器保存
   ↓
CFS 对比 (第 8 章) ── 抢占机制 + 时间片
   ↓
实战观测 (第 9 章) ── GODEBUG + schedtrace
   ↓
综合案例 (第 10 章) ── 修复 + 设计哲学
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

📌 本篇定位:第 08 篇 GMP 协程调度器机制讲了调度器的基本结构——本篇聚焦于"调度器怎么把正在运行的 goroutine 踢下来"。如果说 GMP 是调度器的"骨架",抢占就是调度器的"肌肉"——没有它,goroutine 的公平性和延迟都无法保证。

# 2. 架构概览

# 2.1 协作式 vs 抢占式全景

协作式调度 (Go < 1.14):
  G 主动放弃 CPU → 才能被换出
    ├── 函数调用前的 stackguard0 检查 → morestack → 可能调度
    ├── time.Sleep → gopark
    ├── channel send/recv → gopark
    ├── syscall → entersyscall → P 被释放
    └── runtime.Gosched() → 显式让出

  问题: 纯 CPU 循环无函数调用 → 永不放弃 CPU → 其他 G 饿死

抢占式调度 (Go 1.14+):
  G 被动被调度器踢出 CPU
    ├── 协作式路径仍然有效(低开销路径)
    ├── SIGURG 信号 → 异步抢占(新路径)
    ├── sysmon 监控 → 检测运行超过 10ms 的 G
    └── 最多 10ms 后 G 会被强制换出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 2.2 为什么需要抢占

疑惑:Go 有成千上万个 goroutine——调度器轮流给它们时间片就好了——为什么之前做不到?

论证:

  1. Goroutine 没有"时间片"的概念——和 Linux CFS 不同,Go 在 1.14 之前没有基于时间片的抢占。goroutine 的运行时间取决于它什么时候主动放弃 CPU。如果某个 G 进入了一个没有函数调用的纯 CPU 循环——它永远不会触发 morestack——也就永远不会被调度器换出。

  2. GC 需要"全员 STW 协商"——GC 的 Mark Setup 和 Mark Termination 阶段需要 STW(Stop The World)。STW 要求所有 P 上的 goroutine 都停下来。如果某个 P 上的 goroutine 永远不会被抢占——GC STW 会卡住——直到那个 goroutine 主动放弃 CPU。

  3. 大量的 goroutine 需要公平性——如果 100 个 goroutine 在同一个 P 上——其中一个霸占了 1 秒——其他 99 个就要等 1 秒。在协作式调度下——这是常态。抢占式调度保证了最多 10ms 的公平间隔。

结论:抢占不是"优化"——是正确性保证。没有抢占——GC 无法进行、goroutine 会饿死、P99 延迟不可控。Go 用 10 年完成了从"协作式"到"抢占式"的进化——Go 1.14 的 SIGURG 信号是这场进化的终点。

# 3. 协作式调度时代

# 3.1 stackguard0 检查机制

Go < 1.14 的唯一抢占机制——函数序言中的 stackguard0 检查。回顾第 03 篇第 3.2 节的栈分裂检查——每条函数调用的开头都被编译器插入:

; amd64——每个函数的序言
TEXT main.someFunc(SB), ...
    MOVQ    (TLS), CX          ; CX = 当前 G
    CMPQ    SP, 16(CX)         ; SP vs G.stackguard0
    JHI     ok                 ; 栈够 → 跳过
    CALL    runtime.morestack_noctxt(SB)
ok:
    ; ... 函数体 ...
1
2
3
4
5
6
7
8

stackguard0 有两个用途:

  1. 栈溢出检测——当 SP < stackguard0 时——真的需要扩容
  2. 抢占标记——stackPreempt 这个特殊值被写入 stackguard0——强制触发 morestack——在 newstack 中检查是否是被抢占而非真正的栈溢出
// runtime/stack.go (简化)
const stackPreempt = uintptrMask & -1314 // 特殊标记——表示"请抢占"
1
2

# 3.2 协作式抢占的触发点

Go < 1.14 在以下时机检查抢占:

抢占检查点:
  ├── 函数序言 (stackguard0)
  ├── GC mark assist (gcAssistAlloc)
  ├── channel send/recv (chansend/chanrecv)
  ├── syscall 返回 (exitsyscall)
  └── runtime.Gosched() 显式调用
1
2
3
4
5
6

sysmon 通过设置 preempt 标志来请求抢占:

// runtime/proc.go (简化)
func sysmon() {
    for {
        // 检查所有 P
        for _, p := range allp {
            if p.status == _Prunning && p.schedtick != p.sysmontick {
                // P 在同一个 G 上运行超过 10ms → 请求抢占
                preemptone(p)
            }
        }
    }
}

func preemptone(p *p) bool {
    gp := p.curg
    // 设置抢占标志——下次函数序言检查时触发
    gp.preempt = true
    gp.stackguard0 = stackPreempt
    return true
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 3.3 为什么协作式不够

核心问题——如果 goroutine 在纯 CPU 循环中(没有函数调用)——始终不检查 stackguard0——preempt 标志永远不被响应:

// ❌ Go < 1.14: 这个循环永远不会被抢占
func tightLoop() {
    sum := 0
    for i := 0; i < 1_000_000_000; i++ {
        sum += i // ← 纯算术——没有函数调用——没有 stack check
    }
}
1
2
3
4
5
6
7

Go 1.14+——编译器在循环中插入"安全点"——即使没有函数调用——也会周期性地检查抢占标志。

# 4. Go 1.14 基于信号的抢占

# 4.1 SIGURG 信号机制

Go 1.14 引入了基于信号的异步抢占——使用 SIGURG 信号(在 Linux 上)。SIGURG 是一个通常不被使用的 POSIX 信号——Go 劫持了它用于 goroutine 抢占:

抢占流程:
  sysmon() 检测到 P 上同一个 G 运行超过 10ms
        │
  preemptone(P) → P.curg.preempt = true
        │
  [协作式路径: 如果 G 在下一个函数序言检查 stackguard0 → 被抢占]
        │
  [信号式路径: 如果 G 在纯 CPU 循环中 → 不发函数调用]
        │
  preemptM(P.m) → 向 P.m 绑定的 M 发送 SIGURG 信号
        │
  M 的信号处理函数 sigPreempt() 被调用
        │
  → 在信号栈上执行 asyncPreempt → 保存 G 的寄存器上下文
  → 将 G 的状态从 _Grunning 改为 _Grunnable
  → 将 G 放回 P 的 runq → 完成抢占
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

为什么选择 SIGURG——它不会和用户代码冲突(极少有应用使用 SIGURG),它是一个标准的 POSIX 信号,跨平台支持好。

# 4.2 preemptM 流程

// runtime/signal_unix.go (简化)
func preemptM(mp *m) {
    // 向目标 M 绑定的 OS 线程发送 SIGURG
    signalM(mp, sigPreempt)
}

func signalM(mp *m, sig int) {
    // 通过 tgkill 系统调用——向指定线程发送信号
    tgkill(getpid(), int(mp.procid), sig)
}
1
2
3
4
5
6
7
8
9
10

注意——SIGURG 是发给 OS 线程(M)的——不是发给 goroutine(G)的。M 收到信号后——在执行 G 的上下文中——被 OS 强制中断——进入信号处理函数。

# 4.3 信号处理与 goroutine 视角

G1 正在执行纯 CPU 循环——状态: _Grunning
        │
M 收到 SIGURG → OS 保存 G1 的寄存器上下文到信号栈
        │
M 切换到 gsignal (每个 M 的信号栈 goroutine)
        │
执行 sigPreempt():
  → 检查 G1 的 preempt 标志——是 true
  → 调用 asyncPreempt(G1)
  → 将 G1 的状态: _Grunning → _Grunnable
  → 将 G1 放入 P.runq
        │
M 从信号处理返回 → OS 恢复寄存器上下文
  → 但 Go 已经将 G1 换出了——M 执行的是另一个 G
  → G1 回到 runq——等待下次被调度
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 5. Safe-Point 安全点

# 5.1 GC 安全点的控制收缩

不是任何位置都可以抢占——GC 安全点是一个重要的约束。在 GC 写屏障开启期间执行业务代码——某些位置不能被打断:

安全点 vs 非安全点:

✅ 安全点 (Can be preempted):
  - 函数序言 (stack check 之后)
  - 循环的回边 (back-edge——编译器插入的检查点)
  - 调用点 (call site)

❌ 非安全点 (Cannot be preempted):
  - 写屏障中间 (GC 状态不一致)
  - 原子操作中间
  - runtime 关键区域 (如 mheap.lock 持有期间)
1
2
3
4
5
6
7
8
9
10
11

# 5.2 编译器插入的异步安全点

Go 1.14+ 编译器在循环的回边(back-edge)插入抢占检查:

// 源码——纯 CPU 循环:
func tightLoop() {
    sum := 0
    for i := 0; i < 1_000_000_000; i++ {
        sum += i
    }
}

// Go 1.14+ 编译后(概念):
func tightLoop() {
    sum := 0
    for i := 0; i < 1_000_000_000; i++ {
        // 编译器插入: 每 N 次循环检查一次抢占标志
        if i%4096 == 0 && gp.preempt {
            runtime.morestack_noctxt() // ← 触发抢占
        }
        sum += i
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

关键——这个检查只在"安全点"上生效。如果在当前指令位置不是安全点——代码不能下转跳到抢占路径、而是等到下一个安全点。Go 编译器对此的处理是:在循环回边插入"异步抢占点"——保证最多 4096 次迭代内有一个安全点。

# 5.3 不安全点的处理

如果 SIGURG 到达时——G 恰好执行在非安全点——在信号处理中暂时不做抢占——设置标志——等待 G 自然到达下一个安全点:

// runtime/preempt.go (简化)
func canPreemptM(mp *m) bool {
    // 检查当前是否在 runtime 关键区域
    // 检查是否在 write barrier 中
    // 检查是否持有锁
    if mp.locks != 0 || mp.mallocing != 0 || mp.preemptoff != "" {
        return false // ← 不能抢占
    }
    return true
}
1
2
3
4
5
6
7
8
9
10

# 6. 系统调用中的抢占

# 6.1 M 与 P 的解绑机制

当 goroutine 进入系统调用(如 read())——M 会陷入内核——Go runtime 需要把这个 M 从 P 上解绑——让 P 继续执行其他 G:

// runtime/proc.go (简化)
func entersyscall() {
    // 1. 保存当前 G 的上下文
    save(pc, sp)
    
    // 2. G 状态: _Grunning → _Gsyscall
    casgstatus(gp, _Grunning, _Gsyscall)
    
    // 3. P 状态: _Prunning → _Psyscall
    pp.status = _Psyscall
    
    // 4. M 和 P 解绑——M 保留 P 的引用但 P 可以被其他 M 取走
    // M.p = nil (逻辑上,实际通过更复杂的状态跟踪)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

sysmon 监控——如果 P 在 _Psyscall 状态超过 10ms——sysmon 会触发抢占:

func sysmon() {
    for {
        for _, p := range allp {
            if p.status == _Psyscall && p.syscalltick == p.sysmontick {
                // P 在系统调用超过 10ms → 解绑 P → P 可以跑其他 G
                handoffp(p)
            }
        }
    }
}
1
2
3
4
5
6
7
8
9
10

# 6.2 sysmon 监控线程的职责

sysmon 是 Go runtime 的"管家 goroutine"——运行在独立的 M 上——不绑定任何 P。它负责:

职责 机制 间隔
抢占检查 检查 P 上的 G 是否运行超过 10ms 每 ~20µs
系统调用监控 系统调用超过 10ms → 解绑 P 每 ~20µs
netpoll 触发 批量检查网络事件 每 ~20µs
GC 强制触发 如果堆增长超过阈值 每 ~2min

# 6.3 系统调用返回后的重调度

// runtime/proc.go (简化)
func exitsyscall() {
    // 1. 检查是否可以重新绑定原 P
    if oldp.status == _Psyscall && oldp.m == nil {
        // 原 P 还在等着——直接绑回来
        acquirep(oldp)
        return
    }
    
    // 2. 原 P 已经被其他 M 占用——需要重新找 P 或放入全局队列
    // 如果 P 被解绑-→ G 放入 global runq
    if !acquirep() {
        // 3. 没有空闲 P → G 变为 _Grunnable → 放入 global runq
        globrunqput(gp)
        // M 进入空闲——等待被唤醒
        stopm()
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 7. 异步抢占的汇编实现

# 7.1 信号栈与 gsignal

每个 M 有一个 gsignal——一个特殊的 goroutine——用于执行信号处理。它有自己的栈(信号栈——SIGSTKSZ 大小):

// runtime/runtime2.go
type m struct {
    gsignal      *g          // 信号处理 goroutine
    gsignalstack stack       // 信号栈
    // ...
}
1
2
3
4
5
6

为什么需要独立的信号栈——信号处理不能使用当前 G 的栈。如果信号在栈溢出时到达——当前 G 的栈已经满了——无法再压帧。信号栈是操作系统特性——通过 sigaltstack() 设置。

# 7.2 asyncPreempt 的汇编代码

// runtime/asm_amd64.s (简化)
TEXT runtime.asyncPreempt(SB), NOSPLIT, $0-0
    // 1. 保存所有寄存器到当前 G 的栈
    PUSHQ   AX
    PUSHQ   BX
    PUSHQ   CX
    // ... 所有通用寄存器
    
    // 2. 保存浮点寄存器 (XMM0-XMM15)
    MOVUPS  X0, savedX0(G)
    // ...
    
    // 3. 调用 Go 函数:asyncPreempt2
    //    将 G 的状态从 _Grunning 切换到 _Grunnable
    CALL    runtime.asyncPreempt2(SB)
    
    // 4. 恢复所有寄存器 (如果抢占被拒绝——继续执行)
    // ... 恢复 // 恢复后跳转到被保存的 PC 继续执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 7.3 保存与恢复寄存器上下文

抢占的完整性保证——之后当 G 重新被调度时——它需要从被中断的位置精确恢复:

G 被抢占时的状态:
  PC:  0x408f2a (sum += i 的机器码中的某条指令)
  SP:  0xc0001a0000
  寄存器: AX=0 BX=42 CX=...

asyncPreempt 保存到 G.sched:
  G.sched.pc = 0x408f2a
  G.sched.sp = 0xc0001a0000
  G.sched.bp = ...
  G.sched.g = G
  
G 重新被调度时:
  runtime.gogo(&G.sched) → 恢复 PC/SP/寄存器 → 从 0x408f2a 继续
1
2
3
4
5
6
7
8
9
10
11
12
13

# 8. 与 Linux CFS 的对比

# 8.1 抢占机制对比

Go 调度器 Linux CFS
抢占方式 协作式 + 信号式(SIGURG) 时钟中断(timer interrupt)
抢占粒度 安全点(函数序言/回边) 任意指令边界
时间片管理 无固定时间片——10ms 检测 vruntime——动态时间片
抢占开销 低——只在安全点 较高——每次时钟中断
实时性 非实时——延迟 ~10ms 可配置实时调度(SCHED_FIFO/RR)
GC 协作 抢占与 GC 安全点集成 无 GC——不需要
线程模型 M:N——goroutine 在 M 上 1:1——每个线程有独立时间片

# 8.2 时间片与调度策略

Go 为什么不用时钟中断——时钟中断在每次 tick 时打断 CPU(通常 250Hz 或 1000Hz)——对于高并发 I/O 场景成本太高。Go 的选择是用协作式路径处理短任务(大多数 goroutine 运行不足 10ms)——只用SIGURG 处理长任务。这在"大多数 goroutine 快速主动放弃 CPU"的假设下是最优策略。

CFS 的 vruntime 设计——每个任务有一个 vruntime(虚拟运行时间)——相当于"CPU 时间 / 权重"。调度时选择 vruntime 最小的任务。这和 Go 的"runq 排队 + 全局队列 + work-stealing"完全不同——因为 Go 面对的调度对象是 goroutine(几十万个)——CFS 面对的调度对象是线程(可能只有几百个)。

# 9. 实战观测与调试

# 9.1 GODEBUG asyncpreemptoff

# 关闭异步抢占——验证是否是抢占导致的问题
$ GODEBUG=asyncpreemptoff=1 ./app

# 恢复异步抢占(默认)
$ ./app
1
2
3
4
5

# 9.2 schedtrace 与抢占

$ GODEBUG=schedtrace=1000,scheddetail=1 ./app
SCHED 1000ms: gomaxprocs=8 idleprocs=0 threads=12
  P0: status=1 schedtick=142 syscalltick=10 m=0 runqsize=3
  P1: status=1 schedtick=89  syscalltick=5  m=1 runqsize=1
  ...
# schedtick 递增 → P 在正常工作
# 如果某个 P 的 schedtick 数分钟不变化 → 可能存在抢占失败
1
2
3
4
5
6
7

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章正则匹配服务的七个疑问,逐条作答:

疑问 答案
① 协作式抢占为什么只在函数调用时? 第 3 章:stackguard0 检查在函数序言——纯 CPU 循环无函数调用→跳不过去
② SIGURG 信号怎么实现异步抢占? 第 4 章:sysmon→preemptM→tgkill→sigPreempt→asyncPreempt
③ safe-point 的编译器插入策略? 第 5 章:循环回边插入抢占检查——每 4096 次迭代一次
④ 系统调用中的 goroutine 怎么被抢占? 第 6 章:M 与 P 解绑→P 可被其他 M 取走→原 G 等待系统调用返回后重调度
⑤ sysmon 怎么检测"运行过久"? 第 6.2:每 ~20µs 检查 P 的状态——超过 10ms 触发抢占
⑥ asyncPreempt 怎么保存上下文? 第 7 章:汇编保存所有寄存器到 G.sched——恢复时精确返回被中断位置
⑦ Go vs CFS 的抢占策略差异? 第 8 章:Go 用信号(低开销)——CFS 用时钟中断(更高实时性)

修复方案——Go 1.14+ 已自动修复:编译器在循环回边插入安全点 + SIGURG 信号抢占:

// ✅ Go 1.14+ 无需改动——自动抢占
// 如果需要显式检查(如 CGO 场景)——用 runtime.Gosched()
func safeLoop() {
    for i := 0; i < 1_000_000_000; i++ {
        if i%1000000 == 0 {
            runtime.Gosched() // ← 显式让出——适用于所有 Go 版本
        }
        doWork(i)
    }
}
1
2
3
4
5
6
7
8
9
10

# 10.2 一次抢占的完整旅程

G1 在 P0 上运行纯 CPU 循环——已运行 12ms
─────────────────────────────────────────────────────────
        │
        ├─ sysmon: 检测到 P0.schedtick 超过 10ms 未变
        │    → preemptone(P0) → G1.preempt = true
        │    → G1.stackguard0 = stackPreempt
        │
        ├─ [协作式路径: G1 不调用函数——stackguard0 不被检查]
        │
        ├─ sysmon: G1 仍未被抢占 → preemptM(P0.m)
        │    → tgkill(getpid(), P0.m.procid, SIGURG)
        │
        ├─ OS: 中断 M → 保存 G1 的寄存器 → 切换到信号栈
        │    → 执行 sigPreempt()
        │
        ├─ sigPreempt: 检查 canPreemptM → true
        │    → 调用 asyncPreempt(G1)
        │    → 保存 G1 的 PC/SP/寄存器到 G.sched
        │    → casgstatus(G1, _Grunning, _Grunnable)
        │    → runqput(P0, G1) → G1 回到 runq 尾部
        │
        ├─ OS: 从信号处理返回
        │    → Go 已经将 G1 换出——schedule() 选下一个 G
        │
        └─ G1 重新被调度 (P0 执行完其他 G 后):
             runtime.gogo(&G1.sched) → 恢复寄存器
             → 从被 SIGURG 中断的指令继续执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

# 10.3 设计哲学回扣

哲学 1:信号是"侵入式抢占"的最小侵入方式

Go 没有选择在每个函数序言的 morestack 中加"时间片检查"——因为那样会让每个函数调用都变慢(即使没有被抢占)。信号是一条"旁路"——只在需要抢占时才打断。这和"Lazy GC"哲学一致——不做不必要的检查。

哲学 2:10ms 的抢占间隔是"经验法则"——不是数学推导

Go 选择 10ms 作为抢占间隔——这是一个"人感觉不到"但又"足够短"的时间。Linux CFS 的默认最小调度粒度是 ~0.75ms——Go 比 CFS 粗粒度 13 倍——因为 Go 的 goroutine 切换非常快(~几十 ns)——不需要这么频繁的抢占。

哲学 3:编译器插入的安全点是"可控的性能成本"

Go 没有在每个循环迭代插入抢占检查——而是约每 4096 次迭代插入一次。这个"4096"是平衡了"抢占延迟"(最多 4096 次迭代内的延迟)和"性能开销"(每次检查约 1 条 CMP 指令+1 条条件跳转——几乎免费)。

哲学 4:M 与 P 的解绑是"M:N 调度"对系统调用的标准答案

当 goroutine 进入系统调用——它绑定的 M 也被拖入内核——P 不应该被 M 拖着。Go 选择"解绑"——让 P 继续执行其他 G。这和 Linux 在内核态不可抢占的线程不同——Go 的 goroutine 进入系统调用后——P 上其他 goroutine 依然能被调度。

# 10.4 速查表

抢占机制对比:

协作式 (< 1.14) 信号式 (≥ 1.14)
触发方式 函数序言 stackguard0 SIGURG + asyncPreempt
检测间隔 取决于函数调用频率 ≤ 10ms
纯循环可抢占 ❌ ✅
GC STW 延迟 可能数秒 ≤ 10ms

抢占相关 GODEBUG 选项:

# 关闭异步抢占
GODEBUG=asyncpreemptoff=1 ./app

# schedtrace 查看调度详情
GODEBUG=schedtrace=1000,scheddetail=1 ./app

# 查看抢占相关 goroutine 状态
curl localhost:6060/debug/pprof/goroutine?debug=2 | grep "preempt"
1
2
3
4
5
6
7
8

系统调用抢占流程:

阶段 G 状态 P 状态 M 状态
进入系统调用 _Grunning→_Gsyscall _Prunning→_Psyscall 绑定 P
sysmon 检测超时 _Gsyscall _Psyscall→_Pidle→被其他M取走 仍在系统调用中
系统调用返回 _Gsyscall→_Grunnable 重新找P 空闲或继续
被其他M调度 _Grunnable→_Grunning 绑定新M 执行用户代码

下一篇:我们已经把 Go 抢占式调度器的原理——协作式、信号式、safe-point、系统调用抢占剖开。下一篇将进入新的主题。

上次更新: 2026/06/13, 21:14:36
定时器四叉堆实现
协程栈扩容与缩容

← 定时器四叉堆实现 协程栈扩容与缩容→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式