ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GO [ 并发 · 调度器 ]

GO [ 并发 · 调度器 ] 上一篇我们已经学习了 goroutine、channel、select、WaitGroup、Context、Mutex、RWMutex、Cond 和 atomic。现在有一个更底层的问题go func() { ... }()这行代码到底是怎样被执行起来的一个进程里明明可以有成千上万个 goroutine操作系统线程却没有那么多Go 运行时是如何把它们安排到 CPU 上的当 goroutine 等待 channel、网络 I/O 或锁时为什么不会把整个程序卡住这些问题属于 Go runtime scheduler也就是 Go 运行时调度器。学习调度器不是为了在业务代码里手动操作它而是为了知道并发程序的成本来自哪里goroutine 创建并不等于 CPU 并行阻塞不一定是坏事GOMAXPROCS也不是“启动多少 goroutine”的参数。按照 Go 官方 runtime 内部文档 HACKING.md 的定义调度器管理三类核心资源GGoroutine要执行的 Go 代码MMachine可以执行 Go 代码的操作系统线程PProcessor执行用户 Go 代码所需要的资源和权限。调度器的任务可以概括成一句话把一个可以运行的 G放到拥有 P 的 M 上执行。本篇按照“G/M/P → goroutine 创建 → 运行队列 → 工作窃取 → 阻塞与唤醒 → 系统调用和 netpoll → 抢占 → 实际观测”的顺序展开。每一个概念都尽量用可以复制运行的代码验证。本文代码在 Go 1.27.0 darwin/arm64 环境中实际运行。调度器输出会随 CPU 数量、操作系统和 Go 版本变化示例日志只展示结构不应当把具体数字当成固定协议。G、M、P 分别是什么Ggoroutine 的运行载体G 就是一个 goroutine。它保存函数入口、栈、程序计数器、状态和调度现场等信息。启动一个 goroutine 时Go 编译器会把go语句转换成运行时调用。运行时创建或复用一个 G把它放到可运行队列然后由调度器在合适的时刻执行。从业务代码看G 的生命周期可以简化成创建 → 可运行 → 运行中 → 阻塞/等待 → 再次可运行 → 运行结束G 阻塞时不会占用一个正在执行用户代码的 P。比如下面的 goroutine 在等待 channelpackage main import ( fmt time ) func main() { ready : make(chan struct{}) go func() { fmt.Println(worker waits) -ready fmt.Println(worker wakes) }() time.Sleep(20 * time.Millisecond) fmt.Println(main sends wake signal) close(ready) time.Sleep(20 * time.Millisecond) }worker等待期间调度器可以让其他 goroutine 使用 CPU。这里的“等待”不是忙循环而是把当前 G 挂起等 channel 关闭后再标记为可运行。M操作系统线程M 是 operating system thread。它可以执行用户 Go 代码也可能进入系统调用、执行 runtime 代码或者处于空闲状态。M 的数量不一定等于 P 的数量。一个 M 进入阻塞系统调用时运行时会尽量把它持有的 P 交给其他 M让其他 G 继续执行系统调用返回后原来的 M 可能重新获得 P也可能因为没有空闲 P 而等待。可以用下面的程序制造一些系统调用和 Go 代码混合执行的场景package main import ( fmt os runtime sync time ) func main() { runtime.GOMAXPROCS(2) var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() file, err : os.CreateTemp(, scheduler-demo-*) if err ! nil { return } defer os.Remove(file.Name()) defer file.Close() _, _ file.WriteString(hello) fmt.Println(syscall-like file work finished) }() go func() { defer wg.Done() for i : 0; i 3; i { fmt.Println(go work, i) time.Sleep(5 * time.Millisecond) } }() wg.Wait() }这里不能从输出顺序反推出具体 M 的调度顺序。runtime 会根据系统调用、P 是否空闲、队列中是否有 G 等条件做动态决策。P执行 Go 代码的资源P 不是 CPU 核心也不是操作系统线程。它代表执行用户 Go 代码所需要的资源例如本地可运行队列、内存分配器状态和调度相关状态。P 的数量等于GOMAXPROCS。如果GOMAXPROCS4最多同时有 4 个 P 执行用户 Go 代码每个 P 通常需要绑定一个 M 才能运行。previous : runtime.GOMAXPROCS(2) defer runtime.GOMAXPROCS(previous)不要把GOMAXPROCS(2)理解成“程序最多只能启动两个 goroutine”。它只限制同一时刻能够并行执行用户 Go 代码的 P 数量goroutine 数量可以远大于 2。官方 runtime 文档对三者关系的描述可以画成下面这样可运行的 G ┌─────────────┐ │ G1 G2 G3 G4 │ └──────┬──────┘ │ 调度 ┌────────▼────────┐ │ P │ 执行 Go 代码所需的资源 └────────┬────────┘ │ 绑定 ┌────────▼────────┐ │ M │ OS thread └────────┬────────┘ │ CPUgoroutine 创建以后去了哪里从 go 语句到可运行队列下面的代码go work(10)在语义上做了三件事在当前 goroutine 中计算函数值和参数创建一个新的 goroutine 描述对象 G把 G 标记为 runnable放入调度器可以找到的运行队列。函数参数在调用方求值这一点很重要value : expensiveInput() go work(value)expensiveInput()并不会自动在新 goroutine 中执行。只有work(value)的函数体属于新 goroutine。本地队列和全局队列每一个 P 都有一个本地 runnable queue用来保存准备在这个 P 上运行的 G。运行时还维护一个全局队列用于在本地队列之间转移工作。简化后的结构如下P0: [G1 G2 G3] P1: [G4 G5] │ │ └───────┬────────────┘ │ 本地队列不足时寻找工作 Global run queue: [G6 G7 ...]当前 P 创建出的新 G通常会优先放到自己的本地队列。这样做可以减少所有 goroutine 都竞争一个全局锁的成本。本地队列容量和具体数据结构属于 runtime 实现细节。Go 1.27 的runtime/proc.go中可以看到runqput、runqget、runqgrab和runqsteal等函数。业务代码不应该依赖这些未导出的名字但理解它们有助于解释为什么调度器能在大量 goroutine 下工作。runnext让刚唤醒的工作尽快运行P 还存在一个runnext概念用于保存一个倾向于快速运行的 G。它可以让通信双方在生产者刚发送数据后接收者更快得到调度减少一轮完整队列扫描的延迟。这不是“严格的优先级队列”。runtime 仍然需要防止某个 goroutine 对长期运行的 goroutine 造成饥饿并且在 race 模式下会引入调度随机化来暴露依赖固定顺序的测试。工作窃取空闲 P 如何找到工作如果 P0 的本地队列为空但 P1 还有很多可运行 GP0 不会一直空转。调度器会尝试从其他 P 的本地队列中窃取一部分工作这就是 work stealing。可以把过程简化为P0 本地队列为空 │ ▼ 检查全局队列 │ 没有足够工作 ▼ 随机选择其他 P │ ▼ 窃取对方本地队列的一部分 G │ ▼ 放入 P0 本地队列并继续执行工作窃取的意义是平衡负载同时尽量保持本地队列的低竞争。窃取不是复制 G而是把 G 的所有权从一个 runnable queue 转移到另一个 queue。下面的 CPU 密集型例子可以在 scheduler trace 中看到本地队列和全局队列的变化package main import ( runtime sync ) func busy(iterations int) { value : 0 for i : 0; i iterations; i { value i % 7 } if value -1 { panic(unreachable) } } func main() { runtime.GOMAXPROCS(2) var wg sync.WaitGroup for i : 0; i 100; i { wg.Add(1) go func() { defer wg.Done() busy(5_000_000) }() } wg.Wait() }不要用runtime.Gosched()代替设计良好的同步。Gosched只是主动让出当前时间片它不会等待数据、不建立 happens-before也不会解决数据竞争。goroutine 阻塞以后发生什么channel 阻塞当 goroutine 执行下面的代码而 channel 当前无法完成接收value : -inputruntime 不会让它继续占用 CPU 反复检查。当前 G 会进入等待状态调度器把它从 runnable 集合中移除之后由发送方把它重新变成 runnable。生产者消费者的最小示例package main import fmt func main() { jobs : make(chan int) go func() { jobs - 42 }() value : -jobs fmt.Println(value) }接收操作和发送操作在 runtime 内部都可能调用gopark而匹配成功后通过类似goready的路径把等待中的 G 放回 runnable 状态。函数名属于 runtime 内部实现细节业务代码只需要遵守 channel 的所有权和关闭规则。锁阻塞Mutex 竞争时不能立即获得锁的 goroutine 也会等待而不是一直占用 CPU 自旋。锁实现会维护等待者并在解锁时唤醒合适的 goroutine。var mu sync.Mutex mu.Lock() // 临界区 mu.Unlock()不要在锁的临界区里执行不必要的网络 I/O、磁盘 I/O 或长时间计算。锁持有时间越长等待队列越长调度器越难保持吞吐和延迟。time.Sleep 和定时器time.Sleep不会让 M 线程原地忙等。goroutine 会被挂起计时器到期后再被标记为可运行。package main import ( fmt time ) func main() { started : time.Now() time.Sleep(20 * time.Millisecond) fmt.Println(time.Since(started) 20*time.Millisecond) }不过 Sleep 只适合表达“至少等待一段时间”不适合表达“等另一个任务完成”。后者应该用 channel、WaitGroup 或 Context。系统调用和 netpoll系统调用不会简单等于 goroutine 阻塞网络服务器经常需要同时等待很多连接。如果每个连接都占用一个永久阻塞的操作系统线程线程数量会迅速膨胀。Go runtime 会把网络 I/O 接入 netpollergoroutine 调用网络 API │ ▼ fd 暂时不可读/写 │ ▼ G 被挂起M/P 可以运行其他 G │ ▼ OS 通过 epoll/kqueue/iocp 等机制通知就绪 │ ▼ netpoller 把 G 标记为 runnable具体底层机制由操作系统决定。业务代码不应该直接假设一定使用 epoll 或 kqueue而应该理解使用 Go net 包的网络 I/O 时runtime 会尽量把等待从“占用线程”转化成“等待事件”。为什么文件 I/O 的行为可能不同网络 FD 通常可以交给运行时网络轮询器但普通文件在不同系统上的异步能力和语义不同。文件读写可能通过系统线程池或直接系统调用完成不能把网络 I/O 的调度行为机械套到所有文件操作上。如果一个外部库通过 cgo 进入长时间阻塞的 C 调用runtime 也会把它视为可能阻塞的 M。此时应关注线程数量、P 是否能够继续运行以及是否应该把阻塞操作放到受控 worker 中。抢占与长时间运行的 goroutine早期的 Go 调度更依赖函数调用、channel 操作和显式安全点。如果某个 goroutine 在纯 Go 计算循环中很久不调用可能触发调度的操作其他 goroutine 可能得不到及时运行。现代 Go runtime 具备异步抢占能力。调度器和系统监控线程会识别运行时间过长的 G通过安全的抢占机制让它暂时让出执行权。下面的循环没有主动调用Gosched仍然应该让其他 goroutine 获得机会package main import ( fmt runtime time ) func main() { runtime.GOMAXPROCS(1) go func() { fmt.Println(second goroutine ran) }() deadline : time.Now().Add(30 * time.Millisecond) for time.Now().Before(deadline) { // 模拟长时间计算 } fmt.Println(main finished) }这不意味着可以放心写无限循环。抢占有延迟cgo、不可抢占的 runtime 区域和错误的自旋循环仍然可能造成问题。循环本身如果有明确的协作点可以主动检查 Context 或使用runtime.Gosched但不要把它当成锁或通信机制。用 schedtrace 观察调度器Go 官方 Diagnostics 文档支持通过GODEBUGschedtraceX打印调度摘要X 的单位是毫秒GODEBUGschedtrace1000 ./scheduler-demo可能看到类似输出SCHED 1004ms: gomaxprocs4 idleprocs0 threads11 spinningthreads1 idlethreads4 runqueue8 [0 1 0 3]可以这样理解字段含义gomaxprocs当前 P 的数量idleprocs没有执行用户 Go 代码的 P 数量threadsruntime 创建过的 M/线程数量统计idlethreads当前空闲线程数量runqueue全局 runnable queue 长度[0 1 0 3]每个 P 的本地 runnable queue 长度数组长度通常与GOMAXPROCS对应。trace 只是一个时间点的快照不能单独证明某个 goroutine 一直被饿死要研究延迟还需要执行追踪或 profile。运行实验保存上面的 CPU 任务为scheduler-demo.go然后执行go run scheduler-demo.go GODEBUGschedtrace200 GOMAXPROCS2 go run scheduler-demo.gogo run本身也会启动编译相关进程所以如果想只观察目标程序可以先构建go build -o scheduler-demo scheduler-demo.go GODEBUGschedtrace200 GOMAXPROCS2 ./scheduler-demo使用 execution trace调度摘要适合快速观察execution trace 适合看完整事件时间线package main import ( os runtime/trace sync ) func busy(iterations int) { value : 0 for i : 0; i iterations; i { value i % 7 } if value -1 { panic(unreachable) } } func main() { file, err : os.Create(trace.out) if err ! nil { panic(err) } defer file.Close() if err : trace.Start(file); err ! nil { panic(err) } defer trace.Stop() var wg sync.WaitGroup for i : 0; i 4; i { wg.Add(1) go func() { defer wg.Done() busy(10_000_000) }() } wg.Wait() }运行后使用go tool trace trace.outexecution trace 可以帮助回答goroutine 是运行、阻塞还是被抢占P 是否长期空闲网络轮询是否造成延迟GC、系统调用和调度等待各占用多少时间。官方文档建议把 trace 用于理解延迟和利用率用 CPU profile、heap profile 等工具分析热点和内存成本。不同诊断工具会互相影响最好分开采集。调度器与业务代码的边界调度器会自动完成很多工作但业务代码仍然必须做好这些事情不要依赖 goroutine 的执行顺序不要用time.Sleep代替同步通过 channel 或锁建立明确的 happens-before控制 goroutine 的生命周期避免无人负责退出CPU 密集型任务设置合理 worker 数量不要无上限启动 goroutine网络和 I/O 任务关注超时、取消和结果消费用 trace、pprof 和 race detector 验证猜测不要凭日志顺序推断调度器行为。常见误区误区一GOMAXPROCS 就是 goroutine 数量上限不是。它限制 P 的数量也就是并行执行用户 Go 代码的上限。goroutine 可以远大于 P等待 I/O 的 goroutine 也不会持续占用 P。误区二goroutine 越多程序越快goroutine 创建很轻量但每个 goroutine 仍然需要栈、调度和通信成本。大量 goroutine 竞争同一个锁、同一个 channel 或下游资源时吞吐反而可能下降。误区三runtime.Gosched 可以解决同步问题不能。它只让出当前执行机会不传递数据、不等待条件也不能修复数据竞争。误区四看到线程数多就说明 goroutine 泄漏M 可能因为系统调用、cgo、网络轮询或 runtime 工作而增加。判断泄漏应该看 goroutine 数量、阻塞栈、生命周期和资源是否持续增长不能只看线程总数。误区五schedtrace 的一次快照就是结论一次输出只能说明某个时刻队列状态。要分析饥饿、尾延迟和调度等待需要连续 trace、profile 和可重复压测。总结本篇从 runtime 角度理解了 Go 并发G 是 goroutine保存要执行的代码和调度现场M 是操作系统线程可以执行 Go 代码、系统调用或 runtime 代码P 是执行用户 Go 代码所需的资源数量由GOMAXPROCS决定调度器的核心任务是把 runnable G 匹配到拥有 P 的 MG 通常先进入 P 的本地队列必要时进入全局队列空闲 P 可以通过 work stealing 从其他 P 获取 runnable Gchannel、锁、定时器和网络 I/O 会让 G 阻塞阻塞的 G 不应持续占用 CPUnetpoller 把网络事件转换成 goroutine 的唤醒sysmon 和抢占机制帮助长时间运行的 G 让出执行机会schedtrace适合观察队列摘要execution trace 适合分析事件时间线调度器会尽量提高吞吐但不会替业务代码决定任务所有权、取消和关闭顺序。真正理解 G/M/P 之后可以把 goroutine 看成“可调度的任务”把 P 看成“执行 Go 代码的资源许可”把 M 看成“承载执行的线程”。channel 阻塞时等待的是 GP 可以转去运行其他 G网络事件到来时netpoller 再把等待中的 G 放回调度器。官方资料Go runtime HACKINGScheduler structuresGo runtime 源码proc.goGo runtime 源码runtime2.goGo 官方文档DiagnosticsGo 官方文档runtime.GOMAXPROCSGo 官方文档runtime/traceGo 官方命令go tool traceGo 官方博客Concurrency is not parallelismGo 内存模型
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进