系统调用与 M/P 解绑

文章来源声明: 原文作者:newerp; 来源站点:掘金; 原文链接:https://juejin.cn/post/7687513063491813414; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把阻塞系统调用解绑 M/P 的完整链路讲透了,含 sysmon 阈值与 handoffp 细节。适合排查 Go 高并发 I/O 调度卡顿、准备面试 runtime 原理的读者精读。

系统调用与 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 解绑机制:

  1. M 带着 P 一起进入内核态阻塞
  2. P 上的 LRQ 中还有 10 个等待执行的 goroutine
  3. 整个程序在这 50ms 内完全停摆——没有 goroutine 能执行

Go 的做法是:在 read() 执行前,M 主动把 P"上交"给运行时。P 被释放后,可以立即被另一个空闲的 M 绑定,继续调度其他 goroutine。

1.2 entersyscall 三部曲

runtime.entersyscall() 是进入系统调用前的标准动作:

  1. 保存现场 — 保存 goroutine 的栈指针、程序计数器等寄存器状态
  2. 解绑 P — 调用 releasem,将当前 M 的 m.p 指针置为 nil,同时设置 P 的状态为 _Psyscall
  3. 释放锁 — 解除对 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 解绑机制的核心。它在两种场景下被调用:

  1. 主动 handoffentersyscall 后,sysmon 发现 M 在系统调用中阻塞过久(>20μs),主动将 P 移交给新的 M
  2. retake 机制sysmon 每 20μs 扫描一次所有 P,发现 _Psyscall 状态的 P 对应的 M 已经阻塞超过阈值,就调用 handoffp

handoffp 的工作流程:

  1. 将 P 状态从 _Psyscall 改为 _Pidle
  2. 把 P 放入全局空闲 P 列表
  3. 唤醒一个自旋的 M(如果有)或创建一个新 M 来绑定这个 P
  4. 新 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 都会导致全程序停摆。

<span>package</span> main

<span>import</span> (
	<span>"fmt"</span>
	<span>"os"</span>
	<span>"runtime"</span>
	<span>"sync"</span>
	<span>"sync/atomic"</span>
	<span>"time"</span>
)

<span><span>func</span> <span>main</span><span>()</span></span> {
	fmt.Println(<span>"=== 系统调用与 M/P 解绑机制验证 ==="</span>)

	<span>// 保存并恢复原始 GOMAXPROCS</span>
	oldProcs := runtime.GOMAXPROCS(<span>1</span>)
	<span>defer</span> runtime.GOMAXPROCS(oldProcs)

	demoSyscallDoesNotBlockScheduler()
	demoMultipleConcurrentSyscalls()
}

<span>// demoSyscallDoesNotBlockScheduler 验证单个阻塞 syscall 不会阻塞整个调度器</span>
<span><span>func</span> <span>demoSyscallDoesNotBlockScheduler</span><span>()</span></span> {
	fmt.Println(<span>"\n--- 实验1: 单个阻塞 syscall 不阻塞调度器 ---"</span>)

	<span>// os.Pipe 创建一对关联的文件描述符,对一端 read 会阻塞直到另一端 write</span>
	r, w, err := os.Pipe()
	<span>if</span> err != <span>nil</span> {
		<span>panic</span>(err)
	}
	<span>defer</span> r.Close()
	<span>defer</span> w.Close()

	<span>var</span> computeCount <span>int64</span>
	done := <span>make</span>(<span>chan</span> <span>bool</span>)

	<span>// Goroutine A: 阻塞在 read syscall</span>
	<span>// 进入 read 时,runtime.entersyscall 被调用,M 与 P 解绑</span>
	<span>go</span> <span><span>func</span><span>()</span></span> {
		buf := <span>make</span>([]<span>byte</span>, <span>1</span>)
		<span>// 这个 read 会让 M 陷入内核态阻塞。</span>
		<span>// 在 entersyscall 之后、真正进入内核之前,M 已经将 P 上交。</span>
		_, err := r.Read(buf)
		_ = err
		done <- <span>true</span>
	}()

	<span>// Goroutine B: 纯计算循环</span>
	<span>// 如果 M/P 没有解绑,这个 goroutine 将永远无法被调度</span>
	<span>go</span> <span><span>func</span><span>()</span></span> {
		<span>for</span> {
			atomic.AddInt64(&computeCount, <span>1</span>)
			<span>select</span> {
			<span>case</span> <-done:
				<span>return</span>
			<span>default</span>:
			}
		}
	}()

	<span>// 给 reader 足够的时间进入阻塞状态(进入内核态)</span>
	time.Sleep(<span>100</span> * time.Millisecond)
	before := atomic.LoadInt64(&computeCount)

	<span>// 主 goroutine 写入数据,解除 reader 的阻塞</span>
	w.Write([]<span>byte</span>(<span>"x"</span>))
	<-done

	after := atomic.LoadInt64(&computeCount)
	delta := after - before

	fmt.Printf(<span>"阻塞期间计算 goroutine 计数: %d -> %d (+%d)\n"</span>, before, after, delta)
	<span>if</span> delta > <span>10000</span> {
		fmt.Println(<span>"✓ 计算 goroutine 在 read 阻塞期间持续运行 — M/P 解绑生效"</span>)
	} <span>else</span> {
		fmt.Println(<span>"✗ 计算 goroutine 被阻塞,M/P 解绑可能未生效"</span>)
	}
}

