1.上下文切换

一、背景

1.并行与并发

在过去单CPU时代,单任务在一个时间点只能执行单一程序。之后发展到多任务阶段,计算机能在同一时间点并行执行多任务或多进程。虽然并不是真正意义上的“同一时间点”,而是 多个任务或进程共享一个CPU,并交由操作系统来完成多任务间对CPU的运行切换,以使得每个任务都有机会获得一定的时间片运行,有的场景下也把这个称之为并行。

再后来发展到多线程技术,使得在一个程序内部能拥有多个线程并行执行。一个线程的执行可以被认为是一个CPU在执行该程序。当一个程序运行在多线程下,就好像有多个CPU在同时执行该程序,有的场景下也把这个称之为并发。

多线程比多任务更加有挑战。多线程是在同一个程序内部并行执行,因此会对相同的内存空间进行并发读写操作。这可能是在单线程程序中从来不会遇到的问题。其中的一些错误也未必会在单CPU机器上出现,因为两个线程从来不会得到真正的并行执行。然而,更现代的计算机伴随着多核CPU的出现,也就意味着 不同的线程能被不同的CPU核得到真正意义的并行执行

所以,在多线程、多任务情况下,线程上下文切换是必须的,然而对于CPU架构设计中的概念,应先熟悉了解,这样会有助于理解线程上下文切换原理。

2.超线程、一U多核、一机器多U

超线程这个概念是Intel提出的,简单来说是在一个CPU上真正的并发两个线程,听起来似乎不太可能,因为CPU都是分时的啊,其实这里也是分时,因为前面也提到一个CPU除了处理器核心还有其他设备,一段代码执行过程也不光是只有处理器核心工作,如果两个线程A和B,A正在使用处理器核心,B正在使用缓存或者其他设备,那AB两个线程就可以并发执行,但是如果AB都在访问同一个设备,那就只能等前一个线程执行完后一个线程才能执行。实现这种并发的原理是 在CPU里加了一个协调辅助核心,根据Intel提供的数据,这样一个设备会使得设备面积增大5%,但是性能提高15%~30%。

一个多核的CPU也就是一个CPU上有多个处理器核心,这样有什么好处呢?比如说现在我们要在一台计算机上跑一个多线程的程序,因为是一个进程里的线程,所以需要一些共享一些存储变量,如果这台计算机都是单核单线程CPU的话,就意味着这个程序的不同线程需要经常在CPU之间的外部总线上通信,同时还要处理不同CPU之间不同缓存导致数据不一致的问题,所以在这种场景下多核单CPU的架构就能发挥很大的优势,通信都在内部总线,共用同一个缓存

这个其实很容易想到,如果要运行多个程序(进程)的话,假如只有一个CPU的话,就意味着要经常进行进程上下文切换,因为单CPU即便是多核的,也只是多个处理器核心,其他设备都是共用的,所以 多个进程就必然要经常进行进程上下文切换,这个代价是很高的

说到底,这就是人为的设计下,想多多的并行,又想最少的上下文切换。

3.线程是操作系统调度的最小单位

大学的课本里有一句最重要的话:**线程是操作系统调度的最小单元。**其主要原因是虽然线程与归属于同一个父进程下的其他线程共享同一个堆空间,但是每一个线程都拥有自己的栈空间。在JAVA的世界中,通常描述的栈就是这个线程栈。应该还记得线程的栈空间包括以下数据:程序计数器、寄存器数据(运行中的线程在寄存器中的快照数据)、方法栈、栈帧(包含局部变量等数据)用于方法调用过程中的压栈出栈。必须指出的是:这些栈内存,是交由内核管理的。如果只是看过JVM的话,很可能会存在一个错觉,JVM创建的线程数据都是JVM维护的。JVM只是一个application,与QQ、扫雷一样。其对线程的管理都是操作系统在拿捏,JVM只是通过执行系统调用的方式,来告诉操作系统,我想怎么样。

一般运行中的线程的线程栈数据存储在计算机的L1/L2缓存里面。上下文切换的时候,会被一起切换走。

二、上下文切换

在计算机中,多任务处理是指同时运行两个或多个程序。从使用者的角度来看,这看起来并不复杂或者难以实现,但是它确实是计算机设计史上一次大的飞跃。在多任务处理系统中,CPU需要处理所有程序的操作,当用户来回切换它们时,需要记录这些程序执行到哪里。上下文切换就是这样一个过程,允许CPU记录并恢复各种正在运行程序的状态,使它能够完成切换操作。

这或许会被记录到计算机发展史上最伟大的一次变革。

多任务系统往往需要同时执行多道作业。作业数往往大于机器的CPU数,然而一颗CPU同时只能执行一项任务,如何让用户感觉这些任务正在同时进行呢? 操作系统的设计者 巧妙地利用了时间片轮转的方式, CPU给每个任务都服务一定的时间,然后把当前任务的状态保存下来,在加载下一任务的状态后,继续服务下一任务任务的状态保存及再加载, 这段过程就叫做上下文切换。时间片轮转的方式使多个任务在同一颗CPU上执行变成了可能。

