跳到主要内容

上下文切换

六种常见的上下文切换场景​

函数调用​

函数调用是用户主动发起的指令流和上下文改变。普通的转移指令只改变指令流不改变上下文,函数调用则通过ABI约定实现了一定的上下文变化。

函数调用通常伴随着栈帧的变化,此外部分寄存器也会发生变化。根据ABI的约定,像s0−s0-s8这样约定由被调用者保存(Callee Save)的寄存器在函数调用前后保持不变,而通用暂存器、参数寄存器等则不保证维持调用前的值。

异常和中断​

通常异常和中断的处理对用户程序来说是透明的,相关软硬件需要保证处理前后原来执行中的代码看到的CPU状态保持一致。

这意味着开始异常和中断处理程序之前需要保存所有可能被破坏的、原上下文可见的CPU状态,并在处理完返回原执行流之前恢复。

异常和中断的处理代码通常在内核态执行,如果它们触发前处理器处于用户态,硬件会自动切换到内核态。这种情况下通常栈指针也会被重新设置为指向内核态代码所使用的栈,以便隔离不同特权等级代码的运行信息。

系统调用​

系统调用是操作系统内核为用户态程序实现的子程序。系统调用的上下文切换场景和函数调用比较类似,和普通调用相比主要多了特权等级的切换。

Linux 中的部分系统调用如下表:

类型系统调用调用号作用
进程控制clone220克隆一个进程
进程控制execv221执行一个程序
文件读写read63读文件
文件读写write64写文件
文件系统mkdir34创建目录
文件系统mount40挂载文件系统
系统时钟gettimeofday169获取系统时间
系统时钟reboot142重新启动
内存管理mmap222映射虚拟内存页
信号量semctl191信号量控制

Linux内核中,每个系统调用都被分配了一个整数编号,称为调用号。系统调用号可以从内核源码 include/uapi/asm-generic/unistd.h 查看。

因为涉及特权等级的切换,系统调用通常被当作一种用户发起的特殊异常来处理。与所有异常一样,系统调用在返回时使用 ERTN 指令来同时完成跳转用户地址和返回用户态的操作。

进程切换​

切换时机:

  • 一是进程主动调用某些系统调用时因出现无法继续运行的情况(如等待IO完成或者获得锁)而触发切换。
  • 二是进程分配到的时间片用完了或者有更高优先级的就绪进程要抢占CPU导致的切换。

切换工作的实质是实现对CPU硬件资源的分时复用。操作系统把当前进程的运行上下文信息保存到内存中,再把选中的下一个进程的上下文信息装载到CPU中。

特定时刻只能由一个进程使用的处理器状态信息,包括通用寄存器、eflags等用户态的专有寄存器以及当前程序计数器(PC)、处理器模式和状态、页表基址(例如X86指令系统的CR3寄存器和LoongArch的PGD寄存器)等控制信息,都需要被保存起来,以便下次运行时恢复到同样的状态。

线程切换​

线程逻辑上拥有独立的寄存器状态和栈。现代系统的线程一般也支持线程私有存储区(Thread Local Storage,简称TLS)。例如,GCC编译器支持用__thread int value;这样的语句来定义一个线程私有的全局变量,不同线程看到的变量 value 地址是不一样的。

Linux 系统中最常用的线程库NPTL( POSIX Thread Library)采用内核和用户1:1的线程模型,每个用户级线程对应一个内核线程。除了不切换地址空间,线程的切换和进程的大部分流程一致,都需要进入和退出核心态,经历至少两次用户态和核心态上下文的切换。

虚拟机​

虚拟机的运行上下文包括CPU、内存和外设的状态。在虚拟机内部会发生函数调用、中断和异常、线程和进程等各种内部的上下文切换,它们的处理和物理机的相应场景类似。

但在虚拟机无法独立处理的情况下会退出虚拟机运行状态,借助宿主机的虚拟化管理软件来完成任务。虚拟机和宿主机之间的切换需要保存和恢复所有可能被修改的虚拟机相关状态信息。例如对于CPU的状态信息,之前几种场景需要保存恢复的主要是用户可访问的寄存器,而虚拟机切换时可能还需要保存各种特权态资源,包括众多控制寄存器。如果系统支持在一台物理计算机上虚拟化出多个虚拟机,在物理资源少于虚拟机个数的时候,只能通过保存和恢复相关资源来维持每个虚拟机都独占资源的效果。

对比​

函数调用和系统调用是用户主动发起的,因此可以通过ABI约定来避免不必要的保存恢复。其他几种场景通常都要达到对应用程序透明的效果,因此切换后可能被修改的状态都应该被保存和恢复。

场景上下文切换时保存和恢复的内容
函数调用部分寄存器(包括栈帧相关的sp,p)、返回地址
中断和异常(通常情况)全部定点寄存器、异常现场信息、异常相关信息
系统调用部分定点寄存器(包括栈帧相关寄存器)、异常现场信息
线程全部用户态寄存器、TLS、当前 PC 等相关信息
进程全部用户态寄存器、页表基址等控制寄存器、当前 PC 等相关信息
虚拟机虚拟 CPU 状态(寄存器、必要的特权资源等)