<span>// demoMultipleConcurrentSyscalls 验证多个并发阻塞 syscall 的场景</span>
<span><span>func</span> <span>demoMultipleConcurrentSyscalls</span><span>()</span></span> {
	fmt.Println(<span>"\n--- 实验2: 多个并发阻塞 syscall ---"</span>)

	<span>const</span> n = <span>4</span>
	pipes := <span>make</span>([]<span>struct</span>{ r, w *os.File }, n)
	<span>for</span> i := <span>0</span>; i < n; i++ {
		r, w, err := os.Pipe()
		<span>if</span> err != <span>nil</span> {
			<span>panic</span>(err)
		}
		pipes[i].r = r
		pipes[i].w = w
	}

	<span>var</span> wg sync.WaitGroup
	<span>var</span> computeCount <span>int64</span>
	stop := <span>make</span>(<span>chan</span> <span>bool</span>)

	<span>// 启动 n 个 goroutine,每个都阻塞在自己的 read syscall 上</span>
	<span>// 每个进入 syscall 的 M 都会与 P 解绑</span>
	<span>for</span> i := <span>0</span>; i < n; i++ {
		wg.Add(<span>1</span>)
		<span>go</span> <span><span>func</span><span>(id <span>int</span>, r *os.File)</span></span> {
			<span>defer</span> wg.Done()
			buf := <span>make</span>([]<span>byte</span>, <span>10</span>)
			<span>// 此处的 read 会让当前 M 陷入内核态阻塞。</span>
			<span>// 即使 GOMAXPROCS=1, entersyscall 也会释放 P,</span>
			<span>// 让其他 goroutine 有机会运行。</span>
			r.Read(buf)
			fmt.Printf(<span>"  Reader %d 从 syscall 返回\n"</span>, id)
		}(i, pipes[i].r)
	}

	<span>// 计算 goroutine:监控调度器是否仍然活跃</span>
	<span>go</span> <span><span>func</span><span>()</span></span> {
		<span>for</span> {
			<span>select</span> {
			<span>case</span> <-stop:
				<span>return</span>
			<span>default</span>:
				atomic.AddInt64(&computeCount, <span>1</span>)
			}
		}
	}()

	<span>// 给 reader 时间全部进入阻塞</span>
	time.Sleep(<span>80</span> * time.Millisecond)
	before := atomic.LoadInt64(&computeCount)

	<span>// 逐批解除阻塞,模拟 sysmon/handoffp 的渐进式恢复</span>
	<span>for</span> i := <span>0</span>; i < n; i++ {
		pipes[i].w.Write([]<span>byte</span>(<span>"ok"</span>))
		pipes[i].w.Close()
		time.Sleep(<span>15</span> * time.Millisecond)
	}

	wg.Wait()
	<span>close</span>(stop)

	after := atomic.LoadInt64(&computeCount)
	fmt.Printf(<span>"全部 %d 个 reader 阻塞期间计算推进: %d -> %d (+%d)\n"</span>,
		n, before, after, after-before)
	fmt.Println(<span>"✓ 即使多个 M 同时陷入 syscall,P 仍然能调度其他就绪 goroutine"</span>)

	<span>for</span> i := <span>0</span>; i < n; i++ {
		pipes[i].r.Close()
	}
}

4. 输出解读与分析

程序的典型输出如下:

=== 系统调用与 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