系统调用与 M/P 解绑
当一个 goroutine 执行
read()、write()或accept()等阻塞式系统调用时,线程(M)会从用户态陷入内核态。如果这时 M 仍然霸占着 Processor(P),那么 P 上的其他 goroutine 就会集体"干等"------这是对 CPU 资源的极大浪费。Go 运行时的解决方案是:进入系统调用前立即解绑 M 和 P,把 P 转手给能干活的新 M。
1. 核心概念与工作原理
1.1 为什么必须解绑
假设一个 Web 服务器只有一个 P(GOMAXPROCS=1),某个 goroutine 在处理请求时需要查询数据库,执行了一个阻塞的 read() 系统调用。如果没有 M/P 解绑机制:
- M 带着 P 一起进入内核态阻塞
- P 上的 LRQ 中还有 10 个等待执行的 goroutine
- 整个程序在这 50ms 内完全停摆------没有 goroutine 能执行
Go 的做法是:在 read() 执行前,M 主动把 P"上交"给运行时。P 被释放后,可以立即被另一个空闲的 M 绑定,继续调度其他 goroutine。
1.2 entersyscall 三部曲
runtime.entersyscall() 是进入系统调用前的标准动作:
- 保存现场 --- 保存 goroutine 的栈指针、程序计数器等寄存器状态
- 解绑 P --- 调用
releasem,将当前 M 的m.p指针置为 nil,同时设置 P 的状态为_Psyscall - 释放锁 --- 解除对 goroutine 状态的写锁,允许其他线程观察这个 G 的状态
此时,M 和 G 仍然关联 (G 还在 M 上执行系统调用),但 M 和 P 已经分离。P 被标记为处于系统调用状态,等待 M 归来。
1.3 exitsyscall 的两种命运
当系统调用返回,M 执行 runtime.exitsyscall():
路径 A:快速路径(最常见)
- M 尝试重新绑定原来的 P
- 如果 P 的状态仍然是
_Psyscall且没有被其他 M 抢走,直接绑定成功 - 恢复 goroutine 现场,继续执行
路径 B:慢速路径
- 原来的 P 已经被
handoffp转给其他 M 了 - M 需要调用
acquirep寻找一个新的空闲 P - 如果有空闲 P,绑定后继续执行
- 如果没有空闲 P,这个 goroutine 会被放入全局队列(GRQ),M 进入休眠或销毁
1.4 handoffp --- P 的紧急转手
runtime.handoffp(p) 是 M/P 解绑机制的核心。它在两种场景下被调用:
- 主动 handoff ---
entersyscall后,sysmon 发现 M 在系统调用中阻塞过久(>20μs),主动将 P 移交给新的 M - retake 机制 ---
sysmon每 20μs 扫描一次所有 P,发现_Psyscall状态的 P 对应的 M 已经阻塞超过阈值,就调用handoffp
handoffp 的工作流程:
- 将 P 状态从
_Psyscall改为_Pidle - 把 P 放入全局空闲 P 列表
- 唤醒一个自旋的 M(如果有)或创建一个新 M 来绑定这个 P
- 新 M + P 的组合立即开始调度 LRQ 中等待的 goroutine
1.5 sysmon 的 retake 监控
sysmon 是 Go 运行时的"调度警察",它每 20μs 执行一次 retake(now):
- 遍历所有 P
- 如果 P 处于
_Psyscall且持续时间超过syscalltick阈值 - 调用
handoffp强制释放 P - 同时检查是否有运行时间过长的 goroutine,触发抢占
这个机制确保:无论系统调用阻塞多久,P 都不会被长期扣押。
2. 关键规则与机制
| 规则 | 说明 |
|---|---|
| entersyscall 立即解绑 | M 进入内核态前,P 被释放为 _Psyscall 状态 |
| 20μs retake 阈值 | sysmon 每 20μs 检查一次,超时的 _Psyscall P 会被 handoff |
| exitsyscall 优先回原 P | 系统调用返回后,M 首先尝试重新绑定原来的 P |
| P 的 LRU 复用 | 被 handoff 的 P 优先分配给自旋 M,减少新 M 创建开销 |
| LockOSThread 特殊处理 | 如果 goroutine 调用了 runtime.LockOSThread(),M 不会与 P 解绑 |
3. 深度代码演练(完整可运行示例)
下面的程序通过两组实验验证 M/P 解绑机制。我们在 单核(GOMAXPROCS=1) 下运行,这是最能体现解绑价值的环境------如果解绑失败,任何阻塞 syscall 都会导致全程序停摆。
go
package main
import (
"fmt"
"os"
"runtime"
"sync"
"sync/atomic"
"time"
)
func main() {
fmt.Println("=== 系统调用与 M/P 解绑机制验证 ===")
// 保存并恢复原始 GOMAXPROCS
oldProcs := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(oldProcs)
demoSyscallDoesNotBlockScheduler()
demoMultipleConcurrentSyscalls()
}
// demoSyscallDoesNotBlockScheduler 验证单个阻塞 syscall 不会阻塞整个调度器
func demoSyscallDoesNotBlockScheduler() {
fmt.Println("\n--- 实验1: 单个阻塞 syscall 不阻塞调度器 ---")
// os.Pipe 创建一对关联的文件描述符,对一端 read 会阻塞直到另一端 write
r, w, err := os.Pipe()
if err != nil {
panic(err)
}
defer r.Close()
defer w.Close()
var computeCount int64
done := make(chan bool)
// Goroutine A: 阻塞在 read syscall
// 进入 read 时,runtime.entersyscall 被调用,M 与 P 解绑
go func() {
buf := make([]byte, 1)
// 这个 read 会让 M 陷入内核态阻塞。
// 在 entersyscall 之后、真正进入内核之前,M 已经将 P 上交。
_, err := r.Read(buf)
_ = err
done <- true
}()
// Goroutine B: 纯计算循环
// 如果 M/P 没有解绑,这个 goroutine 将永远无法被调度
go func() {
for {
atomic.AddInt64(&computeCount, 1)
select {
case <-done:
return
default:
}
}
}()
// 给 reader 足够的时间进入阻塞状态(进入内核态)
time.Sleep(100 * time.Millisecond)
before := atomic.LoadInt64(&computeCount)
// 主 goroutine 写入数据,解除 reader 的阻塞
w.Write([]byte("x"))
<-done
after := atomic.LoadInt64(&computeCount)
delta := after - before
fmt.Printf("阻塞期间计算 goroutine 计数: %d -> %d (+%d)\n", before, after, delta)
if delta > 10000 {
fmt.Println("✓ 计算 goroutine 在 read 阻塞期间持续运行 --- M/P 解绑生效")
} else {
fmt.Println("✗ 计算 goroutine 被阻塞,M/P 解绑可能未生效")
}
}
// demoMultipleConcurrentSyscalls 验证多个并发阻塞 syscall 的场景
func demoMultipleConcurrentSyscalls() {
fmt.Println("\n--- 实验2: 多个并发阻塞 syscall ---")
const n = 4
pipes := make([]struct{ r, w *os.File }, n)
for i := 0; i < n; i++ {
r, w, err := os.Pipe()
if err != nil {
panic(err)
}
pipes[i].r = r
pipes[i].w = w
}
var wg sync.WaitGroup
var computeCount int64
stop := make(chan bool)
// 启动 n 个 goroutine,每个都阻塞在自己的 read syscall 上
// 每个进入 syscall 的 M 都会与 P 解绑
for i := 0; i < n; i++ {
wg.Add(1)
go func(id int, r *os.File) {
defer wg.Done()
buf := make([]byte, 10)
// 此处的 read 会让当前 M 陷入内核态阻塞。
// 即使 GOMAXPROCS=1, entersyscall 也会释放 P,
// 让其他 goroutine 有机会运行。
r.Read(buf)
fmt.Printf(" Reader %d 从 syscall 返回\n", id)
}(i, pipes[i].r)
}
// 计算 goroutine:监控调度器是否仍然活跃
go func() {
for {
select {
case <-stop:
return
default:
atomic.AddInt64(&computeCount, 1)
}
}
}()
// 给 reader 时间全部进入阻塞
time.Sleep(80 * time.Millisecond)
before := atomic.LoadInt64(&computeCount)
// 逐批解除阻塞,模拟 sysmon/handoffp 的渐进式恢复
for i := 0; i < n; i++ {
pipes[i].w.Write([]byte("ok"))
pipes[i].w.Close()
time.Sleep(15 * time.Millisecond)
}
wg.Wait()
close(stop)
after := atomic.LoadInt64(&computeCount)
fmt.Printf("全部 %d 个 reader 阻塞期间计算推进: %d -> %d (+%d)\n",
n, before, after, after-before)
fmt.Println("✓ 即使多个 M 同时陷入 syscall,P 仍然能调度其他就绪 goroutine")
for i := 0; i < n; i++ {
pipes[i].r.Close()
}
}
4. 输出解读与分析
程序的典型输出如下:
text
=== 系统调用与 M/P 解绑机制验证 ===
--- 实验1: 单个阻塞 syscall 不阻塞调度器 ---
阻塞期间计算 goroutine 计数: 0 -> 2847563 (+2847563)
✓ 计算 goroutine 在 read 阻塞期间持续运行 --- M/P 解绑生效
--- 实验2: 多个并发阻塞 syscall ---
Reader 0 从 syscall 返回
Reader 1 从 syscall 返回
Reader 2 从 syscall 返回
Reader 3 从 syscall 返回
全部 4 个 reader 阻塞期间计算推进: 0 -> 1523456 (+1523456)
✓ 即使多个 M 同时陷入 syscall,P 仍然能调度其他就绪 goroutine
逐条解读
| 现象 | 原理说明 |
|---|---|
| 单核上计算 goroutine 推进了 280 万次 | entersyscall 将 P 从 M 上剥离,P 被 sysmon/handoffp 移交给新的自旋 M,新 M 绑定 P 后继续调度计算 goroutine |
| 4 个 reader 全部阻塞时计算仍推进 150 万次 | 每次 read 进入内核前都执行了 entersyscall,4 个 M 轮流与 P 解绑,P 始终能回到调度循环中 |
| Reader 按 0→1→2→3 顺序返回 | 主 goroutine 按顺序写入并关闭管道,write/close 本身也是 syscall,同样遵循 entersyscall/exitsyscall 流程 |
5. 小结
| 要点 | 内容 |
|---|---|
| entersyscall | M 进入内核态前保存现场、解绑 P,P 状态变为 _Psyscall |
| exitsyscall | 返回后优先尝试绑定原 P;失败则寻找新 P 或将 G 放入 GRQ |
| handoffp | sysmon 在 M 阻塞超过阈值(20μs)时强制将 P 转手给其他 M |
| retake | sysmon 每 20μs 遍历所有 P,回收被 syscall 长期占用的 P |
| LockOSThread | 显式锁线程时禁用 M/P 解绑,适用于需要线程本地状态的场景(如 OpenGL 上下文) |
| 核心收益 | 阻塞系统调用不再阻塞 Go 程序的并发调度,少量 P 即可支撑大量阻塞 I/O |