开发内功修炼CPU篇

最近网络在爆炒一篇标题为《互联网不需要中年人》,疯狂渲染35岁的码农的前程问题,制造焦虑。本来我觉得这个事情应该只是媒体博眼球的一个炒作而已。不过恰恰最近面试了有70多人,其中有很多工作7,8年以上的的同学。这些人里基本上可以非常明确地划分成两类。第一类是虽然工作了7,8年以上了,但是所有的经验都集中在业务层。换句话说,并不是有7-8年经验,而是工作了7-8年而已。稍微深入问一点性能相关的问题都没有好的思路,技术能力并没有随着工作年限的增长而增长。也确实为这种类型的同学的前途感到担忧。另外一类同学都是除了业务的开发以外,还会额外抽时间注重自己的技术积累,成长的就比较好,对于他们我觉得别说35岁了,45岁也仍然会是团队的中流砥柱。

对于第一类同学来讲,技术成长过慢也不完全是他们自己的原因。现在国内的开发环境中都存在着一个普遍的矛盾,那就是排期过满的需求开发和需要大把时间去磨练技术的矛盾。 排期过满导致开发同学平时绝大部分的时间都是在处理各种各样的业务逻辑和bug。很多同学的时间全部被这种需求吃掉了,导致没有想法或精力去提升自己的底层技术能力,导致技术level并没有随着工作年限而同比增长。平时大家都是用各种语言进行业务逻辑的代码编写,无论你用的是PHP、Go、还是Java,都是属于应用层的范畴。但是应用层是建立在物理层和内核层之上的。如果遇到一些性能问题的时候,很可能需要你对底层有足够深的理解,才能够有效优化性能或排除线上故障。内核和物理层的知识往往又比较难吃透,不投入足够多的时间是难有大的突破。

我把在应用层的技术能力称之为外功,把Linux内核、设备物理结构称之为内功。我想大家的外功能力已经都足够优秀了,但是一部分同学的内功能力却严重不足。我账号的名称之所以命名为开发者内功修炼,就是想把我在内核、物理层上的一些理解思考总结分享给大家,帮助这些同学提升自己的技术木桶短板。本轮数十篇的分享中,我把我工作中关于CPU底层性能的先放了出来,后面还会有内存篇,磁盘篇和网络篇。希望能帮助大家提升你对底层的理解,为你的中年焦虑症送上一剂良药。

第一部分,CPU物理层

提起CPU,大家可能都知道它是几核的,主频是多少。但是作为软件开发人员,我们应该对CPU有更为深入的理解。在这一部分的三篇文章中,分别带大家认识了逻辑核、L1、L2、L3三级缓存,以及一个专门用来做页缓存的TLB。

《你以为你的多核CPU都是真的吗?多核“假象”》

提到CPU核数,相信绝大部分的同学想到的都是top命令,直接到自己的服务器上看一下是多少个核。看到的核越多,貌似笑的越开心。比如说说我的CPU,用top命令展开以后,看到了有24核。那么事实真是你想象的这么美好吗?

# top
top - 17:04:51 up 882 days,  1:16,  1 user,  load average: 0.05, 0.05, 0.00  
Tasks: 596 total,   1 running, 595 sleeping,   0 stopped,   0 zombie  
Cpu0  :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu1  :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu2  :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu3  :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu4  :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu5  :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu6  :  0.0%us,  0.0%sy,  0.7%ni, 99.3%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu7  :  0.0%us,  0.3%sy,  0.0%ni, 99.7%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu8  :  0.0%us,  0.3%sy,  0.0%ni, 99.7%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu9  :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu10 :  0.3%us,  0.0%sy,  0.0%ni, 99.7%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu11 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu12 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu13 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu14 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu15 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu16 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu17 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu18 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu19 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu20 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu21 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu22 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st  
Cpu23 :  0.0%us,  0.0%sy,  0.0%ni,100.0%id,  0.0%wa,  0.0%hi,  0.0%si,  0.0%st

那么是否就说明我的机器安装的CPU真的有24核?其实不是的,我们通过top命令看到的CPU核也叫做逻辑核。 说到这里我们先来普及一下基本概念:

  • 物理CPU:主板上真正安装的CPU的个数,通过physical id可以查查看
  • 物理核:一个CPU会集成多个物理核心,通过core id可以查看到物理核的序号
  • 逻辑核: intel运用了超线程技术,一个物理核可以被虚拟出来多个逻辑核,processor是逻辑核序号

好了,我们了解完cpu的基本概念后,来找一台机器真正看一下。在linux系统下,通过 cat /proc/cpuinfo可以看到CPU更为详细的信息,在操作系统的视角看来是有24个逻辑核,但是在物理上很有可能多个逻辑核对应的是同一个物理核。如下所示例子:

  • 查看物理CPU
#cat /proc/cpuinfo | grep "physical id" | sort | uniq  
physical id     : 0  
physical id     : 1

可以看出,该实机有两个物理CPU。

  • 物理核
#cat /proc/cpuinfo| grep "cpu cores"| uniq  
cpu cores       : 6

cpu cores表示每个cpu有6个物理核心,因为有2个物理CPU,所以该机器总共只有12个物理核。而不是top命令里看到的24个,整整少了一半。 Intel是通过超线程技术把一个物理核虚拟出来了多个,故而操作系统层面看到的比实际的物理核要多。我们寻找一下证据

  • 查看逻辑CPU
#cat /proc/cpuinfo  | grep -E "core id|process|physical id"  
processor       : 0  
physical id     : 0  
core id         : 0  
......  
processor       : 12  
physical id     : 0  
core id         : 0  
......  
processor       : 23  
physical id     : 1  
core id         : 10

processor就是逻辑核的序号,可以看出该机器总共有24个逻辑核。大家注意看processor 0和processor 12的physical id、core id都是一样的,也就说他们他们也处在同一个物理核上。但是他们的processor编号却不一样,一个是0,一个是12。这就是说,这两个核实际上是一个核,只是通过虚拟技术虚拟出来的而已。

  • 超线程作用
    超线程里的2个逻辑核实际上是在一个物理核上运行的,模拟双核心运作,共享该物理核的L1和L2缓存。物理计算能力并没有增加,超线程技术只有在多任务的时候才能提升机器核整体的吞吐量。而且据Intel官方介绍,相比实核,平均性能提升只有20-30%左右。也就是说,在我刚才的机器上看到的24核的处理能力,整体上只比不开超线程的12核性能高30%。让我们再用开发者使用的进程来看,由于你的进程被其它进程分享了L1、L2,这就导致cache miss变多,性能会比不开超线程要差。

所以说,操作系统看到的24核只是一个“假象”。

------------------------------------------------------------------------------------------------------

top命令里看到的可能不是真正的物理CPU核,而有可能是Intel用超线程技术虚拟出来的核。这种核的两个核也就大约只能抵得上不开超线程下的物理核的性能的1.3倍左右。因此大家不要top一看很多核,就觉得自己机器CPU超级厉害,你应该明确知道你的服务器上是真物理核,还是虚拟核。

《听说你只知内存,而不知缓存?CPU表示很伤心》

一般我们的开发同学们都知道自己机器的CPU是几核、内存是多大。但是对于CPU内部对程序性能影响较大的缓存却是一知半解。有些开发同学都是计算机的缓存有L1、L2、L3,但是再详细一点的问题,可能就很少有同学能答的完整了。如果下面这几个问题你能脱口而出,请跳过本节。例如:

  • 缓存究竟在哪里?
  • L1有几种?
  • 你的缓存有几级,分别是多大?
  • 你的24核的机器,一二三级缓存分别有几个,存在共享的情况吗?

其实缓存对计算机程序运行性能影响极大,但是他们在开发同学心目中的存在感却不如内存高。要知道CPU缓存以及缓存算法的设计是现代CPU设计的核心任务之一。飞哥觉得缓存们一定感到很伤心。

Intel CPU体系结构

其实在286之前的时代的CPU本是没有缓存的,因为当时的CPU和内存速度差异没有现在这么大,CPU直接访问内存。但是到386时代,CPU和内存的速度不匹配了,第一次出现了缓存。而且最早的缓存并没有放在CPU模块里,而是放在主板上的。再往后CPU越来越快,现在CPU的速度比内存要快百倍以上,所以就逐步演化出了L1、L2、L3三级缓存结构,而且都集成到的CPU芯片里,以进一步提高访问速度。

我们来看下现代Intel的CPU架构的基本结构。

图1 CPU的Cache

L1最接近于CPU,速度也最快,但是容量最小。一般现代CPU的L1会分成两个,一个用来cache data,一个用来cache code,这是因为code和data的更新策略并不相同,而且因为CISC的变长指令,code cache要做特殊优化。 一般每个核都有自己独立的data L1和code L1。
越往下,速度越慢,容量越大。L2一般也可以做到每个核一个独立的。但是L3一般就是整颗CPU共享的了。
UEFIBlog里提供了一个比较好的物理解剖图,比较好地展示了出来:

图2 CPU内部结构图

实际查看

