当前位置: 代码迷 >> 综合 >> 07. 就该这么学并发 - 上下文切换
  详细解决方案

07. 就该这么学并发 - 上下文切换

热度:56   发布时间:2024-02-01 22:38:54.0

前言

前面的几章, 我们有提到个名词“上下文切换

这是个什么东西呢?

其实很好理解, 我们看一本书籍时,往往间隔的看(很少有人一天看完的吧?!), 那么如何保证自己下次是从上一次断点处看呢? 显而易见, 我们用书签!

“上下文切换”就是计算机的“书签”.

本章,我们来学习“上下文切换”原理以及如何减少“上下文切换”.

在此之前, 还得先复习下前面提到的进程和线程, 因为上下文切换的对象就是进程和线程.

线程与进程

进程是操作系统的管理单位,也是系统分配资源的基本单位;

线程则是进程的管理单位, 也是是CPU调度的基本单位;

线程依托于进程, 一个进程至少包含一个线程;

线程在Linux系统中就是能并行运行并且与他们的父进程(创建他们的进程)共享同一地址空间(一段内存区域)和其他资源的轻量级进程

一个线程指的是进程中一个单一顺序的控制流

不管是在单线程还是多线程中, 每个线程都有

  • 一个程序计数器

记录要执行的下一条指令;

程序计数器是一个专用的寄存器,用于表明指令序列中 CPU 正在执行的位置,存的值为正在执行的指令的位置或者下一个将要被执行的指令的位置,具体依赖于特定的系统.

  • 一组寄存器

保存当前线程的工作变量

寄存器是CPU 内部数量较少但是速度很快的内存(与之对应的是 CPU 外部相对较慢的 RAM 主内存).寄存器通过对常用值(通常是运算的中间值)的快速访问来提高计算机程序运行的速度.

  • 堆栈

记录执行历史,其中每一帧保存了一个已经调用但未返回的过程

进程&线程表项

上下文切换

上下文切换针对的是多任务处理的系统,

多任务处理系统指的是同时运行两个或多个程序的系统.

在多任务处理系统中, CPU需要处理所有程序的操作, 当用户来回切换它们时, 需要记录这些程序执行到哪里.

上下文切换就是这样一个过程:

允许CPU记录并恢复各种正在运行程序的状态,使它能够完成切换操作

多任务系统往往需要同时执行多道作业, 作业数往往大于机器的CPU数.

然而, 一颗CPU同时只能执行一项任务, 如何让用户感觉这些任务正在同时进行呢?

操作系统的设计者 巧妙地利用了时间片轮转的方式:

CPU给每个任务都运行一定的时间, 然后把当前任务的状态保存下来,
再加载下一任务的状态后, 继续服务下一任务.

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

时间片轮转的方式使多个任务在同一颗CPU上执行变成了可能.

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

上下文切换(有时也称做进程切换或任务切换)是指:

CPU从一个进程或线程切换到另一个进程或线程.

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

  • 挂起一个进程

将这个进程在 CPU 中的状态(上下文)存储于内存中的某处

  • 恢复一个进程

在内存中检索下一个进程的上下文并将其在 CPU 的寄存器中恢复

  • 跳转到程序计数器所指向的位置

即跳转到进程被中断时的代码行, 以恢复该进程

切换种类

上下文切换在不同的场合有不同的含义

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

切换步骤

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

从这个角度来看, 上下文切换有点像我们同时阅读几本书,来回切换书本的同时, 我们需要记住每本书当前读到的页码.

在程序中, 上下文切换过程中的“页码”信息是保存在进程控制块(PCB, process control block)中的, PCB还经常被称作“切换桢”(switchframe).

“页码”信息会一直保存到CPU的内存中,直到他们被再次使用.

PCB通常是系统内存占用区中的一个连续存区,
它存放着操作系统用于描述进程情况及控制进程运行所需的全部信息.

它的作用主要是“使一个在多道程序环境下不能独立进行的程序(含数据), 成为一个能独立运行的基本单位, 一个能与其他进程并发执行的进程.

