玩 Go
的同学都知道,Go
语言中的 goroutine
调度会使用经典的 GPM
模型,简单来说,Go
会尽量创建和系统cpu
数量一致的 P(逻辑处理器)
,保持这俩数一致的好处是可以让每个 cpu
都有活干,系统资源是最大化的。如果 P
过少,cpu
则会偷懒;如果 P
过多,cpu
则会累死。
Go
为我们提供了如下函数接口来配置这个值,一般来说我们不用手动设置。但是某些环境下我们还是需要人肉配置的,文章后半部门会提到:
runtime.GOMAXPROCS(n int)
复制代码
如果我们没有手动设置 P
的数量,Go
程序在启动的时候会自动获取系统cpu
可用的数量来设置这个值,所以,回到这篇文章的正题,你是否知道:Go是如何获取当前系统可用的cpu数量呢?
正所谓:"好奇心害死猫",我也想知道 Go
到底是如何做的,难不成直接查看的 /proc/cpuinfo
?或者 lscpu
命令,更或是 sysconf
?
结论
为了不掉人胃口,这里我直接把结论抛出来: Go 是通过汇编方式直接发起了一个系统内核调用:sys_sched_getaffinity(), 通过这个函数来获取当前进程可以使用的cpu计算出来的 。大致流程如下:
这里我只追了 os_linux.go linux系源码实现,如果你还对其他平台感兴趣,不妨自己也去屡屡,golang对每个平台实现还不一样。
如果说最终采用的是一个系统调用,那么这也为我们获取系统cpu数量提供了一个新思路,因为他已经和语言变得无关。所以我还想深入研究一下 Go
是如何利用这个系统调用实现获取cpu总数的呢。此外,我还想说明的是,这种方式获取的cpu值在容器平台(k8s)里是有不准确的!!! (参考文章后半部分)。
这里我们重点分析下上述流程的后三个步骤具体的代码逻辑。
getproccount 切入
我们从 getproccount()
函数入口处开始剖析,该函数的代码量并不多,我们直接贴出来:
func getproccount() int32 {
const maxCPUs = 64 * 1024
var buf [maxCPUs / 8]byte
r := sched_getaffinity(0, unsafe.Sizeof(buf), &buf[0])
if r < 0 {
return 1
}
n := int32(0)
for _, v := range buf[:r] {
for v != 0 {
n += int32(v & 1)
v >>= 1
}
}
if n == 0 {
n = 1
}
return n
}
复制代码
从上面代码我们可以看出,在 getprocount
函数内部又调用了另一个 sched_getaffinity()
函数来获取一个数据,然后巴拉巴拉一堆计算,返回最终的一个值:n
。这个n
代表的就是cpu数量,这就是整个 getprocount
函数全貌,但要搞懂这段代码,我们还得继续往下追,不然我们不清楚为什么要这么做。
关于sched_getaffinity函数
当再次追 sched_getaffinity()
函数时,我发现它直接是使用汇编代码实现(sys_linux_amd64.s
):
TEXT runtime·sched_getaffinity(SB),NOSPLIT,$0
MOVQ pid+0(FP), DI
MOVQ len+8(FP), SI
MOVQ buf+16(FP), DX
MOVL $SYS_sched_getaffinity, AX
SYSCALL
MOVL AX, ret+24(FP)
RET
复制代码
这段汇编代码不算复杂,最核心的点就是产生了一个系统调用: MOVL $SYS_sched_getaffinity, AX
。只要明白了这个系统调用函数,关于 getproccount
函数的逻辑也迎刃而解了。下面我们来重点说下 SYS_sched_getaffinity
。
SYS_sched_getaffinity 以及 cpu亲和
再分析 SYS_sched_getaffinity
函数之前,我们要说另外一个话题:cpu亲和, 这个名词或许你多多少少听过affinity
翻译成中文即:亲和之意;我们要分析的这个函数名称里也有这个词,肯定多半有点什么关系。
本文不会在再深入和你去聊cpu亲和的具体内容,我直接告诉你对于cpu亲和我们能得出的有效信息是什么:
- 默认状态下,进程对系统所有cpu都具有亲和性 ;
- linux系统给我们提供了操作cpu亲和接口函数 。
# include/linux/syscalls.h
# 设置亲和性掩码位
long sys_sched_setaffinity(pid_t pid, unsigned int len,unsigned long __user *user_mask_ptr);
# 获取
long sys_sched_getaffinity(pid_t pid, unsigned int len,unsigned long __user *user_mask_ptr)
复制代码
上面两小点结论的直白意思是:一个服务进程在不做任何设置的情况下,可以使用系统上的所有cpu资源。从另一个角度来看,是不是我们可以利用此方式计算出一个进程的可用cpu数量?
咦,好像有点开窍了,Go
就是这么干的。sys_sched_getaffinity
函数可以获取到当前进程能够使用的系统cpu位掩码值,这个cpu位掩码就是和系统逻辑cpu一一对应的。比如说,当前系统是4核8G的,那么这个掩码位默认会返回:1111
, 注意这个是二进制位,1
代表的是可以使用,0
则是否。如果系统还用了超线程技术,那么掩码为值就是8个掩码值:11111111
。
然后我们只要来数一下这个返回列表里有多个1的数值,就知道总能能用的cpu数量是多少了;为了获得这个位掩码值我们初始化一个数组空间,在Go
语言里,用了一个 byte
数组
const maxCPUs = 64 * 1024
var buf [maxCPUs / 8]byte
复制代码
为了存储足够多的位掩码值,这个初始空间要足够大,函数运行后,buf
会得到一个字节数组,里面存储的就是下面这种位掩码值,把 byte
二进制呈现形式,结果值就长下面这样:
[1111,0000,0000,...] // 逻辑cpu 4核
复制代码
有了这个数据之后,Go
目的并不是去测试亲和性,而是遍历出每一bit
位上的值,如果发现这个位置上的值是1,那么cpu可用数n就累计1,也就有了 getproccount()函数的大致逻辑
:
func getproccount() int32 {
// ...
for _, v := range buf[:r] {
for v != 0 {
n += int32(v & 1) // 一位一位检测是否是1,是则+1, 是0的话,n+0=n值不变。
v >>= 1 // 右移计算下一位
}
}
// ...
return n
}
复制代码
按照这个思路,我们直接也可以使用 c语言实现一个获取cpu
数量的版本,当然你也可以延伸到其他语言:
#define _GNU_SOURCE
#include <assert.h>
#include <sched.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void print_affinity() {
cpu_set_t mask;
long n;
if (sched_getaffinity(0, sizeof(cpu_set_t), &mask) == -1) {
perror("sched_getaffinity");
}
int r = sizeof(mask.__bits) / sizeof(mask.__bits[0]);
for (int i = 0; i < r; ++i) {
int v = mask.__bits[i]
while (v != 0){
n += v & 1;
v >>= 1;
}
}
printf("cpu total: %d \n", n);
}
int main() {
print_affinity();
return 0;
}
复制代码
运行结果同样也能获取到:
cpu total: 4
复制代码
当然,如果是c语言其实还有更好的封装方法,这里不在拓展。
容器中的问题
分析完获取cpu数量原理之后,我们知道了Go
是获取系统可用cpu数量的技巧,但是 不幸的是在容器环境里,这个值获取的值是错误的!!!
如果不信,你可以自己试试,我们假设你的某台容器话宿主机是40核,而你真正运行的pod是4核,如果使用默认的Go
方式,得到的值是宿主机的: 40 而不是 4,你可以使用:runtime.NumCPU()
来验证
printf("cpu total: %d \n", runtime.NumCPU()) // cpu total: 40
复制代码
也就是说,宿主cpu是40个,但真正干活的又只有4核,远超系统资源。这会导致其他P
不断等待再重新分配,最终程序层面你会发觉部分的goroutine
执行会卡顿。如果是web服务,你会发觉部分响应客户端请求相比其他goroutine变得莫名其妙的慢很多,然后你就是不知道是哪里有问题(亲身经历事件)。
如何解决?
那么我们如何来解决这个问题呢?这里我们需要另一个知识点:
我们知道容器的背后cpu资源分配是通过 cgroup
机制来实现的,即一个容器里一个pod使用的需要的cpu、内存等待资源都是由cgroup
以配置文件形式来分配的,这些文件被存放到了系统 /sys/cgroup/cpu/
对应的子目录下。
# /sys/fs/cgroup/cpu/ cgroup文件所在目录
cpu.cfs_period_us
cpu.cfs_quota_us
复制代码
cpu分配有相对和绝对方式,这里拿其中一种来说明,配置文件有很多,这里重点关注如上这俩文件配置,其解释如下:
cpu.cfs_period_us
此参数可以设定重新分配 cgroup 可用 CPU 资源的时间间隔,单位为微秒(µs,这里以 “
us
” 表示)。如果一个 cgroup 中的任务在每 1 秒钟内有 0.2 秒的时间可存取一个单独的 CPU,则请将cpu.rt_runtime_us
设定为2000000
,并将cpu.rt_period_us
设定为1000000
。cpu.cfs_quota_us
参数的上限为 1 秒,下限为 1000 微秒。cpu.cfs_quota_us
此参数可以设定在某一阶段(由
cpu.cfs_period_us
规定)某个 cgroup 中所有任务可运行的时间总量,单位为微秒(µs,这里以 "us
" 代表)。如果你想使某个进程使用2个cpu数,那么可以把 cpu.cfs_quota_us 设置为 2000000,然后把 cpu.cfs_period_us 设置位 1000000
如果你对上面的解释不是很理解,我们记住结论即可:我们可以通过上面俩参数值来计算出pod真正可以使用的cpu数量:
逻辑cpu数 = cpu.cfs_quota_us / cpu.cfs_period_us
复制代码
有了这个值,我们再通过 Go
提供的设置 P
的接口方法在 Go
程序初始化时(通常是 init时)重置即可:
init() {
// 真实cpu核数 = cpu.cfs_quota_us / cpu.cfs_quota_us
runtime.GOMAXPROCS()
}
复制代码
这也是 uber
这个库核心逻辑实现:pkg.go.dev/go.uber.org… 当然你也可以直接使用。