抢占式调度器原理
# 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. 案例引入
- 2. 架构概览
- 3. 协作式调度时代
- 4. Go 1.14 基于信号的抢占
- 5. Safe-Point 安全点
- 6. 系统调用中的抢占
- 7. 异步抢占的汇编实现
- 8. 与 Linux CFS 的对比
- 9. 实战观测与调试
- 10. 综合案例串讲
# 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 可能延迟数秒才打印!
}
}()
}
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 上不放手
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 之间不切换
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 章
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 章) ── 修复 + 设计哲学
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 会被强制换出
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 2.2 为什么需要抢占
疑惑:Go 有成千上万个 goroutine——调度器轮流给它们时间片就好了——为什么之前做不到?
论证:
Goroutine 没有"时间片"的概念——和 Linux CFS 不同,Go 在 1.14 之前没有基于时间片的抢占。goroutine 的运行时间取决于它什么时候主动放弃 CPU。如果某个 G 进入了一个没有函数调用的纯 CPU 循环——它永远不会触发
morestack——也就永远不会被调度器换出。GC 需要"全员 STW 协商"——GC 的 Mark Setup 和 Mark Termination 阶段需要 STW(Stop The World)。STW 要求所有 P 上的 goroutine 都停下来。如果某个 P 上的 goroutine 永远不会被抢占——GC STW 会卡住——直到那个 goroutine 主动放弃 CPU。
大量的 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:
; ... 函数体 ...
2
3
4
5
6
7
8
stackguard0 有两个用途:
- 栈溢出检测——当 SP < stackguard0 时——真的需要扩容
- 抢占标记——
stackPreempt这个特殊值被写入stackguard0——强制触发morestack——在newstack中检查是否是被抢占而非真正的栈溢出
// runtime/stack.go (简化)
const stackPreempt = uintptrMask & -1314 // 特殊标记——表示"请抢占"
2
# 3.2 协作式抢占的触发点
Go < 1.14 在以下时机检查抢占:
抢占检查点:
├── 函数序言 (stackguard0)
├── GC mark assist (gcAssistAlloc)
├── channel send/recv (chansend/chanrecv)
├── syscall 返回 (exitsyscall)
└── runtime.Gosched() 显式调用
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
}
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
}
}
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 → 完成抢占
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)
}
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——等待下次被调度
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 持有期间)
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
}
}
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
}
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 (逻辑上,实际通过更复杂的状态跟踪)
}
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)
}
}
}
}
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()
}
}
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 // 信号栈
// ...
}
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 继续执行
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 继续
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
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 数分钟不变化 → 可能存在抢占失败
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)
}
}
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 中断的指令继续执行
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"
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、系统调用抢占剖开。下一篇将进入新的主题。