🔥本文定位:这是 Linux 线程系列下篇。上篇解决了线程模型、地址空间、资源边界、
pthread_create()与线程 ID;本篇继续完成线程的整个生命周期,重点讨论退出、回收、分离、取消和底层实现。💡学习目标:读完本文,你应该能分清线程终止与进程终止、joinable 与 detached、取消请求与实际取消、线程栈与 guard page、Pthreads 与 NPTL/clone,并能识别线程封装中的生命周期漏洞。
📌阅读建议:如果你对 LWP、共享地址空间、
pthread_t、TGID 和 TID 还不熟悉,建议先阅读上篇;若已有基础,也可以直接从本文开始。
文章目录
- 一、线程的四种终止路径
- 二、pthread_join:等待并回收 joinable 线程
- 三、pthread_detach:不需要返回值时怎样自动回收
- 四、pthread_cancel:取消是一个协作协议
- 五、线程栈、属性与生命周期风险
- 六、NPTL、clone 与 1:1 线程模型
- 七、封装线程类时真正要管理的状态
- 八、排查工具与高频面试题
- 下篇总结
📖上篇回顾:进程提供资源容器,线程是其中可独立调度的执行流;同组线程共享地址空间,但保留独立寄存器、栈、信号掩码、
errno与调度状态。pthread_t是线程库标识,Linux TID 则用于内核和诊断工具。
一、线程的四种终止路径
1.1 从入口函数 return
staticvoid*worker(void*arg){/* ... */returnresult;}从start_routine返回,等价于用该返回值调用pthread_exit()。这只结束当前线程。
1.2 pthread_exit()
voidpthread_exit(void*retval);pthread_exit(retval)终止调用线程,并把retval留给 join 它的线程。返回值指向的对象必须在目标线程退出后仍然有效,不能指向它自己的局部变量。
错误示例:
staticvoid*bad_worker(void*arg){intlocal=42;return&local;// 错误:线程退出后 local 生命周期结束}1.3 pthread_cancel() 导致取消
另一个线程可以发出取消请求。默认是延迟取消:请求先变成 pending,目标线程在启用取消且到达取消点时才执行清理并退出。它不是一般意义上的“立即杀死线程”。
1.4 exit() 或 main 返回终止整个进程
以下行为会结束整个进程,包括其他线程:
- 任意线程调用
exit()、_exit()或触发进程终止; - 初始线程从
main()返回; - 收到默认动作为终止且未被处理的信号。
如果初始线程希望结束自己、但让其他线程继续运行,可以调用:
pthread_exit(NULL);不过工程上更常见的是明确 join 工作线程后让main正常返回。
二、pthread_join:等待并回收 joinable 线程
2.1 为什么线程退出后还要 join
默认创建的线程是 joinable。它退出后,执行已经停止,但线程库/系统仍需保留部分终止信息和资源,等待另一个线程取得返回值并完成回收。
intpthread_join(pthread_tthread,void**retval);成功后:
- 调用者确认目标线程已经终止;
- 可以获得 return 或
pthread_exit()的返回值; - 如果目标被取消,通常得到
PTHREAD_CANCELED; - 目标线程最后一部分资源可被释放。
它与进程waitpid()在“等待并回收”概念上相似,但 joinable 线程不是一个可由ps观察到的传统僵尸进程,具体占用哪些资源也属于实现细节。
2.2 join 的错误边界
- 不能 join 自己,否则可能得到
EDEADLK; - 目标必须是 joinable;
- 同一个线程不能被成功 join 两次;
- 多个线程同时 join 同一目标的结果未定义;
pthread_join()没有 POSIX 标准超时参数;Linux 提供非可移植扩展时要明确标注。
2.3 join 不是创建者专属
只要是同一进程中的合适线程,并满足生命周期与唯一 join 约束,就可以 join 目标;并不存在必须由“父线程”回收“子线程”的 POSIX 层级关系。
2.4 返回值所有权必须明确
若线程返回堆对象,join 方通常接管释放责任:
void*raw=NULL;interr=pthread_join(tid,&raw);if(err==0&&raw!=PTHREAD_CANCELED){free(raw);}如果多个线程可能共享返回对象,应使用更清晰的所有权设计,不能默认 join 方一定能独占free()。
三、pthread_detach:不需要返回值时怎样自动回收
3.1 joinable 与 detached 是互斥状态
- joinable:退出后等待某个线程
pthread_join(); - detached:退出时自动释放线程资源,不能再 join 获取返回值。
运行中可以分离:
interr=pthread_detach(tid);也可以创建时设置 detached 属性:
pthread_attr_tattr;pthread_attr_init(&attr);pthread_attr_setdetachstate(&attr,PTHREAD_CREATE_DETACHED);interr=pthread_create(&tid,&attr,worker,arg);pthread_attr_destroy(&attr);3.2 为什么 sleep 后再 join 不是正确实验
下面这种模式带有竞态:
pthread_create(&tid,NULL,worker,NULL);sleep(1);// 猜线程已经自我 detachpthread_join(tid,NULL);调度器并不保证 1 秒内目标一定执行到pthread_detach()。测试线程状态不能依赖 sleep。创建者若一开始就确定不 join,应通过属性在创建前设为 detached。
3.3 detached 不等于“不用管理生命周期”
自动回收的是线程执行资源,不会自动解决:
- 线程参数指向对象何时销毁;
- detached 线程是否仍访问宿主对象;
- 进程退出时任务是否做完;
- 如何通知线程停止;
- 如何观察失败结果。
Detached 更接近“我放弃 join 和返回值”,不是“这个任务从此没有所有权问题”。服务代码中盲目 fire-and-forget 往往会制造关闭竞态。
四、pthread_cancel:取消是一个协作协议
4.1 pthread_cancel() 只发送取消请求
intpthread_cancel(pthread_tthread);成功返回只说明请求已发送,不代表目标已经退出。默认设置为:
- cancellation state:enabled;
- cancellation type:deferred。
目标线程通常在read()、poll()、sleep()、pthread_cond_wait()、pthread_join()、pthread_testcancel()等取消点观察请求。
4.2 为什么异步取消危险
如果启用PTHREAD_CANCEL_ASYNCHRONOUS,线程可能在几乎任意指令处被取消:
- 刚加锁还未注册清理;
- 正在修改链表的一半;
- 堆分配器内部状态变化中;
- 文件写到一半;
- 对象不变量暂时不成立。
POSIX 只保证极少数函数是 async-cancel-safe,因此一般不要使用异步取消。
4.3 cleanup handler 保证取消路径释放资源
staticvoidunlock_mutex(void*opaque){pthread_mutex_t*mutex=opaque;pthread_mutex_unlock(mutex);}staticvoid*worker(void*opaque){pthread_mutex_t*mutex=opaque;pthread_mutex_lock(mutex);pthread_cleanup_push(unlock_mutex,mutex);/* 可能到达取消点的工作 */pthread_testcancel();pthread_cleanup_pop(1);// 正常路径也执行解锁returnNULL;}pthread_cleanup_push/pop在许多实现中是宏,必须按词法作用域成对使用。进入持锁临界区时还要仔细安排取消状态,避免“锁已获取、清理函数尚未建立”之间留下窗口。
4.4 更推荐显式停止标志
对于普通业务线程,通常更容易证明的方案是:
控制线程设置 stop_requested ↓ 唤醒条件变量 / eventfd / pipe ↓ worker 在正常控制流检查停止条件 ↓ 释放资源并 return ↓ 控制线程 pthread_join取消机制适合明确理解取消点和 cleanup 语义的代码,而不是所有线程停止问题的默认答案。
五、线程栈、属性与生命周期风险
线程栈虽然由不同执行流分别使用,但仍位于同一进程的共享虚拟地址空间中。先结合下图重新确认栈映射与共享区域的关系:
5.1 默认线程栈不是固定 8 MiB
在 Linux NPTL 中,如果进程启动时的RLIMIT_STACK不是 unlimited,它通常影响之后新线程的默认栈大小;若为 unlimited,则使用与体系结构相关的默认值。8 MiB 很常见,但不能写成跨机器常数。
查询某线程实际属性可使用 Linux 扩展pthread_getattr_np(),或观察:
ulimit-scat/proc/<pid>/maps5.2 使用 pthread_attr_t 调整栈
pthread_attr_tattr;interr=pthread_attr_init(&attr);if(err!=0){fprintf(stderr,"pthread_attr_init: %s\n",strerror(err));return1;}size_tstack_size=2*1024*1024;err=pthread_attr_setstacksize(&attr,stack_size);if(err!=0){fprintf(stderr,"pthread_attr_setstacksize: %s\n",strerror(err));pthread_attr_destroy(&attr);return1;}err=pthread_create(&tid,&attr,worker,arg);pthread_attr_destroy(&attr);栈大小至少满足PTHREAD_STACK_MIN和实现对齐要求。设置过小会让深递归、大型局部数组、复杂库调用更容易溢出;设置过大则会消耗大量虚拟地址空间,并限制可创建线程数。
5.3 guard page 的意义
线程栈通常配有保护区域。栈越界触碰不可访问保护页时,内核能更早报告错误,而不是悄悄破坏相邻映射。保护区不是无限保险:一次跨越很大的越界访问可能跳过保护页,应用仍应避免大对象上栈和无界递归。
5.4 参数对象必须活得比线程使用时间更久
危险示例:
staticvoidlaunch(void){pthread_ttid;structtask_argarg={42,"temporary"};pthread_create(&tid,NULL,worker,&arg);pthread_detach(tid);}// arg 已销毁,但 worker 可能刚开始读取可选修复:
- 参数在堆上分配,明确由 worker 释放;
- 参数属于生命周期更长的管理对象,并在销毁前 join;
- 创建后使用同步握手,确保必要内容已复制;
- C++ 使用值捕获、
std::thread/std::jthread和 RAII 管理所有权。
六、NPTL、clone 与 1:1 线程模型
6.1 Pthreads 是接口,NPTL 是 Linux/glibc 实现
应用调用pthread_create(),glibc 的 NPTL 负责准备线程描述信息、栈、TLS 和创建属性,再通过 Linux 内核接口建立新的调度实体。
简化理解:
pthread_create ↓ glibc / NPTL ├─ 分配或复用线程栈 ├─ 准备 TLS / pthread 描述信息 └─ 组织共享语义与 TID 交互 ↓ clone / clone3 相关内核能力 ↓ 新的内核调度实体进入同一线程组 ↓ 从 start_routine(arg) 开始执行不要把某个旧版本glibc-2.4/nptl/pthread_create.c的字段和函数名当成稳定 ABI。源码适合帮助理解,但应用应依赖 POSIX/Pthreads 契约。
6.2 关键共享语义由 clone flags 组合表达
实现线程时会涉及一组共享关系,例如:
CLONE_VM:共享虚拟地址空间;CLONE_FILES:共享文件描述符表;CLONE_FS:共享文件系统上下文;CLONE_SIGHAND:共享信号处理动作;CLONE_THREAD:进入同一线程组;CLONE_SETTLS:设置 TLS;- parent/child TID 相关标志:支持线程创建、退出和 join 协调。
具体组合、底层系统调用和 glibc 内部流程可能随架构和版本变化。不要在业务代码中手写一组 flags,声称它“等价于 pthread_create”。
6.3 CLONE_VM | SIGCHLD 不是 POSIX 线程
有些示例这样调用:
clone(child_func,stack_top,CLONE_VM|SIGCHLD,NULL);它创建的是一个与调用者共享地址空间、但处于不同线程组且可通过waitpid()回收的任务。它没有同时建立 POSIX 线程要求的文件表、信号处理、TLS、取消、join 等完整语义,因此不能把它直接称为 Pthreads 线程。
更危险的是:两个不同线程组的任务共享内存,但常规用户态库未必按这种非标准组合设计。除非在做容器运行时、沙箱或底层系统实验,否则优先使用pthread_create()。
6.4 1:1 模型意味着什么
NPTL 使用 1:1 模型:一个用户 Pthread 对应一个内核可调度实体。
优点:
- 阻塞系统调用只阻塞当前线程;
- 内核可把不同线程调度到不同 CPU;
- 信号、调度、性能分析工具能观察到每个 TID。
代价:
- 每个线程都消耗内核调度资源;
- 大量线程会提高调度和内存成本;
- 用户态协程/绿色线程可以在少量内核线程上复用更多任务,但需要自己的调度与阻塞 I/O 协议。
七、封装线程类时真正要管理的状态
把pthread_create()包成 C++ 类并不难,难的是明确对象与线程的生命周期。
7.1 至少要有这些状态
NEW ── Start ──> RUNNING ── worker return ──> FINISHED │ │ ├─ request stop ├─ Join → JOINED └─ Detach → DETACHED ──────────┘ 自动回收需要明确:
- 是否成功创建;
- 当前是否 joinable;
- 是否已经 join/detach;
- worker 是否仍然访问
this; - 析构时是 join、detach、请求停止还是禁止析构;
- 创建失败时如何回滚;
- 同一对象是否允许重复
Start()。
7.2 原 PDF 中简单封装的几个风险
- 用全局普通计数器生成名字,多线程构造时存在数据竞争;
- worker 写
_status,控制线程同时读写而没有同步; - detached worker 持有
this,对象可能先析构; - 析构函数什么都不做,joinable 线程可能遗漏回收;
pthread_cancel()成功就立刻把状态写成 STOP,但目标可能尚未到取消点;- 默认拷贝会复制
pthread_t与状态,两个对象可能同时管理同一线程。
7.3 一个更稳妥的最小 RAII 方向
生产 C++ 项目优先考虑标准库std::thread或 C++20std::jthread。如果为了学习封装 Pthreads,至少应禁止复制,并在析构前确保线程不再访问对象:
classThread{public:Thread(constThread&)=delete;Thread&operator=(constThread&)=delete;~Thread(){if(joinable_){// 这里只展示所有权约束;真实策略需避免自 join 和无限阻塞pthread_join(tid_,nullptr);}}private:pthread_t tid_{};booljoinable_{false};};析构自动 join 也不是无条件完美:可能阻塞、可能发生 self-join、也可能与程序关闭顺序冲突。更好的接口会提供显式request_stop()+join(),并把共享状态放入独立、引用计数或严格拥有的对象中。
7.4 线程状态不是随便一个 bool
“正在运行”“已请求停止”“线程入口已返回”“资源已 join”是不同状态。若多个线程需要读写这些状态,必须使用互斥锁、条件变量或std::atomic建立同步;仅仅把字段声明为volatile不会让它线程安全。
八、排查工具与高频面试题
8.1 查看线程列表
ps-L-p<PID>-opid,tid,psr,stat,commtop-H-p<PID>PID:线程组 ID;TID/LWP:内核线程 ID;PSR:最近运行的 CPU;STAT:线程状态;COMM:线程名。
Linux 的/proc视图:
ls/proc/<PID>/taskcat/proc/<PID>/task/<TID>/statuscat/proc/<PID>/task/<TID>/stack# 通常需要权限,显示内核栈8.2 给线程命名
Linux/glibc 扩展:
#define_GNU_SOURCE#include<pthread.h>pthread_setname_np(pthread_self(),"io-worker");Linux 线程名限制为 16 字节(包含结尾空字符),因此可见名称最多 15 字节。_np表示 non-portable;跨平台代码要做适配。
8.3 GDB 查看线程
info threads thread 3 bt thread apply all bt定位死锁或停滞时,thread apply all bt能快速看到每条线程卡在哪个锁、条件变量或系统调用上。
8.4 strace 跟踪某进程的所有线程
strace-f-p<PID>strace-ff-otrace ./app-f跟踪派生任务/线程,-ff将不同 PID/TID 的记录拆分到不同文件。高并发程序输出量很大,应通过-e trace=...缩小范围。
8.5 常见错误清单
- 用
-lpthread代替更完整的-pthread; - 把 Pthreads 返回值交给
perror(); - 用
%X打印pthread_t,假定它是整数; - 把
pthread_self()当作 Linux TID; - 创建后用
sleep()猜谁先执行; - 把局部变量地址交给生命周期更长的线程;
- joinable 线程退出后永不 join;
- detached 线程继续访问已经析构的对象;
- 认为
pthread_cancel()返回 0 就表示目标已退出; - 使用异步取消却没有完整不变量和清理设计;
- 把 8 MiB 当成所有机器的固定线程栈大小;
- 用
volatile代替互斥锁或原子变量; - 手写
clone(CLONE_VM | SIGCHLD)并称之为 POSIX 线程; - 依赖某个旧 glibc 版本的
struct pthread布局。
8.6 高频面试题
问题 1:进程与线程最核心的区别是什么?
进程提供资源与隔离边界,线程是进程内部可独立调度的执行流。一个进程内线程共享地址空间和大量进程资源,但各自拥有寄存器上下文、栈、信号掩码、errno、调度属性等。
问题 2:Linux 线程为什么叫轻量级进程?
Linux 使用统一的可调度任务模型表示进程与线程;线程通过共享地址空间、文件表等资源形成线程组。与建立独立资源视图的新进程相比,线程创建和切换通常更轻,但仍是内核调度实体。
问题 3:pthread_t 和 TID 有什么区别?
pthread_t是 POSIX 线程库的不透明标识,只保证在进程内和有效生命周期内可用;TID 是 Linux 内核任务 ID,用于/proc、跟踪和系统级诊断。二者不应互换。
问题 4:为什么 pthread 函数错误不能直接 perror?
大多数 Pthreads 函数失败时直接返回错误号,不设置errno。应把返回值传给strerror()。
问题 5:线程函数 return、pthread_exit 和 exit 有什么区别?
从线程入口 return 等价于pthread_exit(return_value),只结束当前线程;exit()或从main返回终止整个进程及所有线程。
问题 6:joinable 线程退出后为什么还要 join?
它的终止结果和部分资源需保留到pthread_join(),join 同时建立“目标已经结束”的同步点并取得返回值。若不需要 join,应在生命周期一开始明确设为 detached。
问题 7:pthread_cancel 会立即终止目标吗?
默认不会。它发送取消请求,目标在线程取消启用且到达取消点时才执行 cleanup 并退出。异步取消风险极高,一般不推荐。
问题 8:线程栈真的不能被其他线程访问吗?
线程有各自的栈使用区域,但这些映射仍在共享地址空间中,并没有硬件级线程隔离。其他线程若拿到地址原则上可以访问,因此栈对象地址跨线程传递必须严格控制生命周期和同步。
问题 9:线程切换为什么通常比进程切换轻?
同进程线程共享地址空间,切换时通常不需要更换到另一套进程内存映射关系,减少部分页表/TLB相关成本。但寄存器保存、调度、cache 污染和锁竞争仍存在,不能认为线程切换免费。
问题 10:NPTL 与 clone 是什么关系?
Pthreads 是标准接口,NPTL 是现代 glibc 的 Linux 实现。NPTL 准备线程栈、TLS 和控制信息,并使用 Linuxclone能力创建同线程组的内核调度实体。应用不应绕过 NPTL 手工拼 flags 模拟 Pthreads。
8.7 权威参考
- pthreads(7):POSIX 线程共享/私有属性与 Linux NPTL
- pthread_create(3):线程创建、继承属性与默认栈说明
- pthread_join(3):等待并回收 joinable 线程
- pthread_detach(3):分离线程
- pthread_cancel(3):取消状态、类型与清理流程
- pthread_self(3):pthread_t 的作用域和比较方式
- gettid(2):Linux 内核线程 ID
- clone(2):线程组与资源共享标志
下篇总结
把上下两篇串起来,Linux 线程的完整生命周期可以概括为:
pthread_create 创建线程 ↓ 线程在共享地址空间中独立执行 ↓ return / pthread_exit / cancel 结束当前线程 ↓ joinable 由 pthread_join 等待并回收 detached 在线程结束后自动回收线程资源 ↓ glibc/NPTL 借助 clone 能力建立同线程组的内核调度实体写可靠线程代码时,请牢牢记住:
- 线程入口 return 与
pthread_exit()只结束当前线程,exit()或 main 返回会终止整个进程; - joinable 线程必须 join,detached 线程也必须管理业务对象生命周期;
pthread_cancel()成功只表示请求已发送,默认要到取消点才真正生效;- 异步取消可能破坏锁、容器和分配器内部不变量,一般不应使用;
- 线程栈默认值不是跨平台固定 8 MiB,guard page 也不能替代正确的边界设计;
- Pthreads 是接口契约,NPTL 是 glibc 的 Linux 实现,应用不应手拼 clone flags 模拟线程库;
- 线程封装类必须明确 join/detach 所有权,并确保 worker 不会访问已经析构的对象;
- 排查线程问题时,应把用户态
pthread_t映射到 Linux TID,再结合/proc、GDB 和 strace 观察。
当你能准确解释“为什么 cancel 后仍要 join”“为什么 detached 仍会 use-after-free”“为什么CLONE_VM | SIGCHLD不等于 POSIX 线程”时,就真正理解了 Linux 线程控制的边界。
📖系列导航:本文是下篇。发布上下篇后,可以在这里补充上篇链接。
如果本文对你有帮助,欢迎点赞、收藏。下一篇将进入线程同步与互斥,讨论数据竞争、互斥锁、条件变量、生产者消费者模型与死锁。