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

203 lines
6.7 KiB
Markdown
Raw Normal View History

2026-06-07 16:52:15 +08:00
---
tags: [go, golang, go-principle, gmp-scheduler, preemption]
create time: 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`。它全程唯一、终身运行。
```go
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`):
```go
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 时有两条路径:
```mermaid
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
```
```go
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:
```go
// 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 上打两个标记:
```go
func preemptone(_p_ *p) bool {
mp := _p_.m.ptr()
gp := mp.curg
gp.preempt = true // 抢占标志
gp.stackguard0 = stackPreempt // 栈保护区特殊值
return true
}
```
G 在执行函数调用时(尤其是触发栈扩容的 `newstack`),会检查 `stackguard0`:
```go
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 信号的硬抢占机制:
```go
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` 会通过修改寄存器的"指令注入"方式强行接管执行流:
```mermaid
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
```
```go
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,协作式抢占已经足够及时。
## 关联笔记
- [[hzh/GolangStar/Go语言原理/gmp调度原理/gmp-overview]] — GMP 概览
- [[hzh/GolangStar/Go语言原理/gmp调度原理/gmp-datastructures]] — G/M/P/Schedt 数据结构
- [[hzh/GolangStar/Go语言原理/gmp调度原理/gmp-lifecycle]] — Goroutine 生命周期
- [[hzh/GolangStar/Go语言原理/gmp调度原理/gmp-summary]] — 知识卡片
- [[hzh/GolangStar/Go语言原理/垃圾回收]] — sysmon 如何触发 GC