《开发内功修炼-CPU篇》学习笔记


《开发内功修炼-内存篇》学习笔记

目录

  1. CPU物理结构
  2. CPU缓存
  3. 进程/线程切换的开销
  4. 软中断的开销
  5. 系统调用的开销
  6. 函数调用的开销

参考/来源

  • 《开发内功修炼》微信公众号

CPU物理结构

CPU核数

  • 物理CPU:主板上真正安装的CPU的个数,

  • 物理核:一个CPU会集成多个物理核心

  • 逻辑核:超线程技术可以把一个物理核虚拟出来多个逻辑核

超线程里的2个逻辑核实际上是在一个物理核上运行的,模拟双核心运作,共享该物理核的L1和L2缓存。物理计算能力并没有增加,超线程技术只有在多任务的时候才能提升机器核整体的吞吐量。而且据Intel官方介绍,相比实核,平均性能提升只有20-30%30%左右。

Linux的CPU

  • top命令

    其实我们通过top命令看到的CPU核是逻辑核

    image-20220302101308390

  • 更详细信息

    在linux系统下,通过查看/proc/cpuinfo可以看到CPU更为详细的信息。我们通过physical id 可以看到真正的物理CPU的个数,如下:

    #cat /proc/cpuinfo | grep "physical id" | sort | uniq
    physical id     : 0  
    physical id     : 1  

    可以看出,该实机有两个物理CPU。我们继续查看物理核,通过cpu cores可以看到每个CPU有几个物理核

    #cat /proc/cpuinfo| grep "cpu cores"| uniq
    cpu cores       : 6  

    cpu cores显示为6表示每个cpu有6个物理核心,因为有2个物理CPU,所以该机器总共有12个物理核。当然该命令也可以查看逻辑核,那就是grep显示结果中的processor

    #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。这就是说,这两个逻辑核实际上是一个物理核虚拟出来的而已。

CPU缓存

L1\L2\L3缓存

CPU越来越快,现在CPU的速度比内存要快百倍以上,所以就逐步演化出了L1、L2、L3三级缓存结构,而且都集成到的CPU芯片里,以进一步提高访问速度。

现代Intel的CPU架构的基本结构:

image-20220302102323668

越往下,速度越慢,容量越大

  • L1最接近于CPU,速度也最快,但是容量最小。一般现代CPU的L1会分成两个,一个用来cache data,一个用来cache code,这是因为code和data的更新策略并不相同,而且因为CISC的变长指令,code cache要做特殊优化。一般每个核都有自己独立的data L1和code L1。

  • L2一般也可以做到每个核一个独立的

  • 但是L3一般就是整颗CPU共享的了。

    UEFIBlog里提供了一个比较好的物理解剖图,比较好地展示了出来:

image-20220302102332354

查看Linux的三级缓存

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也就是代码缓存。
  • shared_cpu_list显示有共享?我们这里看到的cpu0并不是物理Core,而是逻辑核,都是超线程技术虚拟出来的。实际上cpu0和cpu12是属于一个物理Core,所以每个Data L1和Instruction是这两个逻辑核共享的。

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

Cache Line

Cache Line是本级缓存向下一层取数据时的基本单位

可以看到L1、L2、L3的Cache Line大小都是64字节(注意是字节,可以在相关配置文件查看)。就是说每次cpu从内存获取数据的时候,都是以该单位来进行的,哪怕你只取一个bit,CPU也是给你取一个Cache Line然后放到各级缓存里存起来。

TLB

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

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

  1. CPU产生一个虚拟地址

  2. MMU从TLB中获取页表,翻译成物理地址

  3. MMU把物理地址发送给L1/L2/L3/内存

  4. L1/L2/L3/内存将地址对应数据返回给CPU

TLB并不是很大,只有4k,而且现在逻辑核又造成会有两个进程来共享。所以可能会有cache miss的情况出现。而且一旦TLB miss造成的后果可比物理地址cache miss后果要严重一些,最多可能需要进行5次内存IO才行

进程/线程切换的开销

上下文切换

  • 上下文切换

    在进程A切换到进程B的过程中,先保存A进程的上下文,以便于等A恢复运行的时候,能够知道A进程的下一条指令是啥。然后将要运行的B进程的上下文恢复到寄存器中。这个过程被称为上下文切换。

  • 测试开销的结果

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

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

  • 开销分析

    开销分成两种,一种是直接开销、一种是间接开销。

    直接开销就是在切换时,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要大。

  • 相关命令

    # 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

    cs列表示的就是在1s内系统发生的上下文切换次数

    # 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

