线程
线程
在早期的操作系统中都是以进程作为独立运行的基本单位,直到后面,计算机科学家们又提出了更小的能独立运行的基本单位,也就是线程。
概念
为什么使用线程?
需求场景
我们举个例子,假设你要编写一个视频播放器软件,那么该软件功能的核心模块有三个:
- 从视频文件当中读取数据
- 对读取的数据进行解压缩
- 把解压缩后的视频数据播放出来
单进程版本
对于单进程的实现方式,我想大家都会是以下这个方式:
对于单进程的这种方式,存在以下问题:
- 播放出来的画面和声音会不连贯,因为当 CPU 能力不够强的时候,
Read
的时候可能进程就等在这了,这样就会导致等半天才进行数据解压和播放; - 各个函数之间不是并发执行,影响资源的使用效率;
多进程版本
那改进成多进程的方式:
对于多进程的这种方式,依然会存在问题:
- 进程之间如何通信,共享数据?
- 维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;终止进程时,回收资源、撤销 PCB;进程切换时,保存当前进程的状态信息;
线程版本
那到底如何解决呢?需要有一种新的实体,满足以下特性:
- 实体之间可以并发运行;
- 实体之间共享相同的地址空间;
这个新的实体,就是 线程(Thread),线程之间可以并发运行且共享相同的地址空间。
什么是线程?
线程是进程当中的一条执行流程
线程资源的共享和独立
能共享和独立的东西与进程不同:
- 进程
- 独立(进程上下文):独立的虚拟内存、CPU上下文(寄存器与技术器)
- 线程
- 共享(同一个进程内多个线程之间):代码段、数据段、打开的文件等资源
- 独立(线程上下文):寄存器、栈
优点
- 一个进程中可以同时存在多个线程;
- 各个线程之间可以并发执行;
- 各个线程之间可以共享地址空间和文件等资源;
缺点
当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃
(这里是针对 C/C++ 语言,Java语言中的线程崩溃不会造成进程崩溃,具体分析原因可以看这篇:线程崩溃了,进程也会崩溃吗?)
举个例子,对于游戏的用户设计,则不应该使用多线程的方式,否则一个用户挂了,会影响其他同个进程的线程。
线程的上下文
上下文内容(共享部分 + 独占私有部分)
在前面我们知道了,线程与进程最大的区别在于:线程是调度的基本单位,而进程则是资源拥有的基本单位。
所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。
对于线程和进程,我们可以这么理解:
- 当进程只有一个线程时,可以认为进程就等于线程;
- 当进程拥有多个线程时,这些线程会共享和独占一些东西
- 共享的资源:虚拟内存和全局变量等,这些资源在上下文切换时是不需要修改的;
- 私有的资源:栈和寄存器等,这些在上下文切换时也是需要保存的;
是否同进程的线程切换
这还得看线程是不是属于同一个进程:
- 当两个线程不是属于同一个进程:则切换的过程就跟进程上下文切换一样;
- 当两个线程是属于同一个进程:
- 共享的资源:保持不动 虚拟内存和全局变量等共享资源
- 私有的资源:需要切换 线程的私有数据、寄存器等不共享的数据;
所以,线程的上下文切换相比进程,开销要小很多。
线程的实现
三种方式
哪三种方式
主要有三种线程的实现方式:
- 用户线程(User Thread):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;
- 内核线程(Kernel Thread):在内核中实现的线程,是由内核管理的线程;
- 轻量级进程(LightWeight Process):在内核中来支持用户线程;
用户线程和内核线程的三种对应关系
那么,这还需要考虑一个问题,用户线程和内核线程的对应关系。
首先,第一种关系是多对一的关系,也就是多个用户线程对应同一个内核线程:
第二种是一对一的关系,也就是一个用户线程对应一个内核线程:
第三种是多对多的关系,也就是多个用户线程对应到多个内核线程:
(1) 用户线程
如何理解
用户线程是基于用户态的线程管理库来实现的,那么 线程控制块(Thread Control Block, TCB) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。
所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。
用户级线程的模型,也就类似前面提到的 多对一 的关系,即多个用户线程对应同一个内核线程,如下图所示:
优点
- 兼容性强:每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统;
- 增删切换 快:用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快;
缺点
- 阻塞隔离差
- 线程阻塞问题:由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。
- 线程竞争问题:当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。
- 执行时间片少:由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢;
(2) 内核线程
如何理解
内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。
内核线程的模型,也就类似前面提到的一对一的关系,即一个用户线程对应一个内核线程,如下图所示:
优点
- 阻塞隔离好:在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;
- 执行时间片多:分配给线程,多线程的进程获得更多的 CPU 运行时间;
缺点
- 兼容性低:在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB;
- 增删切换 慢:线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大;
(3) 轻量级进程
如何理解
轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度。
在大多数系统中,LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息。一般来说,一个进程代表程序的一个实例,而 LWP 代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。
在 LWP 之上也是可以使用用户线程的,那么 LWP 与用户线程的对应关系就有三种:
1 : 1
,即一个 LWP 对应 一个用户线程;N : 1
,即一个 LWP 对应多个用户线程;M : N
,即多个 LWP 对应多个用户线程;
接下来针对上面这三种对应关系说明它们优缺点。先看下图的 LWP 模型:
1 : 1 模式
一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型。
- 优点:阻塞隔离好:实现并行,当一个 LWP 阻塞,不会影响其他 LWP;
- 缺点:增删切换慢:每一个用户线程,就产生一个内核线程,创建线程的开销较大。
N : 1 模式
多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见。
- 优点:增删切换快:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高;
- 缺点:阻塞隔离差:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的。
M : N 模式
根据前面的两个模型混搭一起,就形成 M:N
模型,该模型提供了两级控制,首先多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如上图的进程 3。
- 优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核 CPU 的资源。
组合模式
如上图的进程 5,此进程结合 1:1
模型和 M:N
模型。开发人员可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案。