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 的基本单位
一条线程指的是进程中一个单一顺序的控制流, 一个进程中可以并行多个线程, 每条线程并行执行不同的任务每个线程共享堆空间, 拥有自己独立的栈空间
线程划分尺度小于进程, 线程隶属于某个进程;
进程是 CPU 内存等资源占用的基本单位, 线程是不能独立占有这些资源的;
进程之间相互独立, 通信比较困难, 而线程之间共享一块内存区域, 通信方便;
进程在执行过程中, 包含比较固定的入口执行顺序和出口, 而进程的这些过程会被应用程序控制;
2 上下文切换
支持多任务处理是 CPU 设计史上最大的跨越之一在计算机中, 多任务处理是指同时运行两个或多个程序从使用者的角度来看, 这看起来并不复杂或者难以实现, 但是它确实是计算机设计史上一次大的飞跃在多任务处理系统中, CPU 需要处理所有程序的操作, 当用户来回切换它们时, 需要记录这些程序执行到哪里上下文切换就是这样一个过程, 他允许 CPU 记录并恢复各种正在运行程序的状态, 使它能够完成切换操作
多任务系统往往需要同时执行多道作业作业数往往大于机器的 CPU 数, 然而一颗 CPU 同时只能执行一项任务, 如何让用户感觉这些任务正在同时进行呢? 操作系统的设计者 巧妙地利用了时间片轮转的方式, CPU 给每个任务都服务一定的时间, 然后把当前任务的状态保存下来, 在加载下一任务的状态后, 继续服务下一任务任务的状态保存及再加载, 这段过程就叫做上下文切换时间片轮转的方式使多个任务在同一颗 CPU 上执行变成了可能
2.1 基本概念
上下文切换 (有时也称做进程切换或任务切换) 是指 CPU 从一个进程或线程切换到另一个进程或线程
进程 (有时候也称做任务) 是指一个程序运行的实例
在 Linux 系统中, 线程 就是能并行运行并且与他们的父进程 (创建他们的进程) 共享同一地址空间 (一段内存区域) 和其他资源的轻量级的进程
上下文 是指某一时间点 CPU 寄存器和程序计数器的内容
寄存器 是 CPU 内部的数量较少但是速度很快的内存 (与之对应的是 CPU 外部相对较慢的 RAM 主内存) 寄存器通过对常用值 (通常是运算的中间值) 的快速访问来提高计算机程序运行的速度
程序计数器是一个专用的寄存器, 用于表明指令序列中 CPU 正在执行的位置, 存的值为正在执行的指令的位置或者下一个将要被执行的指令的位置, 具体依赖于特定的系统
上下文切换可以认为是内核 (操作系统的核心) 在 CPU 上对于进程 (包括线程) 进行以下的活动:
挂起一个进程, 将这个进程在 CPU 中的状态 (上下文) 存储于内存中的某处;
在内存中检索下一个进程的上下文并将其在 CPU 的寄存器中恢复;
跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行), 以恢复该进程
2.2 切换种类
上下文切换在不同的场合有不同的含义, 在下表中列出:
上下文切换种类 | 描述 |
---|---|
线程切换 | 同一进程中的两个线程之间的切换 |
进程切换 | 两个进程之间的切换 |
模式切换 | 在给定线程中,用户模式和内核模式的切换 |
地址空间切换 | 将虚拟内存切换到物理内存 |
2.3 切换步骤
在上下文切换过程中, CPU 会停止处理当前运行的程序, 并保存当前程序运行的具体位置以便之后继续运行从这个角度来看, 上下文切换有点像我们同时阅读几本书, 在来回切换书本的同时我们需要记住每本书当前读到的页码在程序中, 上下文切换过程中的页码信息是保存在进程控制块 (PCB, process control block) 中的 PCB 还经常被称作切换桢 (switchframe) 页码信息会一直保存到 CPU 的内存中, 直到他们被再次使用
PCB 通常是系统内存占用区中的一个连续存区, 它存放着操作系统用于描述进程情况及控制进程运行所需的全部信息, 它使一个在多道程序环境下不能独立运行的程序成为一个能独立运行的基本单位或一个能与其他进程并发执行的进程
保存进程 A 的状态(寄存器和操作系统数据);
更新 PCB 中的信息, 对进程 A 的运行态做出相应更改;
将进程 A 的 PCB 放入相关状态的队列;
将进程 B 的 PCB 信息改为运行态, 并执行进程 B;
B 执行完后, 从队列中取出进程 A 的 PCB, 恢复进程 A 被切换时的上下文, 继续执行 A;
线程分为用户级线程和内核级线程同一进程中的用户级线程切换的时候, 只需要保存用户寄存器的内容, 程序计数器, 栈指针, 不需要模式切换但是这样会 导致线程阻塞和无法利用多处理器而同一进程中的内核级线程切换的时候, 就克服了这两个缺点, 但是 除了保存上下文, 还要进行模式切换
线程切换和进程切换的步骤也不同进程的上下文切换分为两步:
切换页目录以使用新的地址空间;
切换内核栈和硬件上下文;
对于 linux 来说, 线程和进程的最大区别就在于地址空间对于线程切换, 第 1 步是不需要做的, 第 2 是进程和线程切换都要做的所以明显是进程切换代价大线程上下文切换和进程上下文切换一个最主要的区别是 线程的切换虚拟内存空间依然是相同的, 但是进程切换是不同的这两种上下文切换的处理都是 通过操作系统内核来完成的内核的这种切换过程伴随的 最显著的性能损耗是将寄存器中的内容切换出
对于一个正在执行的进程包括 程序计数器寄存器变量的当前值等 , 而这些数据都是 保存在 CPU 的寄存器中的, 且这些寄存器只能是正在使用 CPU 的进程才能享用, 在进程切换时, 首先得保存上一个进程的这些数据(便于下次获得 CPU 的使用权时从上次的中断处开始继续顺序执行, 而不是返回到进程开始, 否则每次进程重新获得 CPU 时所处理的任务都是上一次的重复, 可能永远也到不了进程的结束出, 因为一个进程几乎不可能执行完所有任务后才释放 CPU), 然后将本次获得 CPU 的进程的这些数据装入 CPU 的寄存器从上次断点处继续执行剩下的任务
操作系统为了便于管理系统内部进程, 为每个进程创建了一张进程表项:
2.4 切换查看
在 Linux 系统下可以使用 vmstat 命令来查看上下文切换的次数, 下面是利用 vmstat 查看上下文切换次数的示例:
vmstat 1 指每秒统计一次, 其中 cs 列就是指上下文切换的数目. 一般情况下, 空闲系统的上下文切换每秒大概在 1500 以下.
3 切换原因
引起线程上下文切换的原因, 主要存在三种情况如下:
中断处理: 在中断处理中, 其他程序打断了当前正在运行的程序当 CPU 接收到中断请求时, 会在正在运行的程序和发起中断请求的程序之间进行一次上下文切换中断分为硬件中断和软件中断, 软件中断包括因为 IO 阻塞未抢到资源或者用户代码等原因, 线程被挂起
多任务处理: 在多任务处理中, CPU 会在不同程序之间来回切换, 每个程序都有相应的处理时间片, CPU 在两个时间片的间隔中进行上下文切换
用户态切换: 对于一些操作系统, 当进行用户态切换时也会进行一次上下文切换, 虽然这不是必须的
对于我们经常 使用的抢占式操作系统 而言, 引起线程上下文切换的原因大概有以下几种:
当前执行任务的时间片用完之后, 系统 CPU 正常调度下一个任务;
当前执行任务碰到 IO 阻塞, 调度器将此任务挂起, 继续下一任务;
多个任务抢占锁资源, 当前任务没有抢到锁资源, 被调度器挂起, 继续下一任务;
用户代码挂起当前任务, 让出 CPU 时间;
硬件中断;
4 切换损耗
上下文切换会带来 直接和间接 两种因素影响程序性能的消耗
直接消耗: 指的是 CPU 寄存器需要保存和加载, 系统调度器的代码需要执行, TLB 实例需要重新加载, CPU 的 pipeline 需要刷掉;
间接消耗: 指的是多核的 cache 之间得共享数据, 间接消耗对于程序的影响要看线程工作区操作数据的大小;
5 减少切换
既然上下文切换会导致额外的开销, 因此减少上下文切换次数便可以提高多线程程序的运行效率但上下文切换又分为 2 种:
让步式上下文切换: 指执行线程主动释放 CPU, 与锁竞争严重程度成正比, 可通过减少锁竞争来避免;
抢占式上下文切换: 指线程因分配的时间片用尽而被迫放弃 CPU 或者被其他优先级更高的线程所抢占, 一般由于线程数大于 CPU 可用核心数引起, 可通过调整线程数, 适当减少线程数来避免
所以, 减少上下文切换的方法有无锁并发编程 CAS 算法使用最少线程和使用协程
无锁并发: 多线程竞争时, 会引起上下文切换, 所以多线程处理数据时, 可以用一些办法来避免使用锁, 如将数据的 ID 按照 Hash 取模分段, 不同的线程处理不同段的数据;
CAS 算法: Java 的 Atomic 包使用 CAS 算法来更新数据, 而不需要加锁;
最少线程: 避免创建不需要的线程, 比如任务很少, 但是创建了很多线程来处理, 这样会造成大量线程都处于等待状态;
使用协程: 在单线程里实现多任务的调度, 并在单线程里维持多个任务间的切换;
6 线程数目
合理设置线程数目, 关键点是: 1. 尽量减少线程切换和管理的开支; 2. 最大化利用 CPU;
对于 1, 要求线程数尽量少, 这样可以减少线程切换和管理的开支;
对于 2, 要求尽量多的线程, 以保证 CPU 资源最大化的利用;
所以 对于任务耗时短的情况, 要求线程尽量少, 如果线程太多, 有可能出现线程切换和管理的时间, 大于任务执行的时间, 那效率就低了;
对于耗时长的任务, 要分是 CPU 任务, 还是 IO 等类型的任务如果是 CPU 类型的任务, 线程数不宜太多; 但是如果是 IO 类型的任务, 线程多一些更好, 可以更充分利用 CPU
高并发, 低耗时的情况: 建议少线程, 只要满足并发即可, 因为上下文切换本来就多, 并且高并发就意味着 CPU 是处于繁忙状态的, 增加更多地线程也不会让线程得到执行时间片, 反而会增加线程切换的开销; 例如并发 100, 线程池可能设置为 10 就可以;
低并发, 高耗时的情况: 建议多线程, 保证有空闲线程, 接受新的任务; 例如并发 10, 线程池可能就要设置为 20;
高并发高耗时: 1. 要分析任务类型; 2. 增加排队; 3. 加大线程数;
来源: https://juejin.im/post/5a72d4bd518825735300f37b