# Linux线程模型:LinuxThreads 与 NPTL

对于需要开发具备可移植性应用程序的开发者来说,理解 LinuxThreads 与 NPTL 模型是相当重要的。

LinuxThreads 模型最早将多线程引入 Linux,但 LinuxThreads 模型并不符合 POSIX 线程标准。虽然最近的原生 POSIX 线程库(NPTL)库弥补了这一缺陷,但仍然存在其他问题。本文为可能需要将应用程序从 LinuxThreads 模型移植到 NPTL 模型的开发人员,或者只是想了解其区别的开发人员,描述了这两种 Linux 线程模型之间的一些区别。

当 Linux 第一次开发时,它的内核并不支持多线程开发。但随着时间的推移,Linux 提供了 clone() 系统调用提供对多线程开发的支持。该调用创建了父进程的副本,但该副本与父进程共享其地址空间,于是我们称 Linux 的线程 为 特殊进程 ---- 共享资源的进程,也称之为 LWP 进程(轻量级进程)。LinuxThreads 项目使用这个系统调用在用户空间中模拟线程支持,但这中线程模型有许多缺点,特别是在信号处理、调度和线程间同步原语方面。此外,线程模型不符合 POSIX 的要求。

要改进 LinuxThreads 模型,需要 修改内核代码以提供支持,并且需要将整个线程库重写。于是产生了两个相互竞争的项目:一个包括 IBM 开发人员在内的团队开发的 NGPT 模型(即下一代POSIX线程)、包含红帽公司的开发团队开发的 NPTL 模型 ( 原生 POSIX 线程模型 )。但 NGPT 模型在2003年中期被放弃,将支持原生 POSIX 的线程库创建的任务交给了 NPTL 模型。

尽管选择 NPTL 模型而不是 LinuxThreads 模型似乎已成定数,但如果读者正在为一个老旧的 Linux 发行版维护应用程序,并计划升级该应用程序,那么迁移到 NPTL 将是移植过程中的一个重要部分。或者读者可能希望了解这些差异,以便设计的应用程序以适应较老的和较新的内核,那么了解这两个模型是相当重要的。

# LinuxThreads 线程模型描述

线程的作用是将程序分解为一个或多个同时运行的任务。线程与传统多任务处理的不同之处在于:线程共享单个进程的状态信息,共享内存和其他资源。同一进程中线程之间的上下文切换通常比进程之间的上下文切换快(比如:多线程共享虚拟地址空间,于是切换时不需要刷新 TLB ,也不需要切换页表)。因此,多线程程序的优点是它比多处理应用程序运行得更快,此外,使用线程可以很轻易的实现 共享数据 和 同步处理。这些相对于基于进程模型的程序优势导致了LinuxThreads 模型的出现。

LinuxThreads 模型的最初设计基于这样一种观念:进程(LWP)之间的上下文切换足够快,以至于每个内核进程都可以处理相应的用户级线程(混沌学堂我们说过:pthread 线程库 创建线程时 mmap 的栈中 包含有线程 TCB和其他数据,我们称之为 用户级线程),这便奠定了一对一线程模型的发展基础。

让我们来看一下 LinuxThreads 模型设计细节的亮点:

1:LinuxThreads 的一个显著特性是 管理线程 。管理器线程需要完成以下要求:

​ 1、必须能够对致命信号作出反应,并终止整个进程

​ 2、线程栈使用的内存分配必须在完成线程创建完成后进行分配,线程本身不能执行此操作

​ 3、必须等待被终止的线程,以便它们不会变成僵尸线程

​ 4、线程本地数据(TLS)的释放需要遍历所有线程

​ 5、如果主线程调用 pthread_exit() 函数,整个进程不会终止,此时主线程进入睡眠状态,当所有其他线程都被终止时,管理线程负责唤醒主线程完成整个进程的终止操作

2:为了维护线程本地数据 和 内存,LinuxThreads 使用堆栈地址下面的进程地址空间来保存 TLS 的数据

3:线程的同步原语是通过信号来实现的。例如:线程阻塞,直到被信号唤醒

4:在 clone 系统调用的初始设计中,LinuxThreads 将每个线程实现为具有自己唯一进程ID的不同进程

5:当向整个进程发送 致命信号 能够终止进程中的所有线程。LinuxThreads 在这方面的设计是一致的,一旦进程接收到致命信号,管理线程就会负责向所有其他线程发送该致命信号,然后进程中的所有线程(轻量级进程)响应该信号停止执行。

6:线程之间的调度执行由内核调度器处理

# LinuxThreads 线程模型的局限性

LinuxThreads 模型的设计总体上运行良好,但是,当在其上运行大量应用程序时,它在高性能、可伸缩性和可用性方面遇到了问题。让我们来看看 LinuxThreads 设计的一些限制:

1:使用管理线程来 创建 和 协调 进程中的所有线程,这增加了 创建 和 销毁 线程的开销

2:因为整个模型是围绕管理器线程设计的,所以会导致大量的上下文切换,这可能会影响可伸缩性和性能

