为什么Go服务容器化之后延迟变高

为什么Go服务容器化之后延迟变高

在今年的gopherchina上发现有两三个topic都在说Go服务容器化的P(如果你不知道P是什么,没有关系,马上就会解释)数量的问题,现象是被容器限制了4C的服务跑在96C的机器上,相比于物理机部署的延时高了很多。原因是因为Go启动的时候读的宿主机的CPU核心数,启动了96个P,96个P都在找G去执行但是只有4C的处理时间,导致大量CPU耗费在找G和上下文切换。解决方案就是引入uber的automaxprocs这个库就可以了。

我回公司之后通过trace发现自己的2C服务确实起了很多P,于是先在自己业务上加上了这个库,经过一周的观察发现并没有什么变化,后来得的结论是服务的QPS不够高,不能引发问题(后面会详细解释)。

后来同事的一个高QPS服务也遇到了这个问题,加上了这个库马上突破了那时候的QPS限制。再后来公司的框架升级,准备把这个库默认启动,我就对框架做了一系列的压测,对这个问题又深入了,所以写这篇文章总结一下。

因为事先已经知道结果了,所以你看的时候可能感觉是反过来了,我是从结论出发一步步推演问题原因。

一、Go 调度知识扫盲

在就不深入讲解GMP的调度模型,为了不影响初学者阅读,我只是简单介绍一下,理解的可以直接跳过这一小段。

Go是在runtime实现的任务调度,每个go关键字都会创建一个用户任务,称之为G;但是线程才是操作系统调度的最小单位,用户任务需要系统线程去执行,那么就需要创建系统线程,我们称之为M;这个就是Go 1.0及其之前的调度器。

后来感觉不行了,如果把所有的任务放在全局的话,每次执行一个任务都要加锁、以前的资源都是挂在M上的,陷入内核态的时候保存和恢复浪费内存以及G没有亲和性可言(在一个M上运行的G被挂起之后,下次执行很大概率不会到这个M,当时的缓存也就没有用了)。

我们可以先暂停一下看下linux的任务调度是怎么样的,简单的一句话:进程是资源的最小单位,线程是调度的最小单位。Go的runtime把G当作最小调度单位了,那么在加一个资源的最小单位不就得了么,这个时候在M和G之间的P就呼之欲出了,通过P这个结构来看下P的作用:

type p struct {
  // ...
  mcache *mcache
  pcache pageCache
  runq [256]guintptr
  runnext guintptr
 
  // ...
  sudogcache []*sudog
  sudogbuf [128]*sudog
   
  // ...
  mspancache struct {
    len int
    buf [128]*mspan
  }
  // ...
}

主要的作用有两个:

1、 通过增加本地队列减少锁操作:有个本地队列runq以及优先级最高的runnext,也就是说一个P最多包含257个G;

2、 内存管理:mcache、pcache以及mspancache这些东西一看就是管理内存的,现在可以简单的理解为他们管理的内存大小不一样,内存管理名词太多,再说能说到天亮;

那么有两个问题自然而然的出现了:

为什么Go服务容器化之后延迟变高

问题一、如果一个P上的G超过了257个咋办?

答:G还有一个全局队列,如果超过了就会放在全局队列上,全局队列是个链表,无限长。

问题二、如果一个P上的所有G执行完了咋办?

答:这个就要从P寻找可用的G来说了,对应的函数是findrunnable(至少记住这个名字,后面还会看到),优先级如下:

1、 如果P的runnext存在有就用这个G【不加锁】,如果没有往下做

2、 从P的local queue中顺序拿一下【不加锁】,如果local queue为空没有往下走

3、 从全局队列中拿一个【加锁】,顺便从全局队列拿出128个给这个P(如果全局队列中没有128个就都给它,省的以后总来要),如果全区队列为空就继续往下走

4、 从netpoll中拿,如果有返回第一个,剩下的通过injectglist放到全局队列中【加锁】,如果没有往下走

5、 通过runqsteal函数随机从其他的P的本地队列中偷一半给当前P,然后将偷到的最后一个返回,如果还没有,那就算了,将P设置为空闲状态并且将M从P中拿下来,但是不会kill M。

这两个问题之后,你可能还会有两个问题:

问题三、系统线程不会被kill么

答:确实是这样的,如果开启100系统线程空闲之后,那岂不是这个100个系统线程都不会被kill就在那静静的等着。