上面介绍的只是笼统的概念。但是每个CPU的缓存都是不一样的,而且“纸上得来终觉浅”,我觉得我们还是有必要进行下一步的实机勘探工作。

Linux的内核的开发者定义了一套框架模型来完成这一目的,它就是CPUFreq系统。
CPUFreq提供的sysfs接口,可以让我们看到比/proc/cpuinfo更为详细的CPU详细信息。

# cd /sys/devices/system/cpu/;ll
drwxr-xr-x 7 root root    0 Apr 15 15:29 cpu0  
drwxr-xr-x 7 root root    0 Apr 15 15:29 cpu1  
......
  • L1一级缓存查看:
# cat cpu0/cache/index0/level  
1  
# cat cpu0/cache/index0/size  
32K  
# cat cpu0/cache/index0/type  
Data  
# cat cpu0/cache/index0/shared_cpu_list  
0,12  
# cat cpu0/cache/index1/level  
1
# cat cpu0/cache/index1/size  
32K  
# cat cpu0/cache/index1/type  
Instruction  
# cat cpu0/cache/index1/shared_cpu_list  
0,12

从上面的level接口可以看出index0和index1都是一级缓存,只不过一个是Data数据缓存,一个是Instruction也就是代码缓存。
等等,上面提到的是每个Core是独立的L1缓存,为什么shared_cpu_list显示有共享?对了我们这里看到的cpu0并不是物理Core,而是逻辑核,都是超线程技术虚拟出来的。 实际上cpu0和cpu12是属于一个物理Core,所以每个Data L1和Instruction是这两个逻辑核共享的。
我的这台电脑里,总共是有12个Data L1,12个Instrunction L1,大小都是32K。

  • L2二级缓存查看:
# cat cpu0/cache/index2/size  
256K  
# cat cpu0/cache/index2/type 
Unified  
# cat cpu0/cache/index2/shared_cpu_list  
0,12

二级缓存要比一级缓存大不少,有256K,但是不分Data和Instruction。另外L2和L1一样,也是总共有12个,每两个逻辑核共享一个L2。

  • L3三级缓存查看:
# cat cpu0/cache/index3/size  
12288K  
# cat cpu0/cache/index3/type  
Unified  
# cat cpu0/cache/index3/shared_cpu_list  
0-5,12-17  
#cat cpu6/cache/index3/shared_cpu_list  
6-11,18-23

L3达到了12M,你去买CPU的时候商品里能看到的缓存属性一般告诉你的就是这个L3属性。因为L3要比L2和L1看起来要大的多,能激发你购买的欲望。但实际上我的这台电脑里L3只有两个,每个CPU各一个,不像是L2、L1有很多。第0-5,12-17号逻辑核共享一个L3,因为它们是在一个物理CPU上。6-11,18-23共享另一个。

另外,Linux上还有个dmidecode命令,也能查看到一些关于CPU缓存的信息,感兴趣的小伙伴们可以试试

# dmidecode -t cache

可能有的同学会问了,我用的操作系统是windows啊,怎么看?打开cmd命令行,输入以下命令试试吧,飞哥在windows上知道的就这么多了,感兴趣的话你自己google上搜搜吧。

# wmic cpu get L2CacheSize,L3CacheSize

扩展知识

Cache Line:我们前面只介绍了各个级别的缓存,但是这里面有个很重要的概念就是Cache Line,就是本级缓存向下一层取数据时的基本单位。可以通过如下方式查看:

# cd /sys/devices/system/cpu/;ll
# cat cpu0/cache/index0/coherency_line_size
64
# cat cpu0/cache/index1/coherency_line_size
64
# cat cpu0/cache/index2/coherency_line_size
64
# cat cpu0/cache/index3/coherency_line_size
64

可以看到L1、L2、L3的Cache Line大小都是64字节(注意是字节)。就是说每次cpu从内存获取数据的时候,都是以该单位来进行的,哪怕你只取一个bit,CPU也是给你取一个Cache Line然后放到各级缓存里存起来。请大家牢牢记住这个概念,以后的文章中我们会用到。

参考文件

------------------------------------------------------------------------------------------------------

CPU缓存是计算机整个存储体系中非常重要的东西,对它们的正确使用会极大提升你的程序的性能。同时我也提供了几个命令,带大家查看你的服务器CPU的L1、L2、L3缓存。我们开发者应该像了解你自己的身高体重一样,了解你服务器CPU的每类的缓存数量、大小,以及是否有和其它核有共享的情况。

《TLB缓存是个神马鬼,如何查看TLB miss?》

介绍TLB之前,我们先来回顾一个操作系统里的基本概念,虚拟内存。

虚拟内存

在用户的视角里,每个进程都有自己独立的地址空间,A进程的4GB和B进程4GB是完全独立不相关的,他们看到的都是操作系统虚拟出来的地址空间。但是呢,虚拟地址最终还是要落在实际内存的物理地址上进行操作的。操作系统就会通过页表的机制来实现进程的虚拟地址到物理地址的翻译工作。其中每一页的大小都是固定的。这一段我不想介绍的太过于详细,对这个概念不熟悉的同学回去翻一下操作系统的教材。

页表管理有两个关键点,分别是页面大小和页表级数

  • 1.页面大小
    在Linux下,我们通过如下命令可以查看到当前操作系统的页大小
# getconf PAGE_SIZE
4096

可以看到当前我的Linux机器的页表是4KB的大小。

  • 2.页表级数
    • 页表级数越少,虚拟地址到物理地址的映射会很快,但是需要管理的页表项会很多,能支持的地址空间也有限。
    • 相反页表级数越多,需要的存储的页表数据就会越少,而且能支持到比较大的地址空间,但是虚拟地址到物理地址的映射就会越慢。

32位系统的虚拟内存实现:二级页表

为了帮助大家回忆这段知识,我举个例子。如果想支持32位的操作系统下的4GB进程虚拟地址空间,假设页表大小为4K,则共有2的20次方页面。如果采用速度最快的1级页表,对应则需要2的20次方个页表项。一个页表项假如4字节,那么一个进程就需要(1048576*4=)4M的内存来存页表项。
如果是采用2级页表,如图1,则创建进程时只需要有一个页目录就可以了,占用(1024*4)=4KB的内存。剩下的二级页表项只有用到的时候才会再去申请。

图1 二级页表

64位系统的虚拟内存实现:四级页表

现在的操作系统需要支持的可是48位地址空间(理论上可以支持64位,但其实现在只支持到了48位,也足够用了),而且要支持成百上千的进程,如果不采用分级页表的方式,则创建进程时就需要为其维护一个2的36次方个页表项(64位Linux目前只使用了地址中的48位的,在这里面,最后12位都是页内地址,只有前36位才是用来寻找页表的), 2的36次方*4Byte=32GB,这个更不能忍了。 也必须和32位系统一样,用分级页表的方法降低内存使用,但级数要更多一点。

Linux在v2.6.11以后,最终采用的方案是4级页表,分别是:

  • PGD:page Global directory(47-39), 页全局目录
  • PUD:Page Upper Directory(38-30),页上级目录
  • PMD:page middle directory(29-21),页中间目录
  • PTE:page table entry(20-12),页表项

图2 四级页表

这样,一个64位的虚拟空间,初始创建的时候只需要维护一个2的9次方大小的一个页全局目录就够了,现在的页表数据结构被扩展到了8byte。这个页全局目录仅仅需要(2的9次方*8=)4K,剩下的中间页目录、页表项只需要在使用的时候再分配就好了。Linux就是通过这种方式支持起(2的48次方=)256T的进程地址空间的。

页表带来的问题

上面终于费劲扒了半天Linux虚拟内存的实现,我终于可以开始说我想说的重点了。
虽然创建一个支持256T的地址空间的进程在初始的时候只需要4K的页全局目录,但是,这也带来了额外的问题,页表是存在内存里的。那就是一次内存IO光是虚拟地址到物理地址的转换就要去内存查4次页表,再算上真正的内存访问,最坏情况下需要5次内存IO才能获取一个内存数据!!

TLB应运而生

和CPU的L1、L2、L3的缓存思想一致,既然进行地址转换需要的内存IO次数多,且耗时。那么干脆就在CPU里把页表尽可能地cache起来不就行了么,所以就有了TLB(Translation Lookaside Buffer),专门用于改进虚拟地址到物理地址转换速度的缓存。其访问速度非常快,和寄存器相当,比L1访问还快。

我本来想实际看一下TLB的信息,但翻遍了Linux的各种命令,也没有找到像sysfs这么方便查看L1、L2、L3大小的方法。仅仅提供下图供大家参考吧! (谁要是找到了查看TLB的命令,别忘了分享给飞哥啊,谢谢!)

图3 CPU的TLB大小

有了TLB之后,CPU访问某个虚拟内存地址的过程如下

  • 1.CPU产生一个虚拟地址
  • 2.MMU从TLB中获取页表,翻译成物理地址
  • 3.MMU把物理地址发送给L1/L2/L3/内存
  • 4.L1/L2/L3/内存将地址对应数据返回给CPU