协程

在网络服务器环境下,为了避免频繁的上下文切换,有一种异步非阻塞的开发模型。那就是用一个进程或线程去接收一大堆用户的请求,然后通过IO多路复用的方式来提高性能(进程或线程不阻塞,省去了上下文切换的开销)。

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

  • 协程切换开销

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

  • 内存开销

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

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

软中断的开销

现代的Linux发明了软件中断,配合硬中断来处理网络IO。硬中断你可以理解只是个收包的,把包收取回来放到“家里”就完事,很快就能完成,这样不耽误CPU响应其它外部高优先级的中断。而软中断优先级较低,负责将包进行各种处理,完成从驱动层、到网络协议栈,最终把处理出来的数据放到socker的接收buffer中。软中断消耗的CPU周期相对比硬中断要多不少。

软中断和系统调用一样,都是CPU停止掉当前用户态上下文,保存工作现场,然后陷入到内核态继续工作。二者的唯一区别是系统调用是切换到同进程的内核态上下文,而软中断是则是切换到了另外一个内核进程ksoftirqd上。

1) 查看软中断总耗时
首先用top命令可以看出每个核上软中断的开销占比,是在si列

top
- 19:51:24 up 78 days,  7:53,  2 users,  load average: 1.30, 1.35, 1.35Tasks: 923 total,   2 running, 921 sleeping,   0 stopped,   0 zombieCpu(s):  7.1%us,  1.4%sy,  0.0%ni, 90.1%id,  0.1%wa,  0.2%hi,  1.2%si,  0.0%stMem:  65872372k total, 64711668k used,  1160704k free,   339384k buffersSwap:        0k total,        0k used,        0k free, 55542632k cached

CPU大约花费了1.2%的时钟周期在软中断上,也就是说每个核要花费12ms。

2)查看软中断次数
再用vmstat命令可以看到软中断的次数

$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu------
r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st1  
0  0 1231716 339244 55474204    0    0     6   496    0    0  7  3 90  0  0  2  
0  0 1231352 339244 55474204    0    0     0   128 57402 24593  5  2 92  0  0  2  
0  0 1230988 339244 55474528    0    0     0   140 55267 24213  5  2 93  0  0  2 
0  0 1230988 339244 55474528    0    0     0   332 56328 23672  5  2 93  0  0

每秒大约有56000次左右的软中断(该机器上是web服务,网络IO密集型的机器,其它中断可以忽略不计)。

3)计算每次软中断的耗时
该机器是16核的物理实机,故可以得出每个软中断需要的CPU时间是=12ms/(56000/16)次=3.428us

从实验数据来看,一次软中断CPU开销大约3.4us左右

这个时间里其实包含两部分,一是上下文切换开销,二是软中断内核执行开销。其中上下文切换和系统调用、进程上下文切换有很多相似的地方。

系统调用的开销

strace命令

strace命令来查看到你的程序正在执行哪些系统调用

# 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  

开销

相比较函数调用时的不到1ns的耗时,系统调用确实开销蛮大的。虽然使用了“快速系统调用”指令,但耗时仍大约在200ns+,多的可能到十几us。每个系统调用内核要进行许多工作,大约需要执行1000条左右的CPU指令,所以确实应该尽量减少系统调用。但是即使是10us,仍然是1ms的百分之一,所以还没到了谈系统调用色变的程度,能理性认识到它的开销既可。

另外为什么系统调用之间的耗时相差这么多?因为系统调用花在内核态用户态的切换上的时间是差不多的,但区别在于不同的系统调用当进入到内核态之后要处理的工作不同,呆在内核态里的时候相差较大。

函数调用的开销

C语言

  • 每个c函数调用耗时大约是0.4ns左右。

  • 每个c函数需要的CPU指令数是8个!

    8次CPU指令中大部分都是寄存器的操作,即使有“内存IO”,也是在栈上进行。而栈操作密集,符合局部性原理,早就被L1缓存住了,其实都是L1的IO,所以耗时很低

其他语言

  • 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。这和代码框架化后给团队项目带来的便利性来对比的话,这点时间开销,我觉得仍然是可以忽略的


文章作者: 小小千千
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 小小千千 !
评论
  目录