《Go 语言运行时原理》3.1 GOMAXPROCS 与容器感知实测

容器里 Go 程序只用了限额一半的 CPU,是高频困惑。本节在真实 Docker 容器做矩阵实验:cgroup 限额 2/4/1.5 核时 GOMAXPROCS 各取多少,关掉 containermaxprocs 后吞吐如何变化;再定位到 runtime/cgroup_linux.go:defaultGOMAXPROCS 与 adjustCgroupGOMAXPROCS。

「容器里 Go 程序只用了限额一半的 CPU」是一个高频困惑:runtime.NumCPU() 报 6,但 cgroup 只给了 2 核,于是 Go 按 6 建了 6 个 P,其中 4 个被 cgroup 节流,白交上下文切换的学费。Go 1.25 起引入了容器感知的 GOMAXPROCS,到 1.27 已成为默认行为。本节用真实容器把它测出来。站内 Go 专题(/posts/golang/)已经讲过 GOMAXPROCS 的基本语义与它与 G/M/P 的关系,本节不复述,只写增量——真实 Docker 容器的限额矩阵,以及 cgroup_linux.go 里容器感知的源码定位。

本节要回答:容器 CPU 限额下,GOMAXPROCS 实际取多少,关掉容器感知会怎样? 结论:Go 1.27 默认按 cgroup 限额设 GOMAXPROCS(限额 2 核→GOMAXPROCS=2、限额 1.5 核→向上取整为 2、限额 4 核→4);关掉后 GOMAXPROCS 回到 NumCPU,吞吐不会变好反而更差,因为真正的限制器是 cgroup 而不是 P 的数量。

3.1.1 实验:容器 CPU 限额矩阵

实验用一个打印 GOMAXPROCS/NumCPU 并跑 8 个独立 CPU 任务的程序,在 golang:1.27-alpine 镜像里运行。先确认镜像与容器环境:

docker info --format '{{.Architecture}} {{.OSType}} {{.NCPU}} {{.MemTotal}}'
docker images --format '{{.Repository}}:{{.Tag}}' | grep golang
aarch64 linux 6 10403864576
golang:1.27-alpine

Docker VM 是 aarch64、6 核、约 10 GiB 内存。

先直接看一眼 cgroup 的 CPU 限额文件,这是 Go 判断「有几核可用」的原始输入:

docker run --rm --cpus=2 golang:1.27-alpine cat /sys/fs/cgroup/cpu.max
200000 100000

cgroup v2 的 cpu.max 格式是 <quota> <period>(单位微秒),200000/100000 = 2,即 2 个 CPU。这就是 --cpus=2 在容器内的真实表示,也是 Go 读到并据此设 GOMAXPROCS 的值。

探针程序:

func main() {
	fmt.Printf("GOMAXPROCS=%d NumCPU=%d\n", runtime.GOMAXPROCS(0), runtime.NumCPU())
	const tasks = 8
	start := time.Now()
	done := make(chan uint64, tasks)
	for i := 0; i < tasks; i++ {
		go func() {
			var x uint64 = 1
			for j := 0; j < 200_000_000; j++ {
				x = x*6364136223846793005 + 1442695040888963407
			}
			done <- x
		}()
	}
	for i := 0; i < tasks; i++ {
		<-done
	}
	fmt.Printf("8 tasks wall=%.3fs\n", time.Since(start).Seconds())
}

按不同 --cpus 限额运行(全部 --rm,各跑 2~3 次取区间):

