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 这个库。
线上容器里的服务通常都对 CPU 资源做了限制,例如默认的 4C。
但是在容器里通过 仍然能看到宿主机的所有 CPU 核心:

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

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

可以看出主要的工作都在 里完成。
至于 的作用

避免外部 cpu quota 设置的过小。
核心函数之一的 可以通过解析 文件,返回这个进程的 cgroup subsystem table,对应的数据结构是:

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

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

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

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

这部分操作在 lambda 函数 中。
总结起来就是:
在 mountinfo 文件中找到 fstype = cgroup && subsystems.contains(cpu) 的记录,分离出 root 和 mount-point。
在 cgroup 文件中找到 subsystems.contains(cpu) 的记录,分理出 pathname
cgroupPath = Join(mount_point, relative(root, pathname)) relative() 函数返回 pathname 相对于 root 的相对路径
BTW:实践中发现 和 基本一致,这样返回的相对路径就是 ;最后组合的最终路径都是
不过考虑到不同发行版甚至不同版本的 docker / k8s 行为可能存在不一致,所以最具有移植性还是上面的做法。
有了前面的目录路径之后,该目录下的:
cfs.cpu_period_us 文件记录了调度周期,单位是 us;默认值一般是 100'000,即 100 ms
cfs.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 为 设置的值。
这部分逻辑对应库函数:

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

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