img

上下文切换(有时也称做进程切换或任务切换)是指CPU从一个进程或线程切换到另一个进程或线程。

  1. 进程(有时候也称做任务)是指一个程序运行的实例。
  2. 在Linux系统中,线程 就是能并行运行并且与他们的父进程(创建他们的进程)共享同一地址空间(一段内存区域)和其他资源的 轻量级的进程
  3. 上下文 是指某一时间点 CPU 寄存器和程序计数器的内容。
  4. 寄存器 是 CPU 内部的数量较少但是速度很快的内存(与之对应的是 CPU 外部相对较慢的 RAM 主内存)。寄存器通过对常用值(通常是运算的中间值)的快速访问来提高计算机程序运行的速度
  5. 程序计数器是一个专用的寄存器,用于表明指令序列中 CPU 正在执行的位置,存的值为正在执行的指令的位置或者下一个将要被执行的指令的位置,具体依赖于特定的系统。

1.上下文切换的种类

上下文切换在不同的场合有不同的含义,在下表中列出:

上下文切换种类描述线程切换同一进程中的两个线程之间的切换进程切换两个进程之间的切换模式切换在给定线程中,用户模式和内核模式的切换地址空间切换将虚拟内存切换到物理内存

前面两个比较好理解,后面两个也是场景的执行步骤。

2.上下文切换过程

在上下文切换过程中,CPU会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行。

给出一个不太恰当的切换过程:很像平时读书的时候,如果突然被其他事情打断了,这个时候,我们就会插上一个书签,书签插到正确的位置,同时给书签上记录读到哪一行,哪一列了。

在程序中,上下文切换过程中的“页码”信息是保存在进程控制块(PCB, process control block)中的。PCB还经常被称作“切换桢”(switchframe)。“页码”信息会一直保存到CPU的内存中,直到他们被再次使用PCB通常是系统内存占用区中的一个连续存区,它存放着操作系统用于描述进程情况及控制进程运行所需的全部信息,它使一个在多道程序环境下不能独立运行的程序成为一个能独立运行的基本单位或一个能与其他进程并发执行的进程。

  1. 保存进程A的状态(寄存器和操作系统数据);
  2. 更新PCB中的信息,对进程A的“运行态”做出相应更改;
  3. 将进程A的PCB放入相关状态的队列
  4. 将进程B的PCB信息改为“运行态”,并执行进程B
  5. B执行完后,从队列中取出进程A的PCB,恢复进程A被切换时的上下文,继续执行A

img

线程切换和进程切换的步骤也不同。进程的上下文切换分为两步:

  1. 切换页目录以使用新的地址空间;【线程与归属于同一个父进程下的其他线程共享同一个堆空间】
  2. 切换内核栈和硬件上下文

对于Linux来说,线程和进程的最大区别就在于地址空间。对于线程切换,第1步是不需要做的,第2是进程和线程切换都要做的。所以明显是进程切换代价大。线程上下文切换和进程上下文切换一个最主要的区别是 线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是 通过操作系统内核来完成的。内核的这种切换过程伴随的 最显著的性能损耗是将寄存器中的内容切换出。对于一个正在执行的进程包括 程序计数器、寄存器、变量的当前值等 ,而这些数据都是 保存在CPU的寄存器中的,且这些寄存器只能是正在使用CPU的进程才能享用在进程切换时,首先得保存上一个进程的这些数据(便于下次获得CPU的使用权时从上次的中断处开始继续顺序执行,而不是返回到进程开始,否则每次进程重新获得CPU时所处理的任务都是上一次的重复,可能永远也到不了进程的结束出,因为一个进程几乎不可能执行完所有任务后才释放CPU),然后将本次获得CPU的进程的这些数据装入CPU的寄存器从上次断点处继续执行剩下的任务

三、触发上下文切换场景

1 系统调用

Linux 按照特权等级,把进程的运行空间分为内核空间和用户空间。CPU特权等级的Ring0Ring3

  • 内核空间(Ring 0)具有最高权限,可以直接访问所有资源
  • 用户空间(Ring 3)只能访问受限资源,不能直接访问内存等硬件设备,必须通过系统调用陷入到内核中,才能访问这些特权资源。

换个角度看,也就是说,进程既可以在用户空间运行,又可以在内核空间中运行。进程在用户空间运行是,被称为进程的用户态,而陷入内核空间的时候,被称为进程的内核态。 从用户态到内核态的转变,需要通过系统调用来完成,比如当我们查看文件内容时,就需要多次系统调用来完成:首先调用open()打开文件,然后调用read()读取文件内容,并调用write()将内容写到标准输出,最后再调用close()关闭文件。

系统调用的过程会发生CPU上下文切换

CPU寄存器里原来用户态的指令位置,需要先保存起来。接着,为了执行内核态代码,CPU寄存器需要更新为内核态指令的新位置。最后才是跳转到内核态运行内核任务。

