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

203 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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