概述
最近在看一个库的实现代码时,发现它依赖于查看进程使用的 CPU 核数,然后根据 CPU 核数做一些限制,所以我就顺便看了一下 Go 是如何实现查看 CPU 核心数的。
Golang 实现
[root@liqiang.io]# cat main.go
runtime.NumCPU()
--> runtime/debug.go return int(ncpu)
这里返回了一个全局变量,这个全局变量的初始化是在进程起来的时候设置的,所以注释中也解释了,一旦进程运行之后,如果更改了 CPU 亲和性的配置也不会生效:
[root@liqiang.io]# cat runtime/os_linux.go
func osinit() {
ncpu = getproccount()
---> func getproccount() int32 {
r := sched_getaffinity(0, unsafe.Sizeof(buf), &buf[0])
... ...
n := int32(0)
for _, v := range buf[:r] {
for v != 0 {
n += int32(v & 1)
v >>= 1
}
}
if n == 0 {
n = 1
}
return n
这里的 sched_getaffinity
不是一个简单的 Go 函数,而是一个系统调用,所以需要从汇编代码中看,但是也很简单,就是将函数入栈,然后调用系统调用:
[root@liqiang.io]# cat runtime/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
sched_getaffinity 系统调用
从 linux 的 man page(https://man7.org/linux/man-pages/man2/sched_setaffinity.2.html) 中可以看到,这个系统调用是返回 cpuset 的信息,通过掩码的方式返回。实际上返回的 cpuset 就是一个位图,如果一个 bit 是 1,那么表示这个 cpu 是可用的,如果是 0 则表示不可用,所以我们从 Go 的代码中可以看到,它遍历返回的 buffer,然后逐位地检查(这个其实有个高效的 CPU 指令可以完成),最终计算当前进程可以使用的 CPU 核数是多少。
我们用系统命令也可以查看容器可以使用的 CPU 核心数:
[root@liqiang.io]# lscpu | egrep -i 'core.*:|socket'
Thread(s) per core: 1
Core(s) per socket: 1
Socket(s): 4
一些简单的小坑
在使用 Docker 的时候,我曾经想限制 CPU 的个数(限制 Docker 容器的 CPU 内存等资源,发现 Docker 有两种不同的选项:
- 简单:限制 CPU 核数,这个很好理解
- 使用方式:
[[email protected]]# docker run --cpus=4
表示允许使用 4 个核心
- 使用方式:
- 复杂:基于 CPU 时间片的限制,docker 是基于 CFS 的调度实现,这是老版本使用的,新版本都推荐使用简单的方式
- 使用方式:
[[email protected]]# docker run --cpu-period=100000 --cpu-quota=200000
表示每个 CPU 使用的时间是 100 ms,这个容器最多使用 200ms(相当于限制了 2 个核,但是不是绝对)
- 使用方式:
对于第二种限制 CPU 时间片的方式,在容器里面实际上看到的还是所有的 CPU 核心,所以 Go 也会认为他有那么多核心数,这个可能是一个坑需要注意。
一些系统底层知识
cgroups v2 限制 cpuset
cpuset 可以通过 cgroups 简单地限制,例如我这里使用 cgroupv2 为例进行:
[root@liqiang.io]# sudo cgcreate -g cpuset:/liqiang2
[root@liqiang.io]# sudo cgexec -g cpuset:/liqiang2 go run /tmp/main.go
16
[root@liqiang.io]# echo "0-1" | sudo tee /sys/fs/cgroup/liqiang2/cpuset.cpus
[root@liqiang.io]# sudo cgexec -g cpuset:/liqiang2 go run /tmp/main.go
2
NUMA
ok,这里我们知道了,Go 是通过 cpuset 来获取可用的 CPU 核数的,那么 cpuset 是什么?为什么会存在这个东西?我们在理解容器的概念之后,很自然地会认为容器之间资源隔离不是一个很正常的功能吗?这没错,但是实际上 cpuset 在容器流行之前就存在了,再次之前,它常被用于 NUMA 的环境,在一些高性能的服务器中,一个主机上其实包含不只有一个 CPU,你很平常就会看到两个 CPU 的机器,例如我这一台:
[root@liqiang.io]# lscpu | grep -i numa
NUMA node(s): 2
NUMA node0 CPU(s): 0-23,48-71
NUMA node1 CPU(s): 24-47,72-95
那么我们知道 CPU 和内存是通过北桥高速通信线路通信的,那么如何存在多个 CPU 的话,这个线路要如何设计?事实上,在 NUMA 架构中,CPU 和内存是有一个远近关系的,每个 CPU 都会有一些本地内存比较快,对于其他 CPU 的本地内存,访问速度会慢一些,于是,为了程序的运行效率,我们就会有绑定 CPU 和内存的需求了,我们可以将进程绑定在固定的 CPU 上,并且内存也使用对应的一些,这样可以保证我们的进程不会跨 CPU,这样的话就可以去除 NUMA 架构的影响了。