容器中如何正确配置 GOMAXPROCS ?
2023-04-30 Golang Docker Go 源码分析 读代码
前言
前两天看了一篇文章 GOMAXPROCS 与容器的相处之道,里面提到了 Go 标准库中 runtime.GOMAXPROCS 方法的问题:
因为系统调用
sched_getaffinity并不感知它对进程的限制,所以运行在 Kubernetes 中的 Go 程序的运行时始终会认为自己可以使用宿主机上的所有 CPU,进而创建了相同数量的 P (处理器), 而当 GOMAXPROCS 被手动地设置为限制后的值后,CPU 密集型场景的性能就得到了很大提高。
Go 标准库在应用层面并没有给出任何解决方案,Uber 公司的技术团队针对这个问题开发并开源了组件 automaxprocs,
对于需要严格限制 P 数量 的业务场景,可以使用该组件在运行时根据容器中 CGroups 的配置动态修改 GOMAXPROCS,避免资源的不合理使用。
内部实现
我们一起探究下 automaxprocs 组件的内部代码实现,笔者选择的版本为 v1.5.1。
备注: 如果读者对 CGroups 概念尚不清晰,建议先阅读一下这篇文章 Docker 基础支撑技术概览。
示例程序
package main
import (
_ "go.uber.org/automaxprocs"
"fmt"
"runtime"
"time"
)
func main() {
for i := 0; i < 10; i++ {
go func() {
time.Sleep(time.Millisecond)
}()
}
// 输出 CPU Core 的数量
// 输出处理器 P 的数量
fmt.Printf("CPU number = %d, P number = %d\n", runtime.NumCPU(), runtime.GOMAXPROCS(-1))
time.Sleep(time.Second)
}
宿主机运行
$ go run main.go
# 输出如下
CPU number = 8, P number = 8
笔者的宿主机 CPU 配置为 8 Core,通过输出的信息可以看到处理器的数量并没有被限制。
容器运行
接下来构建一个配置为 –cpus=2 的容器运行上面的代码:
$ go run main.go
# 输出如下
CPU number = 2, P number = 2
通过输出的信息可以看到处理器的数量已经被限制。
接口
automaxprocs 组件同时兼容 CGroups 和 CGroups2, 通过 queryer 接口来抽象隔离具体的版本实现。
type queryer interface {
CPUQuota() (float64, bool, error)
}
var (
_newCgroups2 = cg.NewCGroups2ForCurrentProcess
_newCgroups = cg.NewCGroupsForCurrentProcess
)

