Author:KC ,直播技术团队
B站:https://space.bilibili.com/27890454
最近直播内部的 golang 服务都使用了 uber 出品的 automaxpROCs 这个库。
据同事 ice 说,这个库解决了一个困扰B站golang(container)技术栈好一段时间的问题。
出于好奇这个库到底做了什么 magic,能够解决这个持续了一年多的 pain in the ass,抽了一点时间,稍微翻了一下库的源代码,记录如下。
不过在进入正题前,先简单阐述一下此前遇到的问题
背景
docker 这种容器化部署逐渐在各家互联网公司中兴起,配合微服务架构可以做到比较迅捷的服务扩缩容。
B 站自然也不例外;并且内部服务容器化部署的 CPU 策略有三种选项:
Default(其实就是 CFS)
Static (cpuset)
Nolimit
使用 Default 的服务占据绝大多数。
但是在使用过程中大家慢慢发现使用 Default 策略的 Golang 服务的接口请求耗时非常不稳定,经常跑着跑着90分位耗时就跑到几百毫秒。
无独有偶,根据监控,有时候服务的 GC Pause 也能跑出百来毫秒,令人非常震惊。
更糟糕的是那会儿的处理方案除了将一些关键敏感服务的 CPU 策略切换到 Static 或者 Nolimit 之外,再没有特别有效的方法。
直到后来有运维同学发现了 uber/automaxpROCs 这个库。
automaxpROCs 解决了什么问题
线上容器里的服务通常都对 CPU 资源做了限制,例如默认的 4C。
但是在容器里通过 lscpu
仍然能看到宿主机的所有 CPU 核心:

这就导致 golang 服务默认会拿宿主机的 CPU 核心数来调用 runtime.GOMAXPROCS()
,导致 P 数量远远大于可用的 CPU 核心,不仅导致 Golang Runtime 的调度成本提高,还引起频繁上下文切换,影响高负载情况下的服务性能。
automaxpROCs 能够正确识别容器允许使用的核心数,合理的设置 go pROCessor,避免这个问题。
automaxpROCs 源码分析
Grand Tour
包级别的 init()
函数(代码位置在 automaxpROCs/automaxpROCs.go
)实现了导入这个包即可产生作用:

核心函数就是 maxpROCs.Set()
;这个函数会从当前的 cgroups 里获取设置的 CPU quota,然后转换为合适的 GOMAXPROCS
。
核心逻辑(略去一些细节):

可以看出主要的工作都在 iruntime.CPUQuotaToGOMAXPROCS()
里完成。
至于 minGOMAXPROCS
的作用

避免外部 cpu quota 设置的过小。
获取进程的 cgroup 信息
核心函数之一的 parseCGroupSubsystems()
可以通过解析 /pROC/$pid/cgroup
文件,返回这个进程的 cgroup subsystem table,对应的数据结构是:

这里看一下 /pROC/$pid/cgroup
的样子(从线上找了一个服务)并和谐了相关内容:

每行都是一条记录,记录的每个 field 之间用 :
分割,从左至右分别是:
id
subsystems,多个 subsystem 之间用
,
分隔pathname
这里的目标是包含 cpu
这个 subsystem 的这条记录;其他的记录其实无关紧要。
同时注意一下 pathname
这个字段,代表进程所属的 cgroup hierarchy 的路径,并且一个相对于 cgroup hierarchy 的 mount point 的一个相对路径。
BTW:这里能看到一条记录可能有多个 subsystem,所以前面的 table 最后会出现多个 subsystem key 指向的其实是同一个 CGroupSubsys
实例。
获取进程的 mountinfo
类似的,核心函数 parseMountInfo()
会打开进程的 mountinfo
文件,然后将每一行记录解析成对应的 MountInfo
结构

看一下一个示例 mountinfo
文件内容(一些敏感信息已和谐)

