上下文切换性能篇

monchickey

2023/07/26

现代操作系统都是多任务的分时操作系统,也就是说同时响应多个用户交互或同时支持多个任务处理,因为 CPU 的速度很快而用户交互的频率相比会低得多。所以例如在 Linux 中,可以支持远大于 CPU 数量的任务同时执行,对于单个 CPU 来说,其实任务并不是在同时执行,而是操作系统在很短的时间内,使得多个进程交替获得 CPU 来执行,由于切换速度比较快,因此这会给我们一种程序同时运行的错觉,这就是操作系统中的多道程序设计技术,对 CPU 实现了虚拟化,使得我们看起来好像有多个 CPU 来运行任务一样,我们也称这多个任务是并发运行的。

每个任务运行时,都有自己的寄存器状态,主要包括:数据寄存器、地址寄存器、控制和状态寄存器等。其中地址寄存器又包括:变址寄存器、段指针寄存器、栈指针寄存器等;控制和状态寄存器主要包括:程序计数器、指令寄存器等,这类寄存器对用户来说是不可见的。

除了寄存器,进程在内存中也会有自己的状态,操作系统基于这种状态来管理和控制进程,而这种状态不允许被用户直接访问或修改,例如页表、进程优先级、进程 I/O 状态等。

上面的寄存器状态和内存状态我们统称为进程的上下文(context switch),而整个进程主要有 3 部分组成:

  1. 可执行的程序(二进制序列)
  2. 程序运行所需的数据(例如:变量、缓冲区等)
  3. 程序的执行上下文

在多道程序设计中,假如有 A 和 B 两个进程,当前 B 进程正在执行,这时操作系统需要调度 A 执行,那么首先需要保存 B 的上下文,然后恢复 A 原来的上下文,恢复的过程会重新设置相关寄存器的值,例如将程序计数器设置为 A 上次执行到的指令地址,这样 A 就继续运行。这个由进程 B 切换为进程 A 的过程就叫做进程的上下文切换。

其实上下文切换不仅是进程上下文切换,还包括:线程上下文切换和中断上下文切换。

系统调用和上下文切换的关系

进程的状态分为用户态和内核态,进程在用户态通过系统调用陷入内核态,那么在系统调用和上下文切换时什么关系呢?我们来看一下,首先是要保存进程在用户态下的寄存器,但并不会保存内存中的资源,比如虚拟内存、栈、进程控制状态等,然后进入内核态会将 CPU 寄存器的值更新为内核指令的位置,确保正确执行内核中的一段代码,然后开始执行特权指令,当系统调用执行完成后,CPU 需要恢复原来保存的用户态寄存器,并切换到用户空间,继续运行进程。所以我们看这个过程类似于发生了 1.5 次 CPU 的上下文切换,但是这个切换比较轻量,并不会保存进程的内存状态等资源,也不会对进程进行切换,所以我们一般不说系统调用是上下文切换,但是这个过程中其实是存在和上下文切换类似的过程的,只是开销比较小一些,所以我们总结系统调用和上下文切换的区别:

  1. 进程上下文切换是多个进程间的切换,而系统调用过程不涉及进程的切换。
  2. 系统调用只进行寄存器的保存和恢复,不涉及进程资源的保存和恢复,因此开销更小。

进程上下文切换的开销

通过 lmbench 测试或者第三方的报告可以发现,进程上下文切换的开销大约在几十纳秒到几微秒之间,如果切换过于频繁,那么很容易导致大量的时间都浪费在寄存器、内核栈以及进程内存状态等资源的保存和恢复上,从而缩短进程真正运行的时间,由于上下文切换主要由 CPU 完成,因此会直接导致 CPU 负载的升高。

另外,CPU 通过 TLB 来提高虚拟内存到物理内存的查找性能,通过高速缓存来加速数据的查找,所以进程的上下文切换会导致 TLB 和高速缓存重新刷新,最终导致程序运行性能降低。

虽然上下文切换有一定的性能开销,但是合理的上下文切换是提升多个进程执行效率的关键,通常上下文切换会发生在下面这些情况中:

  1. 为了保证所有进程都有机会被公平调度,CPU 时间会划分为一个个的时间片,这些时间片会尽量均匀的分配给各个进程,如果某个进程的时间片耗尽,会通过进程的上下文切换选择合适的进程获得进程的时间片继续运行,合理的时间片可以将上下文切换的开销给摊销掉。
  2. 进程在等待资源时,比如发起系统调用需要等待 IO 或者网络完成才可以运行,这个时候操作系统也会调度其他的进程运行。
  3. 进程主动进入睡眠状态时,也会让出 CPU 给其他进程。
  4. 如果有优先级更高的进程加入,优先级低的进程也可能被挂起,转而运行优先级更高的进程。
  5. 发生硬件中断时,进程会被中断挂起,从而执行内核的中断服务程序。

如果系统的上下文切换出现了问题,那么一定是遇到了上面情况中的某一个或多个。

