写点什么

Go、容器以及 Linux 调度器

作者:俞凡
  • 2023-11-18
    上海
  • 本文字数:1906 字

    阅读完需:约 6 分钟

在容器中运行 Go 应用程序时,需要设置合理的 GOMAXPROCS,从而避免调度中因为资源不足而造成 STW。原文: Go, Containers, and the Linux Scheduler



Go 开发的应用程序通常部署在容器中。在容器中运行时,重要的一点是要设置 CPU 限制以确保容器不会耗光主机上的所有 CPU。但 Go 运行时不知道容器上设置的 CPU 限制,因此有可能会把所有可用的 CPU 都用光,从而造成应用延迟很高。这个问题曾经困扰过我,在这篇文章中,我将解释发生了什么以及如何修复。

Go 垃圾收集器是如何工作的

这是对 Go 垃圾收集器(GC)的概要介绍,想要更深入了解,建议阅读Go文档以及Will Kennedy的系列文章


绝大多数情况下,Go 运行时在执行程序的同时执行垃圾收集,这意味着 GC 会与程序同时运行。然而,在 GC 过程中有两个点需要 Go 运行时暂停所有 Goroutine,从而确保数据完整性。在 GC 标记阶段(Mark Phase)之前,运行时将暂停所有 Goroutine,用以启用写屏障(write barrier),确保在此之后创建的任何对象都不会被 GC,这个阶段称为扫描终止(Sweep Termination)。在标记阶段完成后,还有一个 STW(stop the world)阶段,被称为标记终止(Mark Termination),并且也是删除写屏障的过程。整个流程通常需要几十微秒。


我创建了一个简单的 web 应用,分配了大量内存,并使用以下命令在一个限制为 4 个 CPU 核的容器中运行,源代码在Github上。


docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
复制代码


值得注意的是,docker CPU 限制是硬性限制。可以设置--CPU-shares,表示只在主机 CPU 受限时强制执行。这意味着如果主机有空闲容量,容器可以使用超出分配的 CPU 核。但是如果主机资源受限,那么应用程序也将受到限制。


可以使用runtime/trace包收集 trace,然后用go tool trace对其进行分析。下面的 trace 显示了在我的机器上捕获的一个 GC 周期,可以看到在Proc 5中 STW 阶段的扫描终止和标记终止。



这个 GC 周期只花了不到 2.5ms,但我们在 STW 阶段花费了近 10%的时间。这是相当长的一段时间,特别是对于延迟敏感应用来说。

Linux 调度器

完全公平调度程序(Complete Fair Scheduler, CFS)是在 Linux 2.6.23 中引入的,在 2023 年 10 月份发布的 Linux 6.6 之前一直是默认调度程序,很可能你正在使用 CFS。


CFS 是一个比例共享调度器,意味着进程权重与允许使用的 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环境变量。



在这个 trace 中,尽管负载完全相同,但垃圾收集时间要短得多。GC 周期小于 1ms,STW 时间为 26μs,约为无限制时的 1/10。GOMAXPROCS应该设置为容器允许使用的 CPU 核数,通常情况应该向下取整,如果分配的 CPU 内核少于 1 个,则向上取整。可以用GOMAXPROCS=max(1, floor(cpu))来计算。Uber 开源了一个库automaxprocs来自动从容器的 cgroups 中计算这个值。


有一个Github问题支持将这个特性添加到 Go 运行时中,使其开箱即用,希望最终会被 Go 运行时接受!

结论

在容器化应用程序中运行 Go 时,设置 CPU 限制非常重要。通过设置合理的GOMAXPROCS值或使用像 automaxprocs 这样的库,确保 Go 运行时意识到这些限制也很重要。




你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!

发布于: 刚刚阅读数: 4
用户头像

俞凡

关注

公众号:DeepNoMind 2017-10-18 加入

俞凡,Mavenir Systems研发总监,关注高可用架构、高性能服务、5G、人工智能、区块链、DevOps、Agile等。公众号:DeepNoMind

评论

发布
暂无评论
Go、容器以及Linux调度器_golang_俞凡_InfoQ写作社区