每条记录的字段用空格分隔,字段 -
表示后面都是 options
共有三个字段需要我们关心:
索引为3的字段;组成当前挂载点根路径的文件系统的路径,对应
MountInfo.Root
索引为4的字段;当前挂载点相对于进程根目录的路径,对应
MountInfo.MountPoint
-
字段之后的第一个字段,代表 filesystem type,对应MountInfo.FsType
;我们其实只需要cgroup
。上面 fstype 字段之后的第二个字段,是 subsystems,subsystem之间用,分割;这里我们其实需要的是包含
cpu
的这个 subsystem
找到目标 cgroup path
有了前两步之后,就可以找到进程对应的 cpu
这个 subsystem 的 CGroup path。

这部分操作在 lambda 函数 newMountPoint()
中。
总结起来就是:
在 mountinfo 文件中找到
fstype = cgroup && subsystems.contains(cpu)
的记录,分离出root
和mount-point
。在 cgroup 文件中找到
subsystems.contains(cpu)
的记录,分理出 pathnamecgroupPath = Join(mount_point, relative(root, pathname))
relative()
函数返回 pathname 相对于 root 的相对路径
BTW:实践中发现 root
和 pathname
基本一致,这样返回的相对路径就是 .
;最后组合的最终路径都是 /sys/fs/cgroup/cpu
不过考虑到不同发行版甚至不同版本的 docker / k8s 行为可能存在不一致,所以最具有移植性还是上面的做法。
计算 cpu 配额
有了前面的目录路径之后,该目录下的:
cfs.cpu_period_us
文件记录了调度周期,单位是 us;默认值一般是 100'000,即 100 mscfs.cpu_quota_us
记录了每个调度周期进程允许使用 cpu 的量,单位也是 us。
值为 -1 表示无限制;对于 4C 的容器,这个值一般是 400'000
Aside:这两个值限制的是进程使用 cpu 的时间。
上述设置下表示:每 100ms 的调度周期内,该进程可以使用 400ms 的 cpu 时间,所以看起来的效果是可以使用4个CPU核心
更详细的内容请参考 Linux kernel 的文档:CFS Bandwidth Control
quota 和 period 的比值就是 docker 为容器设置的 CPU 核数配置。
这个值也是 automaxpROCs 为 runtime.GOMAXPROCS()
设置的值。
这部分逻辑对应库函数:CGoups.CPUQuota()
Visualize in Flowchart

总结
容器技术(docker)通过 Linux kernel 提供的 cgroups 机制来实现资源隔离和限制,但是这种限制有时候会出现反直觉的结果。
上面的分析过程看,虽然这个库做的事情比较简单,但是要注意的是,我们是通过逆向工程(由果推因)来分析的这个问题。
如果需要从正面解决(执因索果),那么就需要对 1)容器实现细节 2)linux 内核中 cgroups 的实现细节 有很深的了解。
这恐怕也是过了一年多才找到解决方案,而且最后还是直接使用别人的solution的原因。
彩蛋
0x0
事实上,automaxpROCs 仅针对于使用 CFS 调度策略的实例。
CFS 调度测类只限制进程的运行配额,不设置 pROCessor affinity。所以在 4C 的限制下,理论上 G-P-M 调度模型下的 M 可以运行在任意物理核心上
查看 /sys/fs/cgroup/cpuset/cpuset.cpus
这个文件可以发现没有做任何物理核心上的限制。
docker 创建容器时可以使用 --cpus=x
来实现。
0x1
对于只使用 cpuset 策略的容器来说,其实没必要使用这个库。
因为 cpuset 直接设置了容器的 pROCessor affinity,然后神奇的是,golang 的 runtime.NumCores()
获取的核心数是考虑过 pROCessor affinity 的。

sched_getaffinity()
其实是一个 linux syscall
注:虽然 runtime.NumCores()
是根据亲缘性获取的,但是这个值第一次初始化之后与就不再变化了,即使后面 pROCessor affinity 在运行过程中被更改了...
所以有人提了一个相关的 issue:runtime: NumCPU does not change when pROCess affinity changes
0x02
在使用 CFS 策略的容器中跑的 C++ 服务,使用 std::thread::hardware_concurrency()
核心数同样会返回错误的结果,导致和上面 golang 服务类似的情况。
如果需要为部署的 C++ 服务解决这个问题,需要专门实现代码以做到类似的功能。
于是我抽个时间写了一个 C++ 版本:https://github.com/kingsamchen/Eureka/tree/master/auto-cfs-cores/auto_cfs_cores