重新思考 Go 系列:这个系列希望结合工作中在 Go 编程与性能优化中遇到过的问题,探讨 Go 在语言哲学、底层实现和现实需求三者之间关系与矛盾。

前言

过去一段时间,在大量的线上服务 case study 过程中,逐步深入了解了如今的业务 Go 进程是如何在一系列繁杂的基础设施之上运行的。有些表现在意料之中,也有一些出乎意料的发现。

我很喜欢一个叫做「 机械同理心/Mechanical Sympathy 」的概念,大体的意思是你必须深刻了解你的程序/机械装置是在一种怎么样的环境下运行的,设身处地地在这个运行环境下去思考,才能帮助你写出更好的程序,或是解答一些奇怪现象的原因。

本文希望达到的目的,也是构建这份「机械同理心」。

程序能使用多少计算资源?

对于很多人来说,这个问题似乎非常简单,大多的计算平台都会要求你建立服务的时候就指定好「需要的计算资源」,但这与你的「能使用的计算资源」是两件事情。实际上这个问题的复杂性远远超出大部分人现象,甚至都不是一个常量,也无法使用公式简单计算。抽象地讲,这取决于天时地利人和。

  • 「天时」指的是内核 Cgroups 以及容器调度平台的基本原理和参数设置。这是硬指标,大部分时候也是常量。
  • 「地利」指的是部署的实际物理机的繁忙程度。
  • 「人和」指的是写程序的人得在能够有更多计算资源可用的时候真的让自己程序用上这些计算资源。

接下来我们逐步分节来详细探究这个问题。

被封装的 CPU 谎言

在 Oncall 中常见的一个误解是,研发人员在容器平台上申请了 4 核 CPU 的容器,然后自然而然认为自己的程序最多只能使用 4 个 CPU,于是按照这个计算能力去估算需要的容器数量,以及对自己程序套上这个假设进行参数调优。

上线后,进到容器用 top 一看,各项指标确实是按照 4 核的标准在进行。甚至用 cat /proc/cpuinfo 一看,不多不少刚好看到有 4 个 CPU。。。

但实际上,这一切都只是容器平台为你封装出来的一个美好的假象。之所以要把这个假象做的这么逼真,只是为了让你摆脱编程时的心智负担,顺便再让那些传统的 Linux 观测工具在容器环境中也能正常运行。

但是假象终究不是真相,如果有一天你遇到一些疑难问题或是想要去编写极致性能的代码,你会发现这些封装出来的抽象把你的理解带的有多偏。

我到底能同时使用多少个 CPU ?

在 K8s 的环境里,CPU 是一个时间单位而非数量。正常情况下,一个拥有 64 核心的机器,你最多能同时使用的理论 CPU 个数是 64 ,即便你创建容器的时候申请的是 4 核。

但是由于你申请的是所谓的 4 CPU,所以你最终使用的 CPU 时间加起来最多只能相当于 4 CPU 的时间。

即,程序的最大并行数 = 物理机 CPU 核心个数;程序的最大执行时间 = 容器 CPU Quota 。

Go 程序能同时使用多少个 CPU ?

上一节讲的是容器本身的最大并行数并没有明确的限制,上限是 CPU 核心数量。用多用少实际上完全取决于你的程序自身设计。

但由于 Golang 对并行数的设计是隐藏在 Runtime 中的,也就是所谓的 GOMAXPROCS。这个值并没有一个明确的 Guideline 告诉用户应该如何调整,实际上不同公司的部署架构以及容器平台实现对于这个值的调整都会有截然不同的标准。被广泛采用的是 Uber 的标准 ,即根据容器的 CPU Quota 上限来设置 GOMAXPROCS 。所以虽然 K8s 对容器的原本设定虽然不限制你同时使用的 CPU 个数,但其实在 Uber 规范的这种隐式行为设定下,容器的 CPU quota 实际上间接决定了程序的最大并发数。

假如你是一个 1 秒钟内,会同时运行 16 个并行任务且每个仅持续 10ms 的程序,当你部署在一个申请了 4 个 CPU quota 的容器中时,你会发现虽然你 CPU 利用率极低,但是程序无论如何都跑不快。这就是因为这套 GOMAXPROCS 设置给你创造了一个自我受限的运行环境。

我到底能用多少计算资源

前面讲的多少个 CPU 并不等价于「计算资源」,计算资源通俗理解就是单位时间内,这物理机的所有 CPU 核心最终能轮到执行你程序的时间。

你在 K8s 上配置的 CPU quota 之所以叫做 quota ,是说你最多可以运行到这个数字,但不代表一定给你这个数字的计算资源。至于你真的能够有多少计算资源,说实话,nobody knows …….

一般来说,这个问题取决于:

  1. 物理机自身负载:如果物理机负载已经很高 ,你基本上很难有运气运行满 CPU quota 标注的计算量。比如我在某容器平台上部署一个完全无业务逻辑的 PingPong 服务,它的延迟也会随着一天时间变化而变化。这是因为物理机的繁忙程度在变化,所以实际上你程序可获得的计算资源也在变化。

  2. 程序设计问题:假设你是一个单线程程序,你的上限也就 1 CPU 运行满,自然无法使用满全部的计算资源。你可能会说我是一个 Golang 程序,不是单线程程序。然而 Go 的设计偏向于高并发低并行,许多 Go 代码在调度器的作用下,实际上就是一个倾向于单线程运行的代码。这其中的奥秘是后续深入展开的重点。