由于第2步是类似于寄存器的访问速度,所以如果TLB能命中,则虚拟地址到物理地址的时间开销几乎可以忽略。如果想了解TLB更详细的工作机制,请参考《深入理解计算机系统-第9章虚拟内存》

工具

既然TLB缓存命中很重要,那么有什么工具能够查看你的系统里的命中率呢? 还真有

# perf stat -e dTLB-loads,dTLB-load-misses,iTLB-loads,iTLB-load-misses -p $PID
 Performance counter stats for process id '21047':  

           627,809 dTLB-loads  
             8,566 dTLB-load-misses          #    1.36% of all dTLB cache hits  
         2,001,294 iTLB-loads  
             3,826 iTLB-load-misses          #    0.19% of all iTLB cache hits

扩展

因为TLB并不是很大,只有4k,而且现在逻辑核又造成会有两个进程来共享。所以可能会有cache miss的情况出现。而且一旦TLB miss造成的后果可比物理地址cache miss后果要严重一些,最多可能需要进行5次内存IO才行。建议你先用上面的perf工具查看一下你的程序的TLB的miss情况,如果确实不命中率很高,那么Linux允许你使用大内存页,很多大牛包括PHP7作者鸟哥也这样建议。这样将会大大减少页表项的数量,所以自然也会降低TLB cache miss率。所要承担的代价就是会造成一定程度的内存浪费。在Linux里,大内存页默认是不开启的。

参考文献

------------------------------------------------------------------------------------------------------

除了L1、L2和L3之外,CPU还有一个特殊用途的缓存TLB缓存,专门用来加速逻辑地址到物理地址的转换。如果想极致提升你的程序性能,你要学会用Perf命令还统计TLB的缓存命中率。如果有必要,可以尝试使用内核提供的大内存页。

第二部分,CPU内核层

在认识完CPU物理层的一些知识点后,我们紧接着就上升到了内核层来了解CPU开销。在内核层里,主要有三个东西比较吃CPU,你的代码也时时刻刻在使用它们,只不过它一直是你最熟悉的陌生人而已。如果使用不当,它们会对你的应用程序产生重大影响。他们分别是系统调用、上下文切换和软中断。

对于性能开销我所奉行的原则是不光要知道它开销大,而是要知道它具体多大,要用数字能描述出来才算。对于CPU开销来说,最为准确的方式应该是用CPU的周期数。但是周期数的一个问题是在于读者很难有感性的理解。比如我要是和你说进程上下文切换用了12000个CPU周期,你是不是仍然感觉疑惑,12000个周期是多长?因此本文中我用时间单位来描述CPU开销,我和你说进程上下文切换需要5us,你立马理解了。不过这里一个小问题是这个时间直接套用在你的机器上可能会略有出入,因此只是给你提供的一个近似参考值,并非精确。

1)系统调用

《一次系统调用开销到底有多大?》

首先说说系统调用是什么,当你的代码需要做IO操作(open、read、write)、或者是进行内存操作(mmpa、sbrk)、甚至是说要获取一个系统时间(gettimeofday),就需要通过系统调用来和内核进行交互。无论你的用户程序是用什么语言实现的,是php、c、java还是go,只要你是建立在Linux内核之上的,你就绕不开系统调用。

图1 系统调用在计算机系统中的位置

大家可以通过strace命令来查看到你的程序正在执行哪些系统调用。比如我查看了一个正在生产环境上运行的nginx当前所执行的系统调用,如下:

# strace -p 28927
Process 28927 attached  
epoll_wait(6, {{EPOLLIN, {u32=96829456, u64=140312383422480}}}, 512, -1) = 1  
accept4(8, {sa_family=AF_INET, sin_port=htons(55465), sin_addr=inet_addr("10.143.52.149")}, [16], SOCK_NONBLOCK) = 13  
epoll_ctl(6, EPOLL_CTL_ADD, 13, {EPOLLIN|EPOLLRDHUP|EPOLLET, {u32=96841984, u64=140312383435008}}) = 0  
epoll_wait(6, {{EPOLLIN, {u32=96841984, u64=140312383435008}}}, 512, 60000) = 1  

简单介绍了下系统调用,那么相信各位同学都听说过一个建议,就是系统调用的开销很大,要尽量减少系统调用的次数,以提高你的代码的性能。那么问题来了,我们是否可以给出量化的指标。一次系统调用到底要多大的开销,需要消耗掉多少CPU时间?好了,废话不多说,我们直接进行一些测试,用数据来说话。

使用strace命令进行实验

首先我对线上正在服务的nginx进行strace统计,可以看出系统调用的耗时大约分布在1-15us左右。因此我们可以大致得出结论,系统调用的耗时大约是1us级别的,当然由于不同系统调用执行的操作不一样,执行当时的环境不一样,因此不同的时刻,不同的调用之间会存在耗时上的上下波动。

# strace -cp 8527  
strace: Process 8527 attached  
% time     seconds  usecs/call     calls    errors syscall  
------ ----------- ----------- --------- --------- ----------------  
 44.44    0.000727          12        63           epoll_wait  
 27.63    0.000452          13        34           sendto 
 10.39    0.000170           7        25        21 accept4  
  5.68    0.000093           8        12           write  
  5.20    0.000085           2        38           recvfrom  
  4.10    0.000067          17         4           writev  
  2.26    0.000037           9         4           close  
  0.31    0.000005           1         4           epoll_ctl 

使用time命令进行实验

我们再手工写段代码,对read系统调用进行测试,代码如下

#include <fcntl.h>  
#include <stdio.h> 
#include <stdlib.h> 

int main()  
{  	
	char    c;  
	int     in;
	int 	i;

	in = open("in.txt", O_RDONLY);  
	for(i=0; i<1000000; i++){
		read(in,&c,1);
	}
	return 0;  
}

注意,只能用read库函数来进行测试,不要使用fread。因此fread是库函数在用户态保留了缓存的,而read是你每调用一次,内核就老老实实帮你执行一次read系统调用。

首先创建一个固定大小为1M的文件

dd if=/dev/zero of=in.txt bs=1M count=1

然后再编译代码进行测试

#gcc main.c -o main  
#time ./main  
real    0m0.258s   
user    0m0.030s  
sys     0m0.227s  

由于上述实验是循环了100万次,所以平均每次系统调用耗时大约是200ns多一些。

Perf命令查看系统调用消耗的CPU指令数

x86-64 CPU有一个特权级别的概念。内核运行在最高级别,称为Ring0,用户程序运行在Ring3。正常情况下,用户进程都是运行在Ring3级别的,但是磁盘、网卡等外设只能在内核Ring0级别下来来访问。因此当我们用户态程序需要访问磁盘等外设的时候,要通过系统调用进行这种特权级别的切换

对于普通的函数调用来说,一般只需要进行几次寄存器操作,如果有参数或返回函数的话,再进行几次用户栈操作而已。而且用户栈早已经被CPU cache接住,也并不需要真正进行内存IO。

但是对于系统调用来说,这个过程就要麻烦一些了。系统调用时需要从用户态切换到内核态。由于内核态的栈用的是内核栈,因此还需要进行栈的切换。SS、ESP、EFLAGS、CS和EIP寄存器全部都需要进行切换。

而且栈切换后还可能有一个隐性的问题,那就是CPU调度的指令和数据一定程度上破坏了局部性原来,导致一二三级数据缓存、TLB页表缓存的命中率一定程度上有所下降。

除了上述堆栈和寄存器等环境的切换外,系统调用由于特权级别比较高,也还需要进行一系列的权限校验、有效性等检查相关操作。所以系统调用的开销相对函数调用来说要大的多。我们在test02的基础上计算一下每个系统调用需要执行的CPU指令数。

# perf stat ./main

 Performance counter stats for './main':

        251.508810 task-clock                #    0.997 CPUs utilized
                 1 context-switches          #    0.000 M/sec
                 1 CPU-migrations            #    0.000 M/sec
                97 page-faults               #    0.000 M/sec
       600,644,444 cycles                    #    2.388 GHz                     [83.38%]
       122,000,095 stalled-cycles-frontend   #   20.31% frontend cycles idle    [83.33%]
        45,707,976 stalled-cycles-backend    #    7.61% backend  cycles idle    [66.66%]
     1,008,492,870 instructions              #    1.68  insns per cycle
                                             #    0.12  stalled cycles per insn [83.33%]
       177,244,889 branches                  #  704.726 M/sec                   [83.32%]
             7,583 branch-misses             #    0.00% of all branches         [83.33%]

对实验代码进行稍许改动,把for循环中的read调用注释掉,再重新编译运行

# gcc main.c -o main  
# perf stat ./main  

 Performance counter stats for './main':  

          3.196978 task-clock                #    0.893 CPUs utilized
                 0 context-switches          #    0.000 M/sec
                 0 CPU-migrations            #    0.000 M/sec
                98 page-faults               #    0.031 M/sec
         7,616,703 cycles                    #    2.382 GHz                       [68.92%]
         5,397,528 stalled-cycles-frontend   #   70.86% frontend cycles idle      [68.85%]  
         1,574,438 stalled-cycles-backend    #   20.67% backend  cycles idle  
         3,359,090 instructions              #    0.44  insns per cycle  
                                             #    1.61  stalled cycles per insn  
         1,066,900 branches                  #  333.721 M/sec
               799 branch-misses             #    0.07% of all branches           [80.14%]  

       0.003578966 seconds time elapsed  

