「容器里 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 限额 | GOMAXPROCS | NumCPU | 8 任务墙钟区间 |
|---|---|---|---|
| 无限制 | 6 | 6 | 0.44~0.49s |
| 2 | 2 | 6 | 1.12~1.18s |
| 4 | 4 | 6 | 0.63~1.01s |
| 1.5 | 2(向上取整) | 6 | 1.50s |
2 + containermaxprocs=0 | 6 | 6 | 1.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 核) | 知道会被向上取整为 2 | adjustCgroupGOMAXPROCS 的 ceil |
| 需要临时回到旧行为 | GODEBUG=containermaxprocs=0 | 仅在排查时用,不作为长期配置 |
| 怀疑没生效 | 打印 runtime.GOMAXPROCS(0) 与 runtime.NumCPU() 对比 | 两者不等说明容器感知在工作 |
三条结论:
- 不要手动设
GOMAXPROCS。 容器感知已经做了正确的事;手动设值大概率是「设成NumCPU」,反而绕过了限额。 GOMAXPROCS < NumCPU是正常的,不是 bug。 在容器里看到这个差值,说明容器感知在按 cgroup 限额工作。- 限额内的核才是真核。
NumCPU报的是容器可见的 CPU 数(sched_getaffinity),不反映 cgroup 配额;判断真实可用并行度应看GOMAXPROCS。
阅读导航:上一节:2.3 抢占式调度与 sysmon · 下一节:3.2 runtime/trace 解读 goroutine 时间线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。