「流浪」头像
关注
Linux系统篇41——线程(六) 三种退出方式、detach和线程的隔离性封面图

Linux系统篇41——线程(六) 三种退出方式、detach和线程的隔离性

在这里插入图片描述


📚 本文收录于「流浪」的系列专栏

🐧 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 = new Task(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>

class Task
{
public:
    Task(int a, int b) : _a(a), _b(b) {}
    int Execute()
    {
        return _a + _b;
    }
    ~Task() {}

private:
    int _a;
    int _b;
};

class Result
{
public:
    Result(int result) : _result(result)
    {
    }
    int GetResult() { return _result; }
    ~Result() {}
private:
    int _result;
};

void *routine(void *tem)
{
    Task * t=(Task*)tem;
    Result *result=new Result(t->Execute());
    std::cout<<"结果是"<<result->GetResult()<<std::endl;
    delete result;
    return nullptr;
}
int main()
{
    Task *t  = new Task(10,20);
    pthread_t tid;
    pthread_create(&tid,nullptr,routine,(void *)t);
    pthread_join(tid,nullptr);
    delete t;
    return 0;
}

在这里插入图片描述


三、线程的退出,三种终止方式

结束的语义立住了,这一章看线程主动退场的三条路。线程(四)讲 join 时留过一个清单——退出状态有三个来源:线程函数 return 的值、pthread_exit 的参数、被 pthread_cancel 时的固定宏。逐个拆开。

3.1 return,最常规的收场

入口函数执行到 return,线程自然结束——1.2 的语义:入口函数结束,当前线程结束,返回值就是退出状态。最常规,没有额外动作。

void *routine(void *tem)
{
    Task * t=(Task*)tem;
    Result *result=new Result(t->Execute());
    std::cout<<"结果是"<<result->GetResult()<<std::endl;
    delete result;
    return nullptr;
}

主线程的情况 1.1 已经立住了:main 里 return 等价 exit,全体终止。所以 return 作为退出方式,只适用于子线程。

3.2 pthread_exit,谁调用谁退出

void pthread_exit(void *retval);   // 参数即退出状态,join 能拿到

谁调用谁退出——主线程调它就只结束主线程,其他线程照常跑,进程要等所有线程都跑完才终止。

void *routine(void *tem)
{
    Task * t=(Task*)tem;
    Result *result=new Result(t->Execute());
    std::cout<<"结果是"<<result->GetResult()<<std::endl;
    delete result;
    // return nullptr;
    pthread_exit(result);
}
int main()
{
    Task *t  = new Task(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;
    delete t;
    delete res;
    return 0;
}

在这里插入图片描述

子线程里 return 和 pthread_exit 基本等价:子线程 return 到达终点时,库会隐式帮你调一次 pthread_exit,把返回值转成交出状态。差别只在主线程:return 是叫上所有人一起走,pthread_exit 是自己先走。

3.3 retval不能指向线程自己的栈

pthread_exit 的参数是个指针坑,必须记住:retval 指向的对象不能在线程自己的栈上。

线程退出后,它的栈立刻作废,栈上的局部变量随时被复写。join 拿到这个指针时,指向的内容早就不是当年的值——一个野指针。2.2 的结论在这里兑现:要传数据出去,指向全局、静态,或者堆上 new/malloc 出来的空间。

3.4 pthread_cancel,别的线程喊停

前两种都是线程自己退,这一种是别的线程让它退:

int pthread_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;
}

int main()
{
    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 : " << (long long int)val << std::endl;
    return 0;
}

被取消的线程,退出状态固定为 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;
    volatile long long cnt = 0;
    while (1)
    {
        cnt++;
        // 实验 A:什么都不加。纯计算循环不调用任何函数,
        //         没有任何取消点,cancel 请求只能一直挂着。
        //         (刻意不 printf:它要往 stdout 写,某些实现里
        //           也可能被做成取消点,会污染实验)
        // 实验 B:把这行注释打开,循环里有了手动取消点
        // pthread_testcancel();
    }
    return NULL;
}

int main()
{
    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");
    else
        printf("join 正常返回,线程不是被取消的\n");
    return 0;
}

四、pthread_detach,分离线程

4.1 为什么要分离

join 是手动回收:等线程结束、取退出状态、释放资源,三步一次不落。要结果的线程,这一趟值得。

但有些线程天生是后台干活的——日志、心跳、定时清理,主线程根本不关心它返回什么;服务器高并发场景每个请求起一个线程,主线程也不可能挨个 join 一遍。对这些线程,join 是纯负担。

pthread_detach 就是给这个场景的:

int pthread_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;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, newidea, (void *)"pthread-1");
    sleep(2);
    void *val;
    pthread_join(tid,&val);
    std::cout << "ret is : " << (long long int)val << std::endl;
    return 0;
}