CGroups2 是 CGroups 的升级版本,提供了统一的控制系统并增强了资源管理能力,详情可以参考这篇文章 About cgroup v2。
# 确认当前系统的 CGroups 版本
$ stat -fc %T /sys/fs/cgroup/
# CGroups 输出 tmpfs
# CGroups2 输出 cgroup2fs
笔者使用的 Ubuntu 版本为 tmpfs, 所以下面的代码研究以 CGroups 实现为主。
初始化函数
automaxprocs 包内注册了 init 方法,会被其被引用时自动调用。
package automaxprocs
func init() {
maxprocs.Set(maxprocs.Logger(log.Printf))
}
Set 函数
Set 函数是功能实现的核心代码,它会自动匹配 Linux 容器中的 CPU 资源配额并更改 GOMAXPROCS,对于非 Linux 系统或者没有配置 CPU 资源配置的 Linux 系统,
该方法什么也不做,最后方法返回一个 error 和一个 undo 函数 (用来撤销针对 GOMAXPROCS 所作的修改)。
题外话: 这个返回的 undo 函数小技巧值得学习 (设计模式之状态模式)。
func Set(opts ...Option) (func(), error) {
cfg := &config{
// 用来获取 CPU 资源配额的方法
procs: iruntime.CPUQuotaToGOMAXPROCS,
// GOMAXPROCS 最小值为 1
minGOMAXPROCS: 1,
}
...
// 默认的 undo 函数
undoNoop := func() {
}
if max, exists := os.LookupEnv(_maxProcsKey); exists {
// 如果已经设置了 GOMAXPROCS 环境变量,就以环境变量为准
// 不做任何操作,直接返回
return undoNoop, nil
}
// 获取 CPU 资源配额
maxProcs, status, err := cfg.procs(cfg.minGOMAXPROCS)
...
// 未设置 CPU 资源配额,直接返回
if status == iruntime.CPUQuotaUndefined {
return undoNoop, nil
}
// 获取当前 GOMAXPROCS 值
prev := runtime.GOMAXPROCS(0)
// undo 函数更新,恢复到未修改前的 GOMAXPROCS 值
undo := func() {
runtime.GOMAXPROCS(prev)
}
...
runtime.GOMAXPROCS(maxProcs)
return undo, nil
}
CPUQuotaToGOMAXPROCS 函数
CPUQuotaToGOMAXPROCS 函数用于获取 Linux 容器中的 CPU 资源配额,具体的实现是由 newQueryer 函数完成的。
func CPUQuotaToGOMAXPROCS(minValue int) (int, CPUQuotaStatus, error) {
cgroups, err := newQueryer()
...
quota, defined, err := cgroups.CPUQuota()
...
maxProcs := int(math.Floor(quota))
// 如果 CPU 配额比配置的最小值还要小
// 就以配置的最小值为准
if minValue > 0 && maxProcs < minValue {
return minValue, CPUQuotaMinUsed, nil
}
return maxProcs, CPUQuotaUsed, nil
}
newQueryer 函数
newQueryer 函数并没有判断当前系统的 CGroups 版本,而是优先获取 CGroups2 的配置,如果获取不到,再获取 CGroups 的配置。
func newQueryer() (queryer, error) {
// 优先获取 CGroups2 配置
cgroups, err := _newCgroups2()
if err == nil {
return cgroups, nil
}
if errors.Is(err, cg.ErrNotV2) {
// 其次获取 CGroups 配置
return _newCgroups()
}
return nil, err
}
NewCGroupsForCurrentProcess 函数用来获取 CGroups 版本的配置,具体的实现是 NewCGroups 函数完成的。
func NewCGroupsForCurrentProcess() (CGroups, error) {
return NewCGroups(_procPathMountInfo, _procPathCGroup)
}
NewCGroups 函数通过读取 /proc/self/mountinfo 和 /proc/self/cgroup 文件获取配置,最后包装成 CGroups 接口对象返回。
// CGroups 接口对象
type CGroups map[string]*CGroup
func NewCGroups(procPathMountInfo, procPathCGroup string) (CGroups, error) {
...
}
CPUQuota 方法
最后,我们看来看下 CGroups 版本的 CPU 资源配额是如何计算出来的,该功能由 CPUQuota 方法完成。
该方法首先获取 cpu.cfs_quota_us 和 cpu.cfs_period_us 两个字段:
cpu.cfs_quota_us: CPU 周期内最多可使用的时间 cpu.cfs_period_us: CPU 周期时间
例如:如果 cpu.cfs_quota_us 是 cpu.cfs_period_us 的 4 倍,表示允许容器使用 4 个 CPU Core。
如果上述两个字段任意一个未设置,方法返回 -1,表示不限制对 CPU 资源的使用,如果两个字段都设置了,使用下面的公式返回资源配额:
cpu.cfs_quota_us / cpu.cfs_period_us
func (cg CGroups) CPUQuota() (float64, bool, error) {
...
cfsQuotaUs, err := cpuCGroup.readInt(_cgroupCPUCFSQuotaUsParam)
...
cfsPeriodUs, err := cpuCGroup.readInt(_cgroupCPUCFSPeriodUsParam)
...
return float64(cfsQuotaUs) / float64(cfsPeriodUs), true, nil
}
小结
uber-go/automaxprocs 组件通过读取运行时环境中的 CGroups 相关配置文件,完成动态修改 GOMAXPROCS 功能。

Reference
- uber-go/automaxprocs
- Docker 基础支撑技术概览
- GMP 调度器
- GOMAXPROCS 与容器的相处之道
- About cgroup v2
- CFS Bandwidth Control