Linux 上下文切换​

CPU 上下文切换,就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。

根据 Tsuna 的测试报告,每次上下文切换都需要几十纳秒到数微秒的 CPU 时间。这个时间还是相当可观的,特别是在进程上下文切换次数较多的情况下,很容易导致 CPU 将大量时间耗费在寄存器、内核栈以及虚拟内存等资源的保存和恢复上,进而大大缩短了真正运行进程的时间。

Linux 通过 TLB(Translation Lookaside Buffer)来管理虚拟内存到物理内存的映射关系。当虚拟内存更新后,TLB 也需要刷新,内存的访问也会随之变慢。特别是在多处理器系统上,缓存是被多个处理器共享的,刷新缓存不仅会影响当前处理器的进程,还会影响共享缓存的其他处理器的进程。

进程上下文切换时机​

  • 进程执行完终止了,它之前使用的 CPU 会释放出来,这个时候再从就绪队列里,拿一个新的进程过来运行
  • 当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行。
  • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。
  • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。
  • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行。
  • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。

自愿上下文切换和非自愿上下文切换​

  • 自愿上下文切换是指任务由于需要的资源不可用而主动暂停,等待资源可用后再继续运行。
  • 非自愿上下文切换是指任务在用完了自己的时间片后,被操作系统强制暂停并让出 CPU 给其他任务。

pidstat -w 中看到的 cswch/s 就是每秒自愿上下文切换的总次数,nvcswch/s 是每秒非自愿上下文切换的总次数。

每秒上下文切换次数合理值​

如果系统的上下文切换次数比较稳定,那么从数百到一万以内,都应该算是正常的。但当上下文切换次数超过一万次,或者切换次数出现数量级的增长时,就很可能已经出现了性能问题。

这时,你还需要根据上下文切换的类型,再做具体分析。比方说:

  • 自愿上下文切换变多了,说明进程都在等待资源,有可能发生了 I/O 等其他问题;
  • 非自愿上下文切换变多了,说明进程都在被强制调度,也就是都在争抢 CPU,说明 CPU 的确成了瓶颈;
  • 中断次数变多了,说明 CPU 被中断处理程序占用,还需要通过查看 /proc/interrupts 文件来分析具体的中断类型。

案例​

案例机器为阿里云轻量服务器,2 核,1.6G 内存。

先用 vmstat 看一下空闲系统的上下文切换次数:

$ vmstat 1 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 447948 55284 943836 0 0 34 127 1145 1097 7 1 90 2 0

线程切换​

再用 sysbench 模拟多线程调度

# 以10个线程运行5分钟的基准测试,模拟多线程切换的问题
$ sysbench --threads=10 --max-time=300 threads run

运行 vmstat ,观察上下文切换情况:

# 每隔1秒输出1组数据(需要Ctrl+C才结束)
$ 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
7 0 0 450756 56068 956692 0 0 34 126 1151 76 7 1 90 2 0
6 0 0 450756 56068 956692 0 0 0 0 27520 875559 28 71 1 0 0
9 0 0 450756 56068 956692 0 0 0 0 30932 870132 30 68 2 0 0
  • cs 列的上下文切换次数从之前的 1097 骤然上升到了 87 万
  • r 列:就绪队列的长度已经到了 9,远远超过了系统 CPU 的个数 2,所以肯定会有大量的 CPU 竞争。
  • us(user)和 sy(system)列:这两列的 CPU 使用率加起来快到 100%,其中系统 CPU 使用率,也就是 sy 列高达 71%,说明 CPU 主要是被内核占用了。
  • in 列:中断次数也上升到了 3 万左右,说明中断处理也是个潜在的问题。

我们可以知道,系统的就绪队列过长,也就是正在运行和等待 CPU 的进程数过多,导致了大量的上下文切换,而上下文切换又导致了系统 CPU 的占用率升高。

使用 pidstat 查看哪些进程导致了问题。

$ pidstat -w -u 1
Linux 5.15.0-107-generic (iZ2ze5ybozvutjqtoe2zk3Z) 09/23/2024 _x86_64_ (2 CPU)

04:47:04 PM UID PID %usr %system %guest %wait %CPU CPU Command
04:47:05 PM 0 1325 0.00 0.96 0.00 0.00 0.96 0 AliYunDunMonito
04:47:05 PM 1000 39325 58.65 131.73 0.00 0.00 190.38 1 sysbench
04:47:05 PM 1000 39421 0.00 0.96 0.00 0.00 0.96 0 pidstat

04:47:04 PM UID PID cswch/s nvcswch/s Command
04:47:05 PM 0 13 2.88 0.00 ksoftirqd/0
04:47:05 PM 0 14 22.12 0.00 rcu_sched
04:47:05 PM 0 32 1.92 0.00 kcompactd0
04:47:05 PM 0 424 1.92 0.00 multipathd
04:47:05 PM 0 1314 10.58 0.00 AliYunDun
04:47:05 PM 0 1325 28.85 0.00 AliYunDunMonito
04:47:05 PM 0 34916 9.62 0.00 kworker/0:0-events
04:47:05 PM 0 38790 3.85 0.00 kworker/1:1-events
04:47:05 PM 0 39057 5.77 0.00 kworker/u4:0-events_power_efficient
04:47:05 PM 1000 39421 0.96 1.92 pidstat