run() { docker run --rm "$@" -v "$HOME/gbrt1_docker":/w -w /w golang:1.27-alpine sh -c 'go run . 2>/dev/null'; }
for i in 1 2 3; do run; done
for i in 1 2 3; do run --cpus=2; done
for i in 1 2;   do run --cpus=4; done
for i in 1 2;   do run --cpus=1.5; done
for i in 1 2 3; do run --cpus=2 -e GODEBUG=containermaxprocs=0; done
GOMAXPROCS=6 NumCPU=6
8 tasks wall=0.454s
GOMAXPROCS=6 NumCPU=6
8 tasks wall=0.488s
GOMAXPROCS=6 NumCPU=6
8 tasks wall=0.439s
GOMAXPROCS=2 NumCPU=6
8 tasks wall=1.182s
GOMAXPROCS=2 NumCPU=6
8 tasks wall=1.152s
GOMAXPROCS=2 NumCPU=6
8 tasks wall=1.120s
GOMAXPROCS=4 NumCPU=6
8 tasks wall=0.631s
GOMAXPROCS=4 NumCPU=6
8 tasks wall=1.013s
GOMAXPROCS=2 NumCPU=6
8 tasks wall=1.504s
GOMAXPROCS=2 NumCPU=6
8 tasks wall=1.496s
GOMAXPROCS=6 NumCPU=6
8 tasks wall=1.534s
GOMAXPROCS=6 NumCPU=6
8 tasks wall=1.127s
GOMAXPROCS=6 NumCPU=6
8 tasks wall=1.259s

把结果整理成表:

--cpus 限额GOMAXPROCSNumCPU8 任务墙钟区间
无限制660.44~0.49s
2261.12~1.18s
4460.63~1.01s
1.52(向上取整)61.50s
2 + containermaxprocs=0661.13~1.53s

四条结论:

  • GOMAXPROCS 跟着 cgroup 限额走:限额 2 核→2、限额 4 核→4。NumCPU 始终报 6(容器可见的 CPU 数),但 GOMAXPROCS 被 cgroup 拉低了。
  • 小数限额向上取整:--cpus=1.5 得到 GOMAXPROCS=2。
  • 关掉容器感知后 GOMAXPROCS 回到 6(等于 NumCPU),符合 containermaxprocs=0 的语义。
  • 但吞吐没变好:限额 2 核时,无论 GOMAXPROCS=2 还是 6,墙钟都在 1.1~1.5s 区间——因为真正的限制器是 cgroup 的 CPU 配额,把 P 开到 6 只是让 6 个 P 抢 2 核的配额,多出来的 4 个 P 在被节流时白白增加调度开销。

复现基线:容器内 Go 版本 go1.27.2 linux/arm64(golang:1.27-alpine 镜像自带的补丁版本是 1.27.2,非主线 1.27.0,这是镜像的版本,如实标注);Docker Engine 29.5.2(colima),VM 为 aarch64 / 6 核 / 10 GiB。宿主为 Apple M1 Pro / 10 逻辑核 / 32 GiB。限额通过 docker run --cpus=N 设置,容器全部 --rm,未执行 docker pull(镜像已缓存)。每组重复 2~3 次给区间。

3.1.2 源码:容器感知是怎么实现的

开关的默认值。 容器感知由 GODEBUG=containermaxprocs 控制,它在 src/runtime/runtime1.go:362 注册,默认值为 1(开启):

// src/runtime/runtime1.go:362
{name: "containermaxprocs", value: &debug.containermaxprocs, def: 1},

这个默认值不是一开始就有的。它的变更记录在 src/internal/godebugs/table.go:30:

// src/internal/godebugs/table.go:30
{Name: "containermaxprocs", Package: "runtime", Changed: 25, Old: "0"},

Changed: 25, Old: "0" 的意思是:从 Go 1.25 起默认开启,1.25 之前默认关闭。所以如果你的代码要兼容更老的版本,不能假设容器感知一定生效。

实现位置。 核心在 src/runtime/cgroup_linux.go(Linux 专属;其他平台用 cgroup_stubs.go 的空实现)。读取限额的底层在 src/internal/runtime/cgroup/cgroup_linux.go,ReadCPULimit 同时支持 cgroup v1 和 v2:

// src/internal/runtime/cgroup/cgroup_linux.go:118(片段)
func ReadCPULimit(c CPU) (float64, bool, error) {
	switch c.version {
	case 1:
		quota, err := readV1Number(c.quotaFD)
		if err != nil {
			return 0, false, errMalformedFile
		}
		if quota < 0 {
			return 0, false, nil // No limit.
		}
		period, err := readV1Number(c.periodFD)
		if err != nil {
			return 0, false, errMalformedFile
		}
		return float64(quota) / float64(period), true, nil
	case 2:
		// quotaFD is the cpu.max FD.
		return readV2Limit(c.quotaFD)
	...
	}
}

