【Linux线程】线程控制全解析:终止、join/detach、cancel、线程栈与 NPTL(下篇)

🔥 本文定位 :这是 Linux 线程系列下篇。上篇解决了线程模型、地址空间、资源边界、pthread_create() 与线程 ID;本篇继续完成线程的整个生命周期,重点讨论退出、回收、分离、取消和底层实现。

💡 学习目标 :读完本文,你应该能分清 线程终止与进程终止、joinable 与 detached、取消请求与实际取消、线程栈与 guard page、Pthreads 与 NPTL/clone,并能识别线程封装中的生命周期漏洞。

📌 阅读建议 :如果你对 LWP、共享地址空间、pthread_t、TGID 和 TID 还不熟悉,建议先阅读上篇;若已有基础,也可以直接从本文开始。


文章目录


📖 上篇回顾 :进程提供资源容器,线程是其中可独立调度的执行流;同组线程共享地址空间,但保留独立寄存器、栈、信号掩码、errno 与调度状态。pthread_t 是线程库标识,Linux TID 则用于内核和诊断工具。


一、线程的四种终止路径

1.1 从入口函数 return

c 复制代码
static void *worker(void *arg)
{
    /* ... */
    return result;
}

start_routine 返回,等价于用该返回值调用 pthread_exit()。这只结束当前线程。

1.2 pthread_exit()

c 复制代码
void pthread_exit(void *retval);

pthread_exit(retval) 终止调用线程,并把 retval 留给 join 它的线程。返回值指向的对象必须在目标线程退出后仍然有效,不能指向它自己的局部变量。

错误示例:

c 复制代码
static void *bad_worker(void *arg)
{
    int local = 42;
    return &local;  // 错误:线程退出后 local 生命周期结束
}

1.3 pthread_cancel() 导致取消

另一个线程可以发出取消请求。默认是延迟取消:请求先变成 pending,目标线程在启用取消且到达取消点时才执行清理并退出。它不是一般意义上的"立即杀死线程"。

1.4 exit() 或 main 返回终止整个进程

以下行为会结束整个进程,包括其他线程:

  • 任意线程调用 exit()_exit() 或触发进程终止;
  • 初始线程从 main() 返回;
  • 收到默认动作为终止且未被处理的信号。

如果初始线程希望结束自己、但让其他线程继续运行,可以调用:

c 复制代码
pthread_exit(NULL);

不过工程上更常见的是明确 join 工作线程后让 main 正常返回。


二、pthread_join:等待并回收 joinable 线程

2.1 为什么线程退出后还要 join

默认创建的线程是 joinable。它退出后,执行已经停止,但线程库/系统仍需保留部分终止信息和资源,等待另一个线程取得返回值并完成回收。

c 复制代码
int pthread_join(pthread_t thread, 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 方通常接管释放责任:

c 复制代码
void *raw = NULL;
int err = 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 获取返回值。

运行中可以分离:

c 复制代码
int err = pthread_detach(tid);

也可以创建时设置 detached 属性:

c 复制代码
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

int err = pthread_create(&tid, &attr, worker, arg);
pthread_attr_destroy(&attr);

3.2 为什么 sleep 后再 join 不是正确实验

下面这种模式带有竞态:

c 复制代码
pthread_create(&tid, NULL, worker, NULL);
sleep(1);               // 猜线程已经自我 detach
pthread_join(tid, NULL);

调度器并不保证 1 秒内目标一定执行到 pthread_detach()。测试线程状态不能依赖 sleep。创建者若一开始就确定不 join,应通过属性在创建前设为 detached。

3.3 detached 不等于"不用管理生命周期"

自动回收的是线程执行资源,不会自动解决:

  • 线程参数指向对象何时销毁;
  • detached 线程是否仍访问宿主对象;
  • 进程退出时任务是否做完;
  • 如何通知线程停止;
  • 如何观察失败结果。

Detached 更接近"我放弃 join 和返回值",不是"这个任务从此没有所有权问题"。服务代码中盲目 fire-and-forget 往往会制造关闭竞态。


四、pthread_cancel:取消是一个协作协议

4.1 pthread_cancel() 只发送取消请求

c 复制代码
int pthread_cancel(pthread_t thread);

成功返回只说明请求已发送,不代表目标已经退出。默认设置为:

  • 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 保证取消路径释放资源

c 复制代码
static void unlock_mutex(void *opaque)
{
    pthread_mutex_t *mutex = opaque;
    pthread_mutex_unlock(mutex);
}

static void *worker(void *opaque)
{
    pthread_mutex_t *mutex = opaque;

    pthread_mutex_lock(mutex);
    pthread_cleanup_push(unlock_mutex, mutex);

    /* 可能到达取消点的工作 */
    pthread_testcancel();

    pthread_cleanup_pop(1);  // 正常路径也执行解锁
    return NULL;
}

pthread_cleanup_push/pop 在许多实现中是宏,必须按词法作用域成对使用。进入持锁临界区时还要仔细安排取消状态,避免"锁已获取、清理函数尚未建立"之间留下窗口。

4.4 更推荐显式停止标志

对于普通业务线程,通常更容易证明的方案是:

text 复制代码
控制线程设置 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(),或观察:

bash 复制代码
ulimit -s
cat /proc/<pid>/maps

5.2 使用 pthread_attr_t 调整栈

c 复制代码
pthread_attr_t attr;
int err = pthread_attr_init(&attr);
if (err != 0) {
    fprintf(stderr, "pthread_attr_init: %s\n", strerror(err));
    return 1;
}

size_t stack_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);
    return 1;
}

