2021年Go开发者调查(https://go.dev/blog/survey2021-results)表明,用Go编写服务是最常见的用法,见下图。与此同时,Kubernetes是部署这些服务最广泛使用的平台。
理解Go程序是如何在Docker和Kubernetes中运行的至关重要,这样可以防止常见问题产生。比如CPU受限。
在Go语言中常见100问题-#56 Concurrency isn’t always faster中提到,设定GOMAXPROCS可以调整运行时中P(GMP中的P)数量,由于每个系统线程必须要绑定P才能真正地执行,所以P的数量影响程序的并发性。
默认情况下,GOMAXPROCS被设置为操作系统可见的逻辑CPU内核数,这在Docker和Kubernetes环境中有啥影响呢?下面举例说明:
假设我们的Kubernetes集群由八核节点组成,当在Kubernetes中部署一个容器时,可以定义CPU限制来确保应用不会消耗掉所有的主机资源。如下,配置CPU的使用限制为4000m,这里单位后缀m表示千分之一核,也就是说 1 Core = 1000m,所以4000m对应4个CPU核。
现假定我们的应用在部署时,基于上述配置限制GOMAXPROCS值被设置为4。但实际是这样的吗?答案是否定的,GOMAXPROCS实际被设置为主机上逻辑核心的数量8,这会导致什么问题呢?
Kubernetes使用完全公平调度器(CFS)作为进程调度器,此外CFS还会强制按Pod限制的CPU资源执行。在管理Kubernetes集群时,管理员可以配置如下两个参数:
第一个参数设置时长,第二个参数是额度配置。默认情况下,时长设置为100毫秒。额度配置表示应用在100毫秒内可以消耗的CPU时间,默认是-1表示不设置硬限。限制为4个内核意味着总时长为400毫秒(4*100毫秒)。因此CFS保证应用在100毫秒内不会消耗超过400毫秒的CPU时间。
现在有这样一个场景,多个goroutines正在四个不同线程上运行,每个线程被调度到不同的内核(1、3、4和8),如下图所示。
在第一个100毫秒时间内,有四个线程处于忙碌状态,总共消耗了400毫秒时间,即达到限额的100%。在第二个100毫秒时间内,总共消耗了360毫秒,第三个100毫秒时间消耗了275毫秒,它们都没有超过400毫秒限制,一切工作良好。
但是,实际GOMAXPROCS值为8,因此在最坏情况下,可以有八个线程都在运行,每个线程被安排在不同内核上,如下图。
因为配额为400毫秒,如果有8个线程忙于执行goroutines,则50毫秒后就达到400毫秒(8*50毫秒=400毫秒)。接下来CFS将限制CPU资源,因此在下一个周期开始前,没有CPU资源可用。意味着我们的应用将被搁置50毫秒。
这种情况下,平均延迟为50毫秒的服务可能需要150毫秒才能完成,这可能对延迟造成300%的损失。
有什么解决方法吗?关注Go语言第33803 issue问题进展(github.com/golang/go/issues/33803),也许在将来的Go版本中,GOMAXPROCS会支持CFS。
当前解决方法是使用uber公司提供的automaxprocs库(github.com/uber-go/automaxprocs)。使用很简单,在main.go文件中添加一个go.uber.org/automaxprocs空导入即可,它会根据容器中的CPU配额自动设置GOMAXPROCS,前面的例子中,GOMAXPROCS被设置为4而不是宿主机CPU数8,从而避免CPU throttling。