赛博考古: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 的结构:

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 之前可以让经常被访问的内存更持久地留在缓存中。

Reference