所以Go服务最好不要用太多的CPU,几百个线程就将近一个G的内存了,你这台机器要是再部署多个就尴尬了。

问题四、goroutine没有优先级么

答:没有优先级,你要么少开点goroutine或者改runtime的代码。

用下面例子可视化一下流程:

func main() {
 runtime.GOMAXPROCS(4)
 wg := sync.WaitGroup{}
 for i := 0; i <1000; i++ {
  wg.Add(i)
  i := i
  go func() {
   // 假装做一些计算操作
   fmt.Println(i)
  }()
 }
 wg.Done()
}

这个程序会创建1k个goroutine,当创建两个的时候如下图:

为什么Go服务容器化之后延迟变高

在初始化的时候P就会创建出来4个,但是并不会马上创建出来4个系统线程。会等到使用的时候才会去创建,类似于COW的思路。

当所有goroutine都创建出来的时候:

为什么Go服务容器化之后延迟变高

现象

环境

  • Go版本:1.13

  • 物理机核数: 96C

  • 容器设置的CPU:4C

下图中绿色表示通过runtime.GOMAXPROCS()讲P的数量设置为4,即服务能使用的核心数。蓝色表示没有管runtime.GOMAXPROCS(),P的数量默认设置为物理的核心数上(后面会解释为什么)。

为什么Go服务容器化之后延迟变高

我们分别记录了从200Q到4000Q几个段设置runtime.GOMAXPROCS(4)与否的CPU使用内存使用平均延时单POD开启线程数以及CPU受限率的对比。

很明显能看出,当QPS超过1K的时候差距变得特别明显,当压测到4000Q的时候未设置runtime.GOMAXPROCS(4)的CPU使用是设置了的2.2倍,后面的差距越来越大,CPU高了延迟自然就大了起来。设置runtime.GOMAXPROCS(4)的服务还能压到1wQ。

三、CPU为什么会高

我抓了一下,没设置runtime.GOMAXPROCS(4)的情况下limit是4C的pod跑在96C机器上的pprof截图:

为什么Go服务容器化之后延迟变高

能清楚的看到自己服务执行的时间才占了一半的时间。我们再来看下除了http服务哪个函数占用的时间长:

为什么Go服务容器化之后延迟变高

直接看下mcall这个东西:

为什么Go服务容器化之后延迟变高

如果你理解了P找到G的过程就不难理解上述函数的作用,以及为什么findrunable会消耗CPU,因为有96个P去都会执行findrunable找G,执行任务的线程用的CPU就相对少了,因为一共就那么多CPU。

四、为什么CPU受限率高

GMP也是一个生产消费模型,我们的程序去生产G,然后M消费,P负责中间协调,但是上述场景生产速率过高,消费能力太弱(96:4)这就会导致任务挤压。在docker场景中表现出来就是CPU受限率特别高,如果你在一个4C的机器上开启设置runtime.GOMAXPROCS(96)可能表现就是机器挂到了。这个时候就变为了下图:

为什么Go服务容器化之后延迟变高

CPU限制小加餐

在Linux中有专门的Cgroup文件系统(可以通过mount -t cgroup看到),定义了很多子系统,能对某个进程限制CPU使用量、跑在哪几个核上以及限制内存的使用量等。

通过一个例子详细的介绍一些cgroups,我们通过下面的命令启动一个nginx容器:

docker run -d \
--cpu-shares 513 \
--cpus 2 --cpuset-cpus 1,3 \
--memory 1024M \
--memory-swap 1234M \
--memory-swappiness 7 -p 8081:80 nginx
bbdadec016e2667a1de24f2e683bfa84c6a5c0ed1add6e1a4ae4e289e004eefc
[root@172_27_132_95 ~]# docker ps | grep nginx
bbdadec016e2        nginx
  • –cpu-shares: 在多个进程之间,使用CPU的比例
  • –cpus: 用几个cpu,这个是由cpu.cfs_period_uscpu.cfs_quota_us共同决定
  • –cpuset-cpus: 这容器用在哪几个cpu上执行
  • –memory: 内存的限制
  • –memory-swap: 设置交换分区的大小,一般要大于memory
  • –memory-swappiness: 使用swap memory的比例,0~100区间,0表示尽量不要用swap,100表示尽量使用swap

在/sys/fs/cgroup/cpu,cpuacct下面是对CPU的资源控制配置文件:

