部署 Go 服务时,经常能看到这样的启动参数:

GOMAXPROCS=2 ./server

很多开发者把它理解成“限制程序只能使用两个 CPU”,也有人习惯直接调用 runtime.GOMAXPROCS(runtime.NumCPU())。但在容器环境里,宿主机可能有 64 个逻辑 CPU,而容器只被分配 2 核额度。如果 Go 运行时仍按 64 设置并行度,程序就可能频繁触发 CPU 限流,最终表现为接口尾延迟突然升高。

那么,GOMAXPROCS 到底控制什么?升级到新版 Go 后,还需要手动设置吗?

它限制的不是 goroutine 数量

GOMAXPROCS 控制的是:同一时刻最多允许多少个操作系统线程执行用户态 Go 代码。

假设程序创建了 1000 个 goroutine,GOMAXPROCS 设置为 4,并不意味着只能启动 4 个 goroutine。运行时仍会管理全部 goroutine,只是同一时刻最多让 4 个线程并行执行 Go 代码。某个 goroutine 阻塞、休眠或等待网络时,调度器可以换上其他可运行的 goroutine。

可以通过下面的代码查看当前值:

package main

import (
    "fmt"
    "runtime"
)

func main() {
    fmt.Println(runtime.GOMAXPROCS(0))
}

runtime.GOMAXPROCS 传入小于 1 的值时,不会修改配置,而是返回当前设置。

需要注意,阻塞在系统调用中的线程不受这个数量直接限制。因此,GOMAXPROCS=4 不能简单理解成“整个进程最多有 4 个线程”,更不能用它代替连接池、任务队列或业务并发控制。

为什么容器里容易设置错

在 Go 1.24 及更早版本中,默认值是程序启动时 runtime.NumCPU() 返回的逻辑 CPU 数量。问题在于,容器看到的逻辑 CPU 数量和实际能持续使用的 CPU 配额可能完全不同。

例如,一台 Kubernetes 节点有 64 个逻辑 CPU,某个 Pod 的 CPU limit 是 2:

resources:
  limits:
    cpu: "2"

旧版 Go 程序可能把 GOMAXPROCS 设置为 64。当大量 goroutine 同时进行计算,进程会迅速用完 cgroup 在一个周期内允许使用的 CPU 时间,随后被 Linux 暂停到下一个周期。

这种 throttling 并不是普通的线程调度。进程可能在一段时间内整体无法继续执行,垃圾回收和请求处理也会受到影响。平均延迟看起来未必明显,P99、P999 等尾延迟却可能出现尖刺。

这也是过去不少容器项目引入 go.uber.org/automaxprocs 的原因:在程序启动时读取 cgroup CPU quota,再把 GOMAXPROCS 调整到更合理的值。

Go 1.25 改变了默认行为

Go 1.25 引入了容器感知的 GOMAXPROCS 默认值,Go 1.26 继续沿用这套机制。在 Linux 上,没有手动指定 GOMAXPROCS 时,运行时会综合考虑:

  • 机器的逻辑 CPU 数量
  • 当前进程的 CPU 亲和性掩码
  • cgroup 配置的 CPU quota

运行时取三者中的最小值。CPU quota 如果是 2.5 核,会向上取整为 3,以便充分利用配额。仅由 cgroup quota 计算出的默认值不会低于 2;如果逻辑 CPU 数量或 CPU 亲和性掩码只允许使用 1 个 CPU,结果仍可以是 1。

在所有操作系统上,运行时都会周期性检查可用逻辑 CPU 和 CPU 亲和性掩码的变化;在 Linux 上还会检查 cgroup quota。检查频率不会超过每秒一次,程序空闲时可能更低。

但这里有两个容易忽略的前提。

第一,cgroup quota 在 Kubernetes 中通常对应 CPU limit,不是 CPU request。如果 Pod 只配置了 request,没有配置 limit,运行时不会用 request 计算 GOMAXPROCS,因为 request 表示调度和资源保障,不是硬性 CPU 上限。