err = pthread_create(&tid, &attr, worker, arg);
pthread_attr_destroy(&attr);

栈大小至少满足 PTHREAD_STACK_MIN 和实现对齐要求。设置过小会让深递归、大型局部数组、复杂库调用更容易溢出;设置过大则会消耗大量虚拟地址空间,并限制可创建线程数。

5.3 guard page 的意义

线程栈通常配有保护区域。栈越界触碰不可访问保护页时,内核能更早报告错误,而不是悄悄破坏相邻映射。保护区不是无限保险:一次跨越很大的越界访问可能跳过保护页,应用仍应避免大对象上栈和无界递归。

5.4 参数对象必须活得比线程使用时间更久

危险示例:

c 复制代码
static void launch(void)
{
    pthread_t tid;
    struct task_arg arg = {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 内核接口建立新的调度实体。

简化理解:

text 复制代码
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 线程

有些示例这样调用:

c 复制代码
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 至少要有这些状态

text 复制代码
NEW ── Start ──> RUNNING ── worker return ──> FINISHED
                    │                              │
                    ├─ request stop                ├─ Join → JOINED
                    └─ Detach → DETACHED ──────────┘ 自动回收

需要明确:

  • 是否成功创建;
  • 当前是否 joinable;
  • 是否已经 join/detach;
  • worker 是否仍然访问 this
  • 析构时是 join、detach、请求停止还是禁止析构;
  • 创建失败时如何回滚;
  • 同一对象是否允许重复 Start()

7.2 原 PDF 中简单封装的几个风险

  1. 用全局普通计数器生成名字,多线程构造时存在数据竞争;
  2. worker 写 _status,控制线程同时读写而没有同步;
  3. detached worker 持有 this,对象可能先析构;
  4. 析构函数什么都不做,joinable 线程可能遗漏回收;
  5. pthread_cancel() 成功就立刻把状态写成 STOP,但目标可能尚未到取消点;
  6. 默认拷贝会复制 pthread_t 与状态,两个对象可能同时管理同一线程。

7.3 一个更稳妥的最小 RAII 方向

生产 C++ 项目优先考虑标准库 std::thread 或 C++20 std::jthread。如果为了学习封装 Pthreads,至少应禁止复制,并在析构前确保线程不再访问对象:

cpp 复制代码
class Thread {
public:
    Thread(const Thread&) = delete;
    Thread& operator=(const Thread&) = delete;

    ~Thread() {
        if (joinable_) {
            // 这里只展示所有权约束;真实策略需避免自 join 和无限阻塞
            pthread_join(tid_, nullptr);
        }
    }

private:
    pthread_t tid_{};
    bool joinable_{false};
};

析构自动 join 也不是无条件完美:可能阻塞、可能发生 self-join、也可能与程序关闭顺序冲突。更好的接口会提供显式 request_stop() + join(),并把共享状态放入独立、引用计数或严格拥有的对象中。

7.4 线程状态不是随便一个 bool

"正在运行""已请求停止""线程入口已返回""资源已 join"是不同状态。若多个线程需要读写这些状态,必须使用互斥锁、条件变量或 std::atomic 建立同步;仅仅把字段声明为 volatile 不会让它线程安全。


八、排查工具与高频面试题

8.1 查看线程列表

bash 复制代码
ps -L -p <PID> -o pid,tid,psr,stat,comm
top -H -p <PID>
  • PID:线程组 ID;
  • TID/LWP:内核线程 ID;
  • PSR:最近运行的 CPU;
  • STAT:线程状态;
  • COMM:线程名。

Linux 的 /proc 视图:

bash 复制代码
ls /proc/<PID>/task
cat /proc/<PID>/task/<TID>/status
cat /proc/<PID>/task/<TID>/stack   # 通常需要权限,显示内核栈

8.2 给线程命名

Linux/glibc 扩展:

c 复制代码
#define _GNU_SOURCE
#include <pthread.h>

pthread_setname_np(pthread_self(), "io-worker");

Linux 线程名限制为 16 字节(包含结尾空字符),因此可见名称最多 15 字节。_np 表示 non-portable;跨平台代码要做适配。

8.3 GDB 查看线程

gdb 复制代码
info threads
thread 3
bt
thread apply all bt

定位死锁或停滞时,thread apply all bt 能快速看到每条线程卡在哪个锁、条件变量或系统调用上。

8.4 strace 跟踪某进程的所有线程

bash 复制代码
strace -f -p <PID>
strace -ff -o trace ./app

-f 跟踪派生任务/线程,-ff 将不同 PID/TID 的记录拆分到不同文件。高并发程序输出量很大,应通过 -e trace=... 缩小范围。

8.5 常见错误清单

  1. -lpthread 代替更完整的 -pthread
  2. 把 Pthreads 返回值交给 perror()
  3. %X 打印 pthread_t,假定它是整数;
  4. pthread_self() 当作 Linux TID;
  5. 创建后用 sleep() 猜谁先执行;
  6. 把局部变量地址交给生命周期更长的线程;
  7. joinable 线程退出后永不 join;
  8. detached 线程继续访问已经析构的对象;
  9. 认为 pthread_cancel() 返回 0 就表示目标已退出;
  10. 使用异步取消却没有完整不变量和清理设计;
  11. 把 8 MiB 当成所有机器的固定线程栈大小;
  12. volatile 代替互斥锁或原子变量;
  13. 手写 clone(CLONE_VM | SIGCHLD) 并称之为 POSIX 线程;
  14. 依赖某个旧 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 和控制信息,并使用 Linux clone 能力创建同线程组的内核调度实体。应用不应绕过 NPTL 手工拼 flags 模拟 Pthreads。

8.7 权威参考



下篇总结

把上下两篇串起来,Linux 线程的完整生命周期可以概括为:

text 复制代码
pthread_create 创建线程
        ↓
线程在共享地址空间中独立执行
        ↓
return / pthread_exit / cancel 结束当前线程
        ↓
joinable 由 pthread_join 等待并回收
detached 在线程结束后自动回收线程资源
        ↓
glibc/NPTL 借助 clone 能力建立同线程组的内核调度实体

写可靠线程代码时,请牢牢记住:

  1. 线程入口 return 与 pthread_exit() 只结束当前线程,exit() 或 main 返回会终止整个进程
  2. joinable 线程必须 join,detached 线程也必须管理业务对象生命周期
  3. pthread_cancel() 成功只表示请求已发送,默认要到取消点才真正生效
  4. 异步取消可能破坏锁、容器和分配器内部不变量,一般不应使用
  5. 线程栈默认值不是跨平台固定 8 MiB,guard page 也不能替代正确的边界设计
  6. Pthreads 是接口契约,NPTL 是 glibc 的 Linux 实现,应用不应手拼 clone flags 模拟线程库
  7. 线程封装类必须明确 join/detach 所有权,并确保 worker 不会访问已经析构的对象
  8. 排查线程问题时,应把用户态 pthread_t 映射到 Linux TID,再结合 /proc、GDB 和 strace 观察

当你能准确解释"为什么 cancel 后仍要 join""为什么 detached 仍会 use-after-free""为什么 CLONE_VM | SIGCHLD 不等于 POSIX 线程"时,就真正理解了 Linux 线程控制的边界。

📖 系列导航:本文是下篇。发布上下篇后,可以在这里补充上篇链接。


如果本文对你有帮助,欢迎点赞、收藏。下一篇将进入线程同步与互斥,讨论数据竞争、互斥锁、条件变量、生产者消费者模型与死锁。

相关推荐
2602_959960921 小时前
电商大厂Java面试实录:Spring Boot/JVM/Redis/Kafka/微服务/安全/测试全解析
java·jvm·spring boot·redis·面试题
老当益壮梁奶奶1 小时前
Linux软件编程学习笔记(二)Linux文件IO编程入门:从fopen到fgetc
linux·c语言·c++·笔记·学习
sunoo-2291 小时前
C 语言文件 IO 全攻略:从基础函数到实战踩坑(BMP 读取 + 词典查询)
linux·c语言·前端·笔记·vscode·学习
白猫不黑1 小时前
从运维到网络安全:转行优势、路径与薪资前景全解析
运维·网络·web安全·网络安全·信息安全·渗透测试
吃饱了得干活2 小时前
限界上下文之后:微服务怎么拆、上下文怎么聊?
java·后端·架构
草莓熊Lotso2 小时前
【Linux网络加餐】手动部署:SSH 与 Web 服务实战 + 底层原理全解析
linux·运维·网络·人工智能·python·langchain·ssh
CHHH_HHH2 小时前
【Linux系统篇】深入解析Linux文件系统:从磁盘寻址到软硬链接
linux·服务器·开发语言·后端·ubuntu
yngsqq2 小时前
窗体快速取消
java·开发语言
岁月如歌77862 小时前
一次讲透 Redis 缓存击穿、穿透、雪崩,从原理到实战解决方案
java·后端·架构