线程模型 线程切换成本

首先明确进程与进程的基本概念:

  • 进程是资源分配的基本单位
  • 线程是CPU调度的基本单位
  • 一个进程下可能有多个线程
  • 多个线程共享进程的资源

不同的 OS 进程 和 线程是实现细节不一样的

特别是 用户态和内核态 区别非常大

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使用 proc 这个结构体(sys/sys/proc.h)来描述进程 使用 thread 结构体(sys/sys/proc.h)来描述线程信息

FreeBSD的调度单位是 thread

  • 对于单线程的进程而言,它有一个 proc 结构体和一个 thread 结构体
  • 对于多线程的进程而言,则是每个线程都有自己的 thread 结构体,多线程共用一个 proc 结构体

线程和进程的主要区别也是 线程没有自己的独立地址空间

macOS 的 Darwin 内核 由 NetBSDFreeBSDMach 混合而成,再加上一个IO Kit 驱动框架,但BSD只占据很少一部分,NetBSD 和 FreeBSD 只作为一个进程运行在Mach之上,NetBSD负责网络有关的底层逻辑,FreeBSD负责文件系统的底层逻辑,所以需要独立来看

进程 可以属于 进程组,进程组的主要作用是让用户可以同时控制多个进程

通常向一个进程组发送信号的方式控制这些进程

线程 是一组寄存器的状态,一个进程中可以存在多个线程,一个进程内的 多有线程都共享虚拟内存空间文件描述符文件句柄

进程的抽象以一个或多个线程的容器的形式保存下来

MacOS 支持多种线程创造方式,包括 pthread NSThread GCD NSOperation,线程的生命周期管理不一样(后两者是自动管理),导致调度方式支持不一样

进程 是一个执行程序,一个或多个线程在进程的上下文中运行

在 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消耗类似,但是 内存消耗有栈协程非常明显高,出现缓存缺失也会明显 但是,很多情况下,我们并不需要太多的协程,这个时候,有栈协程更容易使用,从而节省人力的优点就充分体现出来了


参考