线程模型 线程切换成本
基础概念
首先明确进程与进程的基本概念:
- 进程是
资源分配的基本单位
- 线程是
CPU调度的基本单位
- 一个进程下可能有多个线程
- 多个线程共享进程的资源
不同的 OS 进程 和 线程是实现细节不一样的
特别是 用户态和内核态 区别非常大
linux 下的进程 线程概念
linux用户态的进程、线程基本满足上述概念,但 内核态不区分进程和线程
可以认为,内核中统一执行的是进程,但有些是 普通进程
(对应进程process)
普通进程 需要 使用 fork
创建进程, 深拷贝虚拟内存
、文件描述符
、信号处理
等等 才能工作
有些是 轻量级进程
(对应线程 pthread 或 npthread ),都使用 task_struct 结构体保存保存
使用 thread_info
结构体(arch/arm/include/asm/thread_info.h)来描述线程状态信息, 而 thread_info是和 architecture 强相关的
而轻量级进程之所以 轻量
,是因为其只需要 使用 pthread_create
创建线程,浅拷贝虚拟内存
等大部分信息,多个轻量级进程共享一个进程的资源
两个系统调用最终都都调用了 do_dork
,而 do_dork
完成了 task_struct 结构体的复制
,并将新的进程加入内核调度
在 linux 下,进程有自己独立地址空间
,而线程则是共享进程的地址空间
FreeBSD 下的进程 线程概念
FreeBSD使用 proc
这个结构体(sys/sys/proc.h)来描述进程
使用 thread
结构体(sys/sys/proc.h)来描述线程信息
FreeBSD的调度单位是 thread
- 对于单线程的进程而言,它有一个 proc 结构体和一个 thread 结构体
- 对于多线程的进程而言,则是每个线程都有自己的 thread 结构体,多线程共用一个 proc 结构体
线程和进程的主要区别也是 线程没有自己的独立地址空间
MacOS 下的进程 线程概念
macOS 的 Darwin 内核 由 NetBSD、FreeBSD 和 Mach 混合而成,再加上一个IO Kit 驱动框架,但BSD只占据很少一部分,NetBSD 和 FreeBSD 只作为一个进程运行在Mach之上,NetBSD负责网络有关的底层逻辑,FreeBSD负责文件系统的底层逻辑,所以需要独立来看
进程 可以属于 进程组,进程组的主要作用是让用户可以同时控制多个进程
通常向一个进程组发送信号的方式控制这些进程
线程 是一组寄存器的状态
,一个进程中可以存在多个线程,一个进程内的 多有线程都共享虚拟内存空间
、文件描述符
和文件句柄
进程的抽象以一个或多个线程的容器的形式保存下来
MacOS 支持多种线程创造方式,包括 pthread
NSThread
GCD
NSOperation
,线程的生命周期管理不一样(后两者是自动管理),导致调度方式支持不一样
windows 下的进程 线程概念
进程 是一个执行程序,一个或多个线程在进程的上下文中运行
在 windows 下,每个 进程 都提供执行程序所需的资源
进程具有 虚拟地址空间
、可执行代码
、系统对象的开放句柄
、安全上下文
、唯一进程标识符
、环境变量
、优先级类
、最小和最大工作集大小
以及 至少一个执行线程
每个 进程 都使用 单个线程(通常称为 主线程)启动,但可以从其任何线程创建其他线程
在 windows 下,线程
是 操作系统分配处理器时间的基本单元
,线程可以执行进程代码的任何部分,包括当前由另一个线程执行的部件,进程的所有线程共享其虚拟地址空间和系统资源
此外,每个线程
都 维护异常处理程序
、计划优先级
、线程本地存储
、唯一线程标识符
以及 系统将用于保存线程上下文的一组结构
,直到计划线程上下文为止
线程上下文
包括线程的 计算机寄存器集
、内核堆栈
、线程环境块
以及线程进程的地址空间中的用户堆栈
。
线程还可以有 自己的安全上下文,可用于模拟客户端
form about-processes-and-threads
线程模型
不同的线程模型,提供的 线程描述 和 线程切换成本是不一样的
一对一
一个用户线程对应一个内核线程。内核负责每个线程的调度,可以调度到其他处理器上面
Linux 2.6默认使用 NPTL 线程库,一对一的线程模型
优点:
- 实现简单
缺点:
- 对用户线程的大部分操作都会映射到内核线程上,引起用户态和内核态的频繁切换
- 内核为每个线程都映射调度实体,如果系统出现大量线程,会对系统性能有影响
实际上,目前的非科研级 OS,都是 一对一 线程模型,原因是维护成本低
多对一
顾名思义,多对一线程模型中,多个用户线程对应到同一个内核线程上,线程的创建、调度、同步的所有细节全部由进程的 用户空间线程库
来处理
优点:
- 用户线程的很多操作对内核来说都是透明的,不需要用户态和内核态的频繁切换。使线程的创建、调度、同步等非常快
缺点:
- 由于多个用户线程对应到同一个内核线程,如果其中一个用户线程阻塞,那么该其他用户线程也无法执行
- 内核并不知道用户态有哪些线程,无法像内核线程一样实现较完整的调度、优先级等
多对多
多对一线程模型是非常轻量的,问题在于多个用户线程对应到固定的一个内核线程
多对多线程模型解决了这一问题:m个用户线程对应到n个内核线程上,通常m>n
由IBM主导的 NGPT 采用了多对多的线程模型,不过现在已废弃 Solaris 第 9 版以前支持多对多,但是后面放弃维护,切换回 一对一
优点:
- 兼具多对一模型的轻量
- 由于对应了多个内核线程,则一个用户线程阻塞时,其他用户线程仍然可以执行
- 由于对应了多个内核线程,则可以实现较完整的调度、优先级等
缺点:
- 实现复杂
线程切换
线程切换成本,跟 OS 对 线程的实现,和线程模型强相关
例如,linux 采用一对一的线程模型,并且因为 linux 线程实现原因,用户线程切换
与 内核线程切换
之间的差别非常小
同时,如果忽略用户主动放弃用户线程的执行权(yield)带来的开销,则只需要考虑内核线程切换的开销
注意,是为了帮助理解做出的简化。
实际上,用户线程库在用户线程的调度、同步等过程中做了很多工作,这部分开销不能忽略
如 jdk8 对 Thread#yield() 的解释:如果底层 OS 不支持yield的语义,则JVM让用户线程自旋至时间片结束,线程被动切换,以达到相似的效果
什么引起线程切换
- 时间片轮转
- 线程阻塞
- 线程主动放弃时间片
线程切换的开销
直接开销
直接开销是线程切换本身引起的,无可避免,必然发生
直接开销包括,线程的创建
、线程等待
、线程终止
、线程分离
等这些操作
用户态与内核态的切换
线程切换设计成只能在内核态完成,如果当前用户处于用户态,则必然引起用户态与内核态的切换
应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。
因此,如果一个程序需要从用户态进入内核态,那么它必须执行系统调用语句。
当程序中有系统调用语句,程序执行到系统调用时,首先使用类似int 80H的软中断指令,保存现场,去系统调用,在内核态执行,然后恢复现场,每个进程都会有两个栈,一个内核态栈和一个用户态栈。
当int中断执行时就会由用户态栈转向内核态栈。
系统调用时需要进行栈的切换。而且内核代码对用户不信任,需要进行额外的检查。系统调用的返回过程有很多额外工作,比如检查是否需要调度等。
系统调用一般都需要保存用户程序得上下文(context), 在进入内核的时候需要保存用户态的寄存器,在内核态返回用户态的时候会恢复这些寄存器的内容。
这是一个开销的地方。 如果需要在不同用户程序间切换的话,那么还要更新cr3寄存器,这样会更换每个程序的虚拟内存到物理内存映射表的地址,也是一个比较高负担的操作
上下文切换
无论 OS 如何实现线程包含的上下文,切换上下文,就要切换比如 寄存器、程序计数器、线程栈(包括操作栈、数据栈)等等
例如: linux 下,线程的信息需要用一个 task_struct 保存,线程切换时,必然需要将旧线程的 task_struct 从内核切出,将新线程的 task_struct 切入
线程调度算法
线程调度算法需要管理线程的状态、等待条件等,如果根据优先级调度,则还需要维护优先级队列
线程切换比较频繁,该成本是绝对最大的成本
间接开销
间接开销是直接开销的副作用,取决于系统实现和用户代码实现
缓存缺失
切换进程,需要执行新逻辑
- 如果二者的访问的地址空间不相近,则会引起缓存缺失,具体影响范围取决于系统实现和用户代码实现
- 如果系统的缓存较大,则能减小缓存缺失的影响;如果用户线程访问数据的地址空间接近,则本身的缓存缺失率也比较低
类似的,比如页表等快慢表式结构同理,如果页表不相近,也会引起缓存缺失
延伸一下,有栈协程和无栈协程资源消耗上,CPU消耗类似,但是 内存消耗有栈协程非常明显高,出现缓存缺失也会明显 但是,很多情况下,我们并不需要太多的协程,这个时候,有栈协程更容易使用,从而节省人力的优点就充分体现出来了
参考