33 线程的更多细节
主要内容
- 线程和传统 UNIX API 之间的交互(信号、进程控制原语)
- Linux 上的两个线程实现
线程栈的大小
在 x86_64-linux-gnu 上,除了主线程,其他线程的缺省大小都是 2MB。
Tip
2025/3/8 省流:
- 缺省:主线程一般 8MB(
ulimit -s
看,单位是 KB 不是 B),非主线程缺省 2MB。 - 最小:16KB。
参考:Go 中协程最小栈大小是 2KB。
// https://go.dev/src/runtime/stack.go#L74
// The minimum size of stack used by Go code
stackMin = 2048
设置线程栈的大小
为了设置线程栈的大小,需要在创建线程前构建一个线程属性(pthread_attr_t
),然后将其作为 pthread_create
的参数。
- 可以使用
pthread_attr_setstacksize()
设置线程栈的大小。 - 也可以用
pthread_attr_setstack()
同时设置线程栈的大小和地址,但是这样做会损害可移植性。
获取线程栈大小的限制
其实我们常常希望线程的栈更小一点,因为我们需要创建更多的线程!假设有 3GB 的虚拟地址空间,2MB 的线程栈大小意味着我们最多只能创建 1500 个线程。
我们可以用 sysconf(_SC_THREAD_STACK_MIN)
来获取线程最小的栈大小,在 Linux/x86-32 上的 NPTL,这个值测出来是 16384,也就是 16K。
我自己测试发现,虽然头文件中有定义 _SC_THREAD_STACK_MIN
这个最小值参数,但是没有定义最大值参数,也就是说不能用 sysconf()
查到线程栈大小的最大限制。另外,sysconf(_SC_THREAD_THREADS_MAX)
的结果是 -1,同时 errno
没有被置为非 0 值,说明进程的线程数不明确设限。
限制子进程的线程栈大小
可以在 shell 中使用 ulimit -s
来设置线程栈的字节数(我本地试了一下返回的是 8192)。在 main()
函数中使用 setrlimit()
来设置线程栈的资源限制可能行不通,因为线程在进入 main()
函数之前就已经创建了。
为什么这个 8192 比从 sysconf(_SC_THREAD_STACK_MIN)
中获取的 16384 还要小了?这是因为 8192 的单位是 KB,也就是说默认情况下主线程的栈大小为 8M。
线程和 UNIX 信号的交互
一些要点
- 信号处理器是属于进程层面的,任何线程对信号处置方式的修改都会影响整个线程。
- 信号可以发送给整个进程,也可以发给特定线程。
- 由指令执行造成的异常会发给线程自己:比如 SIGBUS、SIGFPE、SIGILL 和 SIGSEGV。
- 线程尝试写已断开的管道导致的 SIGPIPE 信号。
- 通过
pthread_kill()
和pthread_sigqueue()
允许向同一进程的其他线程发送信号。 - 其他机制产生的信号都是面向进程的,可能由进程的任何一个线程处理。比如在终端产生的信号(键入控制字符或更改终端的窗口大小)、定时器到期等。
- 线程掩码是对每个线程而言的。设置线程的信号掩码可以用
pthread_sigmask()
。为什么不能用sigprocmask()
见sigprocmask
:用信号掩码阻塞信号的传递,简单来说是这个 API 在多线程环境中没有规定其效果。 - 信号可以针对进程挂起,也可以针对线程挂起,内核会为其分别维护记录。函数
sigpending()
在手册中说明的是返回当前线程上等待的信号集合,实际上返回的是在进程上等待的信号集合和在当前线程上等待的信号集合的并集。 - 为了保证阻塞行为,信号处理器函数对
pthread_mutex_lock()
的中断会被自动重启,无需显式设置重启标志。而如果一个信号处理函数中断了对pthread_cond_wait()
的调用,则该调用要么自动重新开始(Linux 就是如此),要么返回 0,表示遭遇了假唤醒。无论如何,把pthread_cond_wait()
放在循环中总是安全的。
向同一个进程中的线程发送信号用的是 pthread_kill()
,由于标准里仅在同一个进程中保证线程 ID 的唯一性,所以无法调用 pthread_kill()
向其他进程的线程发送信号。(但实际上 Linux 中所有线程的 ID 都是唯一的。)同样,还可以使用 pthread_sigqueue()
来发送带有数据的信号(实时信号)。
Note
pthread_kill()
函数在 Linux 上是使用 tgkill(tgid, tid, sig)
来实现的。而 tkill(tid, sig)
相当于 tgkill(tgid, tid, sig)
,但是已被废弃,这是因为线程号可能被回收,如果不指定线程组号 / 进程号,则少了一项检查,可能会发送信号给错误的线程。
我上次通过 kill(1) 向特定线程发送了信号,这个操作是必定成功的吗?这个问题见 33.1 能不能用 kill(1) 给特定线程发送信号呢?。
书中建议的处理异步信号的方式
- 在创建其他线程之前,阻塞可能接受的所有异步信号。这样创建的新线程会继承信号掩码,从而不会处理信号。
- 创建一个专用线程,用
sigwaitinfo()
、sigtimedwait()
或者sigwait()
等函数来同步获取信号后再处理。
创建专用线程同步处理信号的好处是:可以使用各种非异步信号安全的函数(比如 Pthreads API 中的所有函数都不是异步信号安全的,但是我们仍可能需要使用它们)。
sigwait()
函数在之前没有被介绍,和 sigwaitinfo()
和 sigtimewait()
这两个系统调用不同,它是 POSIX 标准中的一个函数。
#include <signal.h>
int sigwait(const sigset_t *restrict set, int *restrict sig);
它的作用也是挂起当前进程,直到 set
参数中指定的信号之一到达。和 sigwaitinfo()
的区别有:
- 返回的信息不同,只返回信号整数值到
sig
参数中。 - 返回值不同,成功时返回 0,失败时返回表示错误的正值。
如果发送信号时,有多个线程正调用 sigwait
等待信号,那么只会有一个线程获得该信号,而且无法确定是哪一个线程获得信号。
线程和进程控制
线程和 exec()
除了调用者线程,其他线程会全部消失。进程的映像被替换,所有的互斥量、条件变量都会消失, 线程特有数据(TSD) 的析构函数也不会被调用。
留下线程的线程号是什么在标准中也没有规定?书里这一章节有:
After an
exec()
, the thread ID of the remaining thread is unspecified.
但是后文在对 LinuxThreads 实现不满足 SUSv3 的地方中指出:
If a thread calls
exec()
, … According to SUSv3, the process ID should be the same as that of the main thread.
这也太冲突了。我觉得正确的应该是后面那个“exec()
后进程号和之前的主线程相同”。
线程和 fork()
仅会把发起调用的线程复制到子进程中,有一些线程属性也会因此被保留。
有一段描述:
The ID of the thread in the child is the same as the ID of the thread that called
fork()
in the parent.
这和 28 进程的创建和执行过程 中说的线程 ID 在系统范围内不重复是矛盾的吧?
另外:
- 由于
fork()
会将内存状态复制到新的子进程中,Pthreads 对象也会被复制,有些互斥量可能正处于被之前的其他线程锁定的状态,这时留下的线程永远无法获取到锁。 fork()
并不会执行 pthreads 的清理函数(pthread_cleanup_push()
)和针对线程特定数据的析构函数,可能会造成内存泄漏。
所以,在多线程程序中使用 fork()
基本上只建议立马跟一个 exec()
。
如果要执行 fork()
但是又不跟 exec()
,还可以使用 pthread_atfork()
来创建 fork()
处理函数,在 fork()
之前完成一些清理动作。对于使用线程的函数库来说,pthread_atfork()
是很有用的,因为库函数作者并不知道用户是否会在多线程环境下使用库,也不知道用户会用什么样的编程方式使用。fork
处理函数也是会从父进程(调用 fork()
的那个线程)继承到子进程的。
在 Linux 上,如果使用 NPTL 线程库的程序执行了
vfork()
,那么将不再调用fork
处理函数。不过,在使用 LinuxThreads 程序的同一情况下却有效。
线程和 exit()
如果任何线程调用了 exit()
或者主线程从 main()
函数中返回,都会导致进程终止、所有线程消失,线程特有数据的析构函数和 pthread 清理函数都不会执行。
Linux 的线程实现
线程实现模型:重要是线程如何与内核调度实体(KSE)映射。在没有线程的传统 UNIX 中,KSE 就是进程。
线程实现的分类
多对一(M:1)的用户级线程
这种模型在用户空间实现线程管理,线程操作会比较快。但是线程发起系统调用而阻塞时,会阻塞整个程序中的线程(因为所有的线程都属于同一个 KSE)。另外,由于内核不知道用户空间的线程,不会(因为线程多)给程序分配更多的时间片和 CPU 核心数。这种模型因此在多核处理器上无法发挥出计算优势。
一对一(1:1)的内核级线程
每个线程映射到一个单独的 KSE。缺点:线程管理需要切换到内核模式,速度会慢一点;线程多会导致资源消耗严重。优点:不会因为单个线程的系统调用阻塞所有线程、能够充分利用多核 CPU。LinuxThreads 和 NTPL 都是这种实现。
多对多(M:N)的两级模型
最大问题是实现起来复杂,线程调度任务由内核及用户空间的线程库共同承担。曾经 NTPL 也考虑过这种实现,但是为了支持这种实现对内核的修改会比较大,后来就否决了这种方案。
Linux 中出现过的线程实现
一种是 LinuxThreads,另外一种是 NPTL(Native POSIX Threads Library)。对后者的支持需要修改内核,在 Linux 2.6 的时候被支持。NPTL 在接口上兼容 LinuxThreads,只是一些操作在行为上有差异。
在 LinuxThreads 之后还有个 IBM 的 NGPT(Next Generation POSIX Threads),采用的是 M:N 模型,性能明显优于 LinuxThreads,曾一度被视为 LinuxThreads 的继任者。不过后来发布的 NPTL 采用的新设计性能好于了 NGPT,所以 NGPT 的开发逐渐终止。
LinuxThreads 线程实现已经过时,glibc 2.4 开始也不再支持。
LinuxThreads 的要点
- 使用
clone()
实现,有CLONE_VM | CLONE_FILES | CLONE_FS | CLONE_SIGHAND
标志。线程之间不会共享进程 ID 和父进程 ID。 - 还会创建一个附加的进程来管理其他线程的创建和终止。(这可能是因为没有修改内核,所以就这么处理了。)
- 使用信号来处理内部操作,延迟相对比较高。
LinuxThreads 有很多地方和 SUSv3 的规定不同:
- 不同线程的
getpid()
结果不同。 fork()
之后只有创建子进程的线程才能对子进程发起wait()
。(因为 LinuxThreads 几乎可以看成是共享了很多属性的进程。)- 线程之间不会共享进程凭证(用户 ID 与组 ID)。
- 还有很多和信号交互的方面。因为不同的线程有不同的 pid,所以信号发送实际上会发送给特定的线程,而不是发给整个进程;还有备选信号栈也会被新线程继承下来(但是 SUSv3 规定新线程启动时不应该定义备选信号栈),作为对比,NPTL 中新线程不会继承备选信号栈。
- ……(太多了,大多数都是由于线程的进程号不同导致的)
NTPL
为了支持 NTPL,内核 2.6 做出了一些改动:
- 改进线程组的实现。
- 增加 futex 作为一种通用的同步机制。
- 增加新的系统调用
get_thread_area()
和set_thread_area()
以便支持线程本地存储。 - 线程化的核心转储、调试、信号处理。
- 增加新的系统调用
exit_group()
终止所有线程。 - 重写内核调度程序以应对大量 KSE(以前是进程模型,KSE 的数量有限)。
- 提升内核进程终止的执行效率(同上,因为线程可能更频繁被终止)。
- 扩展系统调用
clone()
的功能。
对于第 5 点,书中的说法是:
Glibc 2.3 开始,库函数
exit()
是exit_group()
的包装函数;而函数pthread_exit()
调用_exit()
,只终止当前线程。
但实际上,从 man 手册可以看出来 _exit()
函数是 C 语言标准库函数,在 GNU 平台上由 glibc 提供。在 glibc 的源码文件 sysdeps/unix/sysv/linux/_exit.c 中 中,exit()
只调用了 _exit()
,而 _exit()
在 Linux 平台上会调用 exit_group()
,所以在目前版本看来(可能 glibc 2.3 是书中那样的,但是我没考证),书上的说法不再正确。而 pthread_exit()
的过程比较复杂,经过多次调用最终是用 __jmp_buf
完成了长跳转,跳转回到了 start_thread()
,该函数在最后还注明了不能直接调用 _exit()
,因为 _exit()
会终止整个进程。不过,如果 start_thread()
在清理线程时发现当前线程是最后一个线程,则会直接调用 exit(0)
。(有如下事实:pthreads 线程退出的返回值总是 0;主线程并不是用 pthread_create()
创建的。)
关于第 8 点,NPTL 线程创建使用的 clone()
标志有:
CLONE_VM | CLONE_FILES | CLONE_FS | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | CLONE_SYSVSEM
在 Linux 内核 2.6 的早期版本中,NPTL 还有一些行为是和 SUSv3 标准不同的。在后来的版本,部分行为逐渐得到修正。因此 NPTL 比 LinuxThreads 更加符合 SUSv3。
选择线程实现
如果有老的程序依赖 LinuxThreads 的行为,可以将环境变量 LD_ASSUME_KERNEL
设置为某个特定版本(如 2.2.5
),从而使得动态链接器选择 LinuxThreads 库进行连接。
Pthreads API 的其他高级功能
- 设置线程的实时调度策略和优先级。
- 让进程共享互斥量和条件变量。
- 高级线程同步原语:barrier、read-write lock、spin lock。