ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

TLPI 第33章 读书笔记:Threads: Further Details

2026/9/3 15:34:45 拓冰建站 浏览量
TLPI 第33章 读书笔记:Threads: Further Details 笔记和练习博客总目录见开始读TLPI。本章详细介绍了 POSIX 线程的各个方面。我们讨论了线程与传统 UNIX API 各方面的交互——尤其是信号和进程控制原语fork()、exec() 和 _exit()。我们还概述了 Linux 上可用的两种 POSIX 线程实现——LinuxThreads 和 NPTL并指出这两种实现分别在哪些地方偏离了 SUSv3 的 Pthreads 规范。33.1 Thread Stacks每个线程都有自己的栈栈的大小在创建线程时固定。在Linux/x86-32上除主线程外其他线程的默认栈大小是2 MB。在一些64位架构上默认大小更大例如IA-64上是32 MB。主线程有更大的栈增长空间参见第618页的图29-1。有时改变线程栈的大小是有用的。pthread_attr_setstacksize()函数设置线程属性第29.8节该属性决定使用线程属性对象创建的线程栈的大小。相关的pthread_attr_setstack()函数可以控制栈的大小和位置但设置栈的位置可能会降低程序的可移植性。手册页提供了这些函数的详细信息。改变每个线程栈大小的一个原因是为分配大量自动变量或者进行深度嵌套函数调用可能是递归的线程提供更大的栈。另一种情况是应用程序可能希望减少每个线程栈的大小以便在一个进程中创建更多的线程。例如在x86-32上用户可访问的虚拟地址空间为3 GB默认栈大小2 MB意味着最多可以创建大约1500个线程。具体最大数量取决于文本段、数据段、共享库等消耗了多少虚拟内存。可以通过调用sysconf(_SC_THREAD_STACK_MIN)来确定特定架构上可以使用的最小栈。对于Linux/x86-32上的NPTL实现该调用返回值为16,384。在NPTL线程实现下如果栈大小资源限制RLIMIT_STACK被设置为非无限值则它在创建新线程时用作默认栈大小。该限制必须在程序执行前设置通常使用ulimit -s shell内建命令在C shell中为limit stacksize在执行程序之前设置。在主程序中使用setrlimit()来设置限制是不够的因为NPTL在运行时初始化期间在main()调用之前就已经确定了默认栈大小。$uname-mx86_64 $ulimit-s8192$cata.c#include stdio.h#include unistd.hvoidmain(){long min_stacksysconf(_SC_THREAD_STACK_MIN);printf(%ld\n, min_stack);}$ cc a.c $ ./a.out1638433.2 Threads and SignalsUNIX 的信号模型是在考虑到 UNIX 进程模型的基础上设计的并且比 Pthreads 的出现早了几十年。因此信号模型和线程模型之间存在一些显著的冲突。这些冲突主要是因为需要保持单线程进程的传统信号语义也就是传统程序的信号语义不应被 Pthreads 改变同时又要开发出可以在多线程进程中使用的信号模型。信号模型和线程模型的差异意味着将信号和线程结合起来是复杂的并且应尽量避免。不过有时候我们必须在多线程程序中处理信号。在本节中我们将讨论线程和信号之间的交互并介绍一些在线程程序中处理信号时非常有用的函数。33.2.1 How the UNIX Signal Model Maps to Threads要理解 UNIX 信号如何映射到 Pthreads 模型我们需要知道信号模型中哪些方面是针对整个进程的即所有线程共享的哪些方面是针对进程中单个线程的。下面的列表总结了关键点信号动作是进程范围的。如果任何未处理且默认操作为停止或终止的信号发送到进程中的任何线程那么进程中的所有线程都会被停止或终止。信号处置是进程范围的进程中的所有线程共享每个信号的相同处置。如果一个线程使用 sigaction() 为 SIGINT 等信号建立了处理器那么该处理器可能会被发送 SIGINT 的任何线程调用。类似地如果一个线程将信号的处置设置为忽略那么所有线程都会忽略该信号。一个信号可以被发向整个进程也可以发向特定的线程。如果满足以下情况信号就是线程定向的它是在该线程的上下文中执行某个特定硬件指令时直接产生的即第22.4节中描述的硬件异常SIGBUS、SIGFPE、SIGILL 和 SIGSEGV当线程尝试向已断开的管道写入数据时产生的 SIGPIPE 信号或者它是通过 pthread_kill() 或 pthread_sigqueue() 发送的这些函数在第33.2.3节有描述允许一个线程向同一进程中的另一个线程发送信号。所有由其他机制产生的信号都是面向进程的。例子包括通过 kill() 或 sigqueue() 从另一个进程发送的信号当用户输入终端的特殊字符以产生信号时生成的信号比如 SIGINT 和 SIGTSTP以及为软件事件产生的信号比如终端窗口大小改变SIGWINCH或计时器到期例如SIGALRM。当一个信号发送到已经建立了信号处理程序的多线程进程时内核会随意选择进程中的一个线程来接收该信号并在该线程中调用处理程序。这种行为符合传统的信号语义。对于一个进程来说对同一个信号执行多次信号处理操作是没有意义的。信号掩码是线程独立的。在多线程进程中没有全进程范围的信号掩码来管理所有线程。线程可以使用 pthread_sigmask() 独立地阻塞或解除阻塞不同的信号这是 Pthreads API 新定义的一个函数。通过操作每个线程的信号掩码应用程序可以控制哪些线程可以处理发向整个进程的信号。内核会记录整个进程待处理的信号以及每个线程待处理的信号。调用 sigpending() 会返回进程待处理信号集合和调用线程待处理信号集合的并集。在新创建的线程中每个线程的待处理信号集合最初是空的。线程定向信号只能发送到目标线程。如果线程阻塞了该信号信号会一直处于待处理状态直到线程解除阻塞或者线程终止。如果信号处理程序中断了对 pthread_mutex_lock() 的调用那么该调用总是会自动重新启动。如果信号处理程序中断了对 pthread_cond_wait() 的调用那么该调用要么会自动重新启动这是 Linux 的做法要么返回 0表示出现了虚假唤醒在这种情况下一个设计良好的应用程序会重新检查相应的条件并重新启动调用如第 30.2.3 节所述。SUSv3 要求这两个函数按这里描述的方式工作。备用信号栈是每个线程独有的参考第 21.3 节中 sigaltstack() 的描述。新创建的线程不会从创建它的线程继承备用信号栈。更准确地说SUSv3 指出每个内核调度实体KSE都有一个独立的备用信号栈。在具有 1:1 线程实现的系统上比如 Linux每个线程有一个 KSE参见第 33.4 节。33.2.2 Manipulating the Thread Signal Mask当一个新线程被创建时它会继承创建它的线程的信号掩码的副本。线程可以使用 pthread_sigmask() 来更改它的信号掩码、获取现有掩码或者两者同时进行。#includesignal.hintpthread_sigmask(inthow,constsigset_t*set,sigset_t*oldset);Returns0on success,or a positive error number on error除了它作用于线程信号掩码之外pthread_sigmask() 的使用方式和 sigprocmask()第20.10节的使用方式是一样的。SUSv3 指出在多线程程序中使用 sigprocmask() 是未指定的。在多线程程序中我们不能可移植地使用 sigprocmask()。实际上在很多实现中包括 Linuxsigprocmask() 和 pthread_sigmask() 是相同的。33.2.3 Sending a Signal toa Threadpthread_kill() 函数向同一进程中的另一个线程发送信号 sig。目标线程是通过参数 thread 来指定的。#includesignal.hintpthread_kill(pthread_tthread,intsig);Returns0on success,or a positive error number on error因为线程ID只在一个进程内保证唯一参见第29.5节所以我们不能使用pthread_kill()向另一个进程中的线程发送信号。pthread_kill()函数是使用Linux特有的tgkill(tgid, tid, sig)系统调用实现的它会向由tid标识的线程一个由gettid()返回的内核线程ID类型发送信号sig该线程属于由tgid标识的线程组。详细信息请参见tgkill(2)手册页。Linux特有的pthread_sigqueue()函数结合了pthread_kill()和sigqueue()第22.8.1节的功能它可以向同一进程中的另一个线程发送带有附加数据的信号。#define_GNU_SOURCE#includesignal.hintpthread_sigqueue(pthread_tthread,intsig,constunionsigval value);Returns0on success,or a positive error number on error和 pthread_kill() 一样sig 指定要发送的信号而 thread 用于标识目标线程。value 参数指定随信号发送的数据其用法与 sigqueue() 的相应参数相同。pthread_sigqueue() 函数是在 glibc 2.11 版本中添加的需要内核的支持。这个支持由 rt_tgsigqueueinfo() 系统调用提供该调用在 Linux 2.6.31 中引入。33.2.4 Dealing with Asynchronous Signals Sanely在第20到22章中我们讨论了各种因素——比如重入问题、中断系统调用需要重启以及避免竞争条件——这些都可能让通过信号处理程序处理异步生成的信号变得复杂。此外Pthreads API中的函数没有一个是在信号处理程序中可以安全调用的异步信号安全函数见第21.1.2节。因此需要处理异步生成信号的多线程程序通常不应该使用信号处理程序来接收信号通知。相反更推荐的方法是所有线程都会阻塞进程可能接收到的所有异步信号。最简单的方法是在创建任何其他线程之前在主线程中阻塞这些信号。随后创建的每个线程都会继承主线程信号屏蔽字的副本。创建一个专门的线程来接收传入信号可以使用 sigwaitinfo()、sigtimedwait() 或 sigwait()。我们在第22.10节中描述了 sigwaitinfo() 和 sigtimedwait()。下面我们将介绍 sigwait()。这种方法的优点是异步生成的信号可以同步接收。当它接收传入信号时专用线程可以在互斥锁控制下安全地修改共享变量并调用非异步信号安全的函数。它还可以触发条件变量并使用其他线程和进程的通信与同步机制。sigwait() 函数会等待 signal 集合由 set 指向中的某个信号的到来接受该信号并将其返回到 sig 中。#includesignal.hintsigwait(constsigset_t*set,int*sig);Returns0on success,or a positive error number on errorsigwait() 的操作和 sigwaitinfo() 一样只是sigwait() 不返回描述信号的 siginfo_t 结构而只返回信号编号并且返回值和其他线程相关函数保持一致不像传统 UNIX 系统调用返回 0 或 -1。如果有多个线程在用 sigwait() 等待同一个信号当信号到达时只有其中一个线程会真正接收信号。具体哪个线程会接收是无法确定的。33.3 Threads and Process Control像信号机制一样exec()、fork() 和 exit() 都早于 Pthreads API。在接下来的几段中我们会说明一些关于在多线程程序中使用这些系统调用的细节。Threads and exec()当任何线程调用 exec() 函数之一时调用的程序会被完全替换。除了调用 exec() 的那个线程所有其他线程都会立即消失。没有任何线程会执行线程特定数据的析构函数或调用清理处理程序。属于该进程的所有进程私有的互斥锁和条件变量也会消失。exec() 之后剩下线程的线程 ID 是不确定的。Threads and fork()当一个多线程进程调用 fork() 时只有调用 fork() 的线程会在子进程中被复制。子进程中该线程的 ID 和父进程中调用 fork() 的线程 ID 相同。其他所有线程都会在子进程中消失那些线程的线程特定数据析构函数或清理处理程序都不会执行。这可能导致各种问题虽然在子进程中只有调用的线程会被复制但全局变量的状态以及所有的 Pthreads 对象比如互斥锁和条件变量都会在子进程中保留。这是因为这些 Pthreads 对象是分配在父进程的内存中的而子进程会获得这块内存的副本。这可能会导致一些棘手的情况。例如假设在 fork() 时候另一个线程锁住了一个互斥锁并且正在更新一个全局数据结构。在这种情况下子进程中的线程将无法解锁这个互斥锁因为它不是互斥锁的拥有者如果尝试获取互斥锁它就会被阻塞。此外子进程中的全局数据结构副本可能也处于不一致的状态因为正在更新它的线程在更新过程中就消失了。由于线程专用数据和清理处理程序的析构函数不会被调用多线程程序中的 fork() 可能会导致子进程出现内存泄漏。此外由其他线程创建的线程专用数据项在新子进程中的线程很可能无法访问因为它没有指向这些数据项的指针。因为这些问题通常的建议是在多线程进程中使用 fork() 时唯一安全的方式是紧跟着一个 exec()。exec() 会导致子进程中的所有 Pthreads 对象消失因为新程序会覆盖进程的内存。对于必须使用不跟随 exec() 的 fork() 的程序Pthreads API 提供了一种定义 fork 处理程序的机制。可以使用如下形式的 pthread_atfork() 调用来建立 fork 处理程序pthread_atfork(prepare_func,parent_func,child_func);每次调用 pthread_atfork() 都会将 prepare_func 添加到一个函数列表中这些函数会在调用 fork() 创建新的子进程之前自动执行按注册的相反顺序。类似地parent_func 和 child_func 会被添加到各自的函数列表中分别在父进程和子进程中在 fork() 返回之前自动调用按注册顺序。对于使用线程的库代码来说fork 处理程序有时很有用。如果没有 fork 处理程序库就无法处理那些直接调用 fork() 的应用程序这些应用程序可能没有意识到库已经创建了一些线程。fork() 产生的子进程会继承调用 fork() 的线程的 fork 处理程序。在 exec() 期间fork 处理程序不会被保留因为处理程序的代码在 exec() 时会被覆盖因此无法保留。关于 fork 处理程序的更多细节以及它们的使用示例可以参考 [Butenhof, 1996]。在 Linux 上如果使用 NPTL 线程库的程序调用 vfork()fork 处理程序不会被调用。然而在使用 LinuxThreads 的程序中这种情况下是会调用 fork 处理程序的。Threads and exit()如果任何线程调用 exit()或者等价地主线程执行 return所有线程都会立即消失不会执行任何线程特定的数据析构函数或清理处理程序。33.4 Thread Implementation Models在本节中我们会涉及一些理论简要地考虑三种实现线程 API 的不同模型。这为第 33.5 节提供了有用的背景知识在那一节我们会考虑 Linux 的线程实现。这些实现模型之间的差异取决于线程如何映射到内核调度实体KSE内核调度实体是内核分配 CPU 和其他系统资源的单位。在早期 UNIX 的实现中也就是线程出现之前术语内核调度实体与进程的概念是同义的。Many-to-one (M:1) implementations (user-level threads)在 M:1 线程实现中线程的创建、调度和同步互斥锁、条件变量等待等所有细节完全由用户空间的线程库处理。内核完全不知道进程中存在多个线程。M:1 实现有一些优点。最大优点是许多线程操作比如创建和终止线程、线程之间的上下文切换以及互斥锁和条件变量操作都很快因为不需要切换到内核模式。此外由于线程库不需要内核支持M:1 实现可以相对容易地从一个系统移植到另一个系统。然而M:1 实现也存在一些严重的缺点当一个线程进行系统调用比如 read() 时控制权会从用户空间的线程库转到内核。这意味着如果 read() 调用被阻塞进程中的所有线程都会被阻塞。内核无法调度进程的线程。由于内核不知道进程中存在多个线程它无法将不同线程调度到多处理器硬件的不同处理器上。同时也无法有意义地将一个进程中的线程设置比另一个进程中的线程更高的优先级因为线程的调度完全在进程内部进行。One-to-one (1:1) implementations (kernel-level threads)在 1:1 的线程实现中每个线程都映射到一个单独的 KSE内核调度实体。内核会单独处理每个线程的调度。线程同步操作是通过系统调用进入内核来实现的。1:1 实现消除了 M:1 实现的一些缺点。一次阻塞的系统调用不会导致进程中的所有线程都阻塞而且内核可以在多处理器硬件上将进程的线程调度到不同的 CPU 上。然而在 1:1 实现中线程创建、上下文切换和同步等操作会更慢因为需要切换到内核模式。此外在包含大量线程的应用中为每个线程维护一个单独 KSE 所需的开销可能会给内核调度器带来很大负担降低整体系统性能。尽管有这些缺点1:1 实现通常仍比 M:1 实现更受欢迎。Linux 的两个线程实现——LinuxThreads 和 NPTL——都采用 1:1 模型。在 NPTL 的开发过程中花了大量精力重写内核调度器并设计了一种线程实现使得包含成千上万线程的多线程进程能够高效运行。随后的测试显示这个目标已经实现。Many-to-many (M:N) implementations (two-level model)M:N 实现旨在结合 1:1 和 M:1 模型的优点同时消除它们的缺点。在 M:N 模型中每个进程可以拥有多个关联的 KSE而且多个线程可能映射到每个 KSE。这个设计允许内核将一个应用程序的线程分布到多个 CPU 上同时消除了使用大量线程的应用程序可能遇到的扩展问题。M:N 模型最大的缺点是复杂性。线程调度的任务需要在内核和用户空间的线程库之间共享这两者必须相互合作并传递信息。在 M:N 实现下根据 SUSv3 的要求管理信号也很复杂。最初在 NPTL 线程实现中曾考虑过使用 M:N 实现但最终放弃了因为这需要对内核进行范围过大的修改而且可能没有必要考虑到即使处理大量 KSELinux 调度器也能很好地扩展。 无论从理论还是最终结果来说1:1都是最好的实现其他就可以忽略了。33.5 Linux Implementations of POSIX ThreadsLinux 有两个主要的 Pthreads API 实现LinuxThreads这是最早的 Linux 线程实现由 Xavier Leroy 开发。NPTLNative POSIX Threads Library原生 POSIX 线程库这是现代的 Linux 线程实现由 Ulrich Drepper 和 Ingo Molnar 开发用来替代 LinuxThreads。NPTL 的性能比 LinuxThreads 更优秀而且它更严格地遵循 Pthreads 的 SUSv3 规范。要支持 NPTL需要对内核进行一些修改这些修改在 Linux 2.6 中出现。Xavier Leroy法国计算机科学家OCaml 编译器主要实现者形式化验证编译器 CompCert 的负责人。Ulrich Drepper德国人长期负责 GNU glibc 标准 C 库是 Linux 用户态底层非常关键的人物。《每个程序员都该了解的内存知识》Ingo Molnár匈牙利人。著名 Linux 内核开发者任职于 Red Hat 红帽公司有一段时间看起来 LinuxThreads 的继任者会是另一个实现叫做下一代 POSIX 线程NGPT这是 IBM 开发的一个线程实现。NGPT 采用 M:N 设计性能比 LinuxThreads 好很多。不过NPTL 的开发者决定开发一个新的实现。这种做法是有道理的——1:1 设计的 NPTL 被证明比 NGPT 表现更好。在 NPTL 发布后NGPT 的开发就停止了。在接下来的部分中我们将进一步讨论这两种实现的细节并指出它们偏离 SUSv3 对 Pthreads 要求的地方。此时值得强调的是LinuxThreads 实现现在已经过时它在 glibc 2.4 及更高版本中不再受支持。所有新的线程库开发现在只在 NPTL 中进行。33.5.1 LinuxThreads多年来LinuxThreads 是 Linux 上的主要线程实现它足以支持各种线程应用的开发。LinuxThreads 实现的基本内容如下线程是通过调用 clone() 创建的该调用指定了以下标志CLONE_VM|CLONE_FILES|CLONE_FS|CLONE_SIGHAND这意味着 LinuxThreads 的线程共享虚拟内存、文件描述符、文件系统相关信息umask、根目录和当前工作目录以及信号处理方式。不过线程不共享进程 ID 和父进程 ID。除了应用程序创建的线程外LinuxThreads 还会创建一个额外的“管理”线程来处理线程的创建和终止。该实现使用信号进行内部操作。在支持实时信号的内核Linux 2.2 及更高版本中使用前三个实时信号。在较旧的内核中使用 SIGUSR1 和 SIGUSR2。应用程序不能使用这些信号。使用信号会导致各种线程同步操作的高延迟。LinuxThreads deviations from specified behaviorLinuxThreads 在许多方面不符合 SUSv3 对 Pthreads 的规范。LinuxThreads 的实现受当时内核功能限制在这些限制条件下它已经尽可能地符合规范。下面的列表总结了不符合之处对 getpid() 的调用在一个进程的各个线程中返回不同的值。对 getppid() 的调用反映了这样一个事实除主线程外的每个线程都是由进程的管理线程创建的也就是说getppid() 返回的是管理线程的进程 ID。其他线程中对 getppid() 的调用应该返回与主线程中 getppid() 调用相同的值。如果一个线程使用 fork() 创建了子进程那么其他线程也应该能够使用 wait()或类似方法获得该子进程的终止状态。然而实际情况并非如此只有创建子进程的线程可以对其调用 wait()。如果一个线程调用 exec()那么按照 SUSv3 的要求所有其他线程都会被终止。不过如果 exec() 是由主线程以外的线程执行的那么生成的进程将拥有与调用线程相同的进程 ID——也就是说它的进程 ID 不同于主线程的进程 ID。根据 SUSv3进程 ID 应该与主线程的一样。线程不会共享凭据用户和组 ID。当一个多线程进程执行一个设定用户 ID 的程序时可能会出现这样的情况一个线程无法使用 pthread_kill() 向另一个线程发送信号因为两个线程的凭据已经被修改导致发送线程不再有权限向目标线程发送信号参考第 403 页的图 20-2。此外由于 LinuxThreads 实现内部使用了信号如果一个线程更改了它的凭据各种 Pthreads 操作可能会失败或挂起。SUSv3 关于线程与信号交互的规范的各个方面有些并没有被遵守使用 kill() 或 sigqueue() 发送到进程的信号应该被传递给目标进程中任意一个没有阻塞该信号的线程并由其处理。然而由于 LinuxThreads 的线程具有不同的进程 ID信号只能针对特定线程。如果该线程阻塞了信号即使有其他线程没有阻塞该信号信号也会保持挂起状态。LinuxThreads 不支持针对整个进程的待处理信号的概念只支持每个线程的待处理信号。如果信号是针对包含多线程应用的进程组的那么信号将由应用中的所有线程处理也就是说所有已经设置信号处理程序的线程而不是由单个任意线程处理。例如这种信号可能是通过输入终端字符之一生成的这些字符会为前台进程组生成作业控制信号。备用信号栈的设置由 sigaltstack() 建立是按线程来的。不过由于新线程错误地从 pthread_create() 的调用者那里继承了备用信号栈设置这两个线程就会共享同一个备用信号栈。SUSv3 要求新线程开始时不应该定义备用信号栈。这种 LinuxThreads 不符合规范的后果是如果两个线程恰好同时在它们共享的备用信号栈上处理不同的信号很可能会导致混乱比如程序崩溃。这个问题可能很难重现和调试因为它的发生取决于两个信号恰好同时被处理的概率性事件而这种情况通常很少发生。备用信号栈的设置由 sigaltstack() 建立是按线程来的。不过由于新线程错误地从 pthread_create() 的调用者那里继承了备用信号栈设置这两个线程就会共享同一个备用信号栈。SUSv3 要求新线程开始时不应该定义备用信号栈。这种 LinuxThreads 不符合规范的后果是如果两个线程恰好同时在它们共享的备用信号栈上处理不同的信号很可能会导致混乱比如程序崩溃。这个问题可能很难重现和调试因为它的发生取决于两个信号恰好同时被处理的概率性事件而这种情况通常很少发生。 …线程不共享公共会话 ID 和进程组 ID。setsid() 和 setpgid() 系统调用不能用来改变多线程进程的会话或进程组成员。使用 fcntl() 建立的记录锁不共享。相同类型的重叠锁请求不会合并。线程不共享资源限制。SUSv3 指出资源限制是进程级别的属性。times() 返回的 CPU 时间和 getrusage() 返回的资源使用信息是按线程计算的。这些系统调用本应返回整个进程的总计。某些版本的 ps(1) 会把进程中的所有线程包括管理线程都显示为独立项目拥有不同的进程 ID。线程不共享通过 setpriority() 设置的 nice 值。使用 setitimer() 创建的间隔定时器在线程间不共享。线程不共享 System V 信号量撤销semadj值。Other problems with LinuxThreads除了上述与 SUSv3 的偏差之外LinuxThreads 实现还有以下问题如果管理线程被杀掉那么剩下的线程必须手动清理。多线程程序的核心转储可能不包括进程的所有线程甚至可能不包括触发核心转储的线程。非标准的 ioctl() TIOCNOTTY 操作只能在主线程中调用时才能移除进程与控制终端的关联。33.5.2 NPTLNPTL 的设计目的是解决 LinuxThreads 的大部分不足。具体来说NPTL 在 Pthreads 的 SUSv3 规范符合度上要高得多。使用大量线程的应用程序在 NPTL 下的扩展性比在 LinuxThreads 下好得多。NPTL允许一个应用程序创建大量线程。NPTL的开发者成功运行了创建100,000个线程的测试程序。使用LinuxThreads时线程数量的实际上限只有几千个。当然几乎很少有应用程序需要这么多线程。NPTL的实现工作从2002年开始并在接下来的一年左右取得了进展。同时Linux内核内部也做了各种调整以适应NPTL的要求。在Linux 2.6内核中为支持NPTL出现的更改包括以下内容对线程组实现的改进第28.2.1节添加了futex作为同步机制futex是一个通用机制不仅为NPTL设计添加了新的系统调用get_thread_area()和set_thread_area()以支持线程本地存储支持线程核心转储和多线程进程调试对支持信号管理进行修改使其与Pthreads模型一致新增exit_group()系统调用以终止进程中的所有线程从glibc 2.3开始_exit()——因此exit()库函数也一样——被设为调用exit_group()的包装器而pthread_exit()调用内核中真正的_exit()系统调用只终止调用线程重写了内核调度器以支持高效调度大量例如数千个KSE提升了内核进程终止代码的性能以及扩展了clone()系统调用第28.2节。NPTL 实现的基本要点如下线程是通过调用 clone() 创建的并指定以下标志CLONE_VM|CLONE_FILES|CLONE_FS|CLONE_SIGHAND|CLONE_THREAD|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID|CLONE_SYSVSEMNPTL 线程共享 LinuxThreads 线程共享的所有信息甚至更多。CLONE_THREAD 标志意味着线程会被放置在与其创建者相同的线程组中并共享相同的进程 ID 和父进程 ID。CLONE_SYSVSEM 标志意味着线程会与其创建者共享 System V 信号量的撤销值。当我们使用 ps(1) 列出在 NPTL 下运行的多线程进程时只会看到一行输出。要查看进程内线程的信息我们可以使用 ps –L 选项。实现内部使用了前两个实时信号。应用程序无法使用这些信号。其中一个信号用于实现线程取消。另一个信号用于一种确保进程中所有线程具有相同用户和组 ID 的技术。之所以需要这种技术是因为在内核级别线程有各自独立的用户和组凭证。因此NPTL 在每个会改变用户和组 ID 的系统调用如 setuid()、setresuid() 等以及它们的组对应调用的封装函数中做了一些工作以确保进程中所有线程的 ID 都被改变。和 LinuxThreads 不同NPTL 不使用管理线程。NPTL standards conformance这些变化意味着 NPTL 比 LinuxThreads 更接近 SUSv3 的一致性。在撰写本文时仍有以下不符合之处线程不会共享 nice 值。早期 2.6.x 内核中NPTL 还有一些额外的不符合之处在 2.6.16 之前的内核中备用信号栈是每个线程独有的但新线程错误地继承了 pthread_create() 调用者建立的备用信号栈设置由 sigaltstack() 建立导致两个线程共享同一个备用信号栈。在 2.6.16 之前的内核中只有线程组的领导者即主线程才能通过调用 setsid() 来启动一个新的会话。在 2.6.16 之前的内核中只有线程组领导者可以使用 setpgid() 将主进程设为进程组领导者。在 2.6.12 之前的内核中使用 setitimer() 创建的间隔定时器在进程的线程之间不共享。在 2.6.10 之前的内核中资源限制设置在进程的线程之间不共享。在 2.6.9 之前的内核中times() 返回的 CPU 时间和 getrusage() 返回的资源使用信息是按线程计算的。NPTL 设计上与 LinuxThreads 在 ABI 上兼容。这意味着那些链接到提供 LinuxThreads 的 GNU C 库的程序不需要重新链接就可以使用 NPTL。不过当程序在 NPTL 下运行时一些行为可能会发生变化主要是因为 NPTL 更严格地遵循了 SUSv3 对 Pthreads 的规范。33.5.3 Which Threading Implementation?一些 Linux 发行版附带了一个 GNU C 库该库同时提供 LinuxThreads 和 NPTL默认使用哪个由动态链接器根据系统运行的底层内核来决定。这些发行版现在已经属于历史了因为从 2.4 版本开始glibc 不再提供 LinuxThreads。因此我们有时可能需要回答以下问题在特定的 Linux 发行版中哪种线程实现是可用的在一个同时提供 LinuxThreads 和 NPTL 的 Linux 发行版上默认使用哪种实现以及我们如何显式选择程序使用的线程实现Discovering the threading implementation我们可以使用一些方法来发现特定系统上可用的线程实现或者发现当在同时提供两种线程实现的系统上运行程序时将采用的默认实现。在提供 glibc 版本 2.3.2 或更高版本的系统上我们可以使用以下命令来查看系统提供了哪种线程实现或者如果提供了两种实现默认使用哪一种$ getconf GNU_LIBPTHREAD_VERSION NPTL2.34在一个 NPTL 是唯一或默认实现的系统上这将显示类似以下的字符串NPTL 2.3.4自 glibc 2.3.2 起程序可以使用 confstr(3) 来获取类似信息以检索 glibc 特定的 _CS_GNU_LIBPTHREAD_VERSION 配置变量的值。在使用较旧的 GNU C 库的系统上我们需要做更多的工作。首先可以使用以下命令来显示在运行程序时使用的 GNU C 库的路径名这里我们以标准的 ls 程序为例它位于 /bin/ls$ ldd /bin/ls|greplibc.so libc.so.6/lib64/libc.so.6(0x00007effcf773000)我们在第41.5节中将多说一点关于 ldd列出动态依赖程序的内容。GNU C 库的路径名显示在 之后。如果我们把这个路径名当作命令执行那么 glibc 会显示一系列关于它自身的信息。我们可以通过 grep 在这些信息中筛选出显示线程实现的那一行$ /lib64/libc.so.6|egrep-ithreads|nptl$## 实际是没有输出的Selecting the threading implementation used by a program此略因为目前已无此问题。不过简单来说是通过设置环境变量LD_ASSUME_KERNEL来实现的。33.6 Advanced Features of the Pthreads APIPthreads API 的一些高级功能包括以下几点实时调度我们可以为线程设置实时调度策略和优先级。这类似于第 35.3 节中描述的进程实时调度系统调用。进程共享互斥锁和条件变量SUSv3 指定了一个选项允许互斥锁和条件变量在进程之间共享而不仅仅是在单个进程的线程之间。在这种情况下条件变量或互斥锁必须位于进程共享的内存区域。NPTL 支持此功能。高级线程同步原语这些设施包括屏障、读写锁和自旋锁。有关这些功能的更多详细信息可以参考 [Butenhof, 1996]。33.7 Summary线程和信号不太合得来多线程应用程序设计应尽量避免使用信号。如果多线程应用程序必须处理异步信号通常最干净的方式是阻塞所有线程中的信号然后有一个专门的线程使用 sigwait()或类似方法来接收传入的信号。这个线程可以安全地执行任务比如在互斥控制下修改共享变量以及调用非异步信号安全的函数。在 Linux 上通常有两种线程实现LinuxThreads 和 NPTL。LinuxThreads 在 Linux 上已经存在很多年但在一些方面不符合 SUSv3 的要求现在已经过时。较新的 NPTL 实现更接近 SUSv3 标准并且性能更优是现代 Linux 发行版中使用的实现。Further information请参考第29.10节中列出的进一步信息来源。LinuxThreads 的作者在一个网页中记录了其实现网址是 http://pauillac.inria.fr/~xleroy/linuxthreads/。NPTL 的实现则由其开发者在一篇现在有些过时的论文中描述在线可获得网址是 http://people.redhat.com/drepper/nptl-design.pdf。