# ll /sys/fs/cgroup/cpu,cpuacct
...
drwxr-xr-x   7 root root 0 11月  9 17:24 docker
...

主要这个docker目录:

ll /sys/fs/cgroup/cpu,cpuacct/docker/
总用量 0
bbdadec016e2667a1de24f2e683bfa84c6a5c0ed1add6e1a4ae4e289e004eefc
...

和乱码一样的一长串东西就是我们刚才创建nginx的容器id,再进去看看:

# ll /sys/fs/cgroup/cpu,cpuacct/docker/bbdadec016e2667a1de24f2e683bfa84c6a5c0ed1add6e1a4ae4e289e004eefc/
-rw-r--r-- 1 root root 0 11月 11 09:53 cpu.cfs_quota_us
-rw-r--r-- 1 root root 0 11月 11 09:53 cpu.rt_period_us
...
-rw-r--r-- 1 root root 0 11月 11 09:53 tasks

tasks表示这个容器启动了多少进程,看一下就知道了:

cat /sys/fs/cgroup/cpu,cpuacct/docker/f9ab36a90a5a3cb0ca3276f3937eb875b32bb201255a34783b8778fcf07717c8/tasks
1753
2059

我们可以在机器上看一下这两个进程就是父子关系:

# pstree 1753
nginx───nginx
# pstree 2059
nginx

我们再来看下如何限制CPU的使用量,我们通过–cpus 2设置了这个容器只使用2C的CPU。这是通过下面两个参数共同决定的:

  • cpu.cfs_period_us:指的是运行周期
  • cpu.cfs_quota_us:这个周期内这个进程占用多少时间

我们设置了cpus为2,代表的意思是在10000ms的运行周期内占用20000ms的时间,即要两个CPU。

# cat /sys/fs/cgroup/cpu,cpuacct/docker/bbdadec016e2667a1de24f2e683bfa84c6a5c0ed1add6e1a4ae4e289e004eefc/cpu.cfs_quota_us
200000
# cat /sys/fs/cgroup/cpu,cpuacct/docker/bbdadec016e2667a1de24f2e683bfa84c6a5c0ed1add6e1a4ae4e289e004eefc/cpu.cfs_period_us
100000

对于其他cgroups限制的分析交给兄弟们自己分析,要不然就写的太长了。

五、如何解决

这里我们抛开Go语言,谈谈如何将多核CPU用到极致,比如8C的CPU:

  • 我们开6个线程可不可以,就算每个线程分别在一个CPU上执行那还剩两个CPU没用呢
  • 我们开10个线程可不可以,因为总会有两个线程需要等待执行,当执行到它们的时候就意味着会有上下文的切换,上下文切换就要保存现场、设置寄存器就会浪费时间
  • 开8个线程的话,就会让每个线程充分的利用CPU资源,如果没有新任务就不进行切换
  • 那如果开200个线程呢,我们且不说200个线程占用多少内存,这个比开启10个线程更加损失CPU的亲和性以及不可估量的上下文切换时间

最好的策略就是将runtime.GOMAXPROCS设置为自己服务申请的CPU核心数量。每次让业务自己手动设置runtime.GOMAXPROCS等于自己服务占用核数好像有点不合理,怎么能让业务和运行环境如此耦合呢!

我们知道Go程序启动的时候getncpu通过sysctl获取CPU核心数,它是读的/proc/cpuinfo文件,并不是通过被cgroups限制的cpu子系统计算来的,所以我们通过读容器内的文件就能计算得到,首先我们先拿到我们使用了哪些cgroups(cat /proc/self/cgroup):

