赛博考古:Linux 支持 POSIX 线程标准的前世今生
线程是什么
操作系统能够进行运算调度的最小单位。在一般的操作系统上,它被包含在进程之中,是进程中的实际运作单位。
线程共享了什么
- 代码段、数据段、堆
- 文件描述符
- 进程信息(如 pid )、用户 id 和组 id
- Signal Handle
线程没有共享什么
- 栈
- 寄存器
- 线程优先级和调度策略
- TLS(Thread Local Storage)(线程局部存储)
- tid(可以使用
syscall(SYS_gettid)
查看) - Signal Mask
值得注意的问题(pid vs tgid, getpid vs gettid)
tg for thread group
getpid
是 man 3 里面的,因此它是 Library call ;gettid
是 man 2 里面的,因此它是 System Call 。
我们使用 unistd.h
中的 getpid()
实际上获取的是 tgid ,而 syscall(SYS_gettid)
实际上获取的是 pid 。
如图所示,每个线程都有独一无二的 pid ,有可能重复的 tgid 。当一个进程属于一个单线程程序时,它的 pid 就等于 tgid ;当它创建了一个线程时,新的线程拥有一个新的 pid ,同时继承了同一线程组的 tgid 。
USER VIEW
<-- PID 43 -->|<----------------- PID 42 ----------------->
| +---------+ |
| | process | |
| _| pid=42 |_ |
__(fork) _/ | tgid=42 | \_ (new thread) _
/ | +---------+ | \
+---------+ | | +---------+
| process | | | | process |
| pid=43 | | | | pid=44 |
| tgid=43 | | | | tgid=42 |
+---------+ | | +---------+
<-- PID 43 -->|<--------- PID 42 -------->|<--- PID 44 --->
KERNEL VIEW
POSIX Thread
与其说是 pthread 的历史,不如说是 kernel 变得更大更强,对线程模型支持得更好的历史。
- LinuxThreads(早期)
- NPTL(Native POSIX Thread Library)(现在)
查看你的 pthread 实现:
$ getconf GNU_LIBPTHREAD_VERSION
NPTL 2.38
LinuxThreads
在 Linux 操作系统中, LinuxThreads 是 1996 年推出的 POSIX Threads 的部分实现,其主要开发者是 Xavier Leroy 。
管理线程(Manager Thread)
为什么需要管理线程?
- 对致命的信号做出反应(fatal signals),并杀死或终止整个线程。(后文详细说明这一点的原因,这是由 LinuxThreads 的缺陷导致的)
- 释放用作线程栈的内存不能由该线程本身执行,必须要等这个线程结束再进行,因此需要特殊的管理线程来收尾。
- 释放 tls 需要遍历所有线程,因此需要管理线程。
- 终止线程必须是等待的(由管理线程等待),不然会出现僵尸线程。
- 当主线程调用
pthread_exit()
时,这个进程不能就此结束。正确的行为是,主线程 sleep ,当其他线程被杀死后管理线程再唤醒主线程,主线程此时才能退出。
缺陷
- 管理线程带来的额外开销。
- 使用信号实现同步原语。
- 基本符合 POSIX 标准,信号处理除外。
LinuxThreads 实现的线程,本质上是共享了内存、文件描述符的进程,它们有着不同的 pid ,它们并不作为进程的一个整体:
这里的 pid 是抽象概念,当时的 Kernel 并没有 pid 和 tgid 的区别。
- 发送到线程 pid 的信号只能由该线程处理。只要没有线程阻塞该信号,该行为就符合标准:程序的一个(未指定)线程处理该信号。
- 但是,如果向 pid 发送信号的线程阻塞了信号,那么 LinuxThreads 将简单地在该线程中排队,仅当该线程解除阻塞信号时才执行处理程序,而不是立即在不阻塞信号的另一个线程中执行。
实现者的吐槽:This is to be viewed as a LinuxThreads bug, but I currently don’t see any way to implement the POSIX behavior without kernel support.
线程模型
此时就已经出现了 Linux 代表性的一对一(one-to-one)模型。内核对线程和进程不做区分,所谓的内核线程是 task_struct
,而内核调度时也只考虑它们。
one-to-one vs many-to-one
当用户创建出很多用户线程时, Linux 的做法是:每当用户调用库函数创建线程,库函数让内核创建出一个新的内核线程。
- 多处理器上,CPU 密集型程序的开销最小,即每个处理器上都可以跑一个线程(思考一下如果是 many-to-one ,一个内核线程对应多个用户线程,则很难在多核上并行)。
- I/O 操作开销最小(思考一下如果是 many-to-one ,阻塞 IO 操作实现起来有多么复杂)。
- 实现简单,不与内核过度耦合(内核调度可以完成大部分复杂工作)。
one-to-one vs many-to-many
那么为什么不用多对多模型呢?
当时大多数商业 Unix 系统(Solaris、Digital Unix、IRIX)都以这种方式实现 POSIX 线程。该模型结合了“多对一”和“一对一”模型的优点,很有吸引力。
多对多模型能成功避免两种模型的最坏情况。在上下文切换成本高昂的内核 Digital Unix 上多对多模型几乎可以说是唯一选择。
但是,多对多模型的实现相当复杂,并且需要 Linux 增加支持很多新的功能。另一方面,Linus Torvalds 和其他 Linux 内核开发人员一直以整体简单性的名义推动一对一模型,更何况 Linux 的上下文切换效率相当出色。
虽然多对多模型在 pthread 中被放弃,但是它在一些编程语言的运行时中大放异彩。
管理线程与一对一模型结合导致的问题
- 管理线程只能在一个核上运行,这导致加速比变低,性能变差。
- 由于整个系统围绕着管理线程设计,上下文切换开销变大。
NPTL
为了解决 LinuxThreads 的一些问题,内核开发人员设计实现了 NPTL(Native POSIX Thread Library) 。
- NPTL 完全兼容 POSIX 标准。
- NPTL 在多处理器机器上效果也很好。
- NPTL 的启动开销不大。
- NPTL 也能利用到 NUMA 架构的优势。
- NPTL 和 LinuxThreads 二进制兼容(LD 实现的)。
利用了新的内核特性
- NPTL 无需管理线程:
- 不需要转发 fatal signal 的工作,内核可以处理好向某一进程发送信号的工作。
- 不需要由管理线程析构,内核可以处理线程内存的释放。
- 不需要管理线程来避免僵尸线程,内核可以在清理父线程前等待所有线程。
- 去掉管理线程,对 SMP 和 NUMA 更友好。
- 使用 futex 来实现同步,而且 futex 可以在进程间共享,能在更多场景使用。
- NPTL 的每个线程都有自己的父线程,在进行资源统计时便于统计整个进程,此前的 LinuxThreads 不能做到。
如何实现 TLS
我们都知道在写声明时加入 __thread
可以声明一个线程局部变量。那它是如何实现的呢?
在早期 LinuxThreads 的实现中, TLS 直接放在线程栈旁边。而在引入 NPTL 后,各个 libc (如 glibc, uClibc, musl)都是使用新增的 ELF 标准对 TLS 的支持和动态链接器来实现的。
查看一个 __thread
声明的汇编代码时,我们可能看到 mov %fs:0xfffffffffffffffc,%eax
。
这也就是 mov %fs:-4,%eax
。
这是什么?这太奇怪了!
虽然某些 CPU 架构有专用的寄存器来保存线程特定的上下文,但 x86 没有。众所周知,x86 只有少量的通用寄存器。
英特尔 80386 引入了 FS 和 GS 作为用户定义的段寄存器,但没有规定它们的用途。然而,随着多线程编程的兴起,人们看到了重新利用 FS 和 GS 的机会,并开始将它们用作线程寄存器。
TLS 的结构:
- 使用
dlopen
加载的 DSO(动态共享对象) 放在动态 TLS 中,其余的放在静态 TLS 中。 - $dtv_t$ 可以看作是一个二维数组,可以通过模块 id 和偏移量对任何 TLS 变量进行寻址。TLS 变量偏移量是模块中的本地偏移量,模块 id 是进程中加载的 ELF 对象的索引号。
访问一个 TLS 变量的通用方法:
dtv[-1].counter; /* Pro tip: The length of this dtv array */
dtv[0].counter; /* Generation counter for the DTV in this thread */
dtv[1].pointer; /* Pointer to the main executable TLS block in this thread */
/* Pointer to a TLS variable defined in a module id `ti_module` */
main_tls_var = *(dtv[tls_index.ti_module].pointer + tls_index.ti_offset);
编译器和动态链接器共同计算出地址,因为可执行文件的 TLS 块和 TLS 偏移量都是提前知道的。
这种合作依赖于编译器在构建时的以下「事实」:
- FS 寄存器指向 TCB 。
- 编译器设置的 TLS 变量偏移量在运行时不会更改。
- 可执行文件的 TLS 块位于 TCB 之前。
同时,将 TLS 放在 TCB 之前可以让经常被访问的内存更持久地留在缓存中。