Go 博客

容器感知的 GOMAXPROCS

Michael Pratt 和 Carlos Amedee
2025 年 8 月 20 日

Go 1.25 引入了新的容器感知 GOMAXPROCS 默认值,为许多容器工作负载提供了更合理的默认行为,避免了可能影响尾部延迟的节流,并提高了 Go 的开箱即用生产就绪性。在这篇文章中,我们将深入探讨 Go 如何调度 goroutine,这种调度如何与容器级 CPU 控制交互,以及 Go 如何通过感知容器 CPU 控制来更好地执行。

GOMAXPROCS

Go 的优势之一是其通过 goroutine 实现的内置且易于使用的并发性。从语义角度来看,goroutine 与操作系统线程非常相似,使我们能够编写简单的阻塞代码。另一方面,goroutine 比操作系统线程更轻量,这使得即时创建和销毁它们变得更加便宜。

虽然 Go 的实现可以将每个 goroutine 映射到一个专用的操作系统线程,但 Go 通过运行时调度器使线程具有可替代性,从而保持 goroutine 的轻量级。任何 Go 管理的线程都可以运行任何 goroutine,因此创建新的 goroutine 不需要创建新的线程,唤醒 goroutine 也不一定需要唤醒另一个线程。

话虽如此,伴随调度器而来的还有调度问题。例如,我们到底应该使用多少个线程来运行 goroutine?如果 1,000 个 goroutine 可运行,我们应该将它们调度到 1,000 个不同的线程上吗?

这就是 GOMAXPROCS 的用武之地。从语义上讲,GOMAXPROCS 告诉 Go 运行时 Go 应该使用的“可用并行度”。更具体地说,GOMAXPROCS 是同时运行 goroutine 的最大线程数。

因此,如果 GOMAXPROCS=8 并且有 1,000 个可运行的 goroutine,Go 将使用 8 个线程同时运行 8 个 goroutine。通常,goroutine 运行时间很短然后阻塞,此时 Go 将切换到在同一个线程上运行另一个 goroutine。Go 还会抢占那些不自行阻塞的 goroutine,确保所有 goroutine 都有机会运行。

从 Go 1.5 到 Go 1.24,GOMAXPROCS 默认设置为机器上的 CPU 核心总数。请注意,在这篇文章中,“核心”更精确地表示“逻辑 CPU”。例如,一台具有 4 个物理 CPU 并启用了超线程的机器有 8 个逻辑 CPU。

这通常是一个很好的“可用并行度”默认值,因为它自然地匹配了硬件的可用并行度。也就是说,如果有 8 个核心,Go 同时运行超过 8 个线程,操作系统将不得不将这些线程多路复用到 8 个核心上,就像 Go 将 goroutine 多路复用到线程上一样。这额外的调度层并非总是问题,但它是不必要的开销。

容器编排

Go 的另一个核心优势是通过容器部署应用程序的便利性,在容器编排平台中部署应用程序时,管理 Go 使用的核心数尤其重要。像 Kubernetes 这样的容器编排平台获取一组机器资源,并根据请求的资源在可用资源内调度容器。在集群资源中尽可能多地打包容器需要平台能够预测每个调度容器的资源使用情况。我们希望 Go 遵守容器编排平台设置的资源利用率限制。

让我们以 Kubernetes 为例,探讨 GOMAXPROCS 设置在 Kubernetes 环境中的影响。像 Kubernetes 这样的平台提供了一种机制来限制容器消耗的资源。Kubernetes 具有 CPU 资源限制的概念,它向底层操作系统发出信号,说明特定容器或一组容器将分配多少核心资源。设置 CPU 限制意味着创建 Linux 控制组 CPU 带宽限制。

在 Go 1.25 之前,Go 不了解编排平台设置的 CPU 限制。相反,它会将 GOMAXPROCS 设置为部署机器上的核心数。如果存在 CPU 限制,应用程序可能会尝试使用远超限制的 CPU。为了防止应用程序超出其限制,Linux 内核将 限制 应用程序。

节流是一种粗暴的机制,用于限制那些会超出其 CPU 限制的容器:它会在节流期剩余时间内完全暂停应用程序执行。节流期通常为 100 毫秒,因此与较低 GOMAXPROCS 设置的较柔和的调度多路复用效应相比,节流可能会对尾部延迟产生实质性影响。即使应用程序从未有过太多的并行度,Go 运行时执行的任务(例如垃圾回收)仍然可能导致 CPU 峰值,从而触发节流。

新默认值

我们希望 Go 在可能的情况下提供高效可靠的默认值,因此在 Go 1.25 中,我们已将 GOMAXPROCS 默认考虑其容器环境。如果 Go 进程在具有 CPU 限制的容器中运行,则 GOMAXPROCS 将默认为 CPU 限制(如果它小于核心数)。

