《Go 语言运行时原理》3.3 调度相关的性能决策

GOMAXPROCS 该设几、channel 通信会不会因为核多而变慢,是调度层最常被问的两个问题。本节用 CPU 基准与 ping-pong 基准做核数扫描,给出「核数-吞吐」与「核数-通信延迟」两条真实曲线,再定位到 proc.go:schedule/findRunnable/wakep/stealWork。

前两节分别讲了「GOMAXPROCS 由谁决定」和「trace 怎么读」。这一节把两者收口到决策:核数到底该开多少?为什么有时核越多程序反而越慢?这些问题在调度层有确定的答案,而且能用一个基准测出来。

本节要回答:GOMAXPROCS 与核数如何影响吞吐和通信延迟,实际该怎么设? 结论:CPU 密集任务的吞吐随核数上升但次线性(1→2 核接近翻倍,8→10 核只有约 1.1 倍),而 channel 通信的延迟反而随核数上升(1 核约 210ns,多核约 250ns)。所以「核越多越好」只在计算密集且无频繁跨核通信时成立。与卷三 6.2/6.3 的分工:卷三讲 GC 参数取值与 runtime/metrics 指标清单,本节只写调度层的核数决策与队列源码,不重复参数调优与指标语义。

3.3.1 实验:核数扫描的两条曲线

实验分两组基准,都在同一台机器、同一份代码上跑,只改 -cpu=N(即 GOMAXPROCS=N)。

基准 A:CPU 密集吞吐。 每个任务做固定量的纯算术,用 b.RunParallel 铺满可用 P:

//go:noinline
func cpuWork(n int) uint64 {
	var x uint64 = 1
	for i := 0; i < n; i++ {
		x = x*6364136223846793005 + 1442695040888963407
	}
	return x
}

func BenchmarkCPU(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			cpuWork(20000)
		}
	})
}

基准 B:channel 往返延迟。 两个 goroutine 用两个无缓冲 channel 做乒乓,测一次往返的耗时:

func BenchmarkPingPong(b *testing.B) {
	ch1 := make(chan int)
	ch2 := make(chan int)
	go func() {
		for v := range ch1 {
			ch2 <- v + 1
		}
	}()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		ch1 <- i
		<-ch2
	}
}

两条命令各跑 -cpu=1,2,4,8,10、-count=3:

GOTOOLCHAIN=go1.27.0 go test -run='^$' -bench='BenchmarkCPU' -benchmem -cpu=1,2,4,8,10 -count=3
GOTOOLCHAIN=go1.27.0 go test -run='^$' -bench='BenchmarkPingPong' -benchmem -cpu=1,2,4,8,10 -count=3

基准 A 的原始输出:

BenchmarkCPU       	   48340	     24841 ns/op
BenchmarkCPU-2     	   93836	     12787 ns/op
BenchmarkCPU-4     	  182833	      6548 ns/op
BenchmarkCPU-8     	  360217	      3304 ns/op
BenchmarkCPU-10    	  413630	      2888 ns/op

基准 B 的原始输出:

BenchmarkPingPong       	 5342482	       215.7 ns/op
BenchmarkPingPong-2     	 4924790	       245.7 ns/op
BenchmarkPingPong-4     	 4854176	       247.3 ns/op
BenchmarkPingPong-8     	 4797584	       247.1 ns/op
BenchmarkPingPong-10    	 4816804	       252.2 ns/op

整理成表(ns/op 取三次的中位,加速比以 1 核为基准):

-cpu=NCPU 基准 ns/op相对 1 核加速比ping-pong ns/op
1248411.00x207.3~215.7
2127871.94x243.6~245.7
465483.79x246.7~247.9
833047.52x244.0~247.1
1028888.60x252.2~254.5

