Go如何获取当前系统cpu数量?

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计算出来的 。大致流程如下:

01-go-procs-get.png

这里我只追了 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亲和我们能得出的有效信息是什么:

  1. 默认状态下,进程对系统所有cpu都具有亲和性 ;
  2. 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 设定为 1000000cpu.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

-- access.redhat.com/documentati…

如果你对上面的解释不是很理解,我们记住结论即可:我们可以通过上面俩参数值来计算出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… 当然你也可以直接使用。

猜你喜欢

转载自juejin.im/post/7068181728602357797