3:因为管理器线程只能在一个 CPU 上运行,所以执行的任何同步操作都可能导致 SMP 或 NUMA 系统上的可伸缩性问题

4:由于线程的管理方式,以及每个线程都有不同的进程 ID,LinuxThreads 与 其他 POSIX 相关的线程库不兼容

5:信号机制被用来实现同步原语,这影响了操作的响应时间。此外 向主进程发送信号的概念并不存在,因此这不符合POSIX处理信号的方式。

6:LinuxThreads 模型中的信号处理是在线程的基础上进行的,而不是在整个进程的基础上,因为每个线程都有一个单独的进程 ID。由于信号被发送到指定管理线程,所以信号处理是序列化的—— 也就是说,信号通过该管理线程传递到其他线程,这与 POSIX 定义的 每个线程并行处理信号的要求是相反的。例如,在 LinuxThreads 模型下,通过 kill() 函数发送的信号被传递给单个线程,而不是整个进程。这意味着,如果该线程阻塞了信号,那么 LinuxThreads 模型将简单地将该信号在该线程待处理信号队列中排队,并仅在该线程解除阻塞信号时处理,而不是立即在另一个没有阻塞信号的线程中处理该信号。

7:由于 LinuxThreads 模型中的每个线程都是一个进程,所以 用户 和 组ID 信息可能对单个进程中的所有线程都是不同的。因此,多线程的 setuid() / setgid() 函数对于进程中的不同的线程可能是不同的

8:在某些情况下,创建的多线程程序的核心转储(core dump)并不包含所有线程信息。同样,导致这种行为的原因同样是每个线程都是一个独立的进程。如果在任何线程上发生崩溃,我们只能在系统核心文件上看到该线程的核心转储信息

9:因为每个线程都是一个独立的进程,所以 /proc 目录(虚拟文件系统:保存系统进程信息)中充满了大量进程目录,理想情况下这些进程目录只应该是进程信息,而不是线程信息

10:因为每个线程都是一个进程,所以可以为应用程序创建的线程数量是有限的。例如,在 IA 32系统上,可能的进程总数——也就是可以创建的线程总数——是 4090

11:因为计算线程本地数据(TLS)的方法是基于线程堆栈地址的位置,所以对该数据的访问非常慢。另一个缺点是,用户不能确定堆栈的大小,因为用户可能不小心将栈地址映射到用于不同目的的内存区域。线程栈大小按需增长概念(也称为浮动堆栈概念)在 Linux内核 的 2.4.10 版本中实现,在此之前,LinuxThreads 使用固定堆栈

# NPTL 线程模型描述

NPTL 线程模型也称之为 原生 POSIX 线程库,是 Linux 线程模型的一个新的实现,克服了 LinuxThreads 模型的缺点,也符合 POSIX 的要求。它提供了显著的改进 LinuxThreads 模型的性能和稳定性。与 LinuxThreads 模型一样,NPTL 实现了一对一模型。

Ulrich Drepper 和 Ingo Molnar是红帽公司的两位大佬,他们参与了 NPTL 模型的设计。他们的一些总体设计目标如下:

1、新的线程库应该与 POSIX 兼容

2、线程实现应该在具有大量处理器的系统上工作得很好且具备高性能

3、创建新线程的开销尽可能的低

4、NPTL 线程库应该是与 LinuxThreads 兼容的线程库。注意,可以使用 LD_ASSUME_KERNEL 变量(本文后面将对此进行讨论)来实现此目的

5、新的线程库应该能够支持 NUMA 架构

# NPTL 线程模型优势

NPTL 模型比 LinuxThreads 模型有很多优势:

1、NPTL不使用管理线程。管理线程的一些需求,比如向进程中的所有线程发送致命信号,并不是必需的,因为内核本身可以处理这些需求。内核还负责对每个线程堆栈使用的内存进行清理(当线程退出后),NPTL 模型甚至可以通过在清除主线程之前等待所有线程的终止来管理线程,从而避免僵尸线程。

2、由于 NPTL 线程模型不使用管理线程,所以它在 NUMA 和 SMP 系统上具有更好的可伸缩性和同步机制

3、使用 NPTL 线程库 和 新的内核实现,可以避免通过信号实现线程的同步。为实现此目的,NPTL 模型引入了一种叫做Futex(快速互斥体) 的新机制,Futex 作用于共享内存区域,因此可以在线程之间共享,从而提供线程间 POSIX 同步。也可以跨线程共享 Futex( 进程之间使用 Futex )。这种行为使得 进程间同步 成为现实。事实上,NPTL 包含一个名为PTHREAD_PROCESS_SHARED 的宏,它为开发人员提供一个句柄,使得进程之间可以共享该互斥锁

4、 NPTL 模型符合 POSIX 规范,它在整个进程的基础上处理信号,并且 getpid() 函数为所有线程返回相同的进程 ID。例如,如果发送了 SIGSTOP 信号,整个进程将停止,如果是 LinuxThreads 模型,那么接收到该信号的线程将停止,并不会影响整个进程,这可以在基于 NPTL 的应用程序上更好地使用 GDB 调试器。