容器编排系统可能会动态调整容器 CPU 限制,因此 Go 1.25 也会定期检查 CPU 限制,并在限制发生变化时自动调整 GOMAXPROCS

所有这些默认值仅在未另行指定 GOMAXPROCS 时适用。设置 GOMAXPROCS 环境变量或调用 runtime.GOMAXPROCS 的行为与以前一样。runtime.GOMAXPROCS 文档涵盖了新行为的详细信息。

略有不同的模型

GOMAXPROCS 和容器 CPU 限制都对进程可以使用的最大 CPU 量设置了限制,但它们的模型略有不同。

GOMAXPROCS 是一个并行度限制。如果 GOMAXPROCS=8,Go 将永远不会同时运行超过 8 个 goroutine。

相比之下,CPU 限制是吞吐量限制。也就是说,它们限制了在某个实际时间段内使用的总 CPU 时间。默认周期为 100 毫秒。因此,“8 CPU 限制”实际上是每 100 毫秒实际时间限制 800 毫秒的 CPU 时间。

这个限制可以通过在整个 100 毫秒内连续运行 8 个线程来满足,这等同于 GOMAXPROCS=8。另一方面,这个限制也可以通过运行 16 个线程,每个线程运行 50 毫秒,而每个线程在另外 50 毫秒内处于空闲或阻塞状态来满足。

换句话说,CPU 限制不限制容器可以运行的总 CPU 数量。它只限制总 CPU 时间。

大多数应用程序在 100 毫秒周期内 CPU 使用率相当一致,因此新的 GOMAXPROCS 默认值与 CPU 限制非常匹配,肯定比总核心数更好!然而,值得注意的是,由于 GOMAXPROCS 阻止了超出 CPU 限制平均值的额外线程的短暂峰值,特别是在峰值工作负载中,这种变化可能会导致延迟增加。

此外,由于 CPU 限制是吞吐量限制,它们可以具有小数部分(例如 2.5 CPU)。另一方面,GOMAXPROCS 必须是正整数。因此,Go 必须将限制四舍五入到有效的 GOMAXPROCS 值。Go 总是向上取整,以充分利用 CPU 限制。

CPU 请求

Go 的新 GOMAXPROCS 默认值基于容器的 CPU 限制,但容器编排系统也提供“CPU 请求”控制。CPU 限制指定容器可能使用的最大 CPU,而 CPU 请求指定容器在任何时候都保证可用的最小 CPU。

通常会创建具有 CPU 请求但没有 CPU 限制的容器,因为这允许容器利用超出 CPU 请求的机器 CPU 资源,这些资源否则会因为其他容器缺乏负载而空闲。不幸的是,这意味着 Go 无法根据 CPU 请求设置 GOMAXPROCS,这会阻止利用额外的空闲资源。

如果机器繁忙,具有 CPU 请求的容器在超出其请求时仍然会受到 约束。超出请求的基于权重的约束比 CPU 限制的基于硬时间段的节流“更柔和”,但高 GOMAXPROCS 引起的 CPU 峰值仍然可能对应用程序行为产生不利影响。

我应该设置 CPU 限制吗?

我们已经了解了 GOMAXPROCS 过高导致的问题,以及设置容器 CPU 限制允许 Go 自动设置适当的 GOMAXPROCS,因此下一步自然会想到所有容器是否都应该设置 CPU 限制。

虽然这可能是自动获得合理 GOMAXPROCS 默认值的好建议,但在决定是否设置 CPU 限制时还有许多其他因素需要考虑,例如通过避免限制来优先利用空闲资源,或者通过设置限制来优先实现可预测的延迟。

GOMAXPROCS 和有效 CPU 限制之间不匹配的最糟糕行为发生在 GOMAXPROCS 显著高于有效 CPU 限制时。例如,一个接收 2 个 CPU 的小型容器运行在 128 核机器上。在这些情况下,考虑设置明确的 CPU 限制,或者明确设置 GOMAXPROCS 是最有价值的。

结论

Go 1.25 通过根据容器 CPU 限制设置 GOMAXPROCS,为许多容器工作负载提供了更合理的默认行为。这样做避免了可能影响尾部延迟的节流,提高了效率,并通常试图确保 Go 开箱即用即生产就绪。您只需在 go.mod 中将 Go 版本设置为 1.25.0 或更高版本即可获得新默认值。

感谢社区中所有为实现这一目标做出了 长期 讨论 贡献的人,特别是来自 Uber 的 go.uber.org/automaxprocs 维护者的反馈,他们长期以来一直为其用户提供类似的行为。

下一篇文章:测试时间(及其他异步性)
上一篇文章:Go 1.25 发布
博客索引