理解Go、容器以及Linux调度器

Go开发的应用程序通常部署在容器中 。在容器中运行时,重要的一点是要设置CPU限制以确保容器不会耗光主机上的所有CPU 。但Go运行时不知道容器上设置的CPU限制 , 因此有可能会把所有可用的CPU都用光 , 从而造成应用延迟很高 。这个问题曾经困扰过我 , 在这篇文章中,我将解释发生了什么以及如何修复 。
Go垃圾收集器是如何工作的这是对Go垃圾收集器(GC)的概要介绍 , 想要更深入了解,建议阅读Go文档[2]以及Will Kennedy的系列文章[3] 。
绝大多数情况下 , Go运行时在执行程序的同时执行垃圾收集,这意味着GC会与程序同时运行 。然而,在GC过程中有两个点需要Go运行时暂停所有Goroutine,从而确保数据完整性 。在GC标记阶段(Mark Phase)之前,运行时将暂停所有Goroutine,用以启用写屏障(write barrier) , 确保在此之后创建的任何对象都不会被GC,这个阶段称为扫描终止(Sweep Termination) 。在标记阶段完成后,还有一个STW(stop the world)阶段,被称为标记终止(Mark Termination),并且也是删除写屏障的过程 。整个流程通常需要几十微秒 。
我创建了一个简单的web应用,分配了大量内存,并使用以下命令在一个限制为4个CPU核的容器中运行,源代码在Github[4]上 。
Docker run --cpus=4 -p 8080:8080 $(ko build -L mAIn.go)值得注意的是,docker CPU限制是硬性限制 。可以设置--CPU-shares,表示只在主机CPU受限时强制执行 。这意味着如果主机有空闲容量 , 容器可以使用超出分配的CPU核 。但是如果主机资源受限,那么应用程序也将受到限制 。
可以使用runtime/trace[5]包收集trace,然后用go tool trace对其进行分析 。下面的trace显示了在我的机器上捕获的一个GC周期 , 可以看到在Proc 5中STW阶段的扫描终止和标记终止 。

理解Go、容器以及Linux调度器

文章插图
这个GC周期只花了不到2.5ms,但我们在STW阶段花费了近10%的时间 。这是相当长的一段时间,特别是对于延迟敏感应用来说 。
linux调度器完全公平调度程序(Complete Fair Scheduler, CFS)[6]是在Linux 2.6.23中引入的 , 在2023年10月份发布的Linux 6.6之前一直是默认调度程序,很可能你正在使用CFS 。
CFS是一个比例共享调度器[7],意味着进程权重与允许使用的CPU内核数量成正比 。例如,如果允许一个进程使用4个CPU核 , 那么它的权重将为4 。如果一个进程被允许使用2个CPU核心,它的权重将为2 。
CFS通过分配一小部分CPU时间来实现 , 一个4核系统每秒钟有4秒的CPU时间可以分配 。当我们为容器分配多个CPU内核时,实际上是要求Linux调度器给它n个CPU的时间 。
在上面的docker run命令中 , 指定了4个CPU,意味着容器每秒将获得4秒的CPU时间 。
问题当Go运行时启动时,为每个CPU内核创建一个操作系统线程 。这意味着如果有一个16核的机器,Go运行时将创建16个操作系统线程,不管任何CGroup CPU限制 。然后Go运行时使用这些操作系统线程来调度程序 。
问题是Go运行时不知道CGroup的CPU限制,而是在所有16个操作系统线程上调度goroutine,意味着Go运行时预计每秒能够使用16秒的CPU时间 。
由于Go运行时需要在等待Linux调度器调度的线程上停止gooutine,因此将面临长时间的STW时间,因为一旦容器使用超过了CPU配额,线程就不会被调度 。
解决方案Go通过设置GOMAXPROCS环境变量限制运行时将创建的CPU线程数量 。这一次 , 使用以下命令来启动容器:
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)下面是从与上面相同的应用程序捕获的trace , 现在使用与CPU配额匹配的GOMAXPROCS环境变量 。
理解Go、容器以及Linux调度器

文章插图
在这个trace中,尽管负载完全相同,但垃圾收集时间要短得多 。GC周期小于1ms,STW时间为26μs,约为无限制时的1/10 。
GOMAXPROCS应该设置为容器允许使用的CPU核数,通常情况应该向下取整,如果分配的CPU内核少于1个,则向上取整 。可以用GOMAXPROCS=max(1, floor(cpu))来计算 。Uber开源了一个库automaxprocs[8]来自动从容器的cgroups中计算这个值 。
有一个Github问题[9]支持将这个特性添加到Go运行时中,使其开箱即用 , 希望最终会被Go运行时接受!
结论在容器化应用程序中运行Go时,设置CPU限制非常重要 。通过设置合理的GOMAXPROCS值或使用像automaxprocs这样的库,确保Go运行时意识到这些限制也很重要 。


推荐阅读