上下文切换除了上面所说的进程上下文切换,还包括线程上下文切换、中断上下文切换,下面简单来叙述下。

进程是资源管理的基本单位,而线程是调度的基本单位,也就是说内核中任务调度的对象实际上是线程,进程给线程提供了地址空间、全局变量等资源,每个线程拥有自己的栈和寄存器,这样在同一个进程的多个线程之间做上下文切换时,进程的资源是不需要变化的,只需要对每个线程独立的栈和寄存器进行切换即可,因此线程上下文切换要比进程上下文切换的开销更小,这也是多线程相比多进程的优势所在。但是如果两个线程属于不同的进程,这时候线程上下文切换和进程上下文切换的开销是一样的。

中断上下文切换是为了快速响应其他硬件,中断处理会打断其他进程的正常调度和执行,然后开始运行中断处理程序,从而响应设备事件,因此在打断正常运行的进程时,就需要将进程的寄存器保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。同样值得注意的是,中断上下文不涉及进程的用户态,也就是不需要保存被打断进程的地址空间、全局变量、用户栈等资源,因为中断本身是在内核态执行,仅仅需要用到 CPU 寄存器、内核空间堆栈与硬件中断参数等,因此只需要保存进程的寄存器、内核堆栈即可,恢复时也只需要恢复进程的内核态资源,所以中断上下文的处理也比进程上下文切换的开销更小。

对于同一个 CPU 来说,中断处理比进程运行本身拥有更高的优先级,所以中断上下文切换不会与进程上下文切换同时发生,由于中断会打断正常运行的进程,因此必须保证硬件中断处理程序必须短小精悍,快速执行才可以。

总体来说,正常的上下文切换是保证系统正常运转的必要条件,但是过多的上下文切换会导致系统性能严重降低,需要我们特别注意。

上下文切换常见的排查工具有:vmstatpidstat 等,具体用法这里不再叙述,只是简单的给出原理和分析思路。

vmstat 工具可以查看整个系统全局的上下文切换情况,重点关注 r、b、cs、in 这些列。

  1. r 是就绪队列的长度,包括正在运行和等待 CPU 的进程数。
  2. b 表示处于不可中断睡眠状态的进程数,通常是等待 I/O 资源的进程。
  3. cs 表示系统每秒上下文切换的次数。
  4. in 表示每秒中断的次数。

如果想看每个进程的上下文切换情况,就需要使用 pidstat 这个工具了,使用 -w 参数可以获得进程上下文切换的情况,主要输出有:cswch/s 和 nvcswch/s 这两个参数,分别表示每秒自愿上下文切换次数、每秒非自愿上下文切换次数,这两个次数的含义主要如下:

  1. 自愿上下文切换表示进程由于等待资源而发生的上下文切换,比如发起 I/O、内存、网络等请求时,就会发生自愿上下文切换。
  2. 非自愿上下文切换则表示进程并没有等待资源,而是由于时间片已到而被系统强制调度,进而发生的上下文切换。如果大量的进程都在争抢 CPU 时,就会发生很多非自愿上下文切换。

这两类切换对系统性能有着截然不同的影响,也是需要我们注意的。

性能分析思路

如果系统处在正常运行状态下,那么使用 vmstat 看到的上下文切换次数应该不会太大,可能几十上百最多几千这样子,如果飙到了几十万,那么说明系统的上下文切换肯定是不正常的,除了上下文切换次数,我们还可以通过 vmstat 重点关注下面的现象:

  1. 正常 r 不应该超过 CPU 个数,如果 r 太高则说明有非常多的进程在争抢 CPU,也就是说 CPU 有可能不够用了。这个时候可以观察 us 和 sy 确定 CPU 的占用,或者使用 top 进一步分析。
  2. 通常 b 也不会特别高,如果 b 很高则说明有非常多的进程处在 I/O 等待状态,这个时候 wa 通常会很高,说明系统存在 I/O 瓶颈。
  3. in 如果比较高,达到几万或几十万,说明中断过于频繁,需要注意排查。

然后我们想进一步找到引起问题的进程,那么可以继续使用 pidstat 来分析,不过 pidstat 默认只能看到进程级别的,如果是多线程应用注意添加 -t 参数来显示线程的信息,也就是 pidstat -wt <time>,然后就可以通过分析自愿上下文切换和非自愿上下文切换来确定进程本身存在的问题。

如果上面发现 wa 特别高,怀疑是硬盘的问题,则可以使用 iotopdstat 进一步分析硬盘相关的瓶颈。

如果上面发现中断特别高,则可以通过内核文件 /proc/interrupts 查看,例如下面的命令:

watch -d cat /proc/interrupts

这样可以查看不同类型的中断变化情况,具体内容和含义可以查看 Linux kernel 的文档:https://docs.kernel.org/filesystems/proc.html#kernel-data 其中 interrupts 部分的内容,我们通过统计可以发现具体中断的原因。

我们对上下文切换和含义与性能做了比较详细的分析,下面我们来整理一下相关的知识点:

context-switch