v1 读 cpu.cfs_quota_us/cpu.cfs_period_us 两个文件相除,v2 读单个 cpu.max。两者最终都归约成「几个 CPU」这个浮点数,交给 adjustCgroupGOMAXPROCS 取整。

默认值的计算在 defaultGOMAXPROCS:

// src/runtime/cgroup_linux.go:85
func defaultGOMAXPROCS(ncpu int32) int32 {
	procs := ncpu
	if procs <= 0 {
		procs = getCPUCount()
	}
	if !cgroupOK {
		// No cgroup, or disabled by debug.containermaxprocs.
		return procs
	}
	return adjustCgroupGOMAXPROCS(procs, cgroupCPU)
}

读法:先取 getCPUCount()(Linux 上是 sched_getaffinity 得到的 CPU 数),若 cgroup 可用再用限额压低它。压低逻辑在 adjustCgroupGOMAXPROCS:

// src/runtime/cgroup_linux.go:109
func adjustCgroupGOMAXPROCS(procs int32, cpu cgroup.CPU) int32 {
	limit, ok, err := cgroup.ReadCPULimit(cpu)
	if err == nil && ok {
		limit = ceil(limit)   // 向上取整:1.5 -> 2
		limit = max(limit, 2) // 下限 2
		if int32(limit) < procs {
			procs = int32(limit)
		}
	}
	return procs
}

这两行正是实验现象的源码解释:ceil(limit) 让 --cpus=1.5 变成 2,max(limit, 2) 保证即使限额 1 核也不会把 GOMAXPROCS 压到 1。取的是 min(CPU 数, 限额),所以限额比 CPU 数大时不起作用。

启动时的接入点。 这套逻辑在 src/runtime/proc.go:930 附近被调用:

// src/runtime/proc.go:930(片段)
defaultGOMAXPROCSInit()
...
if n, err := strconv.ParseInt(gogetenv("GOMAXPROCS"), 10, 32); err == nil && n > 0 {
	procs = int32(n)
	sched.customGOMAXPROCS = true
} else {
	procs = defaultGOMAXPROCS(numCPUStartup)
}

注意优先级:显式设的 GOMAXPROCS 环境变量 > 容器感知的默认值。也就是说,如果你手动 GOMAXPROCS=8,容器感知就被绕过了——这正是很多人「设了环境变量反而更慢」的原因。

3.1.3 决策:容器里该不该动 GOMAXPROCS

场景建议理由
普通容器、无特殊需求什么都不做,让容器感知生效Go 1.25+ 已自动按 cgroup 限额设好
手动设了 GOMAXPROCS=NumCPU删掉它会绕过容器感知,把 P 开超过限额
限额是小数(如 1.5 核)知道会被向上取整为 2adjustCgroupGOMAXPROCS 的 ceil
需要临时回到旧行为GODEBUG=containermaxprocs=0仅在排查时用,不作为长期配置
怀疑没生效打印 runtime.GOMAXPROCS(0) 与 runtime.NumCPU() 对比两者不等说明容器感知在工作

三条结论:

  1. 不要手动设 GOMAXPROCS。 容器感知已经做了正确的事;手动设值大概率是「设成 NumCPU」,反而绕过了限额。
  2. GOMAXPROCS < NumCPU 是正常的,不是 bug。 在容器里看到这个差值,说明容器感知在按 cgroup 限额工作。
  3. 限额内的核才是真核。 NumCPU 报的是容器可见的 CPU 数(sched_getaffinity),不反映 cgroup 配额;判断真实可用并行度应看 GOMAXPROCS。

阅读导航:上一节:2.3 抢占式调度与 sysmon · 下一节:3.2 runtime/trace 解读 goroutine 时间线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

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