平均每次系统调用CPU需要执行的指令数(1,008,492,870 - 3,359,090)/1000000 = 1005。

深挖系统调用的实现

如果非要扒到内核的实现上,我建议大家参考一下《深入理解LINUX内核-第十章系统调用》。最初系统调用是通过汇编指令int(中断)来实现的,当用户态进程发出int $0x80指令时,CPU切换到内核态并开始执行system_call函数。 只不过后来大家觉得系统调用实在是太慢了,因为int指令要执行一致性和安全性检查。后来Intel又提供了“快速系统调用”的sysenter指令,我们验证一下。

# perf stat -e syscalls:sys_enter_read ./main  

 Performance counter stats for './main':  

            1,000,001 syscalls:sys_enter_read  

       0.006269041 seconds time elapsed  

上述实验证明,系统调用确实是通过sys_enter指令来进行的。

结论

  • 系统调用虽然使用了“快速系统调用”指令,但耗时仍大约在200ns+,多的可能到十几us
  • 每个系统调用内核要进行许多工作,大约需要执行1000条左右的CPU指令
系统调用确实开销蛮大的,函数调用时ns级别的,系统调用直接上升到了百ns,甚至是十几us,所以确实应该尽量减少系统调用。但是即使是10us,仍然是1ms的百分之一,所以还没到了谈系统调用色变的程度,能理性认识到它的开销既可。
为什么系统调用之间的耗时相差这么多?因为系统调用花在内核态用户态的切换上的时间是差不多的,但区别在于不同的系统调用当进入到内核态之后要处理的工作不同,呆在内核态里的时候相差较大。

参考资料

------------------------------------------------------------------------------------------------------

如果我们的代码需要进行IO操作、内存操作或者是获取一个系统时间,都会使用到系统调用。无论你的代码是Java、Go还是PHP,使用strace命令都可以看到系统调用的执行情况。我们用了多种方式来测量其开销,得到的基本结论是正常情况下开销大约是200ns-15us之间。

《追踪将服务器CPU耗光的凶手》

在前一篇文章《一次系统调用开销到底有多大?》中,我们讨论系统调用的时候结论是耗时200ns-15us不等。不过我今天说的我的这个遭遇可能会让你进一步认识系统调用的真正开销。在本节里你会看到一个耗时2.5ms的connect系统调用,注意是毫秒,相当于2500us!

问题描述

当时是我的一个线上云控接口,是nginx+lua写的。正常情况下,单虚机8核8G可以抗每秒2000左右的QPS,负载还比较健康。但是该服务近期开始出现一些500状态的请求了,监控时不时会出现报警。通过sar -u查看峰值时cpu余量只剩下了20-30%。

第一步、迅速锁定嫌疑人

top命令查看cpu使用,通过top命令发现峰值的时候cpu确实消耗的比较多,idle只有20-30%左右。在使用的cpu里,软中断的占比也比较高,1/3左右。
再通过cat /proc/softirqs查看到软中断是都是网络IO产生的NET_TX,NET_RX,和时钟TIMER。
既然软中断这个贼人吃掉了我这么多的CPU时间,所以案件的嫌疑人就这么初步被我锁定了。

处理,那既然是NET_TX,NET_RX和TIMER都高,那咱就挑可以削减的功能砍一砍呗。

  • 1.砍掉多余的gettimeofday系统调用
  • 2.每个请求砍掉一次非必须Redis访问,只留了必要的。

结果:峰值的cpu余量从确实多出来一些了。报警频率确实下来了,但是还是偶尔会有零星的报警。可见该嫌疑人并非主犯。。

第二步、干掉一大片,真凶在其中

接着查看网络连接的情况ss -n -t -a发现,ESTABLISH状态的链接不是很多,但是TIME-WAIT有11W多。继续研究发现针对..*.122:6390的TIME-WAIT已经超过了3W。所以端口有限。原来呀,上一步执行时只干掉了连接上的数据请求,但是tcp握手请求仍然存在。

处理:彻底干掉了针对..*.122:6390的网络连接请求,只保留了必须保留的逻辑。
结果:问题彻底解决。sar -u查看cpu的idle余量竟然达到了90%多。

Tips:单台机器如果作为TCP的客户端,有如下限制
  1. ESTABLISH状态的连接只能有ip_local_port_range范围内的个数。
  2. 只有针对特定ip,特定port的TIME-WAIT过多,超过或接近ip_local_port_range,再新建立连接可能会出现无端口可用的情况。( 总的TIME-WAIT过多并不一定有问题 )

没想到一个简单砍掉一个对redis server的tcp连接,能把cpu优化到这么多。大大出乎意料,而且也想不明白。 根据我之前的性能测试经验,每个tcp连接的建立大约只需要消耗36usec的cpu时间。我们来估算一下:

当时server的qps大约在2000左右,假设是均匀分布的,则8个核每个核每秒只需要处理250个请求。也就是说每秒一条tcp连接需要消耗的cpu时间为:250*36usec = 9ms.

也就是说,正常来讲砍掉这些握手开销只能节约1%左右的cpu,不至于有这么大的提升。(即使我上面的估算只考虑了建立连接,没有统计释放连接的cpu开销,但是连接释放cpu开销也和建立连接差不多。)

总之,这一步确实解决了问题,但是代价是牺牲了一个业务逻辑。

最终、审出真凶,真相大白于天下

我在某一台机器上把老的有问题的代码回滚了回来,恢复问题现场。然后只修改一下ip_local_port_range。 然后请出了strace这个命令。
通过strace -c 统计到对于所有系统调用的开销汇总。 结果我们发现了connect系统调用这个二货,在正常的机器上只需要22us左右,在有问题的机器上竟然花掉来 2500us,上涨了100倍。我们用strace -c $PID查看一下出问题时和正常时的connect系统调用耗时对比:

图1 Connect系统调用正常情况下耗时

图2 Connect系统调用异常情况下耗时

然后回想起了..*.122:6390的TIME-WAIT已经超过了3W,会不会TIME_WAIT占用了太多端口导致端口不足呢。因此查看端口内核参数配置:

# sysctl -a | grep ip_local_port_range
net.ipv4.ip_local_port_range = 32768    65000

果然发现该机器上的端口范围只开了3W多个,也就是说端口已经几乎快用满了。那就提高端口可用数量:

# vim /etc/sysctl.conf
net.ipv4.ip_local_port_range = 10000 65000

connect系统调用恢复理性状态,整体服务器的CPU使用率非常健康。

问题的根本原因是建立TCP连接使用的端口数量上(ip_local_port_range)不充裕,导致connect系统调用开销上涨了将近100倍!

后来我们的一位开发同学帮忙翻到了connect系统调用里的一段源码

int inet_hash_connect(struct inet_timewait_death_row *death_row,
               struct sock *sk)
{
    return __inet_hash_connect(death_row, sk, inet_sk_port_offset(sk),
            __inet_check_established, __inet_hash_nolisten);
}

int __inet_hash_connect(struct inet_timewait_death_row *death_row,
                struct sock *sk, u32 port_offset,
                int (*check_established)(struct inet_timewait_death_row *,
                        struct sock *, __u16, struct inet_timewait_sock **),
                int (*hash)(struct sock *sk, struct inet_timewait_sock *twp))
{
        struct inet_hashinfo *hinfo = death_row->hashinfo;
        const unsigned short snum = inet_sk(sk)->inet_num;
        struct inet_bind_hashbucket *head;
        struct inet_bind_bucket *tb;
        int ret;
        struct net *net = sock_net(sk);
        int twrefcnt = 1;

        if (!snum) {
                int i, remaining, low, high, port;
                static u32 hint;
                u32 offset = hint + port_offset;
                struct inet_timewait_sock *tw = NULL;

                inet_get_local_port_range(&low, &high);
                remaining = (high - low) + 1;

                local_bh_disable();
                for (i = 1; i <= remaining; i++) {
                        port = low + (i + offset) % remaining;
                        if (inet_is_reserved_local_port(port))
                                continue;
                        ......
        }
}

static inline u32 inet_sk_port_offset(const struct sock *sk)
{
        const struct inet_sock *inet = inet_sk(sk);  
        return secure_ipv4_port_ephemeral(inet->inet_rcv_saddr,  
                                          inet->inet_daddr,  
                                          inet->inet_dport);  
}

从上面源代码可见,临时端口选择过程是生成一个随机数,利用随机数在ip_local_port_range范围内取值,如果取到的值在ip_local_reserved_ports范围内 ,那就再依次取下一个值,直到不在ip_local_reserved_ports范围内为止。原来临时端口竟然是随机撞。出。来。的。。 也就是说假如就有range里配置了5W个端口可以用,已经使用掉了49999个。那么新建立连接的时候,可能需要调用这个随机函数5W次才能撞到这个没用的端口身上。

所以请记得要保证你可用临时端口的充裕,避免你的connect系统调用进入SB模式。正常端口充足的时候,只需要22usec。但是一旦出现端口紧张,则一次系统调用耗时会上升到2.5ms,整整多出100倍。这个开销比正常tcp连接的建立吃掉的cpu时间(每个30usec左右)的开销要大的多。

解决TIME_WAIT的办法除了放宽端口数量限制外,还可以考虑设置net.ipv4.tcp_tw_recycle和net.ipv4.tcp_tw_reuse这两个参数,避免端口长时间保守地等待2MSL时间。

参考文献

------------------------------------------------------------------------------------------------------

在这个文章里,我给大家分享了一个2500us的变态connect系统调用的例子。这就是关于系统调用的第二个结论。这是由于系统调用进入系统态后要做的工作不一样,所处的当前环境不一样,单次开销的差别会比较大。要学会查看你的服务在系统调用上到底花掉了多少开销。

2)上下文切换