查看发现 sysbench 进程的 CPU 使用率 190%,快占满双核。但是奇怪的是上下文切换中找不到 sysbench。

注意这里 pidstat 需要加上 -t 参数,才会输出线程指标。

# 每隔1秒输出一组数据(需要 Ctrl+C 才结束)
# -wt 参数表示输出线程的上下文切换指标
$ pidstat -wt 1
$ pidstat -w -u -t 1
Linux 5.15.0-107-generic (iZ2ze5ybozvutjqtoe2zk3Z) 09/23/2024 _x86_64_ (2 CPU)

04:48:23 PM UID TGID TID %usr %system %guest %wait %CPU CPU Command
04:48:24 PM 0 1314 1318 0.90 0.00 0.00 0.00 0.90 0 (AliYunDun)__AliYunDun
04:48:24 PM 0 1325 - 0.90 0.00 0.00 0.90 0.90 0 AliYunDunMonito
04:48:24 PM 0 34916 34916 0.00 0.90 0.00 0.00 0.90 0 (kworker/0:0-events)__kworker/0:0-events
04:48:24 PM 1000 39325 - 54.95 134.23 0.00 0.00 189.19 1 sysbench
04:48:24 PM 1000 - 39326 6.31 13.51 0.00 45.95 19.82 0 |__sysbench
04:48:24 PM 1000 - 39327 5.41 12.61 0.00 45.95 18.02 1 |__sysbench
04:48:24 PM 1000 - 39328 4.50 16.22 0.00 52.25 20.72 0 |__sysbench
04:48:24 PM 1000 - 39329 5.41 13.51 0.00 47.75 18.92 1 |__sysbench
04:48:24 PM 1000 - 39330 5.41 13.51 0.00 57.66 18.92 1 |__sysbench
04:48:24 PM 1000 - 39331 4.50 14.41 0.00 54.95 18.92 1 |__sysbench
04:48:24 PM 1000 - 39332 6.31 13.51 0.00 43.24 19.82 1 |__sysbench
04:48:24 PM 1000 - 39333 5.41 13.51 0.00 55.86 18.92 1 |__sysbench
04:48:24 PM 1000 - 39334 6.31 11.71 0.00 45.05 18.02 0 |__sysbench
04:48:24 PM 1000 - 39335 4.50 14.41 0.00 50.45 18.92 0 |__sysbench
04:48:24 PM 1000 39422 - 0.00 0.90 0.00 4.50 0.90 0 pidstat
04:48:24 PM 1000 - 39422 0.00 0.90 0.00 4.50 0.90 0 |__pidstat

04:48:24 PM UID TGID TID cswch/s nvcswch/s Command
...
04:48:25 PM 1000 39325 39326 14152.00 84339.00 (sysbench)__sysbench
04:48:25 PM 1000 - 39327 19368.00 61032.00 |__sysbench
04:48:25 PM 1000 - 39328 19687.00 61761.00 |__sysbench
04:48:25 PM 1000 - 39329 11665.00 76034.00 |__sysbench
04:48:25 PM 1000 - 39330 20792.00 53824.00 |__sysbench
04:48:25 PM 1000 - 39331 16817.00 66925.00 |__sysbench
04:48:25 PM 1000 - 39332 13154.00 70917.00 |__sysbench
04:48:25 PM 1000 - 39333 13015.00 73482.00 |__sysbench
04:48:25 PM 1000 - 39334 14376.00 67387.00 |__sysbench
04:48:25 PM 1000 - 39335 13580.00 76441.00 |__sysbench
...

虽然 sysbench 进程(也就是主线程)的上下文切换次数看起来并不多,但它的子线程的上下文切换次数却有很多。

中断类型​

中断只发生在内核态,而 pidstat 只是一个进程的性能分析工具,并不提供任何关于中断的详细信息,怎样才能知道中断发生的类型呢?没错,那就是从 /proc/interrupts 这个只读文件中读取。/proc 实际上是 Linux 的一个虚拟文件系统,用于内核空间与用户空间之间的通信。/proc/interrupts 就是这种通信机制的一部分,提供了一个只读的中断使用情况。

直接观察 /proc/interrupts

$ watch -d cat /proc/interrupts
CPU0 CPU1
...
RES: 5306604 4827183 Rescheduling interrupts
...

变化速度最快的是重调度中断(RES),这个中断类型表示,唤醒空闲状态的 CPU 来调度新的任务运行。这是多处理器系统(SMP)中,调度器用来分散任务到不同 CPU 的机制(也就是可以唤醒空闲状态的 CPU ,来调度新任务运行),通常也被称为处理器间中断(Inter-Processor Interrupts,IPI)。

所以可见中断升高还是因为过多任务的调度问题。