或者说, 操作系统是根据PCB来对并发执行的进程进行控制和管理.

举个例子, 两个进程A和B, 系统需要从A切换到B:

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

进程/线程上下文切换差异

线程切换和进程切换的步骤是有差异的

主要体现在性能和地址空间上.

  • 性能上

进程的上下文切换需要两步

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

线程的上下文切换只需一步

  1. 切换内核栈和硬件上下文;

进程上下文切换比线程上下文切换多了步骤1, 所以明显是进程切换代价大.

  • 地址空间

对于Linux来说, 线程和进程的最大区别就在于地址空间.
线程的切换虚拟内存空间依然是相同的,
而进程切换是不同的.

这两种上下文切换的处理都是通过操作系统内核来完成的.

内核的这种切换过程最显著的性能损耗是将寄存器中的内容切换出.

一个正在执行的进程包括程序计数器、寄存器、变量的当前值等,

这些数据都是保存在CPU的寄存器中的,并且这些寄存器只能是正在使用CPU的进程才能使用.

在进程切换时,

首先得保存上一个进程的这些数据

主要为了下次获得CPU的使用权时,从上次的中断处开始继续顺序执行,
而不是返回到进程开始,否则每次进程重新获得CPU时所处理的任务都是上一次的重复,永远也到不了进程的结束,
因为一个进程几乎不可能执行完所有任务后才释放CPU

然后将本次获得CPU的进程的这些数据装入CPU的寄存器, 从上次断点处继续执行剩下的任务.

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

进程表项

切换查看

在Linux系统下可以使用vmstat命令来查看上下文切换的次数

image.png

vmstat 1指每秒统计一次, 其中cs列就是指上下文切换的数目.

切换原因

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

  • 中断处理

在中断处理中, 其他程序”打断”了当前正在运行的程序.
当CPU接收到中断请求时, 会在正在运行的程序和发起中断请求的程序之间进行一次上下文切换.
中断分为硬件中断和软件中断,
软件中断包括因为IO阻塞、未抢到资源或者用户代码等原因,线程被挂起.

  • 多任务处理

在多任务处理中,CPU会在不同程序之间来回切换,每个程序都有相应的处理时间片,CPU在两个时间片的间隔中进行上下文切换.

  • 用户态切换

对于一些操作系统,当进行用户态切换时也会进行一次上下文切换,虽然这不是必须的.

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

  • 当前执行任务的时间片用完之后,系统CPU正常调度下一个任务

  • 当前执行任务碰到IO阻塞,调度器将此任务挂起,继续下一任务

  • 多个任务抢占锁资源,当前任务没有抢到锁资源,被调度器挂起,继续下一任务

  • 用户代码挂起当前任务,让出CPU时间

  • 硬件中断

切换损耗

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

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

如何减少切换

上下文切换会导致额外的开销, 因此减少上下文切换次数便可以提高多线程程序的运行效率.

但上下文切换又分为2种:

  • 让步式上下文切换

即执行线程主动释放CPU,与锁竞争严重程度成正比;
可通过减少锁竞争来避免;

  • 抢占式上下文切换

指线程因分配的时间片用尽, 而被迫放弃CPU或者被其他优先级更高的线程所抢占;
一般由于线程数大于CPU可用核心数引起;
可通过调整线程数,适当减少线程数来避免.

减少上下文切换的方法如下

  • 无锁并发

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

  • CAS算法

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

  • 最少线程

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

  • 使用协程

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

合理控制线程数目

合理设置线程数目,关键点是

  • 尽量减少线程切换和管理的开支

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

  • 最大化利用CPU

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

针对不同的实际情况,我们需要采取合理的措施:

  • 对于任务耗时短的情况:

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

  • 对于耗时长的任务:

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

  • 对于高并发,低耗时的情况:

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

  • 对于低并发,高耗时的情况:

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

  • 对于高并发高耗时:
  • 要分析任务类型;
  • 增加排队;
  • 加大线程数;

请关注我的订阅号

订阅号.png