6

Go 的调度模型是 GMP,其中 G 是 goroutine,M 是线程,P 是可用的 CPU 核数。多个 G 会共用一个 M。M 作为操作系统层面上的调度单位,在执行时需要绑定到 P。如果操作系统认为的某个 Go 进程可用的 CPU 数,和该进程认为的可用的 CPU 数不一致,那么即使把 M 绑定到某个 P 上,操作系统也不一定会执行这个线程。所以能否获取准确的可用 CPU 核数会影响 Go 的调度效率。

当用户在 k8s 中设置了资源限制:

spec:
  containers:
  - name: app_written_by_go
    resources:
      limits:
        cpu: "4"

Go 会不会识别到可用的 CPU 为 4 呢?读者可能会认为,Go 作为一个云原生时代炙手可热的语言,应该内置了对 k8s 的支持,所以能够识别出来。但事实并非如此。我们可以做个小实验:
如果启动 Go 进程时没有指定 GOMAXPROCS 环境变量,那么会以 runtime.NumCPU() 的输出作为可用的 CPU 核数(也即 P 的值)。让我们加一下打印 NumCPU 的代码,会发现实际上输出的是 Node 上的 CPU 数目,跟 limits.cpu 无关。

runtime.NumCPU() 在 Linux 上是通过系统调用 sched_getaffinity 实现的,但这个系统调用只考虑绑定 CPU 核的情况,不涉及容器环境下 cgroup 对 CPU 资源的限制。以 docker 为例,docker run 时指定 --cpuset-cpus 可以设置容器运行时可以使用的 CPU 核的编号,但限制 CPU 的资源数主要用 --cpus=。只有前者(cpuset)是能被 sched_getaffinity 识别的。具体见 https://docs.docker.com/config/containers/resource_constraint...。如果要想计算后者,那么需要读取机器上的 cgroup fs 文件。

有一个 Go 库支持读取 cgroup fs 并计算出准确的 GOMAXPROCS:
https://github.com/uber-go/automaxprocs
它支持两种不同的 cgroup fs (cgroup v1 和 v2):
其中 v1 是读取文件 cpu.cfs_quota_uscpu.cfs_period_us,并求两者的商。这两个文件通常位于 /sys/fs/cgroup/cpu/ 下面(automaxprocs 会读挂载信息来获取实际位置)。v2 则是读取文件 cpu.max 里面对应表示 quota 和 period 的字段,并求两者的商。除法的结果不一定是整数,所以还有一个向下取整的过程。

存在“应该能够识别可用 CPU 数但在容器环境下实际办不到”这种问题的并不只有 Go 一个。Nginx / Envoy 也有无法识别 cgroup 配置的问题。据说 Rust 和 Java (OpenJDK 实现)有专门处理过 cgroup 配置。如果你的应用符合下面两点:

  1. 计算密集型或主要业务逻辑在若干个固定的 worker 中完成
  2. 会部署到容器环境中
    那么不妨看看所用的框架是否能正确处理 cgroup 配置。

附注:IBM 的员工曾经提过一个 CPU Namespace 的设计,封装每个进程可以看得到的 CPU 信息,避免诸如 CPU 分配不一致这样的“抽象泄漏”。不过后续就没有下文了。


spacewander
5.6k 声望1.5k 粉丝

make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.