2.12 线程
线程和进程到底共享什么、不共享什么?为什么 pthread_create 失败不设置 errno?线程栈有多大、溢出了会怎样?取消一个正在 read 的线程,它手里的锁和内存谁来收?
用 pthread_create/join 写出生命周期正确的多线程程序;用属性控制栈大小与分离状态;用取消点与清理处理器安全地终止线程;写一个固定大小的线程池;用 /proc/PID/task 与 TSan 定位线程问题。
2.11 里的并发还只是「信号打断主流程」。从这一章开始,多个执行流真正同时跑在同一片地址空间里:它们共享堆、全局变量、fd 表,各自拥有栈、寄存器和 errno。共享让通信变得极快——一个指针就能传结构体;也让错误变得极难复现——两个线程谁先写那个指针,取决于调度器。
线程不是「更轻的进程」。进程是资源隔离的单位,线程是调度的单位。Linux 内核不区分两者:fork() 与 pthread_create() 最终都走 clone(),区别只在 flags——是否共享地址空间、fd 表、信号处理器、文件系统信息。理解这一点,就能解释本章所有「为什么」:为什么一个线程 close(fd) 会影响同进程其他线程,为什么一个线程 exit() 会杀掉整个进程,为什么 fork() 在多线程程序里危险。
这一章先把线程的生命周期讲对(创建、连接、分离、取消、清理),再讲每线程状态(栈、TLS、errno、信号掩码),最后落到一个可运行的线程池。线程之间的数据竞争与同步原语留给 2.13,但线程池已经需要互斥锁和条件变量——你会先看到它们怎么用,下一章再讲透为什么。
2.12.1 线程模型:clone 出来的执行流
同一进程内的线程共享:地址空间(代码段、数据段、堆、共享库、mmap 区域)、文件描述符表、当前工作目录、umask、信号处置(sigaction 安装的处理器)、进程 ID、资源限制。
每个线程私有:线程 ID(pthread_t 与内核 TID)、栈与栈指针、寄存器上下文、errno、信号掩码(pthread_sigmask)、调度优先级与 CPU 亲和性、线程局部存储(TLS)、取消状态。
fork() 创建新进程,复制地址空间(COW)、fd 表、信号处置;pthread_create() 创建新线程,只分配栈与 TCB,其余全部共享。两者在内核里都落到 clone(),flags 决定共享哪些资源。
高地址 ┌──────────────────────────────┐ │ 主线程栈(默认 8 MB,向下增长) │ ├──────────────────────────────┤ │ 共享库 / mmap 区域 │ ├──────────────────────────────┤ │ 堆(brk / mmap) │ ← 所有线程共享,malloc 需要加锁 ├──────────────────────────────┤ │ .bss / .data(全局变量) │ ← 所有线程共享,竞态的发源地 ├──────────────────────────────┤ │ .text(只读代码) │ ├──────────────────────────────┤ │ 线程 1 栈(默认 8 MB) │ ← 每线程独立,溢出踩到 guard page ├──────────────────────────────┤ │ 线程 2 栈(默认 8 MB) │ ├──────────────────────────────┤ │ TLS + 线程控制块(TCB) │ ← errno、__thread 变量 └──────────────────────────────┘ 低地址
读图:线程栈不是从堆里 malloc 出来的,而是 mmap 出来的独立区域(glibc 用 mmap 分配,带一个 guard page)。所以「栈大小」是创建线程时就要决定的属性;线程跑起来之后不能扩容。图中堆只有一份,两个线程同时 malloc 时,glibc 内部会用 arena 锁保护——这也是为什么 fork() 在多线程程序里可能死锁:子进程可能继承了「已被另一个线程锁住」的 malloc 锁。
内核视角的线程就是一个 task_struct,有自己的 pid(内核叫 TID)、tgid(线程组 ID,等于进程 ID)。gettid() 返回 TID,getpid() 返回 TGID;ps -eLf 或 ls /proc/PID/task 能看到同一进程的多个 TID。glibc 2.30 起可直接调用 gettid(),更早版本用 syscall(SYS_gettid)。
errno 是「上一次系统调用为什么失败」的全局状态。如果两个线程共享它,A 线程刚因 EAGAIN 设置 errno,B 线程的 read 成功(不修改 errno),A 再去读 errno 仍能看到 EAGAIN;但若 B 失败并覆盖成 EINTR,A 的诊断就错了。glibc 把 errno 定义成宏,展开为 (*__errno_location()),每个线程一块。这也是为什么「保存 errno 再恢复」的写法在信号处理器里常见:处理器可能调用会改 errno 的函数,返回前必须恢复。
- 「线程有自己的 fd 表」——没有。线程共享 fd 表,一个线程
close(fd)后,其他线程手里的同一个整数立即失效(可能已被复用)。 - 「一个线程
exit()只退出自己」——exit()终止整个进程。线程退出用pthread_exit(),或从入口函数return。 - 「线程有独立的信号处理器」——信号处置是进程级的,
sigaction在一个线程里安装,所有线程生效;只有信号掩码是线程私有的。 - 「
pthread_t就是 TID」——pthread_t是不透明句柄(glibc 里是指向 TCB 的指针),与内核 TID 不是一回事;打印或比较只能用pthread_equal()。
2.12.2 创建、连接与分离
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg);
int pthread_join(pthread_t thread, void **retval);
int pthread_detach(pthread_t thread);
void pthread_exit(void *retval) __attribute__((noreturn));
Pthreads 的错误约定与系统调用相反:成功返回 0,失败返回错误码,不设置 errno。所以不能写 if (pthread_create(...) == -1) perror(...);正确写法是拿返回值去 strerror()。
/* thread_basic.c — 创建、传参、连接、取返回值 */
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct job {
int id;
long result;
};
static void *worker(void *arg)
{
struct job *j = arg;
long sum = 0;
for (int i = 1; i <= j->id; i++)
sum += i;
j->result = sum;
printf("worker %d: pthread_t=%lu sum=%ld\n",
j->id, (unsigned long)pthread_self(), sum);
return j; /* 返回值由 pthread_join 的 retval 收到 */
}
int main(void)
{
enum { N = 3 };
struct job jobs[N];
pthread_t tids[N];
for (int i = 0; i < N; i++) {
jobs[i].id = i + 1;
jobs[i].result = 0;
int rc = pthread_create(&tids[i], NULL, worker, &jobs[i]);
if (rc != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(rc));
return EXIT_FAILURE;
}
}
for (int i = 0; i < N; i++) {
void *ret = NULL;
int rc = pthread_join(tids[i], &ret);
if (rc != 0) {
fprintf(stderr, "pthread_join: %s\n", strerror(rc));
return EXIT_FAILURE;
}
struct job *j = ret;
printf("joined job %d: result=%ld\n", j->id, j->result);
}
return EXIT_SUCCESS;
}
编译与运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g -pthread thread_basic.c -o thread_basic
./thread_basic
# 输出顺序不固定:三个 worker 谁先打印由调度器决定
pthread_join 做三件事:等待线程终止、回收它的栈与 TCB、取出返回值。不 join 的 joinable 线程会泄漏资源,类似僵尸进程,但泄漏的是用户态栈(默认 8 MB)而不是 task_struct。
线程的终止方式有四种:
| 方式 | 效果 | 清理处理器 | 返回值 |
|---|---|---|---|
从入口函数 return p | 终止本线程 | 实现相关,不应依赖 | p |
pthread_exit(p) | 终止本线程 | 执行 | p |
pthread_cancel 成功 | 终止目标线程 | 执行(延迟取消时) | PTHREAD_CANCELED |
进程 exit() / main 返回 | 终止所有线程 | 不保证 | 进程退出码 |
- 「主线程
return 0后其他线程还会继续跑」——不会。main返回等价于exit(),整个进程结束。要让其他线程继续,主线程应调pthread_exit()或pthread_join()。 - 「detach 后还能 join」——不能。detached 线程终止时自动回收,
pthread_join返回EINVAL。detach 与 join 只能二选一。 - 「join 一个已 detach 的线程能拿到返回值」——拿不到。detach 的语义就是「不关心结果」。
- 「join 自己没关系」——
pthread_join(pthread_self(), ...)返回EDEADLK(glibc 会检测);不检测的实现会永久阻塞。 - 「线程函数返回局部变量的地址」——返回
&local后栈帧已销毁,join 拿到的是悬空指针。要返回堆内存、静态存储,或像上例那样返回入参指向的结构体。
pthread_detach 适合「发射后不管」的后台任务,例如日志刷盘、心跳。但 detached 线程崩溃时没人收尸,也没有返回值可查;生产代码里更常见的是「joinable + 统一 join」,或者用线程池管理生命周期。
pthread_attr_t:属性对象里究竟有什么
attr 是不透明结构体(glibc 里 pthread_attr_t 是 56 字节的联合体),你只能通过 API 读写。但每个字段都对应 NPTL 或内核的一个真实行为,设错不会报错,只会在运行时表现成「参数没生效」。这张表回答「哪些属性值得设、默认值是什么」:
| 属性 | 设置函数 | 默认值(glibc 2.39 / NPTL) | 影响 | 设错的后果 |
|---|---|---|---|---|
| 栈大小 | pthread_attr_setstacksize | 8 MB,受 RLIMIT_STACK 约束 | mmap 出的栈区长度 | 小于 PTHREAD_STACK_MIN → EINVAL |
| 栈地址 | pthread_attr_setstack | 由 glibc mmap | 用调用者给的缓冲区 | 自己负责对齐与 guard |
| guard 大小 | pthread_attr_setguardsize | 4 KB(一页) | 栈底 PROT_NONE 区长度 | 设 0 后溢出直接踩相邻映射 |
| 分离状态 | pthread_attr_setdetachstate | PTHREAD_CREATE_JOINABLE | 退出后是否自动回收 | 设 DETACHED 后再 join → EINVAL |
| 调度策略 | pthread_attr_setschedpolicy | SCHED_OTHER | SCHED_FIFO/RR/OTHER | 非特权设 FIFO → EPERM |
| 调度参数 | pthread_attr_setschedparam | 继承创建者 | 优先级 | 忘记改 inheritsched → 静默忽略 |
| 调度继承 | pthread_attr_setinheritsched | PTHREAD_INHERIT_SCHED | 是否采用 attr 里的调度设置 | 默认值让上面两项失效 |
| 竞争范围 | pthread_attr_setscope | PTHREAD_SCOPE_SYSTEM | NPTL 只支持 SYSTEM | 设 PROCESS_SCOPE → ENOTSUP |
| CPU 亲和 | pthread_attr_setaffinity_np | 继承创建者 | CPU 掩码 | 掩码超出可用 CPU → EINVAL |
| 信号掩码 | pthread_attr_setsigmask_np | 继承创建者 | 新线程初始掩码 | glibc < 2.32 没有这个函数 |
读表要点:默认值是「继承创建者的调度与掩码 + 8 MB 栈 + 一页 guard」。所谓「pthread_attr_setschedparam 不起作用」,九成是因为没有先调用:
pthread_attr_setinheritsched(&attr, PTHREAD_EXPLICIT_SCHED);
不改这一项,pthread_create 会用创建者的调度参数覆盖你在 attr 里写的一切,而且返回 0,不报错。这是 Pthreads 里最隐蔽的一类「成功但无效」。
线程 ID:三个不同的编号
同一个线程有三个「ID」,混用是排查时最常见的迷惑源:
| 名字 | 类型 | 谁分配 | 唯一范围 | 怎么拿 |
|---|---|---|---|---|
pthread_t | 不透明句柄(glibc 里是 TCB 指针) | NPTL,用户态 | 进程内 | pthread_self()、pthread_create 输出参数 |
| TID | pid_t | 内核 | 系统内 | gettid()(glibc ≥ 2.30)或 syscall(SYS_gettid) |
| TGID | pid_t | 内核 | 系统内 | getpid(),等于线程组 leader 的 TID |
三者的关系值得逐条说清:
pthread_t不能用==比较,标准只给pthread_equal(t1, t2);也不能假设它是整数。glibc 的实现让它恰好是一个指针值,于是printf("%lu", (unsigned long)t)在 x86-64 上「能用」——这是巧合,不是契约。musl 同样是指针,某些商业 UNIX 是整数或结构体下标。- 内核完全不知道
pthread_t。要把两者对上,只能在线程入口里调gettid()并自己记录,或者读/proc/PID/task(目录名就是 TID)。 getpid()在所有线程里返回同一个值(TGID)。kill(getpid(), sig)是进程级信号,tgkill(tgid, tid, sig)才精确到线程;pthread_kill是后者的包装。
/* 把 pthread_t 与 TID 对上:入口处记录,退出前打印 */
static __thread pid_t my_tid; /* 每线程一份,不需要加锁 */
static void *worker(void *arg)
{
my_tid = gettid(); /* glibc < 2.30 用 syscall(SYS_gettid) */
printf("pthread_t=%p tid=%d\n", (void *)pthread_self(), (int)my_tid);
return NULL;
}
一个进程里 pthread_t 与 TID 都不是连续分配的:TID 由内核的 PID 分配器给,回收后会被复用;pthread_t 指向的 TCB 在 join 之后被 munmap,旧值再拿来用就是悬空句柄。不要缓存 pthread_t 跨线程生命周期使用,除非你同时持有「线程还活着」的同步保证。
TID 是内核概念,线程退出后立刻可能被新线程复用;而 pthread_t 要能表达「join 时取回这个线程的返回值」,必须指向一块在退出后仍然存在的用户态内存(TCB 里的 tid、retval、清理链)。用 TID 当句柄就无法区分「同一个线程」与「复用了同一个 TID 的新线程」,pthread_join 的语义会被破坏。代价是句柄只在进程内有意义,且必须由 NPTL 管理生命周期。
2.12.3 线程栈与属性
glibc 在 x86-64 上默认给每个线程 8 MB 栈(受 RLIMIT_STACK 影响),外加一个 4 KB 的 guard page。1000 个线程就是 8 GB 虚拟地址空间——虚拟内存不心疼,但每个栈的物理页在用到时才分配,所以真正的问题是「线程数 × 栈峰值」的 RSS,而不是虚拟大小。
栈溢出不会返回错误,只会 SIGSEGV:
栈溢出的典型症状是「递归没有终止条件」或「大数组放在栈上」:每层消耗几 KB,最终踩到 guard page,进程收到 SIGSEGV。dmesg 里会看到 segfault at ... error 6 in a.out,gdb 的 backtrace 可能有几万层。排查手段:
ulimit -s看主线程栈上限;pthread_attr_getstacksize或pthread_getattr_np看线程实际栈大小;gdb -p后thread apply all bt看哪个线程爆了;cc -fstack-usage编译,看每个函数的帧大小。
属性对象让创建线程前就能定下栈大小、分离状态、调度策略:
/* 创建 256 KB 栈、detached 的线程(节选) */
pthread_attr_t attr;
int rc = pthread_attr_init(&attr);
if (rc != 0) { /* 记录 rc 并返回 */ }
rc = pthread_attr_setstacksize(&attr, 256 * 1024);
if (rc != 0) { /* EINVAL:小于 PTHREAD_STACK_MIN */ }
rc = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
if (rc != 0) { /* ... */ }
pthread_t t;
rc = pthread_create(&t, &attr, worker, arg);
pthread_attr_destroy(&attr); /* 属性对象用完即销毁,与线程生命周期无关 */
pthread_attr_destroy 不会影响已经创建的线程——pthread_create 会把属性复制进线程。栈大小也不要压得太小:glibc 的 PTHREAD_STACK_MIN 在 x86-64 上是 16 KB,但任何一次 printf、DNS 解析、信号处理器都可能需要更多。经验值是「正常业务 ≥ 128 KB,深度递归/大局部数组 ≥ 1 MB」。
自定义栈:setstack 之后,guard page 归你管
pthread_attr_setstack 把一块现成内存交给线程当栈。它适合两类场景:把线程栈放在大页(mmap(MAP_HUGETLB))上减少 TLB miss;或者在实时/嵌入式环境里用静态数组,避免运行期 mmap 带来的不确定性。
代价是责任转移:glibc 不再为这块内存建立 guard page,也不再替你 munmap。
/* 1 MB 自定义栈 + 手工 guard page(片段,完整程序见 2.12.13) */
size_t size = 1 << 20;
void *area = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
if (area == MAP_FAILED) { /* 处理 */ }
/* 栈向低地址增长,所以 guard 放在 area 的低端 */
if (mprotect(area, 4096, PROT_NONE) == -1) { /* 处理 */ }
pthread_attr_t attr;
pthread_attr_init(&attr);
if (pthread_attr_setstack(&attr, area, size) != 0) { /* EINVAL:太小或地址未对齐 */ }
四个容易踩的点:
setstack与setstacksize互斥。先setstack再setstacksize时,后者会把「用户提供的地址」清掉,退回mmap模式,而setstacksize的返回值仍是 0。- 对齐。栈顶由 glibc 计算为
(char *)area + size再向下对齐;area自身至少要是页对齐,否则第一次mmap/mprotect就失败。ABI 要求 16 字节对齐,页对齐自然满足。 - guard 要自己留。
setstack之后pthread_attr_getguardsize仍返回默认值,但这个值不会被执行——glibc 只在它自己mmap时才应用 guard。自定义栈的溢出会直接写坏相邻内存,表现成「数据莫名被改」,而不是SIGSEGV,更难查。 - 回收。
pthread_join不会munmap用户提供的栈。线程 join 之后由调用者munmap(area, size);提前释放则是 use-after-free。
栈溢出:从 SIGSEGV 到一行 dmesg
栈溢出不会返回错误码,只会让进程收到 SIGSEGV。要判断「是不是栈溢出」,看三样东西:
dmesg里的segfault at 7ffd... error 6:error 6= 用户态写 + 页不存在;地址通常紧贴某个线程栈的 guard page。/proc/PID/maps里该线程栈区间下方紧挨着一个---p(PROT_NONE)页。- gdb 的
bt:栈帧几千层,或最深帧地址贴近栈底。
| 现象 | 原因 | 修复 |
|---|---|---|
| 递归几千层后崩溃 | 没有终止条件,或递归深度与输入成正比 | 改迭代 / 显式栈 / 限制深度 |
函数里一个 char buf[1<<20] | 大局部数组占栈 | 改 malloc、static 或 TLS |
循环里 alloca | 每次迭代都占栈、不释放 | 用固定缓冲或堆 |
| 128 KB 小栈线程偶发崩溃 | printf、DNS、信号处理器额外吃栈 | 至少 512 KB,含第三方库时 1 MB |
-O0 才崩、-O2 不崩 | 未优化时每帧更大 | -fstack-usage 看 .su 文件 |
| 协程/用户态调度切栈后崩 | 手工切栈破坏了 16 字节对齐或 red zone | 切换前保存/恢复 rsp,遵守 ABI |
-fstack-usage 会为每个翻译单元生成 .su 文件,逐函数给出 static(编译期可知的帧大小)与 dynamic(含 VLA/alloca)。把最大的几个函数挑出来,就是栈预算的主要开销:
cc -std=c17 -D_GNU_SOURCE -O2 -fstack-usage -c worker.c
sort -t$'\t' -k2 -n -r worker.su | head -5
# worker.c:31:13:process_request 1024 static
# worker.c:88:13:parse_frame 512 static
栈溢出的调试有一个反直觉之处:core dump 往往是空的或无法回溯。gdb 需要栈才能展开,而栈已经被踩坏。可行的办法是 ulimit -c unlimited 后在崩溃点看 info registers rsp,再和 /proc/PID/maps(崩溃前抓一份)对照。生产环境更实用的是给线程栈底预置一个魔数,线程定期自检:
/* 栈底放哨兵,溢出前就能发现(片段) */
#define CANARY 0xdeadbeefUL
static void check_stack_canary(const void *stack_low)
{
if (*(const unsigned long *)stack_low != CANARY)
fprintf(stderr, "stack overflow detected\n");
}
哨兵必须放在栈底之上一点点(guard page 之后的第一个可写位置),并且在线程运行期间周期性调用。它抓的是「还没踩到 guard」的早期溢出,比等 SIGSEGV 早得多。
2.12.4 取消与清理:让线程可被安全打断
pthread_cancel 不是「立刻杀死线程」。默认的取消类型是 deferred(延迟):目标线程运行到下一个取消点时才响应。取消点是一组会阻塞或被 POSIX 指定为取消点的函数,包括 read、write、nanosleep、pthread_cond_wait、sem_wait、accept、connect、select、poll、wait 等。
pthread_setcancelstate(PTHREAD_CANCEL_ENABLE | PTHREAD_CANCEL_DISABLE, &old):是否响应取消。新线程默认ENABLE。pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED | PTHREAD_CANCEL_ASYNCHRONOUS, &old):何时响应。默认DEFERRED(只在取消点)。pthread_testcancel():手动插入一个取消点。pthread_cleanup_push(handler, arg)/pthread_cleanup_pop(execute):压入/弹出清理处理器;线程被取消或调用pthread_exit()时,栈上所有处理器按后进先出执行。pop的execute非 0 时,正常路径也执行一次。
/* cancel_cleanup.c — 延迟取消 + 清理处理器释放锁 */
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static int counter;
static void unlock_cleanup(void *arg)
{
pthread_mutex_t *m = arg;
int rc = pthread_mutex_unlock(m);
if (rc != 0)
fprintf(stderr, "cleanup unlock: %s\n", strerror(rc));
printf("cleanup: mutex unlocked at counter=%d\n", counter);
}
static void *worker(void *arg)
{
(void)arg;
int rc = pthread_mutex_lock(&lock);
if (rc != 0) {
fprintf(stderr, "lock: %s\n", strerror(rc));
return NULL;
}
pthread_cleanup_push(unlock_cleanup, &lock);
for (;;) {
counter++;
struct timespec ts = { .tv_nsec = 100 * 1000000L };
/* nanosleep 是取消点;线程在这睡的时候收到 cancel */
if (nanosleep(&ts, NULL) == -1 && errno == EINTR)
continue;
}
pthread_cleanup_pop(1); /* 不可达;但 push/pop 必须成对 */
return NULL;
}
int main(void)
{
pthread_t t;
int rc = pthread_create(&t, NULL, worker, NULL);
if (rc != 0) {
fprintf(stderr, "create: %s\n", strerror(rc));
return EXIT_FAILURE;
}
struct timespec ts = { .tv_nsec = 350 * 1000000L };
nanosleep(&ts, NULL);
rc = pthread_cancel(t);
if (rc != 0) {
fprintf(stderr, "cancel: %s\n", strerror(rc));
return EXIT_FAILURE;
}
void *ret = NULL;
rc = pthread_join(t, &ret);
if (rc != 0) {
fprintf(stderr, "join: %s\n", strerror(rc));
return EXIT_FAILURE;
}
printf("joined: ret=%s counter=%d\n",
ret == PTHREAD_CANCELED ? "PTHREAD_CANCELED" : "other", counter);
return EXIT_SUCCESS;
}
pthread_cleanup_push/pop 是两个宏,它们展开成「开一个块」和「关一个块」。因此二者必须出现在同一个词法作用域里,且中间不能有 return/goto 跳出——否则编译失败,或者清理链被破坏。这是 Pthreads 设计上最常被吐槽的地方;C++ 里通常用 RAII 替代。
- 「
pthread_cancel立刻停掉线程」——默认要等到取消点。一个纯计算、没有取消点的循环永远不响应取消。 - 「异步取消更快更安全」——
PTHREAD_CANCEL_ASYNCHRONOUS可以在任意指令处中断,可能正持有锁、正在malloc、正在写半条记录;只有不调用任何非 async-signal-safe 函数、且不持有资源的线程才敢用。 - 「清理处理器在任何退出路径都执行」——规范只保证取消与
pthread_exit()执行;从入口函数return是否执行是实现相关的。需要确定性清理时,显式pthread_exit()或pthread_cleanup_pop(1)。 - 「取消点只在阻塞函数里」——
printf、fwrite等 stdio 函数在某些实现里也是取消点;不要假设「只要不睡就不会被取消」。
工程上更推荐「协作式退出」:用一个 volatile sig_atomic_t 或 C11 原子变量做停止标志,线程在循环里检查它,配合 poll/epoll_wait 的超时或 eventfd 唤醒。取消机制适合「线程卡在不可中断的库调用里」的兜底,不适合日常控制流。
取消点清单:哪些函数会响应取消
POSIX 把取消点定义成一组「允许阻塞」的函数,并允许实现在其他函数里也插入取消点。glibc 的实际行为可以归成五类:
| 类别 | 代表函数 | 备注 |
|---|---|---|
| 读写 fd | read/write/readv/writev/pread/pwrite | 在管道、终端、socket 上阻塞时才有实际意义 |
| 睡眠与定时 | nanosleep/clock_nanosleep/sleep/usleep | sleep 被取消时返回未睡完的秒数 |
| 同步原语 | pthread_cond_wait/pthread_cond_timedwait/sem_wait/sigwait/sigtimedwait | pthread_mutex_lock 不是取消点 |
| 网络 | accept/connect/recv/send/recvfrom/sendto | connect 的取消语义依实现 |
| stdio 与其他 | fread/fwrite/fgetc/fputc/getaddrinfo/system | printf 本身未列入,底层 write 可能是 |
读表要点:pthread_mutex_lock 不在清单里。一个线程卡在等锁上,pthread_cancel 不会让它醒来;它必须等到拿到锁、走到下一个取消点才会响应。这也意味着「用一个高优先级线程不断 cancel 卡死的 worker」并不能解决死锁——死锁的线程往往正卡在 mutex_lock 里。
反过来,一个纯计算循环(没有函数调用、没有 I/O)永远不响应延迟取消。要让它可取消,必须显式插入:
for (long i = 0; i < n; i++) {
do_work(i);
if ((i & 0x3ff) == 0)
pthread_testcancel(); /* 每 1024 次检查一次,开销可接受 */
}
pthread_testcancel() 在取消未挂起时是空操作,本质上只读一个线程局部标志,成本接近一次内存读。
线程在 pthread_cond_wait 里被取消时,glibc 先重新获取互斥锁,再执行清理处理器。所以清理函数里 pthread_mutex_unlock 是正确且必要的;反过来,如果你在清理函数里假设「锁已经释放」,会拿到 EPERM 或未定义行为。
sem_wait 被取消时不会消耗信号量计数(glibc 在 futex 唤醒后重新检查取消标志,取消路径上会补偿),但 sem_timedwait 的 ETIMEDOUT 与取消是两条完全不同的返回路径:前者 -1/ETIMEDOUT,后者线程直接终止、join 得到 PTHREAD_CANCELED。清理逻辑要能区分。
异步取消:为什么它几乎总是错的
PTHREAD_CANCEL_ASYNCHRONOUS 允许取消在任意机器指令处生效。后果是线程可能在最坏的位置消失:
- 正持有
malloc的 arena 锁 → 其他线程永久阻塞; - 正
write到文件中间 → 留下半条记录; - 刚
mmap完、还没来得及记录指针 → 映射泄漏且无法找回; - 正处在
pthread_cleanup_push与pop之间 → 清理链状态不一致,甚至清理函数在自己都没准备好的状态下被调用。
POSIX 规定:异步取消的线程只能调用 async-signal-safe 函数,且不能持有需要释放的资源。实践中只有两类线程可以设成异步:纯计算、不分配内存、不持锁的「可抛弃」线程;以及进程马上要 _exit 时的收尾线程。
更常见的需求其实是「短暂屏蔽取消」,而不是异步取消:
/* 让 malloc 与链表插入要么都完成,要么都不发生 */
int old_state;
if (pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &old_state) != 0) { /* ... */ }
struct node *n = malloc(sizeof *n);
if (n != NULL) {
n->next = head;
head = n;
}
if (pthread_setcancelstate(old_state, NULL) != 0) { /* ... */ }
/* 这里才是安全的取消点 */
pthread_testcancel();
这段代码的模式值得记住:取消状态是「临界区的开关」,不是「线程的属性」。只在必须原子的几行里关掉,出来立刻恢复,并在恢复后主动 testcancel,避免取消被无限期推迟。
2.12.5 每线程数据:errno、TLS 与 pthread_once
errno 是编译器层面的 TLS:__thread int 或 C11 的 _Thread_local。你自己也可以定义:
static __thread int tls_counter; /* GNU 扩展;每个线程一份 */
static _Thread_local char tls_buf[256]; /* C11 写法,语义相同 */
TLS 的生命周期与线程相同:线程创建时分配,线程退出时释放。它适合「每线程缓存」「每线程随机数种子」「每线程错误缓冲」。但 __thread 不能用于动态加载的库里的某些场景(dlopen 的 TLS 模型),也不适合需要析构函数的资源。
需要析构时用 pthread_key:
static pthread_key_t key;
static pthread_once_t once = PTHREAD_ONCE_INIT;
static void free_buf(void *p)
{
free(p); /* 线程退出时自动调用 */
}
static void make_key(void)
{
int rc = pthread_key_create(&key, free_buf);
if (rc != 0) {
fprintf(stderr, "pthread_key_create: %s\n", strerror(rc));
abort();
}
}
/* 任意线程、任意次数调用;初始化函数只执行一次 */
pthread_once(&once, make_key);
char *buf = pthread_getspecific(key);
if (buf == NULL) {
buf = malloc(4096);
if (buf == NULL) { /* ... */ }
pthread_setspecific(key, buf);
}
pthread_once 保证初始化函数在整个进程内只执行一次,即使多个线程同时第一次调用。它比自己写「检查标志 + 加锁 + 再检查」可靠,因为 POSIX 保证等待者能看到完整的初始化结果。pthread_once_t 必须静态初始化为 PTHREAD_ONCE_INIT,不能 memset 或赋值。TLS 析构函数按未指定的顺序调用,最多重复 PTHREAD_DESTRUCTOR_ITERATIONS(Linux 上 4)轮;析构函数里不要再依赖其他线程的 TLS。
TLS 的四种模型:为什么 dlopen 会报 “cannot allocate memory in static TLS block”
同一个 __thread 变量,编译器可以生成四种寻址方式,选择取决于「变量在哪个模块、模块何时加载」:
| 模型 | 适用 | 一次访问的代价 | 何时由编译器选中 |
|---|---|---|---|
| local-exec | 主程序内部 | 一条 %fs:offset | 可执行文件里的 TLS |
| initial-exec | 启动时加载的共享库 | 一条 %fs:offset(偏移加载时填) | -ftls-model=initial-exec 或 -fno-plt 场景 |
| local-dynamic | dlopen 的库,仅模块内使用 | 调用 __tls_get_addr | PIC 共享库默认 |
| global-dynamic | dlopen 的库,可能被外部引用 | 最慢,需查 DTV | 默认的最保守选择 |
initial-exec 最快,但它要求「模块在进程启动时就已经在内存里」,这样链接器才能把偏移量算进静态 TLS 块。dlopen 的库如果声明了 initial-exec 的 TLS,而静态 TLS 块剩余空间不够,加载就会失败:
dlopen: cannot allocate memory in static TLS block
glibc 为 dlopen 预留了一小块「静态 TLS 余量」(x86-64 上约 1.6 KB)。超过它,或者多个库叠加超过它,就会看到上面的错误。真实案例:某些 GPU/加速库把大结构体声明成 __thread,被 dlopen 时直接失败。
三种修法,按代价从低到高:
- 启动时
LD_PRELOAD该库,让它变成 initial-exec 的正常参与者(部署侧改动); - 让库作者把 TLS 变量改成
-ftls-model=global-dynamic重新编译(根治); - 用
pthread_key替代__thread(改代码,访问变慢)。
- 「
_Thread_local与__thread完全等价」——语义基本等价,但_Thread_local是 C11 关键字,可用于块作用域;__thread只能用于文件作用域或函数内静态变量,且 GNU 扩展允许它带初始化器调用(C11 不允许非常量初始化)。 - 「TLS 变量可以跨线程传递指针」——不可以。传出去的是「那个线程的那一份」的地址,线程退出后立即失效。要共享就放堆或共享内存。
- 「TLS 会自动调用析构函数」——
__thread的 POD 变量没有析构;C++ 的thread_local对象有,由__cxa_thread_atexit注册,在pthread_exit路径上执行。 - 「TLS 访问一定比全局变量慢」——local-exec/initial-exec 模型下就是一条带段前缀的
mov,与全局变量同量级;只有 global-dynamic 才需要函数调用。
pthread_key 与 TLS:什么时候必须用前者
| 维度 | __thread / _Thread_local | pthread_key_t |
|---|---|---|
| 声明位置 | 编译期,变量定义处 | 运行期,任意位置 |
| 数量上限 | 受静态 TLS 块大小限制 | PTHREAD_KEYS_MAX(Linux 上 1024) |
| 析构 | 无(C++ 由运行时注册) | 可注册析构函数,线程退出时调用 |
dlopen 友好 | initial-exec 模型会受限 | 天然友好 |
| 访问开销 | 一条指令(前两种模型) | 一次函数调用 + 数组索引 |
| 适合 | 每线程缓存、随机数种子、错误缓冲 | 库代码里「每线程一份资源且需要释放」 |
pthread_key 的析构语义有三个细节必须知道:
- 线程退出时,对每个非 NULL 的 key,先把它置为 NULL,再调用析构函数。所以析构函数里再
pthread_getspecific拿到的是 NULL,不会重复释放。 - 如果析构函数里又
pthread_setspecific设置了新值,线程退出流程会再来一轮,最多PTHREAD_DESTRUCTOR_ITERATIONS轮(glibc 上是 4)。写一个「析构里重新申请、再注册」的循环,会白跑四轮然后静默放弃。 - 析构函数的调用顺序未指定,且只在线程正常退出或被取消时执行;进程
exit()时其他线程直接被终止,它们的析构不保证执行。
/* 每线程错误缓冲:key + 一次性创建 */
static pthread_key_t errbuf_key;
static pthread_once_t errbuf_once = PTHREAD_ONCE_INIT;
static void free_errbuf(void *p) { free(p); }
static void make_key(void)
{
int rc = pthread_key_create(&errbuf_key, free_errbuf);
if (rc != 0) { fprintf(stderr, "key_create: %s\n", strerror(rc)); abort(); }
}
char *get_errbuf(void)
{
pthread_once(&errbuf_once, make_key);
char *buf = pthread_getspecific(errbuf_key);
if (buf == NULL) {
buf = malloc(256);
if (buf == NULL) return NULL;
if (pthread_setspecific(errbuf_key, buf) != 0) { free(buf); return NULL; }
}
return buf;
}
这段代码里 pthread_once 不是可选项:两个线程同时第一次调用 pthread_getspecific,没有它就会创建两个 key,其中一个永久泄漏(PTHREAD_KEYS_MAX 是进程级资源)。
2.12.6 线程、信号与 fork
信号送到哪个线程
进程级信号(kill(pid, sig)、终端产生的 SIGINT)会送到「任意一个没有阻塞该信号的线程」。这意味着:
- 你不能假设主线程会收到
SIGINT; - 如果所有线程都阻塞了它,信号会一直 pending,直到某个线程解除阻塞或调用
sigwait; pthread_kill(tid, sig)可以指定线程,但 tid 必须是同进程的线程。
工程上的标准做法是:所有线程都阻塞业务信号,专门起一个线程 sigwait。
/* 信号线程骨架(节选) */
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGTERM);
/* 主线程在创建任何线程之前阻塞;新线程继承这个掩码 */
if (pthread_sigmask(SIG_BLOCK, &set, NULL) != 0) { /* ... */ }
/* 信号线程 */
for (;;) {
int sig = 0;
int rc = sigwait(&set, &sig);
if (rc != 0) { errno = rc; perror("sigwait"); break; }
if (sig == SIGINT || sig == SIGTERM)
break; /* 通知事件循环退出 */
}
pthread_sigmask 与 sigprocmask 的区别:POSIX 规定多线程程序中 sigprocmask 的行为未指定,必须用 pthread_sigmask。两者参数相同,但后者只改当前线程的掩码,且返回错误码而非设置 errno。
多线程程序里的 fork
fork() 只复制调用线程,其他线程在子进程里不存在。但子进程继承的是「其他线程可能正持有锁」的地址空间快照:
- 另一个线程
malloc到一半,malloc的 arena 锁是锁着的;子进程再malloc会死锁; - 另一个线程持有
stdio锁;子进程printf会死锁; - 另一个线程正在写日志文件;子进程看到的是半条记录。
POSIX 只保证子进程可以安全调用 async-signal-safe 函数,直到 execve。所以多线程程序里 fork 之后的正确姿势是「立刻 exec」,中间只做 dup2、close、_exit 这类事。如果必须在 fork 后运行 C 代码,用 pthread_atfork 注册 prepare/parent/child 三个钩子,在 prepare 里获取所有相关锁,在 parent/child 里释放。
创建线程要 mmap 栈、初始化 TCB、进入调度器,成本约几十微秒;销毁同样要 munmap。每秒一万个短任务就是一万次创建/销毁,CPU 大量花在管理线程而不是干活。线程池把「线程」变成长期资源,任务只是队列里的一个指针。代价是:任务之间可能相互影响(一个任务阻塞会占住 worker),需要限制队列长度、设置任务超时,并处理「池满」时的拒绝策略。
线程与进程控制:哪些操作会影响整个进程
多线程程序里,很多「进程级」操作比看起来更危险。把作用域列清,能避免「为什么另一个线程也退出了」这类问题:
| 操作 | 作用范围 | 注意 |
|---|---|---|
exit() / main 返回 | 整个进程 | 其他线程被立刻终止;atexit 处理器在调用 exit 的线程里执行 |
_exit() | 整个进程 | 不执行 atexit、不 flush stdio,多线程下最安全 |
execve() | 整个进程 | 其他线程全部消失;带 FD_CLOEXEC 的 fd 被关闭 |
chdir()、umask()、chroot() | 整个进程 | 与 fd 表一样是共享资源 |
setrlimit() | 整个进程 | 影响后续所有线程能申请的资源 |
fork() | 调用线程 | 子进程只有调用线程的副本,其余线程「消失」 |
signal()、sigaction() | 整个进程 | 处置共享,掩码私有 |
prctl(PR_SET_NAME) | 调用线程 | 只改当前线程的 comm,等价于单线程版 pthread_setname_np |
setpriority(PRIO_PROCESS, 0, n) | 整个进程 | 传 TID 才只改一个线程 |
unshare()、setns() | 调用线程 | 命名空间归属按线程记录,CLONE_THREAD 时共享 |
两个最容易出事的组合:
exit()在库代码里被调用。库函数出错时调exit(),会把宿主进程的所有线程一起杀掉。库代码只应返回错误码。exec前没有清理。其他线程可能正持有文件锁、正写到一半;exec不会等它们。多线程程序要exec时,先停掉所有工作线程,再在唯一存活的线程里exec。
exit() 由哪个线程调用,atexit 注册的函数就在哪个线程执行——它们看到的是该线程的 TLS 与信号掩码,不是「主线程」的。所以「在 main 里注册的清理函数」并不能假设自己运行在主线程上下文里。要保证清理在特定线程完成,用显式的关闭流程(停队列 → join worker → 释放资源),不要依赖 atexit。
2.12.7 线程池:固定 worker + 有界队列
下面这个线程池是本章的落地代码:4 个固定 worker、容量 16 的任务队列、优雅关闭(关闭时把已入队的任务跑完)。它已经用到了 2.13 的互斥锁与条件变量,这里先看结构,下一章再解释每个 while 的必要性。
/* thread_pool.c — 固定 4 worker + 有界队列 + 优雅关闭 */
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define POOL_SIZE 4
#define QUEUE_CAP 16
struct task {
void (*fn)(void *);
void *arg;
struct task *next;
};
struct pool {
pthread_mutex_t lock;
pthread_cond_t not_empty;
pthread_cond_t not_full;
struct task *head;
struct task *tail;
size_t count;
int shutdown;
pthread_t workers[POOL_SIZE];
};
static void *worker(void *arg)
{
struct pool *p = arg;
for (;;) {
int rc = pthread_mutex_lock(&p->lock);
if (rc != 0) {
fprintf(stderr, "worker lock: %s\n", strerror(rc));
return NULL;
}
while (p->count == 0 && !p->shutdown) {
rc = pthread_cond_wait(&p->not_empty, &p->lock);
if (rc != 0) {
fprintf(stderr, "cond_wait: %s\n", strerror(rc));
pthread_mutex_unlock(&p->lock);
return NULL;
}
}
if (p->shutdown && p->count == 0) {
pthread_mutex_unlock(&p->lock);
break; /* 队列已空,退出 */
}
struct task *t = p->head;
p->head = t->next;
if (p->head == NULL)
p->tail = NULL;
p->count--;
pthread_cond_signal(&p->not_full); /* 唤醒被队列满阻塞的提交者 */
pthread_mutex_unlock(&p->lock);
t->fn(t->arg);
free(t);
}
return NULL;
}
static int pool_init(struct pool *p)
{
memset(p, 0, sizeof *p);
int rc = pthread_mutex_init(&p->lock, NULL);
if (rc != 0) return rc;
rc = pthread_cond_init(&p->not_empty, NULL);
if (rc != 0) { pthread_mutex_destroy(&p->lock); return rc; }
rc = pthread_cond_init(&p->not_full, NULL);
if (rc != 0) {
pthread_cond_destroy(&p->not_empty);
pthread_mutex_destroy(&p->lock);
return rc;
}
for (int i = 0; i < POOL_SIZE; i++) {
rc = pthread_create(&p->workers[i], NULL, worker, p);
if (rc != 0) {
/* 已创建的 worker 需要退出:置 shutdown 并唤醒 */
pthread_mutex_lock(&p->lock);
p->shutdown = 1;
pthread_cond_broadcast(&p->not_empty);
pthread_mutex_unlock(&p->lock);
for (int j = 0; j < i; j++)
pthread_join(p->workers[j], NULL);
pthread_cond_destroy(&p->not_full);
pthread_cond_destroy(&p->not_empty);
pthread_mutex_destroy(&p->lock);
return rc;
}
}
return 0;
}
static int pool_submit(struct pool *p, void (*fn)(void *), void *arg)
{
struct task *t = malloc(sizeof *t);
if (t == NULL) return ENOMEM;
t->fn = fn;
t->arg = arg;
t->next = NULL;
int rc = pthread_mutex_lock(&p->lock);
if (rc != 0) { free(t); return rc; }
while (p->count == QUEUE_CAP && !p->shutdown) {
rc = pthread_cond_wait(&p->not_full, &p->lock);
if (rc != 0) {
pthread_mutex_unlock(&p->lock);
free(t);
return rc;
}
}
if (p->shutdown) {
pthread_mutex_unlock(&p->lock);
free(t);
return ECANCELED;
}
if (p->tail != NULL)
p->tail->next = t;
else
p->head = t;
p->tail = t;
p->count++;
pthread_cond_signal(&p->not_empty);
pthread_mutex_unlock(&p->lock);
return 0;
}
static void pool_shutdown(struct pool *p)
{
int rc = pthread_mutex_lock(&p->lock);
if (rc != 0) return;
p->shutdown = 1;
pthread_cond_broadcast(&p->not_empty);
pthread_cond_broadcast(&p->not_full);
pthread_mutex_unlock(&p->lock);
for (int i = 0; i < POOL_SIZE; i++) {
rc = pthread_join(p->workers[i], NULL);
if (rc != 0)
fprintf(stderr, "join worker %d: %s\n", i, strerror(rc));
}
for (struct task *t = p->head, *next; t != NULL; t = next) {
next = t->next;
free(t); /* 理论上为空;保险起见清理 */
}
pthread_cond_destroy(&p->not_full);
pthread_cond_destroy(&p->not_empty);
pthread_mutex_destroy(&p->lock);
}
static void work(void *arg)
{
long id = (long)arg;
struct timespec ts = { .tv_nsec = 10 * 1000000L };
nanosleep(&ts, NULL); /* 模拟 10ms 工作 */
printf("task %2ld done by %lu\n", id, (unsigned long)pthread_self());
}
int main(void)
{
struct pool p;
int rc = pool_init(&p);
if (rc != 0) {
fprintf(stderr, "pool_init: %s\n", strerror(rc));
return EXIT_FAILURE;
}
for (long i = 0; i < 24; i++) {
rc = pool_submit(&p, work, (void *)i);
if (rc != 0) {
fprintf(stderr, "submit %ld: %s\n", i, strerror(rc));
break;
}
}
pool_shutdown(&p);
return EXIT_SUCCESS;
}
关键设计点:
while (p->count == 0 && !p->shutdown):条件变量必须用while而不是if。醒来不代表条件成立——可能是虚假唤醒,也可能是别的 worker 先抢走了任务。- 提交者在队列满时也
while等待:not_full由 worker 在取走任务后signal。这里用signal而不是broadcast,因为一次只腾出一个空位。 shutdown与队列为空同时判断:关闭时先把已入队任务跑完(shutdown && count == 0才退出),这是「优雅关闭」;若要「立刻取消」,在 worker 里检查shutdown后直接 break,并丢弃队列。pool_shutdown先广播再 join:worker 可能卡在cond_wait,不广播就永远等不到shutdown变化。pthread_cond_signal在锁内调用:POSIX 允许在锁内或锁外,但在锁内可以避免「唤醒后立刻又睡」的窗口,代码也更容易推理。
编译与运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g -pthread thread_pool.c -o thread_pool
./thread_pool | sort -k2 -n | head
# 24 个任务由 4 个 worker 分担;输出顺序不稳定,但每个任务只出现一次
2.12.8 观测与排查
线程程序出问题时,先回答三个问题:有几个线程、每个在干什么、谁持有锁。
# 1. 看线程数与名字
ps -eLf | grep myprog
ls /proc/$PID/task
cat /proc/$PID/task/*/comm
# 2. 看每个线程的内核栈(需要 root)
cat /proc/$PID/task/*/stack
# 3. 看谁在等锁(futex)
strace -f -e trace=futex -p $PID
# 4. gdb 看用户态栈
gdb -p $PID -ex 'thread apply all bt' -ex quit
给线程起名能极大降低排查成本:
/* Linux 专有:第二个参数是线程名,最长 15 字节 */
pthread_setname_np(pthread_self(), "worker-io");
名字会出现在 /proc/PID/task/TID/comm、ps -L、gdb 的 info threads 里。没有名字时,所有线程都叫进程名,只能靠 TID 猜。
栈溢出的排查前面讲过;数据竞争用 TSan:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -g -O1 -fsanitize=thread -pthread racy.c -o racy
./racy
# 输出 "WARNING: ThreadSanitizer: data race",并给出两个线程的栈
TSan 需要编译器和运行时支持,会把程序变慢 5–15 倍,不能用于生产,但能在测试里抓住大部分竞争。它抓不到「逻辑上依赖顺序」的 bug(例如 A 线程必须先写配置、B 线程才能读),那需要设计层面的同步。
- 线程数是否符合预期:
/proc/PID/status的Threads,是否有泄漏(创建了没 join/detach)。 - 是否有线程卡死:
gdb thread apply all bt,看是否都在futex_wait(等锁/条件变量)。 - 谁持有锁:
pstack看不到锁拥有者,用gdb的pthread_mutex_t或helgrind的锁序报告。 - 是否栈溢出:
dmesg | grep segfault,看 fault 地址是否紧邻栈 guard page。 - 是否有数据竞争:TSan 跑单元测试;老代码用
valgrind --tool=helgrind。 - 是否 fork 死锁:多线程程序
fork后子进程卡在malloc/printf,检查pthread_atfork。
2.12.9 Pthreads API 与错误码总表
这一节回答「手边这个 pthread_* 到底返回什么、该检查哪个值」。Pthreads 的错误约定与系统调用相反,把这张表放在手边,比每次翻 man 快。
/* 生命周期 */
int pthread_create(pthread_t *t, const pthread_attr_t *attr,
void *(*fn)(void *), void *arg);
int pthread_join(pthread_t t, void **retval);
int pthread_detach(pthread_t t);
void pthread_exit(void *retval) __attribute__((noreturn));
pthread_t pthread_self(void);
int pthread_equal(pthread_t a, pthread_t b);
/* 属性 */
int pthread_attr_init(pthread_attr_t *a);
int pthread_attr_destroy(pthread_attr_t *a);
int pthread_attr_setstacksize(pthread_attr_t *a, size_t n);
int pthread_attr_setstack(pthread_attr_t *a, void *addr, size_t n);
int pthread_attr_setguardsize(pthread_attr_t *a, size_t n);
int pthread_attr_setdetachstate(pthread_attr_t *a, int state);
int pthread_attr_setschedpolicy(pthread_attr_t *a, int policy);
int pthread_attr_setschedparam(pthread_attr_t *a, const struct sched_param *p);
int pthread_attr_setinheritsched(pthread_attr_t *a, int inherit);
/* 取消 */
int pthread_cancel(pthread_t t);
int pthread_setcancelstate(int state, int *old);
int pthread_setcanceltype(int type, int *old);
void pthread_testcancel(void);
/* 一次性初始化与 TLS */
int pthread_once(pthread_once_t *once, void (*init)(void));
int pthread_key_create(pthread_key_t *k, void (*dtor)(void *));
int pthread_key_delete(pthread_key_t k);
int pthread_setspecific(pthread_key_t k, const void *v);
void *pthread_getspecific(pthread_key_t k);
/* 信号 */
int pthread_kill(pthread_t t, int sig);
int pthread_sigmask(int how, const sigset_t *set, sigset_t *old);
| 函数 | 成功返回 | 失败返回 | 真实会出现的错误码 | 备注 |
|---|---|---|---|---|
pthread_create | 0 | 错误码 | EAGAIN、EINVAL、EPERM | EAGAIN:线程数/内存不足;EPERM:attr 里的调度策略无权设置 |
pthread_join | 0 | 错误码 | ESRCH、EINVAL、EDEADLK、EINTR? | EINVAL:线程不可 join(已 detach);EDEADLK:join 自己或形成环 |
pthread_detach | 0 | 错误码 | EINVAL、ESRCH | 对已 detach 的线程再 detach 返回 EINVAL |
pthread_cancel | 0 | 错误码 | ESRCH | 只发请求,不同步等待目标退出 |
pthread_setcancelstate | 0 | 错误码 | EINVAL | state 只能是 ENABLE/DISABLE |
pthread_setcanceltype | 0 | 错误码 | EINVAL | 只能在 DISABLE 状态下改类型(否则行为未定义) |
pthread_once | 0 | 0 | 无 | glibc 永远返回 0;初始化函数不能抛异常/长跳转 |
pthread_key_create | 0 | 错误码 | EAGAIN、ENOMEM | EAGAIN:超过 PTHREAD_KEYS_MAX(1024) |
pthread_setspecific | 0 | 错误码 | EINVAL、ENOMEM | key 无效时 EINVAL |
pthread_kill | 0 | 错误码 | ESRCH、EINVAL | 目标线程已退出 → ESRCH |
pthread_sigmask | 0 | 错误码 | EINVAL | how 非法;不改 errno |
pthread_mutex_lock | 0 | 错误码 | EINVAL、EDEADLK、EPERM、EOWNERDEAD | EOWNERDEAD 只在 robust 锁出现 |
pthread_mutex_trylock | 0 | 错误码 | EBUSY、EINVAL、EPERM | EBUSY 表示「已被占用」,不是错误 |
pthread_mutex_timedlock | 0 | 错误码 | ETIMEDOUT、EINVAL、EDEADLK | 超时用 CLOCK_REALTIME 绝对时间 |
pthread_cond_wait | 0 | 错误码 | EINVAL、EPERM | 未持锁时行为未定义 |
pthread_cond_timedwait | 0 | 错误码 | ETIMEDOUT、EINVAL、EPERM | 超时返回时锁已重新持有 |
pthread_rwlock_rdlock | 0 | 错误码 | EINVAL、EDEADLK、EAGAIN | 读锁嵌套超过实现上限 → EAGAIN |
pthread_barrier_wait | 0 或 PTHREAD_BARRIER_SERIAL_THREAD | 错误码 | EINVAL | 一个线程拿到 SERIAL 值,其余为 0 |
pthread_spin_lock | 0 | 错误码 | EINVAL、EDEADLK | 忙等,不睡眠 |
pthread_setname_np | 0 | 错误码 | ERANGE、ESRCH | 名字最长 15 字节 + NUL |
这张表想说明一件事:Pthreads 函数不设置 errno。正确写法是把返回值直接送进 strerror:
int rc = pthread_create(&t, NULL, worker, arg);
if (rc != 0) {
/* 不要用 perror:errno 可能还是上一次系统调用的值 */
fprintf(stderr, "pthread_create: %s\n", strerror(rc));
return -1;
}
- 「
pthread_*失败返回 -1」——返回的是正的错误码(EAGAIN= 11)。写if (rc == -1)永远不成立,错误被静默忽略。 - 「
pthread_*也会设置errno」——标准没有这个要求,glibc 也不保证。混用errno会读到无关的旧值。 - 「
pthread_mutex_trylock返回EBUSY说明出错了」——EBUSY是正常结果,表示锁被别人持有,调用者应当走另一条路径。 - 「
pthread_join会被信号打断返回EINTR」——标准没有把EINTR列进pthread_join的返回值;glibc 的实现里它不可中断。不要照搬系统调用的重试模板。
2.12.10 NPTL 与 futex:一次 pthread_create 的内核路径
这一节回答「线程创建、退出、join 在内核里到底发生了什么」。看懂这条路径,就能解释三件事:为什么线程栈是 mmap 出来的、为什么 join 不需要轮询、为什么 robust mutex 能在 owner 死掉后恢复。
pthread_create
└─ __pthread_create_2_1 glibc nptl/pthread_create.c
├─ allocate_stack() mmap(栈 + TLS + TCB),guard 页设 PROT_NONE
├─ clone3() / clone() glibc ≥ 2.34 优先 clone3,ENOSYS 回落 clone
│ └─ kernel_clone() kernel/fork.c
│ └─ copy_process()
│ ├─ dup_task_struct() task_struct + 内核栈(THREAD_SIZE)
│ ├─ copy_mm() CLONE_VM → mmget(),不复制页表
│ ├─ copy_files() / copy_fs() / copy_sighand() / copy_signal()
│ ├─ alloc_pid() 新 TID;tgid 指向线程组 leader
│ └─ wake_up_new_task() 放入运行队列
└─ 返回 pthread_t(TCB 地址)
pthread_exit / 从入口函数 return
└─ start_thread → __do_cancel / exit
└─ do_exit() kernel/exit.c
├─ exit_mm() → mm_release() clear_child_tid 清零 + futex_wake
├─ exit_robust_list() 标记 FUTEX_OWNER_DIED,唤醒等待者
└─ exit_notify() 线程不产生僵尸,只有 leader 通知父进程
clone 的 flags 决定了「共享什么」,这张位表就是线程与进程的分界线:
| flag | 共享的资源 | 不设置时 |
|---|---|---|
CLONE_VM | 地址空间(mm_struct) | 复制页表,COW |
CLONE_FS | 根目录、cwd、umask | 各自一份 |
CLONE_FILES | fd 表 | 各自一份,fork 后互不影响 |
CLONE_SIGHAND | 信号处置表 | 各自一份(隐含要求 CLONE_VM) |
CLONE_THREAD | 线程组(同一个 TGID) | 新进程组,独立 TGID |
CLONE_SYSVSEM | System V 信号量的 undo 列表 | 各自一份 |
CLONE_SETTLS | 设置新线程的 %fs 基址 | 不设置 |
CLONE_CHILD_CLEARTID | 退出时清零用户态 tid 并 futex 唤醒 | 需要轮询 |
NPTL 的线程就是 CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID 的组合。少了 CLONE_THREAD 就退化成进程(fork),少了 CLONE_VM 就退化成「共享 fd 但不共享内存」的怪东西。
join 为什么不用轮询
pthread_join 的核心是一行 futex 等待,等的是 TCB 里的 tid 字段:
/* pthread_join 的等待逻辑,简化示意(非逐字引用) */
while ((tid = atomic_load(&pd->tid)) != 0)
futex_wait(&pd->tid, tid); /* 值变了才返回 */
线程退出时,内核在 mm_release() 里做两件事:
/* kernel/fork.c, mm_release(),有删节 */
if (tsk->clear_child_tid) {
if (atomic_read(&mm->mm_users) > 1) {
u32 __user *tidptr = tsk->clear_child_tid;
tsk->clear_child_tid = NULL;
if (!put_user(0, tidptr))
futex_wake(tidptr, 1, 1);
}
}
put_user(0, tidptr) 把 TCB 的 tid 清零,futex_wake 唤醒正在 pthread_join 的线程。所以 join 是「睡到值变」,没有轮询、没有定时器,也不会因为信号而错过唤醒。
robust mutex 的内核支持
每个线程在创建时会通过 set_robust_list(2) 向内核登记一个链表头。线程退出时 exit_robust_list() 遍历这个链表,把每个 robust mutex 的 owner 字段标成 FUTEX_OWNER_DIED,然后唤醒一个等待者。等待者从 pthread_mutex_lock 得到 EOWNERDEAD——这就是 2.15 里跨进程崩溃恢复的基础。
2.12.11 程序一:无同步计数器的竞态与三种修法
这段程序要验证一件事:同一份工作量,换同步方式,结果与耗时都会变。它把「无同步」「互斥锁」「C11 原子」放在一个可执行文件里,用参数切换,方便你用同一个 perf/TSan 流程对比。
/* race_counter.c — 无同步 / 互斥锁 / C11 原子,三种计数器对比 */
#define _GNU_SOURCE
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define THREADS 4
#define PER_THREAD 500000L
/* volatile 只阻止编译器把它缓存进寄存器,不提供原子性,也不建立屏障。
这里用它是为了让竞态在 -O2 下仍然可见,而不是为了“修复”竞态。 */
static volatile long counter_plain;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static long counter_locked;
static atomic_long counter_atomic;
struct job {
int mode; /* 0=无同步 1=互斥锁 2=原子 */
long iters;
};
static void die_pthread(const char *what, int rc)
{
fprintf(stderr, "%s: %s\n", what, strerror(rc));
exit(EXIT_FAILURE);
}
static void *worker(void *arg)
{
const struct job *j = arg;
for (long i = 0; i < j->iters; i++) {
switch (j->mode) {
case 0:
counter_plain++; /* load / add / store,三步 */
break;
case 1: {
int rc = pthread_mutex_lock(&lock);
if (rc != 0)
die_pthread("mutex_lock", rc);
counter_locked++;
rc = pthread_mutex_unlock(&lock);
if (rc != 0)
die_pthread("mutex_unlock", rc);
break;
}
default:
atomic_fetch_add_explicit(&counter_atomic, 1, memory_order_relaxed);
break;
}
}
return NULL;
}
static long now_ms(void)
{
struct timespec ts;
if (clock_gettime(CLOCK_MONOTONIC, &ts) == -1) {
perror("clock_gettime");
exit(EXIT_FAILURE);
}
return ts.tv_sec * 1000L + ts.tv_nsec / 1000000L;
}
/* 跑一轮,返回观察到的计数;elapsed 写入毫秒 */
static long run_round(int mode, long *elapsed)
{
pthread_t tids[THREADS];
struct job j = { .mode = mode, .iters = PER_THREAD };
long start = now_ms();
counter_plain = 0;
counter_locked = 0;
atomic_store_explicit(&counter_atomic, 0, memory_order_relaxed);
for (int i = 0; i < THREADS; i++) {
int rc = pthread_create(&tids[i], NULL, worker, &j);
if (rc != 0)
die_pthread("pthread_create", rc);
}
for (int i = 0; i < THREADS; i++) {
int rc = pthread_join(tids[i], NULL);
if (rc != 0)
die_pthread("pthread_join", rc);
}
*elapsed = now_ms() - start;
switch (mode) {
case 0: return counter_plain;
case 1: return counter_locked;
default: return atomic_load_explicit(&counter_atomic, memory_order_relaxed);
}
}
int main(int argc, char **argv)
{
int mode = 0;
if (argc == 2) {
if (strcmp(argv[1], "plain") == 0) mode = 0;
else if (strcmp(argv[1], "mutex") == 0) mode = 1;
else if (strcmp(argv[1], "atomic") == 0) mode = 2;
else {
fprintf(stderr, "usage: %s plain|mutex|atomic\n", argv[0]);
return EXIT_FAILURE;
}
}
const long expect = THREADS * PER_THREAD;
long elapsed = 0;
long got = run_round(mode, &elapsed);
const char *name = mode == 0 ? "plain" : mode == 1 ? "mutex" : "atomic";
printf("%-6s got=%ld expect=%ld lost=%ld elapsed=%ldms\n",
name, got, expect, expect - got, elapsed);
/* 无同步模式丢更新是预期行为;另外两种丢一个都说明程序有 bug */
if (mode != 0 && got != expect) {
fprintf(stderr, "BUG: synchronized counter lost updates\n");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
编译与三次运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g -pthread race_counter.c -o race_counter
./race_counter plain
# plain got=1387421 expect=2000000 lost=612579 elapsed=12ms
./race_counter mutex
# mutex got=2000000 expect=2000000 lost=0 elapsed=158ms
./race_counter atomic
# atomic got=2000000 expect=2000000 lost=0 elapsed=14ms
plain 的 lost 每次运行都不同,甚至偶尔为 0——竞态不是稳定复现的 bug。mutex 正确但慢一个数量级:每次 ++ 两次原子操作,还可能进 futex 睡眠。atomic 在低竞争下接近 plain,因为它只是一条带 lock 前缀的 inc。
修法不止一种。如果这个计数器只是统计量,还有一个更快的方案:每线程一个局部计数,最后汇总。
/* 分片计数:没有共享写,就没有竞争 */
static _Thread_local long shard;
static void *worker_shard(void *arg)
{
const struct job *j = arg;
shard = 0;
for (long i = 0; i < j->iters; i++)
shard++;
return (void *)(intptr_t)shard; /* 用返回值带回,避免再引入共享 */
}
join 的返回值汇总即可。代价是「读到的是过期总数」——监控场景可接受,需要精确值时不行。
volatile long counter_plain++ 编译成三条指令:mov(从内存读)、add、mov(写回内存)。volatile 的约束是「每条 mov 都必须真的访问内存,不能缓存进寄存器」,它并不把三条指令合成一条。两个线程仍可能同时读到 100,各自写回 101。
要真正原子,只有两条路:让硬件提供「读-改-写」不可分割的指令(lock inc、ldxr/stxr、amoadd),或者用锁把这三条指令包起来。C11 的 atomic_fetch_add 走第一条路,pthread_mutex 走第二条。
2.12.12 程序二:ABBA 死锁、trylock 与锁序
这段程序要验证三件事:死锁可以稳定复现、可以用超时检测、可以用两种方式修复。它用同一个二进制、三个模式,方便你反复对照。
/* abba_deadlock.c — 复现 ABBA 死锁,并给出两种修复 */
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#define MODE_DEADLOCK 0
#define MODE_ORDER 1
#define MODE_TRYLOCK 2
static pthread_mutex_t lock_a = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t lock_b = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t done_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t done_cond = PTHREAD_COND_INITIALIZER;
static int done_count;
static int mode = MODE_DEADLOCK;
static void die_pthread(const char *what, int rc)
{
fprintf(stderr, "%s: %s\n", what, strerror(rc));
exit(EXIT_FAILURE);
}
static void sleep_us(long usec)
{
struct timespec ts = { .tv_sec = usec / 1000000,
.tv_nsec = (usec % 1000000) * 1000L };
while (nanosleep(&ts, &ts) == -1) {
if (errno != EINTR) {
perror("nanosleep");
exit(EXIT_FAILURE);
}
}
}
static void mark_done(void)
{
int rc = pthread_mutex_lock(&done_lock);
if (rc != 0)
die_pthread("done lock", rc);
done_count++;
rc = pthread_cond_signal(&done_cond);
if (rc != 0)
die_pthread("done signal", rc);
rc = pthread_mutex_unlock(&done_lock);
if (rc != 0)
die_pthread("done unlock", rc);
}
/* 拿两把锁:根据模式选择“制造环”“按地址排序”“trylock 退避” */
static int lock_pair(pthread_mutex_t *first, pthread_mutex_t *second)
{
if (mode == MODE_TRYLOCK) {
for (int attempt = 0; attempt < 100000; attempt++) {
int rc = pthread_mutex_lock(first);
if (rc != 0)
return rc;
rc = pthread_mutex_trylock(second);
if (rc == 0)
return 0;
if (rc != EBUSY) {
pthread_mutex_unlock(first);
return rc;
}
rc = pthread_mutex_unlock(first);
if (rc != 0)
return rc;
/* 随机退避,避免两个线程同步重试形成活锁 */
struct timespec ts = { .tv_nsec = (attempt % 7 + 1) * 100000L };
nanosleep(&ts, NULL);
}
return EBUSY;
}
int rc = pthread_mutex_lock(first);
if (rc != 0)
return rc;
sleep_us(200); /* 放大“持有一把、再要另一把”的窗口 */
rc = pthread_mutex_lock(second);
if (rc != 0) {
pthread_mutex_unlock(first);
return rc;
}
return 0;
}
struct worker_arg {
pthread_mutex_t *first;
pthread_mutex_t *second;
const char *name;
};
static void *worker(void *arg)
{
struct worker_arg *w = arg;
int rc = lock_pair(w->first, w->second);
if (rc != 0) {
fprintf(stderr, "%s: lock_pair: %s\n", w->name, strerror(rc));
return NULL;
}
printf("%s: got both locks\n", w->name);
rc = pthread_mutex_unlock(w->second);
if (rc != 0)
die_pthread("unlock second", rc);
rc = pthread_mutex_unlock(w->first);
if (rc != 0)
die_pthread("unlock first", rc);
mark_done();
return NULL;
}
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s deadlock|order|trylock\n", argv[0]);
return EXIT_FAILURE;
}
if (strcmp(argv[1], "deadlock") == 0) mode = MODE_DEADLOCK;
else if (strcmp(argv[1], "order") == 0) mode = MODE_ORDER;
else if (strcmp(argv[1], "trylock") == 0) mode = MODE_TRYLOCK;
else {
fprintf(stderr, "unknown mode: %s\n", argv[1]);
return EXIT_FAILURE;
}
/* 锁序模式:按对象地址排序,所有线程得到同一个顺序 */
pthread_mutex_t *lo = &lock_a;
pthread_mutex_t *hi = &lock_b;
if (mode == MODE_ORDER && (uintptr_t)lo > (uintptr_t)hi) {
pthread_mutex_t *tmp = lo;
lo = hi;
hi = tmp;
}
struct worker_arg w1, w2;
if (mode == MODE_DEADLOCK) {
w1 = (struct worker_arg){ &lock_a, &lock_b, "t1(a->b)" };
w2 = (struct worker_arg){ &lock_b, &lock_a, "t2(b->a)" };
} else {
w1 = (struct worker_arg){ lo, hi, "t1(lo->hi)" };
w2 = (struct worker_arg){ lo, hi, "t2(lo->hi)" };
}
pthread_t t1, t2;
int rc = pthread_create(&t1, NULL, worker, &w1);
if (rc != 0)
die_pthread("create t1", rc);
rc = pthread_create(&t2, NULL, worker, &w2);
if (rc != 0)
die_pthread("create t2", rc);
/* 最多等 2 秒:超时说明两个线程都没有返回,即死锁 */
struct timespec deadline;
if (clock_gettime(CLOCK_REALTIME, &deadline) == -1) {
perror("clock_gettime");
return EXIT_FAILURE;
}
deadline.tv_sec += 2;
rc = pthread_mutex_lock(&done_lock);
if (rc != 0)
die_pthread("done lock", rc);
while (done_count < 2) {
rc = pthread_cond_timedwait(&done_cond, &done_lock, &deadline);
if (rc == ETIMEDOUT) {
pthread_mutex_unlock(&done_lock);
fprintf(stderr, "watchdog: %d/2 workers finished -> deadlock\n",
done_count);
/* 死锁无法在进程内安全拆解:直接退出,交给外部监控 */
_exit(2);
}
if (rc != 0)
die_pthread("done timedwait", rc);
}
rc = pthread_mutex_unlock(&done_lock);
if (rc != 0)
die_pthread("done unlock", rc);
rc = pthread_join(t1, NULL);
if (rc != 0)
die_pthread("join t1", rc);
rc = pthread_join(t2, NULL);
if (rc != 0)
die_pthread("join t2", rc);
printf("both workers done\n");
return EXIT_SUCCESS;
}
运行三个模式:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g -pthread abba_deadlock.c -o abba_deadlock
./abba_deadlock deadlock; echo "exit=$?"
# t1(a->b): got both locks ← 有时只打印这一行就卡住,取决于交错
# watchdog: 1/2 workers finished -> deadlock
# exit=2
./abba_deadlock order; echo "exit=$?"
# t1(lo->hi): got both locks
# t2(lo->hi): got both locks
# both workers done
# exit=0
./abba_deadlock trylock; echo "exit=$?"
# 两种顺序都可能出现,但一定以 both workers done 结束
三个模式对应三种工程手段,各有代价:
| 手段 | 原理 | 代价 | 适用 |
|---|---|---|---|
| 全局锁序 | 所有线程按同一顺序加锁,环不可能形成 | 需要全局约定,跨模块难维护 | 默认选择 |
trylock + 退避 | 拿不到第二把就释放第一把,打破「持有并等待」 | 活锁风险、额外系统调用 | 锁序无法统一时 |
| 超时放弃 | pthread_mutex_timedlock 到期返回 ETIMEDOUT | 无法区分「慢」与「死锁」,可能误伤 | 兜底、可重试的业务 |
注意 trylock 模式里的随机退避:如果两个线程都用固定 100 µs 重试,它们会一直同步碰撞,形成活锁——CPU 100%,进度为零。退避必须是随机的,或者按线程 ID 错开。
- 「
gdb能看到谁持有锁」——默认看不到。pthread_mutex_t的内部字段没有类型信息,p gdb只能打出裸字节;要么用pthread_db的info threads(需要调试符号),要么用 helgrind 的锁序报告。 - 「死锁一定会让 CPU 到 100%」——不会。ABBA 死锁的线程都睡在 futex 上,CPU 占用为 0,
top看起来「很闲」。判断依据是「进程还在、但没有进展」,不是 CPU。 - 「加个超时就不算死锁了」——超时只是把「永久阻塞」换成「周期性失败」。如果重试逻辑仍然按原顺序加锁,每个周期都会再死一次,表现为「服务隔几分钟抖一下」。
2.12.13 程序三:自定义栈、TLS 与线程 ID 的观测
这段程序要验证四件事:自定义栈的边界从哪来、guard page 触发时进程怎么死、TLS 是否真的每线程一份、pthread_t 与 TID 如何对应。它有两个模式:info 打印观测值,overflow 故意踩 guard page。
/* thread_stack_tls.c — 自定义栈 + TLS + gettid;overflow 模式演示栈溢出 */
#define _GNU_SOURCE
#include <dirent.h>
#include <errno.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>
#define STACK_SIZE (256 * 1024)
#define GUARD_SIZE 4096
#define CANARY 0xdeadbeefUL
static _Thread_local unsigned long tls_counter; /* 每线程一份 */
static const char *g_mode = "info";
static void *g_stack; /* 自定义栈,fork 后子进程也能用 */
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
/* 数 /proc/self/task 下的线程目录;非 Linux 平台返回 -1 */
static int count_tasks(void)
{
DIR *d = opendir("/proc/self/task");
if (d == NULL)
return -1;
int n = 0;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (e->d_name[0] != '.')
n++;
}
closedir(d);
return n;
}
/* 每层 2 KB,返回值让编译器无法把它优化成尾递归 */
static unsigned long burn_stack(unsigned long depth)
{
volatile unsigned char pad[2048];
pad[0] = (unsigned char)depth;
pad[sizeof pad - 1] = (unsigned char)(depth >> 8);
if (depth > 100000000UL)
return depth;
return burn_stack(depth + 1) + pad[0];
}
static void *worker(void *arg)
{
(void)arg;
int rc = pthread_setname_np(pthread_self(), "stack-tls");
if (rc != 0)
fprintf(stderr, "setname: %s\n", strerror(rc));
printf("worker: pthread_t=%p tid=%d\n",
(void *)pthread_self(), (int)gettid());
tls_counter = 42;
printf("worker: &tls_counter=%p value=%lu\n",
(void *)&tls_counter, tls_counter);
/* 从 NPTL 反查这块栈的真实范围与 guard 大小 */
pthread_attr_t attr;
rc = pthread_getattr_np(pthread_self(), &attr);
if (rc != 0) {
fprintf(stderr, "getattr_np: %s\n", strerror(rc));
return NULL;
}
void *base = NULL;
size_t size = 0, guard = 0;
if (pthread_attr_getstack(&attr, &base, &size) != 0)
fprintf(stderr, "getstack failed\n");
if (pthread_attr_getguardsize(&attr, &guard) != 0)
fprintf(stderr, "getguardsize failed\n");
pthread_attr_destroy(&attr);
printf("worker: stack=[%p,%p) size=%zu guard=%zu\n",
base, (char *)base + size, size, guard);
printf("worker: tasks=%d\n", count_tasks());
if (strcmp(g_mode, "overflow") == 0) {
printf("worker: burning stack until guard page...\n");
fflush(stdout);
unsigned long d = burn_stack(0);
printf("worker: returned depth=%lu (no overflow)\n", d);
}
return NULL;
}
/* 在子进程里跑 overflow,父进程只负责解码 wait 状态 */
static int run_overflow(void)
{
pid_t pid = fork();
if (pid == -1)
die("fork");
if (pid == 0) {
pthread_attr_t attr;
pthread_t t;
if (pthread_attr_init(&attr) != 0)
_exit(10);
if (pthread_attr_setstack(&attr, g_stack, STACK_SIZE) != 0)
_exit(11);
if (pthread_create(&t, &attr, worker, NULL) != 0)
_exit(12);
pthread_join(t, NULL);
_exit(0); /* 正常情况不会到这 */
}
int status = 0;
if (waitpid(pid, &status, 0) == -1)
die("waitpid");
if (WIFSIGNALED(status)) {
printf("parent: child killed by signal %d (%s)\n",
WTERMSIG(status), strsignal(WTERMSIG(status)));
return 0;
}
printf("parent: child exited with %d\n", WEXITSTATUS(status));
return 1;
}
int main(int argc, char **argv)
{
if (argc == 2)
g_mode = argv[1];
/* 一块 256 KB 的自定义栈,低端手工留一页 guard */
g_stack = mmap(NULL, STACK_SIZE, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
if (g_stack == MAP_FAILED)
die("mmap stack");
if (mprotect(g_stack, GUARD_SIZE, PROT_NONE) == -1)
die("mprotect guard");
/* 在 guard 之后的第一个可写字节放哨兵,溢出到它时能早一步发现 */
*(unsigned long *)((char *)g_stack + GUARD_SIZE) = CANARY;
tls_counter = 7;
printf("main: pthread_t=%p tid=%d &tls_counter=%p value=%lu\n",
(void *)pthread_self(), (int)gettid(),
(void *)&tls_counter, tls_counter);
int ret;
if (strcmp(g_mode, "overflow") == 0) {
ret = run_overflow();
} else {
pthread_attr_t attr;
pthread_t t;
int rc = pthread_attr_init(&attr);
if (rc != 0) { fprintf(stderr, "attr_init: %s\n", strerror(rc)); return 1; }
rc = pthread_attr_setstack(&attr, g_stack, STACK_SIZE);
if (rc != 0) { fprintf(stderr, "setstack: %s\n", strerror(rc)); return 1; }
rc = pthread_create(&t, &attr, worker, NULL);
if (rc != 0) { fprintf(stderr, "create: %s\n", strerror(rc)); return 1; }
rc = pthread_join(t, NULL);
if (rc != 0) { fprintf(stderr, "join: %s\n", strerror(rc)); return 1; }
pthread_attr_destroy(&attr);
if (*(unsigned long *)((char *)g_stack + GUARD_SIZE) != CANARY) {
fprintf(stderr, "main: canary overwritten -> stack overflow\n");
return 1;
}
printf("main: canary intact, threads=%d\n", count_tasks());
ret = 0;
}
if (munmap(g_stack, STACK_SIZE) == -1)
die("munmap");
return ret;
}
编译与两个模式:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g -pthread thread_stack_tls.c -o thread_stack_tls
./thread_stack_tls info
# main: pthread_t=0x16d0b7000 tid=51321 &tls_counter=0x16d0b6f80 value=7
# worker: pthread_t=0x16d0af000 tid=51322
# worker: &tls_counter=0x16d0aef80 value=42
# worker: stack=[0x16d06f000,0x16d0af000) size=262144 guard=4096
# worker: tasks=2
# main: canary intact, threads=1
./thread_stack_tls overflow
# worker: burning stack until guard page...
# parent: child killed by signal 11 (Segmentation fault)
两个线程的 &tls_counter 相差 4 KB,值分别是 7 和 42,互不干扰。stack=[0x16d06f000,0x16d0af000) 的 256 KB 正是 mmap 的长度;guard=4096 是属性对象里的默认值,真正生效的是我们 mprotect 的那一页。tasks=2 只在 Linux 上有意义,其他平台 /proc 不存在,返回 -1。
overflow 模式里,子进程的线程踩到 PROT_NONE 页,内核向整个进程发 SIGSEGV,父进程用 WIFSIGNALED/WTERMSIG 解码出 11。这解释了为什么「线程栈溢出会杀掉整个进程」——信号是进程级的。
- 「
pthread_attr_setstack之后 glibc 会自动加 guard」——不会。guard 只在 glibc 自己mmap栈时建立;用户提供的区域必须自己mprotect。 - 「
pthread_attr_getguardsize返回的是实际保护大小」——它返回属性对象里的配置值(默认 4096),与自定义栈无关。要确认实际保护,读/proc/PID/maps里的---p页。 - 「哨兵能替代 guard page」——哨兵只能发现「已经写穿」的溢出,且要求线程主动检查;guard page 由 MMU 在越界的瞬间触发,代价是一次
SIGSEGV。两者互补,不能互相替代。
2.12.14 实验:用 strace、perf 与 TSan 看线程
三个实验分别回答「线程创建/同步走了哪些系统调用」「时间花在等锁还是算」「有没有数据竞争」。
实验一:strace -f 看 clone 与 futex
strace -f -e trace=clone,clone3,futex,set_robust_list,exit_group \
-o /tmp/trace.txt ./thread_basic
grep -E 'clone3|set_robust_list|FUTEX_WAIT' /tmp/trace.txt | head -8
典型输出(地址与 TID 每次不同):
12345 clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID, exit_signal=SIGCHLD, stack=0x7f8c4a2f0000, stack_size=0x800000, tls=0x7f8c4a2ef640}, 88) = 12346
12346 set_robust_list(0x7f8c4a2ef660, 24) = 0
12345 futex(0x7f8c4a2ef640, FUTEX_WAIT_PRIVATE, 12346, NULL) = 0
12346 exit_group(0) = ?
读法:
clone3的 flags 就是 2.12.10 那张位表;stack_size=0x800000是 8 MB 默认栈。set_robust_list出现在新线程里:NPTL 在每个线程启动时向内核登记 robust 链表。FUTEX_WAIT_PRIVATE的第三个参数12346是期望值——pthread_join在等 TCB 里的tid等于 12346 时睡眠;线程退出把它清零并唤醒。exit_group出现在main返回时,它终止整个线程组;线程自己退出用的是exit(不是exit_group)。
strace -f -c 给出系统调用耗时汇总,能看出线程程序里 futex 占了多少:
strace -f -c -e trace=futex ./thread_pool 2>&1 | tail -6
# % time seconds usecs/call calls errors syscall
# 100.00 0.004120 41 100 futex
实验二:perf 与 /proc 看上下文切换
perf stat -e context-switches,cpu-migrations,page-faults,instructions \
./thread_pool
perf sched record -- ./thread_pool
perf sched latency | head -12
没有 perf 权限时(容器、perf_event_paranoid 限制),用 /proc/PID/status 与 pidstat:
pidstat -w -t -p $(pgrep -n thread_pool) 1 3
# 每行给出该线程每秒的自愿/非自愿上下文切换次数
grep ctxt_switches /proc/$$/status
# voluntary_ctxt_switches: 12
# nonvoluntary_ctxt_switches: 3
判读标准:自愿切换高说明线程在等锁、等 I/O、等条件变量(正常);非自愿切换高说明线程数超过 CPU 核数,或被更高优先级抢占(要减线程或调亲和性)。线程池里 worker 数远大于核数时,非自愿切换会显著上升,吞吐反而下降。
实验三:TSan 抓 race_counter 的竞争
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -g -O1 -fsanitize=thread \
-pthread race_counter.c -o race_tsan
./race_tsan plain; echo "exit=$?"
==================
WARNING: ThreadSanitizer: data race (pid=23456)
Write of size 8 at 0x... by thread T3:
#0 worker /tmp/race_counter.c:47 (race_tsan+0x...)
Previous write of size 8 at 0x... by thread T1:
#0 worker /tmp/race_counter.c:47 (race_tsan+0x...)
Location is global 'counter_plain' of size 8 at 0x...
==================
ThreadSanitizer: reported 3 warnings
exit=66
读法:两条栈指向同一全局变量,至少一个是写,且 TSan 找不到任何 happens-before 边(没有锁、没有原子操作、没有 join 之外的同步)。exit=66 是 TSan 的默认退出码,CI 里可以用它让任务失败。
用同一个二进制跑 mutex 与 atomic 模式,TSan 应当零报告——这正好验证「锁和原子操作都能建立 happens-before」。TSan 报不报,不取决于代码看起来是否安全,而取决于它是否观测到同步边。
2.12.15 陷阱清单:线程的十二个坑
这一节把全章的失败模式压成一张表,按「现象 → 原因 → 修复」组织。遇到线上问题时按现象查,比从头读快。
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | pthread_create 返回 11,程序继续跑,后面莫名崩溃 | 用 if (rc == -1) 或 perror 检查,把正错误码当成功 | 判 rc != 0,用 strerror(rc) 报告 |
| 2 | 进程 RSS 每次请求涨 8 MB | joinable 线程创建后既不 join 也不 detach | 统一 join,或创建时设 PTHREAD_CREATE_DETACHED |
| 3 | pthread_join 返回 EINVAL | 目标线程已 detach;或 join 自己 | 一个线程只能 join 或 detach 一次;自 join 用 EDEADLK 检测 |
| 4 | 主线程 return 0 后后台线程全没了 | main 返回等价于 exit() | 主线程 pthread_exit(),或 join 所有 worker |
| 5 | pthread_cancel 后目标线程纹丝不动 | 延迟取消,目标卡在 pthread_mutex_lock 或纯计算循环里 | 循环里插 pthread_testcancel();不要指望 cancel 解死锁 |
| 6 | 取消后互斥锁永久锁死 | 目标线程在持锁时被取消,没有清理处理器 | pthread_cleanup_push(unlock, ...) 覆盖持锁区间 |
| 7 | 异步取消后进程偶发卡在 malloc | 取消点在 malloc 内部,arena 锁没释放 | 改回 DEFERRED;只在临界区临时 DISABLE |
| 8 | dlopen 报 “cannot allocate memory in static TLS block” | 库的 __thread 用了 initial-exec 模型,超出静态 TLS 余量 | 启动时 LD_PRELOAD,或改 global-dynamic,或用 pthread_key |
| 9 | 多线程程序 fork 后子进程 printf 卡死 | 子进程继承了被其他线程持有的 stdio/malloc 锁 | fork 后只调 async-signal-safe 函数并尽快 exec;或用 pthread_atfork |
| 10 | SIGINT 有时不触发退出逻辑 | 进程级信号被投递到任意一个未阻塞它的线程 | 所有线程阻塞信号,专线程 sigwait |
| 11 | 设了 pthread_attr_setschedparam 但优先级没变 | 默认 PTHREAD_INHERIT_SCHED,attr 里的调度设置被忽略 | 先 pthread_attr_setinheritsched(EXPLICIT_SCHED) |
| 12 | 线程栈 128 KB 时偶发 SIGSEGV,-O2 下又不复现 | 优化级别改变帧大小,或库函数在深调用链上吃栈 | 栈 ≥ 512 KB;-fstack-usage 核对最大帧 |
表里第 5、6、7 条都指向同一个结论:取消是协作机制,不是 kill。需要「立刻停」的语义时,用 eventfd/pipe 唤醒 + 停止标志,把控制权交回线程自己。
第 9 条在多线程 + fork 的组合里最危险,因为它在测试环境常常不复现:单线程测试永远不触发,只有在生产的多核负载下才偶发。判断方法是在 fork 后立刻 strace,看子进程是否停在 futex 上——如果是,就是继承了别人的锁。
版本差异也要记一笔:gettid() 的 glibc wrapper 从 2.30 才有,更早要 syscall(SYS_gettid);glibc 2.34+ 优先用 clone3,旧版 Docker 的默认 seccomp 会让它返回 ENOSYS 并回落到 clone,自定义 seccomp 策略必须同时放行两者。
2.12.16 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 画出线程共享/私有资源 | 能说出 fd 表共享、信号掩码私有 | 2.12.1 |
用 pthread_create/join 传递并取回结果 | 返回值用 strerror(rc) 而非 errno | 2.12.2 |
| 解释 join 与 detach 的区别 | 说出资源回收与返回值 | 2.12.2 |
| 设置线程栈大小并解释溢出症状 | 说出 guard page 与 SIGSEGV | 2.12.3 |
| 写一个带清理处理器的可取消线程 | push/pop 同作用域、锁被释放 | 2.12.4 |
解释 pthread_once 与 TLS 的用途 | 能说出析构函数调用时机 | 2.12.5 |
| 写一个固定 worker 的线程池 | 条件变量用 while、能优雅关闭 | 2.12.7 |
Q1 主线程 return 0 时,其他线程还在跑。进程会等它们结束吗?如果希望它们继续,主线程该怎么做?
Q2 pthread_create 返回 11(EAGAIN)。应该看 errno 还是返回值?可能的原因有哪些?
Q3 一个线程在 read 上阻塞,另一个线程调用 pthread_cancel。取消什么时候生效?如果目标线程屏蔽了取消呢?
Q4 多线程程序在 fork 后,子进程调用 printf 卡死。解释原因和两种修法。
Q5 线程池的 worker 为什么用 while (count == 0 && !shutdown) 而不是 if?
答案
A1 不会等。main 返回等价于 exit(),整个进程终止。要让其他线程继续,主线程应调用 pthread_exit(),或 pthread_join() 等待它们。
A2 看返回值。Pthreads 函数成功返回 0、失败返回错误码,不设置 errno。EAGAIN 通常表示资源不足:线程数超过 RLIMIT_NPROC、内存不足以分配栈、或系统级线程上限。
A3 默认延迟取消,目标线程在下一个取消点(read 本身就是取消点)响应;read 会以取消语义返回,清理处理器执行。如果目标线程 pthread_setcancelstate(PTHREAD_CANCEL_DISABLE),取消请求会 pending,直到它重新 ENABLE 并到达取消点。
A4 fork 只复制调用线程;子进程可能继承了一把被其他线程持有的 stdio/malloc 锁,而持锁线程在子进程里不存在,锁永远不释放。修法:① fork 后只调用 async-signal-safe 函数并尽快 exec;② 用 pthread_atfork 在 fork 前后加/解锁。
A5 条件变量可能虚假唤醒,且被唤醒后可能发现任务已被别的 worker 取走。if 只检查一次,条件不成立时会继续往下执行,取到空指针;while 会重新检查并继续等待。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 28 章 详述进程创建和程序执行 | 2.12.1 | 背景(clone flags 与线程/进程的统一) |
| K | 第 29 章 线程:介绍 | 2.12.1–2.12.3、2.12.9 | 完整(创建、join、detach、属性、栈、API 错误码) |
| K | 第 32 章 线程:线程取消 | 2.12.4 | 完整(取消点、清理处理器、异步取消风险) |
| K | 第 33 章 线程:更多细节 | 2.12.3、2.12.5–2.12.6、2.12.10 | 完整(栈、信号、TLS、fork、NPTL 实现与线程栈) |
| C | 第 12.3–12.4 节 并发编程 | 2.12.1–2.12.2 | 背景(线程模型与共享变量) |
| O | 第 26–27 章 并发:介绍、锁 | 2.12.7 | 背景(线程创建成本与锁的必要性) |
| W | — | — | 无直接落点 |
| N | — | — | 无直接落点(线程与网络在 2.17–2.18 结合) |
下一站:2.13 线程同步与线程安全把线程池里出现的
mutex与cond讲透:为什么条件变量必须配while、什么是虚假唤醒与惊群、如何用 TSan 与 helgrind 把数据竞争钉死在测试阶段。