《进程/线程上下文切换会用掉你多少CPU?》

进程是操作系统的伟大发明之一,对应用程序屏蔽了CPU调度、内存管理等硬件细节,而抽象出一个进程的概念,让应用程序专心于实现自己的业务逻辑既可,而且在有限的CPU上可以“同时”进行许多个任务。但是它为用户带来方便的同时,也引入了一些额外的开销。如下图,在进程运行中间的时间里,虽然CPU也在忙于干活,但是却没有完成任何的用户工作,这就是进程机制带来的额外开销。

图1 进程上下文切换

在进程A切换到进程B的过程中,先保存A进程的上下文,以便于等A恢复运行的时候,能够知道A进程的下一条指令是啥。然后将要运行的B进程的上下文恢复到寄存器中。这个过程被称为上下文切换。上下文切换开销在进程不多、切换不频繁的应用场景下问题不大。但是现在Linux操作系统被用到了高并发的网络程序后端服务器。在单机支持成千上万个用户请求的时候,这个开销就得拿出来说道说道了。因为用户进程在请求Redis、Mysql数据等网络IO阻塞掉的时候,或者在进程时间片到了,都会引发上下文切换。

图2 进程状态转化图

一个简单的进程上下文切换开销测试实验

废话不多说,我们先用个实验测试一下,到底一次上下文切换需要多长的CPU时间!实验方法是创建两个进程并在它们之间传送一个令牌。其中一个进程在读取令牌时就会引起阻塞。另一个进程发送令牌后等待其返回时也处于阻塞状态。如此往返传送一定的次数,然后统计他们的平均单次切换时间开销。
具体的实验代码:

#include <stdio.h>  
#include <stdlib.h>  
#include <sys/time.h>  
#include <time.h>  
#include <sched.h>  
#include <sys/types.h>  
#include <unistd.h>      //pipe()  
  
int main()  
{  
    int x, i, fd[2], p[2];  
    char send    = 's';  
    char receive;  
    pipe(fd);  
    pipe(p);  
    struct timeval tv;  
    struct sched_param param;  
    param.sched_priority = 0;  
  
    while ((x = fork()) == -1); 
    if (x==0) {  
        sched_setscheduler(getpid(), SCHED_FIFO, &param);  
        gettimeofday(&tv, NULL);  
        printf("Before Context Switch Time%u s, %u us\n", tv.tv_sec, tv.tv_usec);  
        for (i = 0; i < 10000; i++) {  
            read(fd[0], &receive, 1);  
            write(p[1], &send, 1);  
        }  
        exit(0);  
    }  
    else {  
        sched_setscheduler(getpid(), SCHED_FIFO, &param);  
        for (i = 0; i < 10000; i++) {  
            write(fd[1], &send, 1);  
            read(p[0], &receive, 1);  
        }  
        gettimeofday(&tv, NULL);  
        printf("After Context SWitch Time%u s, %u us\n", tv.tv_sec, tv.tv_usec);  
    }  
    return 0;  
} 
# gcc main.c -o main
# ./main./main
Before Context Switch Time1565352257 s, 774767 us
After Context SWitch Time1565352257 s, 842852 us

每次执行的时间会有差异,多次运行后平均每次上下文切换耗时3.5us左右。当然了这个数字因机器而异,而且建议在实机上测试。

前面我们测试系统调用的时候,最低值是200ns。可见,上下文切换开销要比系统调用的开销要大。系统调用只是在进程内将用户态切换到内核态,然后再切回来,而上下文切换可是直接从进程A切换到了进程B。显然这个上下文切换需要完成的工作量更大。

进程上下文切换开销都有哪些

那么上下文切换的时候,CPU的开销都具体有哪些呢?开销分成两种,一种是直接开销、一种是间接开销。

直接开销就是在切换时,cpu必须做的事情,包括:

  • 1、切换页表全局目录
  • 2、切换内核态堆栈
  • 3、切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文)
    • ip(instruction pointer):指向当前执行指令的下一条指令
    • bp(base pointer): 用于存放执行中的函数对应的栈帧的栈底地址
    • sp(stack poinger): 用于存放执行中的函数对应的栈帧的栈顶地址
    • cr3:页目录基址寄存器,保存页目录表的物理地址
    • ......
  • 4、刷新TLB
  • 5、系统调度器的代码执行

间接开销主要指的是虽然切换到一个新进程后,由于各种缓存并不热,速度运行会慢一些。如果进程始终都在一个CPU上调度还好一些,如果跨CPU的话,之前热起来的TLB、L1、L2、L3因为运行的进程已经变了,所以以局部性原理cache起来的代码、数据也都没有用了,导致新进程穿透到内存的IO会变多。 其实我们上面的实验并没有很好地测量到这种情况,所以实际的上下文切换开销可能比3.5us要大。

想了解更详细操作过程的同学请参考《深入理解Linux内核》中的第三章和第九章。

一个更为专业的测试工具-lmbench

lmbench用于评价系统综合性能的多平台开源benchmark,能够测试包括文档读写、内存操作、进程创建销毁开销、网络等性能。使用方法简单,但就是跑有点慢,感兴趣的同学可以自己试一试。
这个工具的优势是是进行了多组实验,每组2个进程、8个、16个。每个进程使用的数据大小也在变,充分模拟cache miss造成的影响。我用他测了一下结果如下:

-------------------------------------------------------------------------
Host                 OS  2p/0K 2p/16K 2p/64K 8p/16K 8p/64K 16p/16K 16p/64K  
                         ctxsw  ctxsw  ctxsw ctxsw  ctxsw   ctxsw   ctxsw  
--------- ------------- ------ ------ ------ ------ ------ ------- -------  
bjzw_46_7 Linux 2.6.32- 2.7800 2.7800 2.7000 4.3800 4.0400 4.75000 5.48000

lmbench显示的进程上下文切换耗时从2.7us到5.48之间。

线程上下文切换耗时

前面我们测试了进程上下文切换的开销,我们再继续在Linux测试一下线程。看看究竟比进程能不能快一些,快的话能快多少。

在Linux下其实本并没有线程,只是为了迎合开发者口味,搞了个轻量级进程出来就叫做了线程。轻量级进程和进程一样,都有自己独立的task_struct进程描述符,也都有自己独立的pid。从操作系统视角看,调度上和进程没有什么区别,都是在等待队列的双向链表里选择一个task_struct切到运行态而已。只不过轻量级进程和普通进程的区别是可以共享同一内存地址空间、代码段、全局变量、同一打开文件集合而已。

同一进程下的线程之所有getpid()看到的pid是一样的,其实task_struct里还有一个tgid字段。 对于多线程程序来说,getpid()系统调用获取的实际上是这个tgid,因此隶属同一进程的多线程看起来PID相同。

我们用一个实验来测试一下:

#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include<pthread.h>

int pipes[20][3];
char buffer[10];
int running = 1;

void inti()
{
    int i =20;
    while(i--)
    {
        if(pipe(pipes[i])<0)
            exit(1);
        pipes[i][2] = i;
    }
}

void distroy()
{
    int i =20;
    while(i--)
    {
        close(pipes[i][0]);
        close(pipes[i][1]);
    }
}

double self_test()
{
    int i =20000;
    struct timeval start, end;
    gettimeofday(&start, NULL);
    while(i--)
    {
        if(write(pipes[0][1],buffer,10)==-1)
            exit(1);
        read(pipes[0][0],buffer,10);
    }
    gettimeofday(&end, NULL);
    return (double)(1000000*(end.tv_sec-start.tv_sec)+ end.tv_usec-start.tv_usec)/20000;
}

void *_test(void *arg)
{
    int pos = ((int *)arg)[2];
    int in = pipes[pos][0];
    int to = pipes[(pos + 1)%20][1];
    while(running)
    {
        read(in,buffer,10);
        if(write(to,buffer,10)==-1)
            exit(1);
    }
}

