bigywater.tech
Go · 57

工作窃取:空闲的 P 怎么从别的 P 偷 goroutine

调度器怎么让所有 CPU 都有活干?一个 goroutine 创建的任务一开始都在它所在 P 的队列里。这篇不描述抽象的算法,而是用加速比、schedtrace 里每个 P 队列长度的变化,以及源码里的关键几行,看工作窃取到底是怎么发生的。

9.52 倍
GOMAXPROCS=10 时对 200 个任务的加速比(理想 10)
127
一个空闲 P 一次从别处拿到的 goroutine 数,约为队列的一半
774
1000 个任务一口气创建后,全局队列里的任务数

窃取让加速接近线性

实测 · 本机执行输出
  GOMAXPROCS= 1:[1000, 1000, 1000, 1000, 1000] ms(中位 1000 ms);相对 1 个 P 的加速比 1.00(理想 1)
  GOMAXPROCS= 2:[500, 500, 500, 500, 500] ms(中位 500 ms);相对 1 个 P 的加速比 2.00(理想 2)
  GOMAXPROCS= 4:[250, 250, 250, 250, 250] ms(中位 250 ms);相对 1 个 P 的加速比 4.00(理想 4)
  GOMAXPROCS= 8:[125, 125, 125, 125, 126] ms(中位 125 ms);相对 1 个 P 的加速比 8.00(理想 8)
  GOMAXPROCS=10:[100, 100, 105, 105, 105] ms(中位 105 ms);相对 1 个 P 的加速比 9.52(理想 10)

200 个任务全部由同一个 goroutine 创建,所以起初都在它那个 P 的本地队列(放不下的进全局队列)。如果没有窃取,其它 P 就没有活可干,加速比会是 1。实测 P=2、4、8 的加速比正好是 2.00、4.00、8.00,P=10 是 9.52(开销与任务边界的影响),说明工作被均匀地分到了所有 P 上。

队列长度的变化

实测 · 本机执行输出
  共 64 个采样点;挑选其中变化有代表性的几个(时间 ms | idleprocs | spinningthreads | 全局队列 runqueue | 各 P 本地队列 runqsize):
      0 ms | idle=1 | spinning=1 | 全局    0 | 本地 [0, 0, 0, 0]
      1 ms | idle=0 | spinning=0 | 全局  774 | 本地 [205, 0, 3, 14]
      6 ms | idle=0 | spinning=0 | 全局  646 | 本地 [205, 127, 1, 12]
     12 ms | idle=0 | spinning=0 | 全局  519 | 本地 [205, 125, 127, 10]
     18 ms | idle=0 | spinning=0 | 全局  519 | 本地 [203, 123, 125, 8]
     23 ms | idle=0 | spinning=0 | 全局  519 | 本地 [202, 122, 124, 7]
     29 ms | idle=0 | spinning=0 | 全局  519 | 本地 [200, 120, 122, 5]
     35 ms | idle=0 | spinning=0 | 全局  519 | 本地 [198, 118, 120, 3]
     41 ms | idle=0 | spinning=0 | 全局  519 | 本地 [196, 116, 118, 1]
     47 ms | idle=0 | spinning=0 | 全局  391 | 本地 [194, 114, 116, 127]
  所有采样里单个 P 本地队列的最大长度:205
  全局队列的最大长度:774

schedtrace 每毫秒打印一次各 P 的 runqsize 与全局 runqueue:一开始只有 P0 有活,本地队列装满后溢出到全局队列(774),其它 P 一旦空闲就开始拿——可以看到一次拿走 127 个左右,正好是“拿一半”。注意这是调度器的实际快照,我没有逐个 goroutine 追踪它们的迁移路径。

源码里的规则

实测 · 本机执行输出
  proc.go:7710-7725
    // Batch is a ring buffer starting at batchHead.
    // Returns number of grabbed goroutines.
    // Can be executed by any P.
    func runqgrab(pp *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 {
    	for {
    		h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with other consumers
    		t := atomic.LoadAcq(&pp.runqtail) // load-acquire, synchronize with the producer
    		n := t - h
    		n = n - n/2
    		if n == 0 {
    			if stealRunNextG {
    				// Try to steal from pp.runnext.
    				if next := pp.runnext; next != 0 {
    					if pp.status == _Prunning {
  proc.go:7715-7721
    		h := atomic.LoadAcq(&pp.runqhead) // load-acquire, synchronize with other consumers
    		t := atomic.LoadAcq(&pp.runqtail) // load-acquire, synchronize with the producer
    		n := t - h
    		n = n - n/2
    		if n == 0 {
    			if stealRunNextG {
    				// Try to steal from pp.runnext.
  proc.go:3848-3848
    	const stealTries = 4
  proc.go:3847-3854
    
    	const stealTries = 4
    	for i := 0; i < stealTries; i++ {
    		stealTimersOrRunNextG := i == stealTries-1
    
    		for enum := stealOrder.start(cheaprand()); !enum.done(); enum.next() {
    			if sched.gcwaiting.Load() {
    				// GC work may be available.
  proc.go:3491-3491
    		gp, q := globrunqgetbatch(int32(len(pp.runq)) / 2)

runqgrab 里 n = n - n/2 就是“偷一半”(奇数时多拿一个);窃取最多尝试 4 轮,并且只在最后一轮才去偷对方的 runnext(刚创建、预期马上运行的那个 goroutine)和定时器——这样尽量不打乱对方的局部性。本地队列用完时,P 也会从全局队列成批取。摘录的只是几行关键代码,没有展开整个 findRunnable 的流程。

分步演示

工作窃取
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。任务是纯 CPU 的忙等循环(每个 5 ms 或 3 ms);加速比是 5 次中位数的墙钟时间;schedtrace 来自 GODEBUG=schedtrace=1,scheddetail=1 的原样输出并解析 runqsize 与 runqueue;读过的源码:runtime/proc.go 的 runqgrab 与 stealWork
    • 没有验证:runnext 的窃取时机、本地队列溢出到全局队列的具体规则(只从 schedtrace 的数字推断“放不下的进全局”)、Linux 上的结果、有 I/O 或阻塞的负载下的窃取行为、GOMAXPROCS 大于核数时的表现、spinning 线程数对窃取延迟的影响
    京ICP备2026067502号-1京公网安备11010502063487号