9.1 进程线程切换 CPU 开销分析

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

Transclude of 进程上下文切换示意图

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

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

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

具体的实验代码参见:https://github.com/yanfeizhang/coder-kung-fu/tree/main/tests/cpu/test03

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

# gcc main.c -o main
# ./main./main
Before Context Switch Time1565352257 s, 774767 us
After Context SWitch Time1565352257 s, 842852 us

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

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

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

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

  1. 切换页表全局目录
  2. 切换内核态堆栈
  3. 切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文)
    • ip (instruction pointer):指向当前执行指令的下一条指令
    • bp (base pointer):用于存放执行中的函数对应的栈帧的栈底地址
    • sp (stack pointer):用于存放执行中的函数对应的栈帧的栈顶地址
    • 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.48us 之间。

线程上下文切换耗时

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

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

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

我们用一个实验来测试一下 https://github.com/yanfeizhang/coder-kung-fu/tree/main/tests/cpu/test05。其原理和进程测试差不多,创建了 20 个线程,在线程之间通过管道来传递信号。接到信号就唤醒,然后再传递信号给下一个线程,自己睡眠。这个实验里单独考虑了给管道传递信号的额外开销,并在第一步就统计了出来。

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

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

Linux 相关命令

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

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 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

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

sar -w

# 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
......

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

pidstat -w

# 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 时间片的概念,时间片到了会将进程从 CPU 上赶下来,换另一个进程上。但其实在我们互联网的网络 IO 密集型的应用里,真正因为时间片到了而发生的非自愿切换很少,绝大部分都是因为等待网络 IO 而进行的自愿切换。上面的例子你也可以看出,我的一个 fpm 进程主动切换有 57W 次,而被动切换只有不到 9W 次。所以,在同步阻塞的开发模式里,网络 IO 是导致上下文切换频繁的元凶


相关链接: