一个 Pod 明明只给了 2 核,Go 服务却会突然卡住几十毫秒。问题往往不是 GC 太慢,而是 Go 1.24 及更早版本默认按宿主机的逻辑 CPU 数设置 GOMAXPROCS。容器跑在 64 核节点上,即使 CPU limit 只有 2 核,运行时仍可能让 64 个线程同时执行 Go 代码。
这和 cgroup 的限制方式正好撞上了。2 核 limit 通常表示每 100ms 可以消耗 200ms CPU 时间,并不表示进程只能占用两个逻辑 CPU。假设 64 个线程同时跑满,它们理论上只需 3.125ms 就能花完这笔额度,随后可能被暂停到下一个周期。这个计算是最坏情况推演,但它解释了一个常见现象:平均 CPU 看着不高,p99 却周期性冒尖。
Go 1.25 改了默认值。运行时会读取容器的 CPU quota,把 GOMAXPROCS 压到更接近实际额度的数字。
GOMAXPROCS 和 CPU limit 管不同东西#
GOMAXPROCS 是并行度上限。设为 8 时,最多有 8 个操作系统线程同时执行用户态 Go 代码。因系统调用而阻塞的线程不计入这个上限,进程的线程总数仍可超过 8。
放回 Go 调度器的 G-M-P 模型会更好理解。goroutine 是 G,操作系统线程是 M,P 保存运行 Go 代码需要的调度资源。GOMAXPROCS 主要决定 P 的数量。某个 M 卡在系统调用里时会交出 P,运行时可以让另一个 M 接手,因此线程数超过 GOMAXPROCS 是正常现象。受限的是同一时刻拿着 P 执行 Go 代码的线程数。
cgroup CPU limit 管的是吞吐额度。cgroup v2 用 cpu.max 表示 quota 和 period:
| |
这行配置给进程每 100ms 分配 250ms CPU 时间,也就是 2.5 核。容器可以短时间铺开更多线程,只要总消耗没有越过 quota;额度一旦用完,内核就会 throttle 整个 cgroup。Go 官方博客给出的典型 period 是 100ms,这种整段停顿对尾延迟很不友好,GC 的短时 CPU 峰值也可能触发它。
因此,直接把宿主机核数当成容器可用并行度并不靠谱。Go 1.25 选择了更保守的默认行为:少制造 CPU 尖峰,换取较稳定的请求延迟。
Go 1.25 怎么算默认值#
Linux 上的新默认值会比较三个数字:机器的逻辑 CPU 数、进程 CPU affinity 掩码里的 CPU 数、cgroup 的 quota / period。运行时通常取三者中的最小值;quota 是小数时向上取整,所以 2.5 核得到 GOMAXPROCS=3。
仅由 cgroup quota 得出的值一般不会低于 2,除非逻辑 CPU 数或 affinity 本身小于 2。给容器设置 500m limit,不代表默认值一定变成 1。这个下限属于实现细节,升级 Go 后最好重新测一次,不要把它写死在容量模型里。
运行时会周期性检查逻辑 CPU、affinity 和 quota 的变化,更新频率最高约为每秒一次;应用空闲时可能更慢。Kubernetes 原地调整 CPU limit 后,进程不必重启就能跟着改并行度。
Go 1.25 会把相关 cgroup 控制文件的描述符缓存到进程退出,轮询时直接读已经打开的文件。原地调整 limit 会改同一份文件,运行时下一次检查便能读到新值。
设置环境变量 GOMAXPROCS 或调用 runtime.GOMAXPROCS,都会把控制权交给应用并停掉自动更新。想交还控制权,调用 runtime.SetDefaultGOMAXPROCS() 即可恢复运行时默认值和后续更新。
用一个探针看出差别#
先把模块的语言版本设为 1.25。go 行会影响新默认值是否启用。
| |
下面这段程序先读取初始值,再手动改成 1,最后交还给运行时:
| |
我用带 2.5 核限制的 Go 1.25 容器跑了一次:
| |
实际输出如下。容器能看到宿主机的 16 个逻辑 CPU,但初始值和恢复值都是 3。
| |
只把 go.mod 改成 go 1.24.0,仍用同一个 Go 1.25.14 工具链,初始值会回到 16:
| |
Go 为旧模块保留了兼容行为。也可以用 GODEBUG=containermaxprocs=0 关闭容器感知,或用 GODEBUG=updatemaxprocs=0 只关掉周期更新。排查升级差异时这些开关有用,长期配置则应写清原因,否则半年后很难判断是谁接管了并行度。
Kubernetes 里 request 和 limit 别混了#
下面的 Pod 请求 0.5 核,最多使用 2.5 核:
| |
Go 1.25 读取的是 limit 对应的 cgroup bandwidth,不读取 request。request 主要参与调度和 CPU 竞争时的权重分配;没有 limit 时,Go 无法从 request 推导硬吞吐上限,默认值会继续受逻辑 CPU 数和 affinity 约束。
给所有 Pod 加 CPU limit 并非通用答案。limit 能换来更可预测的资源边界,也会阻止工作负载使用节点上的空闲 CPU。短突发任务如果想借空闲 CPU,可以不设 limit,再显式给 GOMAXPROCS 一个经过压测的值;持续吃 CPU、看重 p99 的服务,容器感知默认值通常更省心。
尾延迟要和 throttle 一起看#
先记录应用启动时的 runtime.GOMAXPROCS(0),再看 cgroup v2 的 cpu.stat。nr_periods 是已经历的带宽周期数,nr_throttled 是发生过限流的周期数,throttled_usec 是累计暂停时间。只看平均 CPU 使用率会漏掉这些停顿。
监控时我更关心一个窗口内的增量,而不是进程启动以来的累计值。比如 5 分钟内 nr_throttled / nr_periods 突然升高,并且 HTTP p99 同时抬头,就该把 quota、GOMAXPROCS 和 GC CPU 放到一张图上看。若 throttle 很少而运行队列持续堆积,问题可能只是 limit 给小了;若 throttle 很高但 CPU 利用率曲线并不扎眼,采样窗口大概率把 100ms 周期内的尖峰抹平了。
压测也不能只喂稳定流量。持续负载能检验吞吐,短脉冲负载才容易暴露新默认值的代价:GOMAXPROCS 向下收紧后,应用不再借用更多逻辑 CPU 快速处理瞬时积压。Go 官方博客也提醒了这种情况。延迟敏感服务应同时跑稳态和突发两组流量,再决定接受默认值还是手动覆盖。
Uber 的 automaxprocs README 给过一组 2 核 quota 的内部负载均衡器数据:GOMAXPROCS=2 时约 44,715 RPS、P99.9 为 26.38ms;默认值 24 时约 22,191 RPS、P99.9 为 76.19ms。这个比例不能直接套到其他服务上,但它记录了一个具体案例:并行度远高于 quota 时,吞吐和尾延迟一起变差。
我会把升级拆成两步。先把 go.mod 的版本改到 1.25 以上,在相同流量下对比 GOMAXPROCS、throttle 比例和 p99;确认新默认值合适后,再清理旧的手动设置或 automaxprocs 依赖。Go 1.25 修了危险的默认值。CPU limit 该不该存在,仍由应用负载决定。