12:devices:/kubepods/burstable/pod5bb7d18f-1440-4104-bf00-bf4998acc6ff/c2ccf53dabe5ecde2447e7e1d51de77b7f23ddf79b5a8714c46d77f0b4b1ef1b
11:cpuset:/kubepods/burstable/pod5bb7d18f-1440-4104-bf00-bf4998acc6ff/c2ccf53dabe5ecde2447e7e1d51de77b7f23ddf79b5a8714c46d77f0b4b1ef1b
10:blkio:/kubepods/burstable/pod5bb7d18f-1440-4104-bf00-bf4998acc6ff/c2ccf53dabe5ecde2447e7e1d51de77b7f23ddf79b5a8714c46d77f0b4b1ef1b
9:freezer:/kubepods/burstable/pod5bb7d18f-1440-4104-bf00-bf4998acc6ff/c2ccf53dabe5ecde2447e7e1d51de77b7f23ddf79b5a8714c46d77f0b4b1ef1b
8:perf_event:/kubepods/burstable/pod5bb7d18f-1440-4104-bf00-bf4998acc6ff/c2ccf53dabe5ecde2447e7e1d51de77b7f23ddf79b5a8714c46d77f0b4b1ef1b
7:pids:/kubepods/burstable/pod5bb7d18f-1440-4104-bf00-bf4998acc6ff/c2ccf53dabe5ecde2447e7e1d51de77b7f23ddf79b5a8714c46d77f0b4b1ef1b
6:cpu,cpuacct:/kubepods/burstable/pod5bb7d18f-1440-4104-bf00-bf4998acc6ff/c2ccf53dabe5ecde2447e7e1d51de77b7f23ddf79b5a8714c46d77f0b4b1ef1b
...

用:分割,分别是id、子系统、挂载根路径。

因为docker通过Mount namespace已经修改了我们的挂载点,通过cat /proc/self/mountinfo拿到修改后挂载点:

7040 7036 0:24 /kubepods/burstable/pod5bb7d18f-1440-4104-bf00-bf4998acc6ff/c2ccf53dabe5ecde2447e7e1d51de77b7f23ddf79b5a8714c46d77f0b4b1ef1b /sys/fs/cgroup/net_cls ro,nosuid,nodev,noexec,relatime master:10 - cgroup cgroup rw,net_cls
7041 7036 0:25 /kubepods/burstable/pod5bb7d18f-1440-4104-bf00-bf4998acc6ff/c2ccf53dabe5ecde2447e7e1d51de77b7f23ddf79b5a8714c46d77f0b4b1ef1b /sys/fs/cgroup/memory ro,nosuid,nodev,noexec,relatime master:11 - cgroup cgroup rw,memory
7044 7036 0:28 /kubepods/burstable/pod5bb7d18f-1440-4104-bf00-bf4998acc6ff/c2ccf53dabe5ecde2447e7e1d51de77b7f23ddf79b5a8714c46d77f0b4b1ef1b /sys/fs/cgroup/cpu,cpuacct ro,nosuid,nodev,noexec,relatime master:14 - cgroup cgroup rw,cpu,cpuacct
7052 7036 0:33 /kubepods/burstable/pod5bb7d18f-1440-4104-bf00-bf4998acc6ff/c2ccf53dabe5ecde2447e7e1d51de77b7f23ddf79b5a8714c46d77f0b4b1ef1b /sys/fs/cgroup/cpuset ro,nosuid,nodev,noexec,relatime master:19 - cgroup cgroup rw,cpuset
...

以空格分割,分别是mountID、parentID、deviceID、挂在根结点挂载点、可选配置、可选field、-表示可选参数结束、文件系统类型、文件系统挂在源、额外配置

我们以第一条为例,选取关注的三个信息:

  • 挂在根结点(即宿主机的目录): kubepods/burstable/pod5bb7d18f-1440-4104-bf00-bf4998acc6ff/c2ccf53dabe5ecde2447e7e1d51de77b7f23ddf79b5a8714c46d77f0b4b1ef1b
  • 挂载点(即容器目录):/sys/fs/cgroup/net_cls
  • 额外配置:rw,net_cls,用逗号分隔,net_cls表示这是net_cls子系统,同理cpuset表示这是cpuset子系统

如果这个进程里面使用了cpu 子系统并且在对应的挂载信息额外配置中包含cpu cgroups,那么对应的挂载点就是在宿主机上对这个进程的限制,将cpu.cfs_period_us和cpu.cfs_quota_us读出来做除法就行了。

如果你理解了这个过程,那么你也自然理解uber-go/automaxprocs的原理。

总结

这是一个在容器化Go服务必须要面对了问题,这次我们从结论出发相比于从问题出发缺少了一下分析过程,以及排查思路。但是能够从更多更全的指标分析这个问题,串起来从上层应用到Go runtime调度最后到内核如何对一个进程进行资源限制的整个过程。

欢迎扫描下面的二维码关注云影原生公众号
为什么Go服务容器化之后延迟变高

上一篇:进程线程(五) groups和CPU资源分群分配


下一篇:findmnt、lsblk、mount 命令查看磁盘、目录挂载、挂载点以及文件系统格式等情况