double threading_test()
{
    int i = 20;
    struct timeval start, end;
    pthread_t tid;
    while(--i)
    {
        pthread_create(&tid,NULL,_test,(void *)pipes[i]);
    }
    i = 10000;
    gettimeofday(&start, NULL);
    while(i--)
    {
        if(write(pipes[1][1],buffer,10)==-1)
            exit(1);
        read(pipes[0][0],buffer,10);
    }
    gettimeofday(&end, NULL);
    running = 0;
    if(write(pipes[1][1],buffer,10)==-1)
        exit(1);
    return (double)(1000000*(end.tv_sec-start.tv_sec)+ end.tv_usec-start.tv_usec)/10000/20;
}


int main()
{
    inti();
    printf("%6.6f\n",self_test());
    printf("%6.6f\n",threading_test());
    distroy();
    exit(0);
}

其原理和进程测试差不多,创建了20个线程,在线程之间通过管道来传递信号。接到信号就唤醒,然后再传递信号给下一个线程,自己睡眠。 这个实验里单独考虑了给管道传递信号的额外开销,并在第一步就统计了出来。

# gcc -lpthread main.c -o main
0.508250  
4.363495

每次实验结果会有一些差异,上面的结果是取了多次的结果之后然后平均的,大约每次线程切换开销大约是3.8us左右。从上下文切换的耗时上来看,Linux线程(轻量级进程)其实和进程差别不太大

Linux相关命令

既然我们知道了上下文切换比较的消耗CPU时间,那么我们通过什么工具可以查看一下Linux里究竟在发生多少切换呢?如果上下文切换已经影响到了系统整体性能,我们有没有办法把有问题的进程揪出来,并把它优化掉呢?

# vmstat 1  
procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu-----  
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st  
 2  0      0 595504   5724 190884    0    0   295   297    0    0 14  6 75  0  4  
 5  0      0 593016   5732 193288    0    0     0    92 19889 29104 20  6 67  0  7  
 3  0      0 591292   5732 195476    0    0     0     0 20151 28487 20  6 66  0  8  
 4  0      0 589296   5732 196800    0    0   116   384 19326 27693 20  7 67  0  7  
 4  0      0 586956   5740 199496    0    0   216    24 18321 24018 22  8 62  0  8

或者是

# sar -w 1  
proc/s  
     Total number of tasks created per second.  
cswch/s  
     Total number of context switches per second.  
11:19:20 AM    proc/s   cswch/s  
11:19:21 AM    110.28  23468.22  
11:19:22 AM    128.85  33910.58  
11:19:23 AM     47.52  40733.66  
11:19:24 AM     35.85  30972.64  
11:19:25 AM     47.62  24951.43  
11:19:26 AM     47.52  42950.50  
......

上图的环境是一台生产环境机器,配置是8核8G的KVM虚机,环境是在nginx+fpm的,fpm数量为1000,平均每秒处理的用户接口请求大约100左右。其中cs列表示的就是在1s内系统发生的上下文切换次数,大约1s切换次数都达到4W次了。粗略估算一下,每核大约每秒需要切换5K次,则1s内需要花将近20ms在上下文切换上。要知道这是虚机,本身在虚拟化上还会有一些额外开销,而且还要真正消耗CPU在用户接口逻辑处理、系统调用内核逻辑处理、以及网络连接的处理以及软中断,所以20ms的开销实际上不低了。

那么进一步,我们看下到底是哪些进程导致了频繁的上下文切换?

# pidstat -w 1  
11:07:56 AM       PID   cswch/s nvcswch/s  Command
11:07:56 AM     32316      4.00      0.00  php-fpm  
11:07:56 AM     32508    160.00     34.00  php-fpm  
11:07:56 AM     32726    131.00      8.00  php-fpm  
......

由于fpm是同步阻塞的模式,每当请求Redis、Memcache、Mysql的时候就会阻塞导致cswch/s自愿上下文切换,而只有时间片到了之后才会触发nvcswch/s非自愿切换。可见fpm进程大部分的切换都是自愿的、非自愿的比较少。

如果想查看具体某个进程的上下文切换总情况,可以在/proc接口下直接看,不过这个是总值。

grep ctxt /proc/32583/status  
voluntary_ctxt_switches:        573066  
nonvoluntary_ctxt_switches:     89260

本节结论

上下文切换具体做哪些事情我们没有必要记,只需要记住一个结论既可,测得作者开发机上下文切换的开销大约是2.7-5.48us左右,你自己的机器可以用我提供的代码或工具进行一番测试。
lmbench相对更准确一些,因为考虑了切换后Cache miss导致的额外开销。

参考文献

------------------------------------------------------------------------------------------------------

进程上下文切换无时无刻不在发生,你的服务器性能低下有没有可能是由于进程上下文切换太频繁导致的呢?你要学会查看你的服务的上下文切换次数,以及要在心中对单次切换的CPU花销有数。单次进程上下文切换我们通过多个实验的方式统计到每次大约需要3-5us。在这时间里包括了进程切换的时候保存前一个进程上下文、加载下一个进程上下文,以及刷新TLB,甚至也考虑到了切换带来的L1、L2等缓存miss造成的额外开销。

《协程究竟比线程能省多少开销?》

前文《进程/线程上下文切换会用掉你多少CPU?》中中我们用实验的方式验证了Linux进程和线程的上下文切换开销,大约是3-5us之间。这个开销确实不算大,但是海量互联网服务端和一般的计算机程序相比,特点是:

  • 高并发:每秒钟需要处理成千上万的用户请求
  • 周期短:每个用户处理耗时越短越好,经常是ms级别的
  • 高网络IO:经常需要从其它机器上进行网络IO、如Redis、Mysql等等
  • 低计算:一般CPU密集型的计算操作并不多

即使3-5us的开销,如果上下文切换量特别大的话,也仍然会显得是有那么一些性能低下。例如之前的Web Server之Apache,就是这种模型下的软件产品。(其实当时Linux操作系统在设计的时候,目标是一个通用的操作系统,并不是专门针对服务端高并发来设计的)

为了避免频繁的上下文切换,还有一种异步非阻塞的开发模型。那就是用一个进程或线程去接收一大堆用户的请求,然后通过IO多路复用的方式来提高性能(进程或线程不阻塞,省去了上下文切换的开销)。Nginx和Node Js就是这种模型的典型代表产品。平心而论,从程序运行效率上来,这种模型最为机器友好,运行效率是最高的(比下面提到的协程开发模型要好)。所以Nginx已经取代了Apache成为了Web Server里的首选。但是这种编程模型的问题在于开发不友好,说白了就是过于机器化,离进程概念被抽象出来的初衷背道而驰。人类正常的线性思维被打乱,应用层开发们被逼得以非人类的思维去编写代码,代码调试也变得异常困难。

于是就有一些聪明的脑袋们继续在应用层又动起了主意,设计出了不需要进程/线程上下文切换的“线程”,协程。用协程去处理高并发的应用场景,既能够符合进程涉及的初衷,让开发者们用人类正常的线性的思维去处理自己的业务,也同样能够省去昂贵的进程/线程上下文切换的开销。因此可以说,协程就是Linux处理海量请求应用场景里的进程模型的一个很好的的补丁。

背景介绍完了,那么我想说的是,毕竟协程的封装虽然轻量,但是毕竟还是需要引入了一些额外的代价的。那么我们来看看这些额外的代价具体多小吧。

协程开销测试

  • 1、协程切换CPU开销
    测试过程是不断在协程之间让出CPU。核心代码如下。
func cal()  {
    for i :=0 ; i<1000000 ;i++{
        runtime.Gosched()
    }
}

func main() {
    runtime.GOMAXPROCS(1)

    currentTime:=time.Now()
    fmt.Println(currentTime)

    go cal()  
    for i :=0 ; i<1000000 ;i++{
        runtime.Gosched()
    }

    currentTime=time.Now()
    fmt.Println(currentTime)
}

编译运行

# cd tests/test05/src/main/;  
# go build  
# ./main  
2019-08-08 22:35:13.415197171 +0800 CST m=+0.000286059
2019-08-08 22:35:13.655035993 +0800 CST m=+0.240124923

平均每次协程切换的开销是(655035993-415197171)/2000000=120ns。相对于前面文章测得的进程切换开销大约3.5us,大约是其的三十分之一。比系统调用的造成的开销还要低。

  • 2、协程内存开销
    在空间上,协程初始化创建的时候为其分配的栈有2KB。而线程栈要比这个数字大的多,可以通过ulimit 命令查看,一般都在几兆,作者的机器上是10M。如果对每个用户创建一个协程去处理,100万并发用户请求只需要2G内存就够了,而如果用线程模型则需要10T。
# ulimit -a  
stack size              (kbytes, -s) 10240

本节结论

协程由于是在用户态来完成上下文切换的,所以切换耗时只有区区100ns多一些,比进程切换要高30倍。单个协程需要的栈内存也足够小,只需要2KB。所以,近几年来协程大火,在互联网后端的高并发场景里大放光彩。

