📚 本文收录于「流浪」的系列专栏
| 🐧Linux系统 | ⚙️C++ |
| 📊数据结构与算法 | 🐍Python |
| 🔗LangChain & LangGraph | 🗄️MySQL 数据库 |
| 🌿Git 工具 | 🌐计算机网络 |
| 🤖AI | 💯大厂面试、八股 |
| 📚学习筑基专栏 |
🏠 博客主页:流浪 | 📝 原创首发于 CSDN
前言:上一篇拆了 pthread_t 的地址真相、flag 实验钉死了共享。本篇:
线程结束时 return、pthread_exit、cancel 三种退出方式,detach 怎么放手,线程之间为什么没有隔离墙。线程控制的全貌就齐了。
一、什么是线程结束
聊退出之前,先把「什么叫做线程结束了」这件事说清楚。main 的结束、入口函数的结束、exit 的调用,各自代表什么,语义不立住,后面三种终止方式就会讲成一锅粥。
1.1 main函数结束,代表什么
一句话三层含义:main 函数结束,代表主线程结束,也代表进程结束。
main 是进程的初始线程,main 里 return 等价于调 exit——POSIX 标准的原文口径:从 main 返回,行为与调用 exit() 并传入返回值完全相同。而 exit 终止的是整个进程:所有线程无论干到哪一步,全部强制结束。
1.2 入口函数结束,代表当前线程结束
新线程所在的入口函数执行结束,就代表当前这条线程运行结束——不动别人,只收自己。
对比着记:主线程(main)结束是全体 termination,子线程入口函数结束只收自己。同是「函数返回」,因为所在线程的地位不同,效果天差地别。
1.3 任何一个线程都不能用exit终止自己
线程想退场,能不能调 exit?不能。因为 exit 终止的是进程——任何一个线程调 exit,整个进程连同所有线程一起被带走,不管别的线程愿不愿意。
所以 exit 在多线程程序里只有一个用途:整个进程确实要收摊了。线程想只结束自己,用下一章的 pthread_exit。
1.4 join只等健康跑完的线程
线程(四)讲的 pthread_join,定位要说准:它解决的是线程健康跑完的情况,不负责处理异常信号。
原因在信号的归属权——异常信号是进程级的处理(第五章细说链路),一个线程被信号带走时,进程本身已经终止了,不存在「join 一个异常退出的线程」这回事,自然也看不到什么结果。所以 join 的世界里只有一种东西:线程正常退出时留下的状态。
二、传参和返回值,可以是任意类型
线程(四)讲过 pthread_create 的第四参数和入口函数的返回 void*——当时说「要传多个值就打包成结构体传地址」。这一章把这句话展开:给线程传递的参数和返回值,可以是任意类型。void* 只是个筐,装什么由你设计。
2.1 参数,打包成
要把一堆数据交给线程,最干净的做法是封装成一个类,把对象地址从第四参数递进去:
Task*t=newTask(10,20);// 任务数据打进对象pthread_create(&tid,nullptr,routine,t);// 对象地址当 arg入口函数里把 void* 还原成 Task*,数据就全拿回来了。参数是几个数字还是一个上下文,对接口毫无影响——接口不变,类型随便换,这就是 void* 设计的弹性。
2.2 返回值,打包成Result
返回方向同理。线程(四)说过入口函数返回的 void* 就是返回值通道——结果只有一个整数,转成 void* 塞回去就行;结果是一组数据,就封装成 Result 类对象,返回它的地址,join 那边还原。
指向哪有讲究——不能指向线程自己的栈,这是下一章的栈坑,先记住结论:指向堆上 new/malloc 出来的空间,或者全局、静态区。
#include<iostream>#include<pthread.h>classTask{public:Task(inta,intb):_a(a),_b(b){}intExecute(){return_a+_b;}~Task(){}private:int_a;int_b;};classResult{public:Result(intresult):_result(result){}intGetResult(){return_result;}~Result(){}private:int_result;};void*routine(void*tem){Task*t=(Task*)tem;Result*result=newResult(t->Execute());std::cout<<"结果是"<<result->GetResult()<<std::endl;deleteresult;returnnullptr;}intmain(){Task*t=newTask(10,20);pthread_t tid;pthread_create(&tid,nullptr,routine,(void*)t);pthread_join(tid,nullptr);deletet;return0;}三、线程的退出,三种终止方式
结束的语义立住了,这一章看线程主动退场的三条路。线程(四)讲 join 时留过一个清单——退出状态有三个来源:线程函数 return 的值、pthread_exit 的参数、被 pthread_cancel 时的固定宏。逐个拆开。
3.1 return,最常规的收场
入口函数执行到 return,线程自然结束——1.2 的语义:入口函数结束,当前线程结束,返回值就是退出状态。最常规,没有额外动作。
void*routine(void*tem){Task*t=(Task*)tem;Result*result=newResult(t->Execute());std::cout<<"结果是"<<result->GetResult()<<std::endl;deleteresult;returnnullptr;}主线程的情况 1.1 已经立住了:main 里 return 等价 exit,全体终止。所以 return 作为退出方式,只适用于子线程。
3.2 pthread_exit,谁调用谁退出
voidpthread_exit(void*retval);// 参数即退出状态,join 能拿到谁调用谁退出——主线程调它就只结束主线程,其他线程照常跑,进程要等所有线程都跑完才终止。
void*routine(void*tem){Task*t=(Task*)tem;Result*result=newResult(t->Execute());std::cout<<"结果是"<<result->GetResult()<<std::endl;deleteresult;// return nullptr;pthread_exit(result);}intmain(){Task*t=newTask(10,20);pthread_t tid;pthread_create(&tid,nullptr,routine,(void*)t);void*statu=nullptr;pthread_join(tid,&statu);Result*res=(Result*)statu;std::cout<<"退出码为:"<<res->GetResult()<<std::endl;deletet;deleteres;return0;}子线程里 return 和 pthread_exit 基本等价:子线程 return 到达终点时,库会隐式帮你调一次 pthread_exit,把返回值转成交出状态。差别只在主线程:return 是叫上所有人一起走,pthread_exit 是自己先走。
3.3 retval不能指向线程自己的栈
pthread_exit 的参数是个指针坑,必须记住:retval 指向的对象不能在线程自己的栈上。
线程退出后,它的栈立刻作废,栈上的局部变量随时被复写。join 拿到这个指针时,指向的内容早就不是当年的值——一个野指针。2.2 的结论在这里兑现:要传数据出去,指向全局、静态,或者堆上 new/malloc 出来的空间。
3.4 pthread_cancel,别的线程喊停
前两种都是线程自己退,这一种是别的线程让它退:
intpthread_cancel(pthread_t thread);void*newidea(void*mes){while(true){std::string name=(char*)mes;std::cout<<"我是新线程,我的name:"<<name<<" "<<"我的pid是:"<<getpid()<<std::endl;sleep(1);}return(void*)123;}intmain(){pthread_t tid;pthread_create(&tid,nullptr,newidea,(void*)"pthread-1");sleep(1);void*val;pthread_cancel(tid);pthread_join(tid,&val);std::cout<<"ret is : "<<(longlongint)val<<std::endl;return0;}被取消的线程,退出状态固定为 PTHREAD_CANCELED 这个宏——join 拿到它,一眼就知道这个线程是被取消的,不是正常退出的。
这个宏的值有讲究:POSIX 只规定它是一个「不与任何内存对象指针相等、也不等于 NULL」的特殊 (void*) 值;Linux 的 glibc 实现里,它就是-1——所以有些资料会说「被取消的线程退出结果是 -1」。
还有一个容易被忽略的事实:join 是确认取消完成的唯一方式。man 手册的原文——pthread_cancel 返回 0 只说明取消请求成功递出,目标线程是否真的终止了,只有 join 它才知道。
3.5 取消不是立刻生效的
pthread_cancel 发出后,目标线程马上就停吗?不一定。取消的生效时机由目标线程自己的两个属性决定:
- 取消状态:可取消(默认)或忽略取消——设成忽略,请求就先挂着
- 取消类型:延迟取消(默认)或异步取消
默认组合是「可取消 + 延迟」:取消请求递到之后并不立即执行,挂起等线程跑到下一个取消点。取消点是一批系统规定的位置——read、write、sleep、pthread_join 这些可能阻塞的调用处,完整清单在 man pthreads(7) 里。线程跑到取消点,检查有没有挂起的取消请求,有就执行。
这带来一个实际问题:一段纯计算的死循环里没有任何取消点——pthread_cancel 发一万次,线程照样转。解法是线程自己在循环里插一个pthread_testcancel(),手动造一个取消点。
取消真正生效时,线程做的事等价于调pthread_exit(PTHREAD_CANCELED):清理函数逆序执行、线程退出,join 拿到那个宏。Linux 上这个请求的递送也和信号系列对得上——NPTL 线程库用第一个实时信号(32 号)把取消请求送进目标线程。
异步取消(PTHREAD_CANCEL_ASYNCHRONOUS)可以做到随到随取消,但随时可能砍在线程持有资源、状态没收拾干净的瞬间——官方文档明确不推荐,知道有这个东西就行。
void*worker(void*arg){(void)arg;volatilelonglongcnt=0;while(1){cnt++;// 实验 A:什么都不加。纯计算循环不调用任何函数,// 没有任何取消点,cancel 请求只能一直挂着。// (刻意不 printf:它要往 stdout 写,某些实现里// 也可能被做成取消点,会污染实验)// 实验 B:把这行注释打开,循环里有了手动取消点// pthread_testcancel();}returnNULL;}intmain(){pthread_t tid;pthread_create(&tid,NULL,worker,NULL);sleep(1);// 给新线程一点跑起来的时间pthread_cancel(tid);// 只是发一个取消"请求",不保证停void*ret=NULL;pthread_join(tid,&ret);// 等线程真的停下来if(ret==PTHREAD_CANCELED)printf("线程被取消,join 拿到 PTHREAD_CANCELED\n");elseprintf("join 正常返回,线程不是被取消的\n");return0;}四、pthread_detach,分离线程
4.1 为什么要分离
join 是手动回收:等线程结束、取退出状态、释放资源,三步一次不落。要结果的线程,这一趟值得。
但有些线程天生是后台干活的——日志、心跳、定时清理,主线程根本不关心它返回什么;服务器高并发场景每个请求起一个线程,主线程也不可能挨个 join 一遍。对这些线程,join 是纯负担。
pthread_detach 就是给这个场景的:
intpthread_detach(pthread_t thread);把线程设为分离状态:它一退出,库自动回收它的全部资源,不需要任何人 join。
有一个容易误解的点要掰正:分离的线程,依旧在进程的地址空间中,进程的所有资源,被分离的线程依旧可以访问、可以操作——分离改变的是资源回收方式,不是把线程踢出进程。
4.2 joinable和分离互斥
规矩很硬:一个线程要么被 join,要么被分离,二选一。
- 对分离的线程调 pthread_join,直接报错 EINVAL——它已经不是 joinable 状态了
- 分离是单向门:一旦分离,不能被 join,也不能改回 joinable
- 对已经分离的线程再 detach 一次,是未定义行为
反方向的账线程(四)算过:joinable 的线程不 join 不 detach,就是僵尸线程,TCB 和退出状态永久挂着,泄漏照算。所以每个线程出生时都带着一道选择题——join 还是 detach,必须选一个。
还有一条 man 手册明确提醒的边界:分离不改变线程的从属关系——进程 exit(等价于主线程 return)时,分离线程照样被强制终止,不会因为「分离开了」就多活一秒。想让后台线程跑完,主线程要么 join 要么 pthread_exit,不能直接 return 走人。
void*newidea(void*mes){pthread_detach(pthread_self());std::string name=(char*)mes;std::cout<<"我是新线程,我的name:"<<name<<" "<<"我的pid是:"<<getpid()<<std::endl;sleep(1);return(void*)123;}intmain(){pthread_t tid;pthread_create(&tid,nullptr,newidea,(void*)"pthread-1");sleep(2);void*val;pthread_join(tid,&val);std::cout<<"ret is : "<<(longlongint)val<<std::endl;return0;}4.3 两种分离姿势
分离有两个方向,对应两种姿势:
1. 主线程分离新线程
创建之后主线程调 pthread_detach(tid),从此不管。
2. 新线程自己分离自己
最常见的写法是新线程入口第一行:
void*thread_func(void*arg){pthread_detach(pthread_self());// man 手册原生的标准示例// ... 干活 ...returnNULL;}从此自生自灭,主线程完全不用管。
课堂 demo(代码由浪哥填充):新线程分离后,主线程尝试对它调 pthread_join——试试会出什么结果:观察返回 EINVAL 报错,印证 4.2 的单向门。
五、隔离性,线程之间没有隔离墙
5.1 一个线程崩,全进程崩
线程(三)给线程列缺点时说过「健壮性低——一个线程崩全进程崩」,当时按住了没展开,这里正面拆。
一个线程野指针、数组越界、除 0——CPU 触发硬件异常,内核捕获后把异常转成信号(SIGSEGV、SIGFPE 这类),信号发给的是整个进程。信号的处理粒度本来就是进程级(线程(四)kill -9 的账),默认处理是终止进程——于是所有线程一起陪葬。除 0 的例子线程(三)写过,这里不重演。
void*newidea(void*mes){inta=10/0;return(void*)123;}intmain(){pthread_t tid;pthread_create(&tid,nullptr,newidea,(void*)"pthread-1");void*val;pthread_join(tid,&val);return0;}为什么操作系统这么狠?因为线程共享地址空间,一个线程把共享数据踩坏了,别的线程读到的全是脏的——无法确认谁还干净,只能整体终止。这不是设计缺陷,是共享的必然代价。1.4 说 join 不管异常信号,根子也在这:信号一到,进程都没了,join 没有可以等待的对象。
5.2 进程之间有墙,线程之间没有
把进程和线程放一起看,「隔离」这个词才立体:
| 对比项 | 进程之间 | 同进程线程之间 |
|---|---|---|
| 地址空间 | 各自独立 | 共享同一个 |
| 隔离墙 | 有——各自的页表把空间隔开 | 没有,一锅端 |
| 崩溃波及 | 崩了不牵连别的进程 | 一个崩,全进程陪葬 |
| 数据往来 | 要走进程间通信 | 直接读写共享变量 |
| 拿什么换 | 稳定,但通信贵 | 通信零成本,但无隔离 |
进程之间的那堵墙,就是页表——线程(二)拆过:每个进程一套页表,CR3 一换,谁也看不见谁的空间,这就是隔离的物理载体。同进程的线程共享同一套页表,天然没有墙——flag 实验里子线程改完全局变量主线程立刻可见,是共享的福利;一个线程崩了全员陪葬,是共享的账单。一张页表,两头都写明白了。
5.3 健壮性低和复用结构更健壮,为什么不矛盾
上一篇 2.3 已经正面回答过这对看似打架的结论(它串起篇36「复用结构更健壮」和线程(三)「健壮性低」两个说法),这里不再重讲,只接一句定性:内核实现层的健壮和应用层的脆弱是两个层面——隔离性的缺失不是内核的锅,是共享的代价,两句话合起来正好是线程的完整画像。
六、全篇总结
一条线收拢:
- 结束语义:main 结束 = 主线程结束 = 进程结束;入口函数结束 = 当前线程结束;任何线程调 exit 都是终止进程,join 只等健康跑完的线程、不管异常信号
- 传参返回值:void* 是筐,参数打包成 Task、返回值打包成 Result,接口不变、类型随便换;指向别碰线程自己的栈
- 三种退出:return 最常规但只适用于子线程(主线程等价 exit 全体陪葬);pthread_exit 谁调用谁退出;pthread_cancel 退出状态固定 PTHREAD_CANCELED(glibc 里就是 -1);默认延迟取消,要跑到取消点才生效,纯计算循环得 pthread_testcancel 手动造点
- detach:分离换自动回收,与 join 互斥、单向门、不能改回;主线程分离新线程、新线程自己分离自己两种姿势;进程 exit 面前分离线程照样终止
- 隔离:线程之间没有墙——页表是进程的墙,线程共享页表所以一崩全崩;健壮性两层说法上一篇已经对齐
七、文末面试题
7.1 推导题(按本讲知识点,附答案)
先自己想,再看答案——答案都用本章的逻辑推,不引入新知识。
【推导】主线程 return 和 pthread_exit 有什么区别?
答:main 里 return 等价于调 exit,整个进程终止,所有线程强制结束;主线程调 pthread_exit 只结束自己,其他线程照常运行,进程等所有线程跑完才终止。【推导】pthread_exit 的返回值为什么不能指向栈上局部变量?
答:线程退出后自己的栈立刻作废,栈内存随时被复写,join 拿到的是野指针。要传数据就指向全局、静态或堆上的对象。【推导】被 pthread_cancel 取消的线程,join 拿到什么?
答:固定是 PTHREAD_CANCELED 宏(glibc 实现里值为 -1),一眼区分被取消和正常退出。pthread_cancel 返回 0 只说明请求递出了,join 是确认取消完成的唯一方式。【推导】detach 之后还能 join 吗?还能改回 joinable 吗?
答:都不能。join 分离线程直接报 EINVAL;分离是单向门,不能改回 joinable;对已分离线程再 detach 是未定义行为。join 或 detach 必须二选一。【推导】为什么取消线程不是立刻生效的?
答:默认是延迟取消——请求挂起,等线程跑到下一个取消点(read/write/sleep 这类可能阻塞的调用处)才执行。纯计算循环没有取消点,要靠 pthread_testcancel 手动造一个。【推导】分离的线程,主线程 return 后还会继续跑吗?
答:不会。detach 只改变资源回收方式,不改变从属关系——进程 exit 时分离线程照样被强制终止。【推导】join 能等到一个被信号杀死的线程吗?
答:不能,这个问题本身不成立。异常信号是进程级处理,线程被信号带走时进程已经终止,不存在可供 join 的退出状态——join 的世界里只有健康退出的线程。
7.2 真题(来源已核实,转述注明)
主线程退出,其他线程会退出吗?
答:看主线程怎么退。return 或调 exit——整个进程终止,其他线程全部强制结束;调 pthread_exit——只结束主线程自己,其他线程继续运行,进程等所有线程结束才终止。
【真题·转述自 CSDN 博客《6 Linux系统编程-面试题》(blog.csdn.net/bdarkray/article/details/153815650)为什么一个线程崩溃会导致整个进程崩溃?
答:线程共享进程地址空间,野指针、越界、除 0 触发硬件异常,内核转成信号发给整个进程(信号的处理粒度是进程级),默认动作终止进程,所有线程陪葬;且共享数据可能已被破坏、无法确认影响范围,只能整体终止。
【真题·转述自 博客园《操作系统面试题总结》(www.cnblogs.com/WindSun/p/13562923.html)
💬 结束的语义立住了,Task/Result 打包传参收结果,return、pthread_exit、pthread_cancel 三条退出路走通,detach 换自动回收,没墙的隔离一崩全崩——线程的控制接口到此收官。觉得有收获,点个赞再走。