两条曲线给出四个结论:

  • CPU 吞吐次线性增长。 2 核接近翻倍(1.94x),4 核 3.79x,但到 8 核只有 7.52x、10 核 8.60x。原因是 P 的数量超过物理性能核后,调度开销、缓存争用、work stealing 的成本开始显现。M1 Pro 是 8 性能核 + 2 能效核,-cpu=10 时有两个 P 落在能效核上,单核性能更低,所以边际收益很小。
  • 通信延迟随核数上升。 ping-pong 在 1 核时最快(207216ns),2 核及以上升到 244254ns,多了约 35~40ns。原因是:单核时两个 goroutine 共享同一个 P 的本地队列,goready 直接把对方放到 runnext,几乎立即被调度;多核时对方在另一个 P 上,goready 要走 wakep/startm 唤醒路径,跨核通信成本更高。
  • 两者方向相反。 想让吞吐高就多开核,想让通信延迟低反而核越少越好——这是同一个调度器在不同负载下的两面,没有「全能配置」。
  • 拐点在性能核数量。 本机 8 性能核处加速比 7.52x(接近线性),再往上的 2 个能效核只贡献约 1.1x。设 GOMAXPROCS 时把物理性能核数当作软上限,比按 NumCPU 一刀切更合理。

复现基线:Go 1.27.0 darwin/arm64(GOTOOLCHAIN=go1.27.0);Apple M1 Pro,8 性能核 + 2 能效核共 10 逻辑核,32 GiB 内存;两个基准均 -count=3 取中位/区间,-benchmem 显示 0 allocs/op;-cpu=N 同时把 GOMAXPROCS 设为 N。

3.3.2 源码:一次调度要走哪几条路径

理解上面两条曲线,要回到调度循环本身。

调度主循环。 src/runtime/proc.go:4150 的 schedule 是每个 P 空闲时的入口,它调用 findRunnable 拿到下一个 G,然后 execute 它:

// src/runtime/proc.go:4150(片段)
func schedule() {
	mp := getg().m
	...
top:
	pp := mp.p.ptr()
	pp.preempt = false
	if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
		throw("schedule: spinning with local work")
	}
	gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
	pp = mp.p.ptr()
	...
}

schedule 本身几乎不做事,成本都在 findRunnable。CPU 基准里「核数上升、单核效率下降」的开销,主要就来自 findRunnable 里的队列查找与偷取。

找活的顺序。 src/runtime/proc.go:3404 的 findRunnable 按固定优先级找 G,顺序是:本地队列 → runnext → 全局队列 → netpoll → stealWork:

// src/runtime/proc.go:3404(片段)
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
top:
	pp := getg().m.p.ptr()
	...
	if gp, inheritTime := runqget(pp); gp != nil {
		return gp, inheritTime, false
	}
	if !sched.runq.empty() {
		lock(&sched.lock)
		gp := globrunqget()
		unlock(&sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}
	...
	if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 {
		if list, delta := netpoll(0); !list.empty() { // non-blocking
			...
		}
	}
	...
	gp, inheritTime, tnow, w, newWork := stealWork(now)
	...
}

读法:本地队列命中是零锁的(runqget 只动 P 的私有字段),全局队列要拿 sched.lock,netpoll 要进网络轮询,stealWork 要跨 P 偷。 前两条路径便宜,后两条贵——这就是「核越多、单核效率越低」的微观来源:P 多了,本地队列命中率下降,stealWork 被触发得更频繁。

唤醒一个 P。 当一个 G 变成可运行、而本地队列已被塞满(或当前没有空闲 P)时,会调用 src/runtime/proc.go:3227 的 wakep:

// src/runtime/proc.go:3227(片段)
func wakep() {
	// Be conservative about spinning threads, only start one if none exist already.
	if sched.nmspinning.Load() != 0 || !sched.nmspinning.CompareAndSwap(0, 1) {
		return
	}
	mp := acquirem()
	var pp *p
	lock(&sched.lock)
	pp, _ = pidlegetSpinning(0)
	if pp == nil {
		if sched.nmspinning.Add(-1) < 0 {
			throw("wakep: negative nmspinning")
		}
		unlock(&sched.lock)
		releasem(mp)
		return
	}
	unlock(&sched.lock)
	startm(pp, true, false)
	releasem(mp)
}