无论是空间还是时间性能都比进程(线程)好这么多,那么Linus为啥不把它在操作系统里实现了多好?操作系统为了实现实时性更好的目的,对一些优先级比较高的进程是会抢占其它进程的CPU的。而协程无法实现这一点,还得依赖于挡前使用CPU的协程主动释放,于操作系统的实现目的不相吻合。所以协程的高效是以牺牲可抢占性为代价的。

扩展:由于go的协程调用起来太方便了,所以一些go的程序员就很随意地go来go去。要知道go这条指令在切换到协程之前,得先把协程创建出来。而一次创建加上调度开销就涨到400ns,差不多相当于一次系统调用的耗时了。虽然协程很高效,但是也不要乱用,否则go祖师爷Rob Pike花大精力优化出来的性能,被你随意一go又给葬送掉了。

参考文献

------------------------------------------------------------------------------------------------------

如果你的后端服务是被上下文切换吃去了太多的CPU,那么可能你需要在你的技术方案中引入协程了。通过实验数据可以看出,它的上下文切换开销大约只有进程切换开销的三十分之一。

3)软中断

《软中断会吃掉你多少CPU?》

------------------------------------------------------------------------------------------------------

Linux网络IO在底层是用软中断来实现的。只要你的机器上执行了网络IO,就必然会设计到它,你不要把人家用了都不知道人家的名字。你的服务器上内核每秒要执行多少次软中断,每次需要多大的开销,也是要做到心中有数。vmstat可以查看到软中断的当前执行次数,单次软中断的CPU开销大约是3.5us左右。基本上和进程上下文切换的开销差不太多,在一个数量级上。

《软中断竟然是可一个CPU使劲造?》

一个奇怪的问题

在了解了软中断对CPU的占用之后,如果你动手操作查看过的话,相信会和我一样会遇到下面这个的问题。如果没有实际遇到也没关系,你可以用hping命令制造一些网络接收包来观测。

# hping3 -S -p 80 -i 你的服务器ip

我当时对一台线上虚机查看软中断造成的CPU开销的时候,发现很奇怪,那就是所有的软中断几乎都是被一个CPU处理的。用top看si列,绝大部分都是消耗在CPU1上的,其它CPU只有很少。怎么样,你有没有和我一样感觉到一脸蒙圈呢?

寻找问题原因

网卡和内核交互是通过软中断的方式来进行的。既然是中断,那就每个可中断到CPU的设备就都会有一个中断号。来,我们现在虚机上找到软中断对应的中断号。

# cat /proc/interrupts  
           CPU0       CPU1       CPU2       CPU3
 27:        351          0          0  280559832   PCI-MSI-edge      virtio1-input.0
 28:          1          0          0          0   PCI-MSI-edge      virtio1-output.0
 29:          0          0          0          0   PCI-MSI-edge      virtio2-config
 30:    4233459  375136079     244872     474097   PCI-MSI-edge      virtio2-input.0
 31:          1          0          0          0   PCI-MSI-edge      virtio2-output.0
......

其中的virtio1-output.0和virtio1-output.0对应的是虚拟网卡eth0的发送和接收队列。其中断号分别是27和28。virtio2-input.0和virtio2-output.0对应是eth1的发送和接收队列,其中断号分别是30和31。

我们分别查看着几个中断号的cpu亲和性配置:

# cat /proc/irq/27/smp_affinity
8
# cat /proc/irq/28/smp_affinity
1
# cat /proc/irq/30/smp_affinity
2
# cat /proc/irq/31/smp_affinity
4

原来虚拟机是通过将不同网卡的不同队列绑定在不同的CPU上来实现软中断均衡的。刚才我们服务器的包都是发送到eth1上的,它的读队列请求特别的多,因此30号“引脚”上的中断也会特别的多。自然和30亲和的2号CPU,也就是CPU1就会出现明显比其它CPU高的软中断了。

这下你明白了吧?

带你认识irqbalance

其实我们上面看到的中断的亲和性都是由一个叫irqbalance的服务来维护的。通过ps命令可以查看到

# ps -ef | grep irqb
root     29805     1  0 18:57 ?        00:00:00 /usr/sbin/irqbalance --foreground

irqbalance根据系统中断负载的情况,自动维护和迁移各个中断的CPU亲和性,以保持各个CPU之间的中断开销均衡。绑定了亲和性的好处是运行中断的时候CPU缓存L1、L2命中率高。但如果有必要,irqbalance也会自动把中断从一个CPU迁移到另一个CPU上。一般情况下,我们都不需要去手工干涉irqbalance的配置。

如果实在有必要,你可以通过修改这些参数来达到将软中断转移到其它CPU上,但是得先关掉irqbalance。修改方法很简单,直接echo既可。

# service irqbalance stop
# echo 2 > /proc/irq/30/smp_affinity

不过我遇到了一个未解的问题,我想把一个中断的亲和性绑到2个CPU上,貌似是不work的。仍然是一个核被打满,另外一个核闲着,没有想明白。如果你有答案,欢迎评论!

在实机上,原理是一样的。不过现在的实机上网卡都是多队列,也就是说eth0可能会有多个读取队列,多个写队列,都可以各自分开配其CPU亲和性。你手头如果有实机的话,可以试一试。

参考文献

------------------------------------------------------------------------------------------------------

Linux还有一个关于软中断的特点,就是一个中断号的软中断都是在一个CPU上执行的。如果你的网络IO密集到一个核已经处理不过来的时候,你很可能需要了解irqbalance和多队列网卡。

4)代码实践

《一次简单的redis请求会有哪些CPU开销?》

我问大家一个问题,下图中一个最简单的例子,会导致哪些CPU开销产生?你是否能够说清楚?

<?php  
    ... 
    $redis->get('test'); 
    ...

这个例子一下子就把大家在我的文章里学到的东西和你的实际工作结合起来了。怎么样,是不是足够简单?就是一句php代码从redis实例中获取一个key的value值而已,相信类似的代码你天天都在写。对这句redis get实际开销的理解水平,就代表了你的内功的深度。

在前面的文章中介绍了一些和CPU相关的硬件、内核知识,把开销大户进程上下文切换、系统调用、软中断的具体开销都挨个分析了一遍。没有看过的同学请关注并翻看我以前的文章。不过,这时候我觉得有很多开发同学都有一个疑惑,仍然是觉得:“我是应用层的开发,这么底层的开销和我有什么关系?” 或者是说:“线上服务器的运维不都应该是运维的工作吗?和开发又没关系”

我想说的是,如果你只是一个初级或者中级开发工程师,这些确实没有必要了解。但是如果你想成为一名高级、或者是资深开发工程师,那么你应该具备大致估算你手下写出的每一行代码开销的能力,要对自己代码在线上的运行开销负责!

接下来,我们就来带大家从更深层次的方向认识到这句简单代码的开销。为了便于测试,我们对代码进行一些简单的改造,最终的实际测试文件如下。

<?php  
    $redis = new Redis();  
    $redis->connect('10.153.55.119', 6339);  

    sleep(60);  
    echo "Test begin\n";    
    for($i=0; $i<10000; $i++){  
        $redis->get('test');  
    }     
    echo "Test end!\n ";      
    sleep(60);

例子非常的简单,就是一句后端同学代码里经常出现的从Redis里获取了一条数据而已。那么让我们看看它到底会产生哪些开销?

1.系统调用

# strace -c php main.php  
% time     seconds  usecs/call     calls    errors syscall  
------ ----------- ----------- --------- --------- ----------------  
 97.24    0.039698           1     30003           poll  
  2.20    0.000899           0     10003           sendto  
  0.30    0.000122           0     10000           recvfrom  
  0.13    0.000053           0     10069           gettimeofday  
  0.03    0.000013           2         6           socket  
  0.03    0.000012           0       408           munmap  
  0.02    0.000008           0       657           read

我们代码所调用的get函数,其实是php的一个redis扩展提供的。该扩展又会去调用Linux系统的网络库函数,库函数再去调用内核提供的系统调用。这个调用层次模型如下:

图1 redis get函数的底层实现

从实际测试结果可见,每次get操作都需要执行多次系统调用才可完成。

2、进程上下文切换

每次调用get后,如果数据没有返回。进程都是阻塞掉的,因此还会导致进程进入主动上下文切换。 我们用实验的方式来查看一下:

# php main.php

然后再另起一个控制台,分别赶在实验开始前和实验开始后执行如下两行命令:

# grep ctxt /proc/14862/status  
voluntary_ctxt_switches:        4  
nonvoluntary_ctxt_switches:     43  

# grep ctxt /proc/14862/status  
voluntary_ctxt_switches:        10005  
nonvoluntary_ctxt_switches:     49

每次get都会导致进程进入自愿上下文切换,在网络IO密集型的应用里自愿上下文切换要比时间片到了被动切换要多的多!

3、软中断

每次在redis服务器返回数据的时候,网卡都会通过软中断的方式来让内核处理数据包。因此

# cat /proc/softirqs  
                CPU0       CPU1       CPU2       CPU3  
      HI:          0          0          0          0  
   TIMER:  196173081  145428444  154228333  163317242  
  NET_TX:          0          0          0          0  
  NET_RX:  178159928     116073      10108     160712  