Go 如何调度计算资源

每一个 Goroutine 都可以理解为一个任务,CPU 是任务的执行器。一个任务从「可以运行 - Ready」到 「开始运行 - Running 」的时间即为调度延迟。

调度延迟的产生可能是因为 CPU 太忙在执行其他 Goroutine 甚至是其他进程的线程,也可能是因为虽然有其他 CPU 空闲,但是我任务挂在本地线程队列里,没有被其他 CPU 上的线程拿取。

调度时机与开销

一个 Goroutine 被真正调度到执行存在下以下时机:

  1. 【开销最低】前一个 Goroutine 执行完毕或者主动进入 Waiting 状态,当前 G 存在于当前 P 的 local runq 队列
  2. 【开销低】前一个 Goroutine 执行时间过长(~=10ms),在调用它的某个函数时,执行了协作式抢占,当前 G 存在于当前 P 的 local runq 队列
  3. 【开销中】sysmon 线程异步抢占(通过中断信号)执行时间过长的 Goroutine,强行发起调度。当前 G 存在于当前 P 的 local runq 队列。
  4. 【开销大】上述事件发生时,当前 local runq 队列不存在可运行 G,从全局队列中获取当前 G
  5. 【开销最大】上述事件发生时,local 和 全局 runq 都不存在可运行 G,从别的 P 偷取任务

调度状态切换

func main() { // G1
   ctx, cancel := context.WithTimeout(context.Background(), time.Second)
   defer cancel()

   done := make(chan int)
   go func() { // G2
      // do something
      done<-1
   }()
   select {
   case <-ctx.Done():
   case res := <-done:
   }
}

上面这段代码,一共有两个 Goroutine: G1, G2。

这两个 Goroutine 的状态变化为:

  1. G1 runnable=>running: 一开始函数开始运行
  2. G2 dead=>runnable: 创建完 G2 后,初始状态
  3. G1 running=>waiting: 阻塞在 select 语句
  4. G2 runnable=>running: G2 开始运行。有可能和 G1 在同一个 P,也可能被别的 P 偷走了。
  5. G2 running=>waiting: 阻塞在 channel 操作。因为 done 是无缓冲的,所以此时 G2 并不会退出
  6. G1 waiting=>runnable: channel send 会知道 G1 阻塞在这里,所以会先修改 G1 状态为 runnable
  7. G1 runnable=>running: G1 恢复执行

虽然这段代码很简单,但存在多次协程切换。这还不算每一行代码之间还可能存在异步抢占(假设代码逻辑复杂的话)。

每一次切换,都意味着下一次必须依赖 runtime 调度器调度才能获得执行机会。

调度的代价

每一个 Goroutine 从 runnable => running 都会存在一定延迟。这部分延迟称为调度延迟。

每一次调度,都不保证会立刻执行,也不保证一定在某个 P 上执行,更不保证一定在一个物理线程 M 上执行。

所以即便上述代码理论上应该是一个纳秒级别的代码开销,在真实繁忙的场景下,调度延迟很可能在毫秒级别。甚至 5ms 以上也是常见线上情况。

除此之外,这里假设的是底层物理 CPU 资源是充足的情况下,而真实线上线程本身也有内核的调度延迟,甚至是 cgroup throttle 故意休眠线程。此时真正看到的延迟甚至能达到几十毫秒级别。

衡量调度开销

调度开销都在火焰图的 runtime.mcall stack 下:

  1. 如果 checkTimers 开销特别大,意味着你一定创建了大量 timer,且这些 timer 还大概率都能到期。如文章开头这种超时控制的 timer,虽然很多,但是绝大部分都不会到期,所以性能损耗还好。
  2. 如果 mPark 开销特别大,意味着你线程切换频率很高。比如你每秒运行一次,每一次创建几百个 goroutine 批处理一些任务,然后 10ms 后就完成了,休眠 990ms。那么每一次你程序运行任务,都要唤醒一大批已经因为没事可做被休眠的线程。
  3. 如果 runtime.netpoll 开销特别大,大概率你访问数据库等网络 IO 特别频繁。注意,Kitex 框架时,网络并不走 runtime 所以并没有这个开销。
  4. 如果 stealWork 开销特别大,大概率你的程序经常在一个 goroutine 中,一次性创建一大堆 goroutine,这样导致大量 goroutine 积压在本地 local runq,依赖其他 P 去偷走执行。

Go GC 如何影响延迟

和所有依赖 GC 的编程语言一样,GO 的 GC 延迟也是一大延迟来源。

触发 GC 的时机

除去长时间不发生 GC 引起的定时 GC 外,GC 通常发生在用户创建堆对象调用 mallocgc 函数时。例如如下代码:

buf := make([]byte, 128)

编程者的通常预期最差这行代码也应该在 1ms 之内完成,然而实际上由于这个操作有可能被 Runtime 切去帮助执行 GC 的任务,所以该调用的延迟是完全不受控的,甚至长达两位数毫秒也是可能的。

GC 流程

Go GC 分为 Mark 和 Sweep 两个阶段。

Mark - 标记对象

Go 的三色扫描 Mark 过程的原理相对简单,即把全局变量,所有 Goroutine 的栈上变量作为根对象开始扫描标记。

如果某一个堆变量已经不被持有,则没有 Mark 阶段的成本。

Sweep - 清理释放对象的内存

分配器协助 Sweep

Sweep 被嵌入到程序的每一次 Malloc 操作中判断是否要额外执行 sweep 操作。也就是说,程序即便只是调用一个 make([]int, 1024) ,也可能会被切去执行 sweep,从而产生用户意想不到的延迟。

在 Mallocgc 的时,如果当前 mcache span 没有空间,且上一次 GC mark 后还有 sweep 工作没有完成,则协助参与 sweep,从所有没有被 sweep 的 span 中,找到能够分配当前需要内存的 span 。一直会执行 100 次(Go >= 1.19 前是死循环一直执行直到找到或者完成 sweep 工作)。

所以如果存在大量没有空余对象可以分配的 span 会极大影响分配器效率。

后台 bgsweep

bgsweep goroutine 是低优先级的异步 sweep,在每次 GC Mark 结束后,会被唤起。

每 sweep 10 个 span,就判断下是否有其他 idle P,没有就 gosched 让其他 goroutine 执行。

func bgsweep() {
    for sweep_10_span() {
        goschedIfBusy()
    }
}

func goschedIfBusy() {
   if !gp.preempt && sched.npidle.Load() > 0 {
      return
   }
   mcall(gosched_m)
}

衡量 Mark & Sweep 开销

系统中通常存在两种生命周期的对象:

var longLife = [][]byte

func handler(n int) {
    var shortLife = make([]byte, n) // 因为 n 是变量,所以 shortLife 会分配在堆上
    longLife[n] = make([]byte, 1024 * 8)
}

对于短生命周期的对象

Mark 开销较低

因为 GC 只会扫描当前有指针指向的对象,短生命周期对象已经没有 goroutine 持有了,所以不需要被扫描。

Sweep 开销较低

理论上,sweep 开销应该是和长生命周期一样的。但是这里有一个问题是,RPC 服务的对象一般是突然分配一波,例如 RPC Request/Response 结构体,且由于需要分配大量对象,这类对象大概率都集中在一些独占的 span 中。也就是说多个对象占用同一个 span,即 span 密度很高 。

而 Sweep 的开销与 span 个数相关,也就是说同样的对象数量,所涉及到的 span 数量更少。而且一旦 Mark 后,整段 span 剩余空间很大,可以继续分配。

对于长生命周期的对象

Mark 开销较高

由于每次 GC 都需要扫描这类对象,如果这类对象就不会释放,这里的扫描都是浪费的。

Sweep 开销较高

和前面的原因一样,一直需要去重复 sweep 相同的 span。

此外,由于这类对象大概率也是被集中分配出来的,所以他们也会集中在小部分 span 中,而这些对象如果整体都不释放,这类 span 的空余空间很少,这就导致 malloc 的时,需要先去处理这些 sweep 工作,进而导致分配延迟极大。

NUMA 架构下 Sweep 额外的访存延迟

NUMA 架构的一大缺点是在出现跨 NUMA 节点访问内存时,会出现巨大的访问内存延迟。

正常 Go 程序都符合局部性原理,不太可能出现极高密度跨节点访问内存的情况,但是 sweep 过程不一样,它的代码设计就是不同线程从一个全局队列中取可能在不同 NUMA node 上分配的地址,所以这个阶段会出现大量跨节点访问内存的情况。

PS:其他带 GC 的语言其实也是这几年才引入 NUMA 架构的优化的,比如 Java

总结

根据上面罗列的众多线上 Case 总结,我们可以看到哪怕是一个最简单的并发程序,也可能并非我们单纯从程序自身所看到的那样简单地运行,既可能被物理资源设定所间接影响,也可能被 Go Runtime 注入执行非代码所描述的不相关任务。要在一个计算资源不稳定,执行代码段也不稳定的现实世界,写出一段执行时间稳定的 Go 代码并非易事。

即便 Go 编译器做出十足的进步,以至于 Go 静态编译的出来的代码性能能够媲美 C 编译器。实际运行时,这套 Runtime 机制依然会造成程序 avg 性能持平 C 但是有 1% 的概率突然延迟抖动一下。而实际上研发在做服务容量规划时,看的往往就是这 1% 概率出现的性能短板,为这 1% 的情况堆上大部分时候可能并用不到的计算资源。

虽然这里的每一个子问题或多或少都有一定的通用解法,但对程序性能影响最大的往往就是程序自身的代码。只有了解这些限制与原理,才能针对不确定的现实世界写出相对有确定性性能的代码。