5、因为在 NPTL 中所有线程都有一个父线程,所以统计父线程的资源使用情况(比如:CPU 和 内存 使用 百分比)是为整个进程而不是为一个线程统计的

6、NPTL 线程库引入的一个重要特性是对 ABI(应用程序二进制接口)的支持。这有助于实现与 LinuxThreads 的向后兼容,这可以在 LD_ASSUME_KERNEL 变量的帮助下完成

# LD_ASSUME_KERNEL 环境变量

如上所述,ABI 的引入使代码同时支持 NPTL 和 LinuxThreads 模型成为可能。基本上这是由ld(动态链接器)负责的,它决定动态链接到哪个运行时线程库(混沌学堂中我们说过 ELF)。

作为示例,以下是 WebSphere ® Application Server 使用的该变量的一些常见设置:

1、LD_ASSUME_KERNEL=2.4.19:这会覆盖 NPTL 线程库实现,这个实现通常被称为启用了浮动堆栈特性的标准LinuxThreads 模型

2、LD_ASSUME_KERNEL=2.2.5:这会覆盖 NPTL 线程库实现。这个实现通常被称为具有固定堆栈大小的 LinuxThreads 模型

可以使用以下命令设置该变量:

export LD_ASSUME_KERNEL=2.4.19

注意,对任何 LD_ASSUME_KERNEL 环境变量设置的支持将取决于 线程库 当前支持的 ABI 版本。例如,如果任何线程库不支持ABI 2.2.5 版本,那么用户将无法将 LD_ASSUME_KERNEL 设置为 2.2.5。通常,NPTL 需要2.4.20,而LinuxThreads 需要 2.4.1。

如果应用程序运行在支持 NPTL 模型 的 Linux发 行版上,但该应用程序是基于 LinuxThreads 模型设计的,那么所有这些设置通常都会用到。

# The GNU_LIBPTHREAD_VERSION macro

大多数现代 Linux 发行版同时提供了 LinuxThreads 模型和 NPTL模型的支持 ,并且它们提供了在两者之间切换的功能。要发现当前在系统上使用的线程库的哪个版本,运行以下命令:

$ getconf GNU_LIBPTHREAD_VERSION
1

输出如下所示:

NPTL 0.34
1

或者:

linuxthreads-0.10
1

# 具有线程模型、Glibc 版本 和 内核版本的 Linux 发行版

表1 列出了一些流行的 Linux 发行版,以及 线程实现的类型、Glibc 库 和每种发行版的内核版本。表中:

1、第一列 表示线程库模型

2、第二列 表示 GLIBC 函数库版本

3、第三列 表示内核发行版

4、第四列 表示内核版本

img

请注意,在内核 2.6.x 和 Glibc 2.3.3 之后,NPTL的版本编号约定似乎发生了变化:库的编号现在与正在使用的 Glibc 版本一致。

# 结论

在 NPTL 模型以及 LinuxThreads 模型的一些后续版本中,已经克服了 LinuxThreads 的限制。例如,最新的LinuxThreads 实现使用 线程寄存器 来定位线程本地数据,例如,在Intel®处理器上,它使用 %fs 和 %gs 段寄存器来定位访问线程本地数据的虚拟地址。尽管在 LinuxThreads 模型中所做的更改显示了改进,但由于对管理线程的过度依赖、信号处理问题等因素,在高负载或压力测试下仍然会重新出现问题。

读者还应该记住,在使用 LinuxThreads 构建库时,使用 -D_REENTRANT 编译时标志位,这使得库线程是安全的。

在使用多线程函数时,使用编译器选项-lpthread 和 -D_REENTRANT,前者告诉链接器链接库文件 libpthread.so (opens new window),对于后者,gcc 使用 -D 选项定义宏 REENTRANT 的值为1,在 _POSIX_C_SOURCE宏被定义为199506L时该宏与THREAD_SAFE宏作用相同,都是使用线程安全(即可重入)版本的C库函数(如gethostbyname函数)。

_REENTRANT宏会做以下工作:1:对部分函数重新定义它们的可重入版本,可重入版本的函数名以 _r 结尾,如 gethostbyname 函数的可重入版本为gethostbyname_r2:头文件stdio.h中原来以宏形式实现的一些函数将变成可重入函数3:头文件errno.h中的变量 errno 会变为函数调用,以便以线程安全的方式获取每个线程的 errno 值

最后,也是最重要的,请记住:LinuxThreads 模型不再由项目发起者主动更新,他们将 NPTL 视为其替代品。LinuxThreads 模型的缺点并不意味着 NPTL线程库是没有缺陷的。作为面向 SMP 的设计,NPTL 也有缺点,在最近的Red Hat 内核上看到过这样的情况:一个简单的线程应用程序在单处理器机器上运行良好,但却在SMP上挂掉了。相信在 Linux 上还有更多的工作要做,才能真正使其可伸缩,以满足更高端的应用程序。