什么是上下文切换
在多任务操作系统中,为了提高cpu的利用率,可以让当前系统运行远多于cpu核数的线程。但是由于同时运行的线程数是由cpu核数来决定的,所以为了支持更多的线程运行,cpu会把自己的时间片轮流分给其他线程,这个过程就是上下文切换。
导致上下文切换的原因有很多,比如通过wait()、sleep()等方法阻塞当前线程,这时cpu不会一直等待,而是重新分配去执行其他线程。当后续cpu重新切换到当前线程时,cpu需要沿着上次执行的指令位置继续运行。因此,每次在cpu切换之前,需要把cpu寄存器和程序计数器保存起来,这些信息会存储到系统内核中,cpu再次调度回来时会从系统内核中加载并继续执行。简而言之,上下文切换,就是cpu把自己的时间片分配给不同的任务执行的过程。
根据任务类型的不同,上下文切换又分为三种类型:
●进程上下文切换。
●线程上下文切换。
●中断上下文切换。
进程上下文切换,是指当前进程的cpu时间片分配给其他进程执行,进程切换有以下三种情况:
●cpu时间片分配。
●当进程系统资源(如内存)不足时,进程会被挂起。
●当存在优先级更高的进程运行时,当前进程有可能会被挂起,cpu时间片分配给优先级更高的进程运行。
进程的上下文切换和线程的上下文切换相同,进程切换之后,再恢复执行时,还是需要沿着上一次执行的位置继续运行,但是与线程相比,进程的上下文切换的损耗会更大。
原因是进程在做上下文切换时,需要把用户空间中的虚拟内存、栈、全局变量等状态保存起来,还需要保存内核空间的内核堆栈、寄存器等状态(之所以要保存内核态的状态信息,是因为进程的切换只能发生在内核态)。同时在加载下一个进程时,需要再次恢复上下文信息,而这些操作都需要在cpu上运行。
每次进程的上下文切换需要几纳秒或几微秒的cpu时间,从我们的感官上看起来好像不算很长,但是如果进程上下文切换次数非常多,就会导致cpu把大量的时间耗费在寄存器、内核栈、虚拟内存、全局变量等资源的保护和恢复上,使得cpu真正工作的时间很少,这也是为什么我们常说上下文切换过于频繁会影响性能。
现在,相信读者能够理解为什么要设计线程,因为线程的上下文切换对资源的保存和恢复占用更少,从而使得线程的上下文切换的时间更短。
线程就是轻量级进程,它们最大的区别是,进程是cpu调度的最小单元,而线程是系统资源分配的基本单元。一个 进程中允许创建多个线程,这些线程可以共享同一进程中的资源。
线程上下文切换需要注意两点:
●当两个线程切换属于不同的进程时,由于进程资源不共享,所以线程的切换其实就是进程的切换。
●当两个线程属于同一个进程时,只需要保存线程的上下文。
线程的上下文切换,需要保存上一个线程的私有数据、寄存器等数据,这个过程同样会占用cpu资源,当上下文切换过于频繁时,会使得cpu不断进行切换,无法真正去做计算,最终导致性能下降。
中断上下文切换是指cpu对系统发生的某个中断事件做出反应导致的切换,比如:
●cpu本身故障、程序故障。
●i/o中断。
为了快速响应硬件事件,中断处理会打断当前正常的进程调度和执行过程,此时cpu会调用中断处理程序响应中断事件。而这个被打断的进程在切换之前需要保存该进程当前的运行状态,以便在中断处理结束后,继续恢复执行被打断的进程。这里不涉及用户态中的资源保存,只需要包含内核态中必需的状态保存,如cpu寄存器、内核堆栈等资源。即便如此,中断导致的上下文切换仍然会消耗cpu资源。
既然频繁的上下文切换会影响程序的性能,那么如何减少上下文切换呢?
●减少线程数, 同一时刻能够运行的线程数是由cpu核数决定的,创建过多的线程,就会造成cpu时间片的频繁切换。
●采用无锁设计解决线程竞争问题,比如在同步锁场景中,如果存在多线程竞争,那么没抢到锁的线程会被阻塞,这个过程涉及系统调用,而系统调用会产生从用户态到内核态的切换,这个切换过程需要保存上下文信息对性能的影响。如果采用无锁设计就能够解决这类问题。
●采用cas自旋操作,它是一种无锁化的编程思想,原理是通过循环重试的方式避免线程的阻塞导致的上下文切换。
总的来说,cpu的切换本意是为了提高cpu的利用率,但是给过多的cpu上下文切换,会使cpu把时间都消耗在上下文信息的保存和恢复上,从而使真正的有效执行时间缩短,最终导致整体的运行效率大幅下降。
遍历一个比较大的数字,多大呢,100000000 可以吧?
public class threadconcurrentexample implements runnable{
private static final long num=100000000l;
private int sum;
public threadconcurrentexample(int sum) {
this.sum=sum;
}
public static void runwiththread() throws interruptedexception {
long start=system.currenttimemillis();
//执行两个任务
//1. 计算指定目标数的和
int tempsum=0;
threadconcurrentexample tce=new threadconcurrentexample(tempsum);
thread thread=new thread(tce);
thread.start();
//2.同步计算遍历次数
int count=0;
for (int i = 0; i < num; i ) {
count ;
}
thread.join(); //确保线程执行结束
long totalfree=system.currenttimemillis()-start;//打印耗时
system.out.println("runwiththread: totalfree=" totalfree ",count=" count);
}
public static void runwithserial() throws interruptedexception {
long start=system.currenttimemillis();
//执行两个任务
//1. 计算指定目标数的和
int tempsum=0;
for (int i = 0; i < num; i ) {
tempsum =i;
}
//2.同步计算遍历次数
int count=0;
for (int i = 0; i < num; i ) {
count ;
}
long totalfree=system.currenttimemillis()-start;//打印耗时
system.out.println("runwithserial: totalfree=" totalfree ",count=" count);
}
@override
public void run() {
for (int i = 0; i < num; i ) {
// synchronized (this) {
sum = i;//先用无锁感受下速度
// }
}
}
public static void main(string[] args) throws interruptedexception {
runwiththread();
runwithserial();
}
}
结果如下:
针对sum =i这个操作,由于不是原子的,所以线程不安全,我们增加一个锁,将run()方法的注释打开
public void run() {
for (int i = 0; i < num; i ) {
synchronized (this) {
sum = i;//加上锁
}
}
}
可以看到,增加同步锁之后,采用多线程执行的任务运行时长增加了20多倍。原因是增加synchronized锁会导致线程去竞争锁,这个竞争的过程会导致线程的上下文切换。即便不增加synchronize锁,当线程的创建数量远远超过cpu核数时,也会因为上下文切换导致性能下降。
利用我们掌握的知识,导致线程上下文切换的原因总结如下:
●多个任务抢占synchronized同步锁资源。
●在线程运行过程中存在io阻塞,cpu调度器会切换cpu时间片。
●在线程中通过主动阻塞当前线程的方法释放cpu时间片。
●当前线程执行完成后释放cpu时间片,cpu重新调度。
实际上,对于上下文切换次数,在linux中可以使用vmstat命令来查看,vmstat 命令是linux中比较常见的针对cpu、内存等信息的监控工具,下面是笔者利用vmstat命令打印的生产服务器的监控信息。
vmstat1表示每隔ls打印一次数据。
在上述打印结果中,有一个cs (content switch)字段,它表示每秒上下文切换的次数,这个值越小越好,如果过大,就要考虑降低线程或进程的数量。