啃碎并发(三):Java线程上下文切换

13,358 阅读16分钟

0 前言

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

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

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

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

1 多核、多CPU、超线程、多线程

1.1 为什么要多核

先要说的是多核、多CPU、超线程,这三个其实都是CPU架构设计的概念,一个现代CPU除了处理器核心之外还包括寄存器、L1L2缓存这些存储设备、浮点运算单元、整数运算单元等一些辅助运算设备以及内部总线等。一个多核的CPU也就是一个CPU上有多个处理器核心,这样有什么好处呢?比如说现在我们要在一台计算机上跑一个多线程的程序,因为是一个进程里的线程,所以需要一些共享一些存储变量,如果这台计算机都是单核单线程CPU的话,就意味着这个程序的不同线程需要经常在CPU之间的外部总线上通信,同时还要处理不同CPU之间不同缓存导致数据不一致的问题,所以在这种场景下多核单CPU的架构就能发挥很大的优势,通信都在内部总线,共用同一个缓存

1.2 为什么要多CPU

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

1.3 为什么要超线程

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

1.4 为什么要多线程

这个问题也许是面试中问的最多的一个经典问题了,一个进程里多线程之间可以共享变量,线程间通信开销也较小,可以更好的利用多核CPU的性能,多核CPU上跑多线程程序往往会比单线程更快,有的时候甚至在单核CPU上多线程程序也会有更好的性能,因为虽然多线程会有上下文切换和线程创建销毁开销,但是单线程程序会被IO阻塞无法充分利用CPU资源,加上线程的上下文开销较低以及线程池的大量应用,多线程在很多场景下都会有更高的效率

1.5 线程与进程

进程是操作系统的管理单位,而线程则是进程的管理单位;一个进程至少包含一个执行线程。不管是在单线程还是多线程中,每个线程都有一个程序计数器(记录要执行的下一条指令),一组寄存器(保存当前线程的工作变量),堆栈(记录执行历史,其中每一帧保存了一个已经调用但未返回的过程)。虽然线程寄生在进程中,但与他的进程是不同的概念,并且可以分别处理:进程是系统分配资源的基本单位,线程是调度CPU的基本单位

一个线程指的是进程中一个单一顺序的控制流,一个进程中可以并行多个线程,每条线程并行执行不同的任务。每个线程共享堆空间,拥有自己独立的栈空间

  1. 线程划分尺度小于进程,线程隶属于某个进程;
  2. 进程是CPU、内存等资源占用的基本单位,线程是不能独立占有这些资源的;
  3. 进程之间相互独立,通信比较困难,而线程之间共享一块内存区域,通信方便;
  4. 进程在执行过程中,包含:固定的入口、执行顺序和出口而进程的这些过程会被应用程序控制

进程&线程表项

2 上下文切换

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

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

任务的状态保存及再加载, 这段过程就叫做上下文切换

2.1 基本概念

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

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

上下文切换可以认为是内核(操作系统的核心)在 CPU 上对于进程(包括线程)进行以下的活动:

  1. 挂起一个进程,将这个进程在 CPU 中的状态(上下文)存储于内存中的某处;
  2. 恢复一个进程,在内存中检索下一个进程的上下文并将其在 CPU 的寄存器中恢复;
  3. 跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行),以恢复该进程。

2.2 切换种类

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

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

2.3 切换步骤

在上下文切换过程中,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

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

  1. 切换页目录以使用新的地址空间
  2. 切换内核栈和硬件上下文

对于Linux来说,线程和进程的最大区别就在于地址空间。对于线程切换,第1步是不需要做的,第2是进程和线程切换都要做的。所以明显是进程切换代价大。线程上下文切换和进程上下文切换一个最主要的区别是 线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是 通过操作系统内核来完成的。内核的这种切换过程伴随的 最显著的性能损耗是将寄存器中的内容切换出

对于一个正在执行的进程包括 程序计数器、寄存器、变量的当前值等 ,而这些数据都是 保存在CPU的寄存器中的,且这些寄存器只能是正在使用CPU的进程才能享用在进程切换时,首先得保存上一个进程的这些数据(便于下次获得CPU的使用权时从上次的中断处开始继续顺序执行,而不是返回到进程开始,否则每次进程重新获得CPU时所处理的任务都是上一次的重复,可能永远也到不了进程的结束出,因为一个进程几乎不可能执行完所有任务后才释放CPU),然后将本次获得CPU的进程的这些数据装入CPU的寄存器从上次断点处继续执行剩下的任务

操作系统为了便于管理系统内部进程,为每个进程创建了一张进程表项:

进程表项

2.4 切换查看

在Linux系统下可以使用vmstat命令来查看上下文切换的次数,下面是利用vmstat查看上下文切换次数的示例:

上线文切换查看

vmstat 1指每秒统计一次, 其中cs列就是指上下文切换的数目. 一般情况下, 空闲系统的上下文切换每秒大概在1500以下.

3 切换原因

引起线程上下文切换的原因,主要存在三种情况如下:

  1. 中断处理:在中断处理中,其他程序”打断”了当前正在运行的程序。当CPU接收到中断请求时,会在正在运行的程序和发起中断请求的程序之间进行一次上下文切换。中断分为硬件中断和软件中断,软件中断包括因为IO阻塞、未抢到资源或者用户代码等原因,线程被挂起。
  2. 多任务处理:在多任务处理中,CPU会在不同程序之间来回切换,每个程序都有相应的处理时间片,CPU在两个时间片的间隔中进行上下文切换。
  3. 用户态切换:对于一些操作系统,当进行用户态切换时也会进行一次上下文切换,虽然这不是必须的。

对于我们经常 使用的抢占式操作系统 而言,引起线程上下文切换的原因大概有以下几种:

  1. 当前执行任务的时间片用完之后,系统CPU正常调度下一个任务;
  2. 当前执行任务碰到IO阻塞,调度器将此任务挂起,继续下一任务;
  3. 多个任务抢占锁资源,当前任务没有抢到锁资源,被调度器挂起,继续下一任务;
  4. 用户代码挂起当前任务,让出CPU时间;
  5. 硬件中断;

4 切换损耗

上下文切换会带来 直接和间接 两种因素影响程序性能的消耗。

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

5 减少切换

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

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

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

  1. 无锁并发:多线程竞争时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash取模分段,不同的线程处理不同段的数据;
  2. CAS算法:Java的Atomic包使用CAS算法来更新数据,而不需要加锁;
  3. 最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态;
  4. 使用协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换;

6 线程数目

合理设置线程数目,关键点是:1. 尽量减少线程切换和管理的开支;2. 最大化利用CPU

对于1,要求线程数尽量少,这样可以减少线程切换和管理的开支;

对于2,要求尽量多的线程,以保证CPU资源最大化的利用;

所以 对于任务耗时短的情况,要求线程尽量少,如果线程太多,有可能出现线程切换和管理的时间,大于任务执行的时间,那效率就低了;

对于耗时长的任务,要分是CPU任务,还是IO等类型的任务。如果是CPU类型的任务,线程数不宜太多;但是如果是IO类型的任务,线程多一些更好,可以更充分利用CPU。

高并发,低耗时的情况:建议少线程,只要满足并发即可,因为上下文切换本来就多,并且高并发就意味着CPU是处于繁忙状态的, 增加更多地线程也不会让线程得到执行时间片,反而会增加线程切换的开销;例如并发100,线程池可能设置为10就可以;

低并发,高耗时的情况:建议多线程,保证有空闲线程,接受新的任务;例如并发10,线程池可能就要设置为20;

高并发高耗时:1. 要分析任务类型;2. 增加排队;3. 加大线程数;