啃碎并发(三):Java线程上下文切换
前言
在过去单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的基本单位。
一个线程指的是进程中一个单一顺序的控制流,一个进程中可以并行多个线程,每条线程并行执行不同的任务。每个线程共享堆空间,拥有自己独立的栈空间。
![](https://upload-images.jianshu.io/upload_images/22555065-4d5175341e2b6bec.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
2 上下文切换
支持多任务处理是CPU设计史上最大的跨越之一。在计算机中,多任务处理是指同时运行两个或多个程序。从使用者的角度来看,这看起来并不复杂或者难以实现,但是它确实是计算机设计史上一次大的飞跃。在多任务处理系统中,CPU需要处理所有程序的操作,当用户来回切换它们时,需要记录这些程序执行到哪里。上下文切换就是这样一个过程,允许CPU记录并恢复各种正在运行程序的状态,使它能够完成切换操作。
多任务系统往往需要同时执行多道作业。作业数往往大于机器的CPU数,然而一颗CPU同时只能执行一项任务,如何让用户感觉这些任务正在同时进行呢? 操作系统的设计者巧妙地利用了时间片轮转的方式, CPU给每个任务都服务一定的时间,然后把当前任务的状态保存下来,在加载下一任务的状态后,继续服务下一任务。任务的状态保存及再加载, 这段过程就叫做上下文切换。时间片轮转的方式使多个任务在同一颗CPU上执行变成了可能。
![](https://upload-images.jianshu.io/upload_images/2062729-4c5c5e8b4fd1e2c0.png?imageMogr2/auto-orient/strip|imageView2/2/w/780/format/webp)
2.1 基本概念
上下文切换(有时也称做进程切换或任务切换)是指CPU从一个进程或线程切换到另一个进程或线程。
![](https://upload-images.jianshu.io/upload_images/22555065-86179f362221b06a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
上下文切换可以认为是内核(操作系统的核心)在 CPU 上对于进程(包括线程)进行以下的活动:
![](https://upload-images.jianshu.io/upload_images/22555065-630079a1454d875f.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
2.2 切换种类
上下文切换在不同的场合有不同的含义,在下表中列出:
![](https://upload-images.jianshu.io/upload_images/22555065-3f5d77111fd90d5d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
2.3 切换步骤
在上下文切换过程中,CPU会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行。从这个角度来看,上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。在程序中,上下文切换过程中的“页码”信息是保存在进程控制块(PCB, process control block)中的。PCB还经常被称作“切换桢”(switchframe)。“页码”信息会一直保存到CPU的内存中,直到他们被再次使用。
PCB通常是系统内存占用区中的一个连续存区,它存放着操作系统用于描述进程情况及控制进程运行所需的全部信息,它使一个在多道程序环境下不能独立运行的程序成为一个能独立运行的基本单位或一个能与其他进程并发执行的进程。
![](https://upload-images.jianshu.io/upload_images/22555065-d8f934427d5bcaf6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
线程切换和进程切换的步骤也不同。进程的上下文切换分为两步:
![](https://upload-images.jianshu.io/upload_images/22555065-2c7c9cc0b8f81ed2.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
对于Linux来说,线程和进程的最大区别就在于地址空间。对于线程切换,第1步是不需要做的,第2是进程和线程切换都要做的。所以明显是进程切换代价大。线程上下文切换和进程上下文切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
对于一个正在执行的进程包括程序计数器、寄存器、变量的当前值等,而这些数据都是保存在CPU的寄存器中的,且这些寄存器只能是正在使用CPU的进程才能享用,在进程切换时,首先得保存上一个进程的这些数据(便于下次获得CPU的使用权时从上次的中断处开始继续顺序执行,而不是返回到进程开始,否则每次进程重新获得CPU时所处理的任务都是上一次的重复,可能永远也到不了进程的结束出,因为一个进程几乎不可能执行完所有任务后才释放CPU),然后将本次获得CPU的进程的这些数据装入CPU的寄存器从上次断点处继续执行剩下的任务。
操作系统为了便于管理系统内部进程,为每个进程创建了一张进程表项:
![](https://upload-images.jianshu.io/upload_images/2062729-e2afee842676828d.png?imageMogr2/auto-orient/strip|imageView2/2/w/980/format/webp)
2.4 切换查看
在Linux系统下可以使用vmstat命令来查看上下文切换的次数,下面是利用vmstat查看上下文切换次数的示例:
![](https://upload-images.jianshu.io/upload_images/2062729-73c88cc6c86895f3.png?imageMogr2/auto-orient/strip|imageView2/2/w/727/format/webp)
vmstat 1指每秒统计一次,其中cs列就是指上下文切换的数目. 一般情况下, 空闲系统的上下文切换每秒大概在1500以下.
3 切换原因
引起线程上下文切换的原因,主要存在三种情况如下:
![](https://upload-images.jianshu.io/upload_images/22555065-5b05e9a643297538.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
对于我们经常使用的抢占式操作系统而言,引起线程上下文切换的原因大概有以下几种:
![](https://upload-images.jianshu.io/upload_images/22555065-eab9ca8319df982b.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
4 切换损耗
上下文切换会带来直接和间接两种因素影响程序性能的消耗。
![](https://upload-images.jianshu.io/upload_images/22555065-a71248709e8cce4e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
5 减少切换
既然上下文切换会导致额外的开销,因此减少上下文切换次数便可以提高多线程程序的运行效率。但上下文切换又分为2种:
![](https://upload-images.jianshu.io/upload_images/22555065-55f557194da16d16.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
所以,减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
![](https://upload-images.jianshu.io/upload_images/22555065-f904cc22a3f55f1e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
6 线程数目
合理设置线程数目,关键点是:1. 尽量减少线程切换和管理的开支;2. 最大化利用CPU;
![](https://upload-images.jianshu.io/upload_images/22555065-e58edb9e3b07457c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
所以对于任务耗时短的情况,要求线程尽量少,如果线程太多,有可能出现线程切换和管理的时间,大于任务执行的时间,那效率就低了;
对于耗时长的任务,要分是CPU任务,还是IO等类型的任务。如果是CPU类型的任务,线程数不宜太多;但是如果是IO类型的任务,线程多一些更好,可以更充分利用CPU。
高并发,低耗时的情况:建议少线程,只要满足并发即可,因为上下文切换本来就多,并且高并发就意味着CPU是处于繁忙状态的, 增加更多地线程也不会让线程得到执行时间片,反而会增加线程切换的开销;例如并发100,线程池可能设置为10就可以;
低并发,高耗时的情况:建议多线程,保证有空闲线程,接受新的任务;例如并发10,线程池可能就要设置为20;
高并发高耗时:1. 要分析任务类型;2. 增加排队;3. 加大线程数