# cat /proc/softirqs  
                CPU0       CPU1       CPU2       CPU3  
      HI:          0          0          0          0  
   TIMER:  196173688  145428634  154228610  163317624  
  NET_TX:          0          0          0          0  
  NET_RX:  178170212     116073      10108     160712

该虚机的软中断亲和性在CPU0上,178170212-178159928 = 10284(多出来的284是机器上其它的小服务)。 每次get请求收到数据返回的时候,内核必须要支出一次软中断的开销!

总结

看似一次非常简单的redis get操作就会把所有系统态的高开销操作都涉及到了。一次进程上下文切换、一次软中断、若干次系统调用。在经过了前面多篇文章的历练,相信大家对它们的开销有了一个量化的拿捏。其实除了上面这些容易评估到的开销外,还有如L1、L2 cache miss,以及TLB cache miss这些在进程被切换掉都会造成cache命中率下降,也会导致额外开销。

所以,你以为的简单,其实不一定简单!

------------------------------------------------------------------------------------------------------

这一个非常简单的例子,是打通你应用层到内核态任督二脉的关键武器。系统调用、软中断、上下文切换这些概念,在我介绍完了之后你可能还是感觉有点虚幻。但其实即使是一行redis的get请求,这三种开销可能都会被用到。

第三部分、CPU用户态

内核态和物理层是本人想重点分享的内容,但是在用户态我也举了一个例子,那就是大家天天在用的函数。

《函数调用太多了会有性能问题吗?》

在现代的开发工作中,相信绝大部分的同学手头的项目都不是从第零行代码开始搭建的。各个语言都有自己流行的代码框架,如PHP的有Laravel、CodeIgniter、ThinkPHP等等。大家都是在自己的框架的基础上添加自己的业务代码逻辑,开启开发工作。还记得我们团队有位开发同学当时问过我一个问题,我们用xx框架这么重,一个用户请求过来即使什么也不干,都已经进行了那么多次的函数调用了,适合用来做接口开发吗?
我当时给她的回答是,没问题放心吧,函数调用的开销很小的,不必担心。但回答完她的问题之后,我回头一想,我只知道函数调用的开销很小,但是具体是多大,我心里并吃不准,这就在我心里又种下了草。后来终于抽空进行了一次实践研究,把草拔掉了。

C语言测试

1) 准备测试代码

测试代码很简单,这就是一个for循环的函数调用。

#include <stdio.h> 
int func(int p){ 
 return 1;
} 
int main() 
{ 
 int i; 
 for(i=0; i<100000000; i++){ 
 func(2); 
 } 
 return 0; 
}

2) 函数调用耗时测试

我们用time命令来进行耗时测试

# gcc main.c -o main 
# time ./main 
real 0m0.335s 
user 0m0.334s 
sys 0m0.000s 
#perf stat ./main 
...... 
1,100,989,673 instructions # 1.37 insns per cycle 
...... 

不过上面的实验中有个多余的开销,那就是for循环。我们单独计算一下这个for的开销,把func()调用那行注释掉,单独保留1亿次的for循环,再重新编译执行一遍。结果是

time ./main 
real 0m0.293s 
user 0m0.292s 
sys 0m0.000s 
perf stat ./main 
...... 
301,252,997 instructions # 0.43 insns per cycle
...... 

通过上面两步测试的数据,(0.335-0.293)/100000000=0.4ns。我们可以得出结论1:每个c函数调用耗时大约是0.4ns左右。

3) 函数调用CPU指令数分析

我们用perf命令可以统计到程序运行的底层CPU指令个数。1亿次的函数调用统计结果如下:

# perf stat ./main 
...... 
1,100,989,673 instructions # 1.37 insns per cycle 
...... 

去掉for循环后,单独1亿次的for循环统计如下:

# perf stat ./main 
...... 
301,252,997 instructions # 0.43 insns per cycle
...... 

通过这两个数据,(1,100,989,673-301,252,997)/100000000=8个。所以我们得出结论2:每个c函数需要的CPU指令数是8个!

4) 函数调用CPU指令剖析

如果有同学和我一样好奇结论2中的每个c函数的CPU指令到底干了些啥,请和我一起来,否则请开启3倍速快进。还是上述的实验代码,我们通过gdb的disassemble来查看一下其内部汇编执行过程,编译之。

gcc -g main.c -o main

再用gdb命令调试:

gdb ./main
start
disassemble
mov $0x2,%edi

看到函数到了main函数处,并打印出了main函数的汇编代码

......
=> 0x0000000000400486 <+4>: mov $0x2,%edi
 0x000000000040048b <+9>: callq 0x400474 <func>
......

这是进入函数调用的两个CPU指令,每个指令大概含义如下:

  • 指令1:mov $0x2,%edi是为了调用函数做准备,把参数放到寄存器中。
  • 指令2:callq表示cpu开始执行func函数的代码段。

接下来让我们进入到func函数内部看一下:

break func
run

这时函数停在了func函数的入口处, 继续使用gdb的disassemble命令查看汇编指令:

(gdb) disassemble
Dump of assembler code for function func:
 0x0000000000400474 <+0>: push %rbp
 0x0000000000400475 <+1>: mov %rsp,%rbp
 0x0000000000400478 <+4>: mov %edi,-0x4(%rbp)
=> 0x000000000040047b <+7>: mov $0x1,%eax
 0x0000000000400480 <+12>: leaveq 
 0x0000000000400481 <+13>: retq 
End of assembler dump.

这6个指令是对应在函数内部执行,以及函数返回的操作。加上前面2个,这样在结论2中的每个函数8个CPU指令就都水落石出了。

  • 指令3:push %rbp bp寄存器的值压入调用栈,即将main函数栈帧的栈底地址入栈(对应一次压栈操作,内存IO)
  • 指令4:mov %rsp,%rbp被调函数的栈帧栈底地址放入bp寄存器,建立func函数的栈帧(一次寄存器操作)。
  • 指令5:mov %edi,-0x4(%rbp)是从寄存器的地址-4的内存中取出,即获取输入参数(内存IO)
  • 指令6:mov $0x1,%eax对应return 0,即是将返回参数写到寄存器中(内存读IO)

再接下来的两个执行令是进行调用栈的退栈,以便于返回到main函数继续执行。是指令3和指令4的逆操作。

  • 指令7:leave q等价于mov %rbp, %rsp,寄存器操作
  • 指令8:retq 等价于pop %rbp(内存IO)

总结:8次CPU指令中大部分都是寄存器的操作,即使有“内存IO”,也是在栈上进行。而栈操作密集,符合局部性原理,早就被L1缓存住了,其实都是L1的IO,所以耗时很低。前面实验结果表明1次函数调用的开销是0.4ns, 耗时竟然小于1次真正物理内存IO的耗时(40ns左右),

5) 介绍指令并行

不知道大家有没有人注意到,前面两次perf stat的结果中分别有如下两个提示

  • 0.43 insns per cycle
  • 1.37 insns per cycle

这是说现代的CPU可以通过流水线的方式对CPU指令进行并行处理,当指令符合并行规则的时候,每个CPU周期内执行的指令数可能会大于1。这就是CPU指令并行的功劳。 所以增加函数调用后耗时并没有增加太多,除了函数调用本身开销不大的原因以外,还有一个原因就是函数调用让CPU的流水线并行技术得以施展,每秒处理的CPU指令数更多了。

PHP语言测试

很多同学又会问题,你用的是C语言进行测试,性能当然高了。

  • “我用的可是PHP,这可是脚本语言”
  • “我用的可是Java,中间可还有一层虚拟机”
  • “我用的可是...”

好了,不抬杠,我们继续试一试不就完了么。就用php来继续实验一把。

<?php 
function func(){ 
 return true; 
} 
for($i=0;$i<10000000;$i++){ 
 func(); 
} 

实验结果:

  • php7: 1000W次耗时0.667s,减去0.140s的for循环耗时,平均每次函数调用耗时52ns
  • php53:1000W次耗时2.1s,减去0.5s的for循环耗时,平均每次耗时160ns

结论

php的函数调用确实比c的要慢很多,从不到1ns升高到了50ns左右。因为php又用c虚拟了一层指令集,这层指令集还需要变成CPU的指令集后才可以真正运行。但是要知道的是ns这个时间单位太小了,假如你用的框架特别变态,一个用户请求来了直接就搞了1000次的函数调用,那么消耗在函数调用上的时间会是50ns*1000=50us。这和代码框架化后给团队项目带来的便利性来对比的话,这点时间开销,我觉得仍然是可以忽略的。

参考

------------------------------------------------------------------------------------------------------

函数你天天在用,但是它给你造成的开销,我想大部分人是心里没数的,我也是经过实践测试才有了理性的认识。其实用户态还有很多高开销的操作,比如正则匹配、加解密、计算md5等等。以后如果有机会我们再分享。

发布了125 篇原创文章 · 获赞 57 · 访问量 16万+

猜你喜欢

转载自blog.csdn.net/armlinuxww/article/details/105586331
今日推荐