调度器怎么让所有 CPU 都有活干?一个 goroutine 创建的任务一开始都在它所在 P 的队列里。这篇不描述抽象的算法,而是用加速比、schedtrace 里每个 P 队列长度的变化,以及源码里的关键几行,看工作窃取到底是怎么发生的。
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
全局队列的最大长度:774schedtrace 每毫秒打印一次各 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 的流程。