5.2 线程
线程
概念
进程的调度太折磨了, 一个进程包含的资源太多了, 相互通信也不方便, 需要整一个更加轻量级的东西作为调度单位. 线程因此诞生了.
线程只包含了运行时的状态, 静态部分由进程提供, 自身包括执行所需的最小状态(主要是寄存器和栈).
一个进程可以有多个线程, 每个线程共享同一地址空间, 允许进程内并行.
一个进程的多线程可以在不同处理器上运行, 这样调度的基本单元就从进程变成了线程, 每个线程都有状态, 上下文切换的单位也变成了线程.
根据线程是否受内核管理, 可以分成用户态线程和内核态线程. 内核态线程的相关信息存放在内核中, 用户态线程的相关信息主要存放在应用数据中.
线程模型
可以把用户态线程理解成由内核态线程调度的线程, 内核态线程是由操作系统调度的线程. 因此, 一个用户态线程需要由某个或者某些内核态线程管理.
多对一模型
把多个用户态线程映射给单一的内核线程,
优点是内核管理简单,
但是可扩展性差, 无法适应多核机器的发展.
目前已经被主流操作系统弃用, 用在各种用户态线程库中.
一对一模型
每个用户线程映射单独的内核线程,
优点是解决了多对一的可扩展性问题,
但是导致内核线程数量大, 开销大.
主流操作系统都采用一对一模型
多对多模型
N个用户态线程映射到M个内核线程, N>M
优点是解决了多对一的可扩展性问题和一对一的内核线程过多问题
缺点是管理更加复杂
在虚拟化中得到了广泛应用.
线程相关的数据结构 TCB
一对一模型的TCB可以分为两部分, 一部分是内核态的, 另一部分是用户态的
- 内核态 与PCB结构类似, Linux中进程和线程使用的是同一种数据结构, 上下文切换会用到TCB的内核态部分
实际上, Linux不怎么区分线程和进程, 只是把线程当作一种进程
- 应用态 可以由线程库定义, Linux的是pthread结构体, windows是TIB, 可以认为是内核TCB的扩展
线程本地存储 TLS
不同线程可能会执行相同的代码, 因此线程不具有独立的地址空间, 多线程共享代码段
但是对于一些全局变量, 不同线程可能需要不同的版本. 想要有自己的版本就需要用到线程本地存储了.
线程库允许定义每个线程独有的数据, 会给每个线程分配一个独有标识符.
每个线程的TLS结构相似, 因此可通过TCB索引.
TLS的寻址模式是基地址+偏移量, 在X86中是段页式(fs寄存器), 在arm64中是特殊寄存器tpdir_el0
线程的基本操作
线程的创建
pthread_create
在内核态中会创建响应的内核态线程及内核栈.
在用户态会创建TCB, 用户栈和TLS
线程的合并
ptrhead_join
等待另一个线程执行完成, 并获取其执行结果
线程的退出
pthread_exit
可设置返回值, 会被join捕获
线程的暂停
pthread_yield
立即暂停执行, 让出CPU资源给其它线程. 可以帮助调度器做出更优的决策
写到这里想起来之前看到的一个问题: sleep(0)有没有意义. sleep(0)也可以让出CPU资源给其它线程, 帮助调度器做出决策
线程的上下文切换
进入内核态, 保存上下文
应用线程可以通过异常, 中断, 系统调用进入内核态.
运行状态转为内核态, 然后换用栈指针, 使用内核栈. 保存当前的上下文, 如PC, CPU状态.
这些都是硬件自动完成的
切换页表和内核栈
操作系统决定下一个被调度的线程, 然后切换页表, 和内核栈.
到这里, 原线程就暂时停止了, 接下来是新线程的执行
恢复上下文, 返回用户态
新线程被调度, 就需要恢复之前的上下文了.
调用eret, 由硬件执行一系列操作. 如PC, CPU状态的恢复等, 然后返回用户态.
纤程
可以设想这样一种情况, 假如我一个线程正在等待IO, 如网络请求, 键盘事件之类的, 那此时是不是应该进入阻塞态, 然后等操作系统调度, 即使自己时间片还有空余的时间?
纤程就是解决这样的问题. 纤程是用户态线程的一种, 最早貌似是go弄出来的.
go将线程进一步细分成纤程, 并且有一个纤程调度器. 当一个纤程阻塞时, 那就进入"阻塞态", 调度另一个"就绪态"的纤程.
当然, 纤程的切换也有上下文的切换, 只是代价很小了, 没有特权级的切换, 也没有太多状态的保存与切换, 都在用户态完成.