专栏/uber automaxprocs 源码分析

uber automaxprocs 源码分析

2020年10月25日 02:04--浏览 · --点赞 · --评论
粉丝:582文章:6

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() 中。

总结起来就是:

  1. 在 mountinfo 文件中找到 fstype = cgroup && subsystems.contains(cpu) 的记录,分离出 root 和 mount-point

  2. 在 cgroup 文件中找到 subsystems.contains(cpu) 的记录,分理出 pathname

  3. cgroupPath = 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 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 为 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


投诉或建议