关键是那句注释:「保守地只启一个 spinning 线程」。sched.nmspinning 用 CAS 保证同时只有一个 M 在「找活」状态,避免所有核都忙着找活反而没人干活(thundering herd)。ping-pong 在多核下变慢,一部分成本就在这里:接收方在另一个 P 上,唤醒要走 wakep → startm(proc.go:3050)这条链,而不是单核时直接塞 runnext。

跨 P 偷取。 src/runtime/proc.go:3843 的 stealWork 会随机挑目标 P、偷它一半的本地队列:

// src/runtime/proc.go:3843(片段)
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
	pp := getg().m.p.ptr()
	ranTimer := false
	const stealTries = 4
	for i := 0; i < stealTries; i++ {
		for enum := stealOrder.start(cheaprand()); !enum.done(); enum.next() {
			...
			if gp, inheritTime := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
				return gp, inheritTime, now, pollUntil, ranTimer
			}
		}
	}
	...
}

stealTries = 4、每次随机起点,是为了在负载不均时尽快找到活干;但每一次尝试都要遍历其它 P 的队列,核数越多、遍历的候选越多,这是 CPU 基准在高核数处收益递减的另一个来源。stealOrder.start(cheaprand()) 里的 cheaprand 是运行时内联的伪随机(避免调 math/rand 的锁),说明偷取路径对性能极其敏感。

本地队列的两端。 runqput(proc.go:7529)把新 G 放本地队列,runqget(proc.go:7649)取。runqput 有个 next 参数:为真时放进 runnext 槽(最高优先级,下一个必被调度),这就是单核 ping-pong 快的原因——接收方被 goready 放进 runnext,当前 G 让出后立刻轮到它,无需跨核。

3.3.3 决策:核数与队列相关的取舍

场景建议理由
纯 CPU 计算、无跨核通信GOMAXPROCS = 物理性能核数超过性能核后边际收益骤降(实测 8→10 仅 1.1x)
高并发 channel 通信为主核数不必开满,优先减少跨核唤醒单核 ping-pong 比多核快约 35~40ns/次
容器环境不手动设,交给容器感知(见 3.1)手动设值会绕过 cgroup 限额
延迟敏感的乒乓式服务考虑用 runtime.LockOSThread 或批处理减少唤醒减少 wakep/startm 次数
观察到大量 stealWork说明本地队列命中率低、负载不均检查任务粒度,避免小任务 + 多核
单核跑基准反而更快正常现象,非 bug通信密集负载在单 P 上省掉跨核成本

三条结论:

  1. 核数不是越多越好,拐点在物理性能核数。 计算密集任务开到性能核数即可;能效核(或超线程)带来的边际收益很低,还可能因调度抖动伤害延迟。判断方法就是本节这套 -cpu 扫描——用自己程序的基准数据决定,而不是照抄 NumCPU。
  2. 通信密集负载要警惕「核数惩罚」。 channel 往返在多核下比单核慢约 15%~20%(实测 207→247ns),因为跨 P 唤醒要走 wakep。如果一个服务是大量小消息在 goroutine 间传递,把 GOMAXPROCS 开满可能适得其反——降低核数、增大任务粒度、用批处理减少往返,往往比加核更有效。
  3. 调度成本藏在 findRunnable 的路径顺序里。 本地队列 → 全局队列 → netpoll → steal 是成本递增的。想让调度便宜,就要提高本地队列命中率:任务粒度不要太细(避免频繁入队出队)、不要让所有 G 同时就绪去抢全局队列、把通信限制在少数 P 之间。理解了 runnext、wakep、stealWork 三个机制,就理解了 Go 调度器在「公平」与「低开销」之间的取舍。

一句话总结本章:GOMAXPROCS 由容器感知决定(3.1),调度行为用 trace 观测(3.2),核数与队列的取舍靠自己的基准数据决定(3.3)。 三者合起来,就是「调度相关的性能决策」的完整闭环。

阅读导航:上一节:3.2 runtime/trace 解读 goroutine 时间线 · 下一节:4.1 size class 与 mcache/mcentral/mheap 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练