在这里插入图片描述

4.3 两种分离姿势

分离有两个方向,对应两种姿势:

1. 主线程分离新线程

创建之后主线程调 pthread_detach(tid),从此不管。

2. 新线程自己分离自己

最常见的写法是新线程入口第一行:

void* thread_func(void* arg) {
    pthread_detach(pthread_self());   // man 手册原生的标准示例
    // ... 干活 ...
    return NULL;
}

从此自生自灭,主线程完全不用管。

课堂 demo(代码由浪哥填充):新线程分离后,主线程尝试对它调 pthread_join——试试会出什么结果:观察返回 EINVAL 报错,印证 4.2 的单向门。


五、隔离性,线程之间没有隔离墙

5.1 一个线程崩,全进程崩

线程(三)给线程列缺点时说过「健壮性低——一个线程崩全进程崩」,当时按住了没展开,这里正面拆。

一个线程野指针、数组越界、除 0——CPU 触发硬件异常,内核捕获后把异常转成信号(SIGSEGV、SIGFPE 这类),信号发给的是整个进程。信号的处理粒度本来就是进程级(线程(四)kill -9 的账),默认处理是终止进程——于是所有线程一起陪葬。除 0 的例子线程(三)写过,这里不重演。

void *newidea(void *mes)
{
    int a=10/0;
    return (void*)123;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, newidea, (void *)"pthread-1");
    void *val;
    pthread_join(tid,&val);
    return 0;
}

在这里插入图片描述

为什么操作系统这么狠?因为线程共享地址空间,一个线程把共享数据踩坏了,别的线程读到的全是脏的——无法确认谁还干净,只能整体终止。这不是设计缺陷,是共享的必然代价。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 推导题(按本讲知识点,附答案)

先自己想,再看答案——答案都用本章的逻辑推,不引入新知识。

  1. 【推导】主线程 return 和 pthread_exit 有什么区别?
    答:main 里 return 等价于调 exit,整个进程终止,所有线程强制结束;主线程调 pthread_exit 只结束自己,其他线程照常运行,进程等所有线程跑完才终止。

  2. 【推导】pthread_exit 的返回值为什么不能指向栈上局部变量?
    答:线程退出后自己的栈立刻作废,栈内存随时被复写,join 拿到的是野指针。要传数据就指向全局、静态或堆上的对象。

  3. 【推导】被 pthread_cancel 取消的线程,join 拿到什么?
    答:固定是 PTHREAD_CANCELED 宏(glibc 实现里值为 -1),一眼区分被取消和正常退出。pthread_cancel 返回 0 只说明请求递出了,join 是确认取消完成的唯一方式。

  4. 【推导】detach 之后还能 join 吗?还能改回 joinable 吗?
    答:都不能。join 分离线程直接报 EINVAL;分离是单向门,不能改回 joinable;对已分离线程再 detach 是未定义行为。join 或 detach 必须二选一。

  5. 【推导】为什么取消线程不是立刻生效的?
    答:默认是延迟取消——请求挂起,等线程跑到下一个取消点(read/write/sleep 这类可能阻塞的调用处)才执行。纯计算循环没有取消点,要靠 pthread_testcancel 手动造一个。

  6. 【推导】分离的线程,主线程 return 后还会继续跑吗?
    答:不会。detach 只改变资源回收方式,不改变从属关系——进程 exit 时分离线程照样被强制终止。

  7. 【推导】join 能等到一个被信号杀死的线程吗?
    答:不能,这个问题本身不成立。异常信号是进程级处理,线程被信号带走时进程已经终止,不存在可供 join 的退出状态——join 的世界里只有健康退出的线程。

7.2 真题(来源已核实,转述注明)

  1. 主线程退出,其他线程会退出吗?
    答:看主线程怎么退。return 或调 exit——整个进程终止,其他线程全部强制结束;调 pthread_exit——只结束主线程自己,其他线程继续运行,进程等所有线程结束才终止。
    【真题·转述自 CSDN 博客《6 Linux系统编程-面试题》(blog.csdn.net/bdarkray/article/details/153815650)

  2. 为什么一个线程崩溃会导致整个进程崩溃?
    答:线程共享进程地址空间,野指针、越界、除 0 触发硬件异常,内核转成信号发给整个进程(信号的处理粒度是进程级),默认动作终止进程,所有线程陪葬;且共享数据可能已被破坏、无法确认影响范围,只能整体终止。
    【真题·转述自 博客园《操作系统面试题总结》(www.cnblogs.com/WindSun/p/13562923.html)


💬 结束的语义立住了,Task/Result 打包传参收结果,return、pthread_exit、pthread_cancel 三条退出路走通,detach 换自动回收,没墙的隔离一崩全崩——线程的控制接口到此收官。觉得有收获,点个赞再走。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2502_94387000/article/details/166485608

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--