一、什么是"抢占式调度"?
先理解什么是"抢占"。
非抢占式(早期 Go)
早期 Go(1.14 之前):
-
goroutine 只有在
- 函数调用
- channel 操作
- syscall
- runtime 检查点
才会被切换。
如果一个 goroutine 写成:
go
for {
}
会发生什么?
👉 它永远不会让出 CPU
👉 其他 goroutine 全部饿死
这叫:
协作式调度(主动让出)
抢占式调度
抢占式调度是:
即使 goroutine 不愿意让出 CPU,调度器也可以强制打断它
这就是现代 Go(1.14+)的机制。
二、Go 现在是如何"强制打断"的?
核心答案:
通过"信号 + 栈检查"实现异步抢占
我们拆开讲。
三、抢占的触发条件
Go 会给每个运行中的 goroutine 设置一个"时间片"。
大概:
10ms 左右
如果:
某个 G 连续运行超过时间片
调度器就会尝试抢占它。
四、真正的技术实现(关键来了)
Go 通过:
发送操作系统信号(SIGURG)
去打断正在运行的线程 M。
步骤如下:
第一步:标记需要抢占
调度器发现:
这个 G 运行太久了
就会:
go
g.preempt = true
标记它需要被抢占。
第二步:给 M 发信号
Go runtime 会向正在运行的 M 发送一个系统信号:
SIGURG
这会打断线程执行。
第三步:信号处理函数触发
当 M 收到信号时:
会执行 Go runtime 的信号处理函数。
这个函数会:
- 保存当前寄存器状态
- 检查是否可以安全抢占
- 修改程序计数器
- 跳转到调度函数
五、什么叫"安全抢占点"?
Go 不能在任何地方强行打断。
比如:
- 正在修改栈
- 正在执行 runtime 内部关键代码
- 正在 GC 关键阶段
所以必须在"安全点"抢占。
Go 在函数调用前后插入:
栈边界检查
类似:
asm
CMP SP, stackGuard
如果发现:
- 栈溢出
- 或被标记需要抢占
就跳到 runtime。
这就实现了:
在几乎所有函数调用处都可被抢占
六、为什么 1.14 是重大升级?
1.14 之前:
- 只能在函数调用点抢占
- 死循环没函数调用就无法抢占
1.14 之后:
- 加入"异步抢占"
- 即使死循环也可以被打断
例如:
go
for {
}
现在也会被强制暂停。
七、完整抢占流程(一步一步)
假设:
G1 正在运行
1️⃣ G1 运行超过 10ms
2️⃣ 调度器标记 G1 需要抢占
3️⃣ 给 M 发送信号
4️⃣ M 收到信号
5️⃣ 保存当前执行现场
6️⃣ 切换到 runtime 调度代码
7️⃣ G1 状态改为 runnable
8️⃣ 放回队列
9️⃣ 执行其他 G
八、和操作系统抢占有什么区别?
操作系统线程调度:
- 由内核控制
- 时间片强制切换线程
Go goroutine 调度:
- 用户态调度
- 通过信号模拟"线程级抢占"
- 更轻量
九、为什么这样设计?
目标是:
- 防止 goroutine 独占 CPU
- 保证公平性
- 提高多核利用率
- 让 GC 可以安全暂停世界
尤其是 GC:
如果不能抢占 goroutine:
STW(Stop The World)会卡死
十、和 GC 的关系
Go 的 GC 需要:
在安全点暂停所有 goroutine
抢占机制保证:
- 即使 goroutine 死循环
- GC 也能暂停它
这非常关键。
十一、核心总结
一句话版本:
Go 通过向线程发送信号,在安全点打断 goroutine,实现异步抢占式调度。
再浓缩:
时间片到
→ 标记抢占
→ 发信号
→ 跳到调度器
→ 切换 goroutine
十二、一个形象比喻
想象:
- G 是学生
- M 是老师
- P 是教室
以前:
学生必须自己举手说"我讲完了"。
现在:
老师可以直接打断学生:
"时间到了,换人!"
这就是抢占式调度。