而系统调用结束后,CPU寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程,所以一次系统调用的过程,其实是发生了两次CPU上下文切换。

  • 进程上下文切换,是指从一个进程切换到另一个进程运行。
  • 而系统调用过程中一直是同一个进程在运行。

系统调用过程通常称为特权模式切换,而不是上下文切换。但实际上系统调用过程中,CPU的上下文切换还是无法避免的。

1.2 进程切换

首先需要知道的是,进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,进程的上下文不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包含了内核堆栈、寄存器等内核空间状态。

因此,进程的上下文切换就比系统调用多了一步:在保存当前进程的内核状态和CPU寄存器之前,需要先把该进程的虚拟内存、栈等保存下来;而加载了下一进程的内核态后,还需要刷新新进程的虚拟内存和用户栈。如下图所示:

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

进程切换时才需要切换上下文,换句话说,只有在进程调度的时候,才需要切换上下文。Linux为每个CPU都维护了一个就绪队列,将获取进程(即正在运行和等待CPU的进程)按照优先级和等待CPU的时间排序,然后选择最需要CPU的进程,也就是优先级最高和等待CPU时间最长的进程来运行。

进程调度是一个比较复杂的算法,这里简要说明几种调度:

  1. 为了保证所有进程可以得到公平调度,CPU时间片被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其他正在等待CPU的进程运行。
  2. 进程在系统资源不足(比如内存不足)时,需要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。
  3. 当进程通过随眠函数sleep这样的方法将自己主动挂起时,自然也会重新调度。
  4. 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级的进程来运行。
  5. 当发生硬件中断时,CPU上的进程会被中断挂起,转而执行内核中中断服务程序。

1.3 进程上下文切换

线程与进程最大的区别在与,线程是调度的基本单位,而进程则是资源拥有的基本单位。说白了,所谓内核中的任务调用,实际上的调度对象是线程;而进程只是给线程提供了虚拟内存、全局变量等资源。

  • 当进程只有一个线程时,可以认为进程就等于线程。
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。

另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

这么一来,线程的上下文切换其实就可以分为两种情况:

  • 前后俩个线程属于不同进程,此时,由于资源不共享,所以切换过程就跟进程上下文切换是一样的。
  • 前后两个线程属于同一个进程,此时,应为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据,寄存器等不共享的数据。 到这里你应该也发现了,虽然同为上下文切换,但同进程内的线程切换,要比多进程间切换消耗更少的资源,而这,也正是多线程代替多进程的一个优势。

1.4 中断上下文切换

除了前面两种上下文切换,还有一个场景也会也换CPU上下文,那就是中断。

为了快速响应硬件的时间,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备时间。而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。

跟进程上下文不同,中断上下文切换并不涉及到进程的用户态。所以,即便中断打断了一个正处于用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。中断上下文,其实只包括内核态中断服务程序所必须的状态,包括CPU寄存器、内核堆栈、硬件中断等参数等。

对同一个CPU来说,中断处理比进程拥有更高的优先级,所以中断上下文切换并不会与进程上下文切换同时发生。同样的道理,由于中断会打断正常进程的调度和执行,所以大部分中断处理程序都短小精悍,以便尽可能快的执行结束。

另外,跟进程上下文切换一样,中断上下文切换也需要消耗CPU,切换次数过多也会耗费大量的CPU,甚至严重降低系统的整体性能。所以,当你发现中断次数过多时,就需要注意去排查它是否会给你的系统带来严重的性能问题。

不管是哪种场景导致的上下文切换,你都应该知道:

CPU上下文切换,是保证Linux系统正常工作的核心功能之一,一般情况下不需要我们特别关注。 但过多的上下文切换,会把CPU时间消耗在寄存器、内核栈以及虚拟内存等数据的保存和恢复上,从而缩短进程真正运行的时间,导致系统的整体性能大幅下降。

四、上下文切换带来的损耗

  • 直接消耗:指的是CPU寄存器需要保存和加载, 系统调度器的代码需要执行, TLB实例需要重新加载, CPU 的pipeline需要刷掉;
  • 间接消耗:指的是多核的cache之间得共享数据, 间接消耗对于程序的影响要看线程工作区操作数据的大小;

五、减少切换

既然上下文切换会导致额外的开销,因此减少上下文切换次数便可以提高多线程程序的运行效率。但上下文切换又分为2种:

  1. 让步式上下文切换:指执行线程主动释放CPU,与锁竞争严重程度成正比,可通过减少锁竞争来避免;
  2. 抢占式上下文切换:指线程因分配的时间片用尽而被迫放弃CPU或者被其他优先级更高的线程所抢占,一般由于线程数大于CPU可用核心数引起,可通过调整线程数,适当减少线程数来避免。

所以,减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程

无锁并发:多线程竞争时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash取模分段,不同的线程处理不同段的数据;

CAS算法:Java的Atomic包使用CAS算法来更新数据,而不需要加锁;

最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态;

使用协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换;