第二,新行为与模块声明的 Go 语言版本有关。项目应在 go.mod 中声明 Go 1.25.0 或更高版本:

module example.com/server

go 1.25.0

只更换编译器,却继续保留 go 1.24 或更低的语言版本声明,容器感知和自动更新默认处于关闭状态。这是 Go 通过 GODEBUG 保持兼容性的机制。

手动设置可能关闭自动更新

下面两种操作都会告诉运行时“开发者已经明确指定”,从而关闭默认的周期性更新:

GOMAXPROCS=4 ./server

或者在代码中调用:

func init() {
    runtime.GOMAXPROCS(4)
}

因此,不建议为了“保险”而在所有项目里固定调用 runtime.GOMAXPROCS(runtime.NumCPU())。这段代码不仅大多是多余的,还可能覆盖新版运行时根据容器配额得到的正确结果。

即使没有手动设置,也可以在 Linux 上通过 GODEBUG=containermaxprocs=0 关闭 cgroup 感知,或在所有操作系统上通过 GODEBUG=updatemaxprocs=0 关闭周期性更新。除非需要兼容旧行为或进行对照测试,否则一般不建议修改这两个开关。

如果程序曾手动修改过并行度,后来希望恢复运行时默认值,可以在 Go 1.25 及以上版本调用:

func restoreDefault() {
    runtime.SetDefaultGOMAXPROCS()
}

它会重新计算默认值,并恢复后续自动更新能力。这个函数也适合程序明确知道 CPU 亲和性掩码或 cgroup quota 刚刚发生变化、希望立即刷新配置的场景。

哪些场景仍值得手动调整

对使用 Go 1.25 及以上工具链、将模块语言版本声明为 Go 1.25.0 或更高,并设置了 CPU limit 的普通容器服务,优先使用运行时默认值即可。手动设置应该建立在测量结果上,而不是形成固定模板。

以下场景仍可能需要显式配置:

  • 程序仍运行在 Go 1.24 或更早版本
  • 容器只有 CPU request,没有 CPU limit
  • 希望主动为同机其他进程预留 CPU
  • CPU 密集任务经过基准测试后,需要限制并行度
  • 极短的突发计算希望临时利用超过平均 quota 的并行能力
  • 排查调度、GC 或尾延迟问题时,需要做对照实验

尤其要注意,CPU limit 是一段时间窗口内允许使用的平均 CPU 吞吐额度,GOMAXPROCS 则是同一时刻的并行度限制,两者并不完全等价。某些突发型任务可能从更高并行度获益,也可能因此更快触发 cgroup throttling。最终应该通过真实流量或可信压测观察吞吐量、P99 延迟、CPU throttling 和 GC 指标。

可以将当前值暴露到启动日志或监控中:

func logRuntimeConfig() {
    slog.Info("runtime config",
        "gomaxprocs", runtime.GOMAXPROCS(0),
        "num_cpu", runtime.NumCPU(),
    )
}

如果两者不同,不一定是异常。在新版 Go 中,这很可能说明运行时根据 CPU 亲和性掩码或容器 quota 主动降低了并行度。

写在最后

GOMAXPROCS 限制的是 Go 代码的执行并行度,不是 goroutine 总数,也不是进程线程总数。设置过高可能让受限容器频繁遭遇 CPU throttling,设置过低则可能浪费可用算力。

对于使用 Go 1.25 及以上工具链,并将模块语言版本声明为 Go 1.25.0 或更高的项目,应优先信任容器感知的默认行为,不要习惯性调用 runtime.GOMAXPROCS(runtime.NumCPU())。旧版 Go、没有 CPU limit 或存在特殊延迟目标时,再结合基准测试和生产指标手动调整。

真正可靠的原则不是“永远手动设置”或“永远不用设置”,而是先理解部署环境给了多少 CPU,再让并行度与实际资源约束相匹配。