如果你写过基础的多进程,那种"每个程序干一件独立的事,靠管道、信号互相通信"的路子,会在一类问题上显得特别笨重:当我只想让"程序内部"同时干几件事——比如一边下载、一边渲染、一边响应用户点击——为每一件事都 fork 一个全新进程,代价大、通信又麻烦。这时候就该让子弹换一种飞法:不再把"每一份工作"物化成一个个进程,而是让它们作为"同一个程序内部的几条执行流"并行推进。这条执行流,就是我们这节课的主角——线程。
这篇是 Linux 线程系列的开篇,我会把"线程到底是什么"讲到骨子里,再带你把 pthread 库最核心的四个操作(创建、终止、等待、分离)亲手敲一遍。
学这一节你需要先有的底子
- 进程与 fork:进程是运行中的程序,fork 一下就是一个新进程。线程的很多"坑"都源于你把它想成了"廉价进程"。
- 虚拟地址空间:每个进程都认为自己独占一整块连续的虚拟内存(32 位约 0~4GB),由页表映射到真实物理内存。线程共享地址空间这件事,就建立在这之上。这一节我会把这块地基再夯一夯,因为它正是理解线程共享的钥匙。
- 栈:函数调用就在栈上压帧。每个线程都有自己私有的栈,而"新线程的栈"恰恰是最容易出 bug 的地方之一。
如果你对上面还有模糊,别担心,跟着走,我们就在文章里一点点讲明白。
什么是线程
先从最朴素的比喻说起。什么是线程?一个程序内部的一条执行路线,就叫线程(thread)。更准确的定义是:线程是"一个进程内部的执行序列"(也可以叫控制序列、执行流)。你写得再简单的一个 C 程序,也有且至少有一条执行流——从 main 进去,一路走到 return,这就是所谓的"一切进程至少都有一个执行线程"。这条由 main 衍生出来的线程,习惯上叫主线程。
线程在哪里运行?它运行在进程内部,本质上是在进程的地址空间里运行。这句话是全文的地基,我先把它钉在这里,后面反复用:
线程不是一个独立的"世界",它活在进程的虚拟地址空间这个"世界"里。
为了把这句话讲透,我们得绕一小段路,看清操作系统到底是怎么把一个进程打包给 CPU 的。这里的核心是虚拟地址空间和页表。你可能会问:学线程为什么要先看内存?因为线程共享的大部分"资源",本质上都是"共享了进程的虚拟地址空间"。不把地址空间搞懂,后面讲"为什么线程能互相看到对方的全局变量"就会像空中楼阁。
分页式存储管理:虚拟地址与页表
如果不用虚拟内存,物理内存会怎样
想象一个没有虚拟内存、没有分页的原始时代:每一个用户程序,它所占用的物理内存必须是连续的。可问题是,每个程序的代码段、数据段长度都不一样。当程序们进进出出、有些退出把内存还回来时,物理内存就会被切得七零八落,到处都是大小不一的"洞"——这些碎片之间夹着正在使用的内存,很难被再利用。
我们希望给用户程序提供的空间是"看起来连续"的,但物理内存最好别强行连续。怎么两全?答案是虚拟内存 + 分页。
页、页框、页表
把物理内存按固定长度切成一个个小格子,每一格叫一个页框(frame,也叫物理页)。物理内存每个页框都装着一个页(page)——页是数据块,页框是存储区域,两者的区分很重要:页框是一块物理存储区域,而页是一份可大可小、可以被塞进任意一个页框、也可以先躺在磁盘上的数据块。一个页的大小等于页框的大小。多数 32 位体系结构支持 4KB 的页,多数 64 位体系结构一般用更大的页(常见 4KB~8KB,有的体系也支持 2MB/1GB 的巨页)。
有了页框,操作系统为每个进程维护一张 页表(page table),这张表记下"进程的哪个虚拟页,落在了哪个物理页框上"。CPU 访问数据时用的不是物理地址,而是虚拟地址——它先进页表查虚实映射,再间接访问物理内存。所谓虚拟地址空间,就是操作系统为每个运行中的进程分配的一套逻辑地址,在 32 位机上范围是 0 ~ 4GB-1。
这样一来,进程眼里的"连续内存"是连续的(因为它看到的是连续的虚拟地址),而真实的物理内存却是离散的碎片正正好——哪里的页框空着就用哪里的。当初"物理内存必须连续"导致的内存碎片问题,就此被绕过去了。
物理内存由内核用 struct page 管理
物理页这么多,操作系统也要把它们管起来。内核用一个叫 struct page 的结构体描述系统中每个物理页(定义在 include/linux/mm_types.h),出于省内存的考量,这个结构体里塞了大量 union 联合体,让不同的子系统(buddy 伙伴系统、slab)可以复用同一块字段。对我们来说,只需记住里面几个关键成员:
// include/linux/mm_types.h 中 struct page 的若干关键字段(示意)
struct page {
unsigned long flags; // 页面状态,例如是否脏、是否被锁、是否 up-to-date
// union 中:_mapcount 表示有多少页表项指向本页(被引用了几次)
// virtual 是页在内核中的虚拟地址(高端内存时为 NULL)
// 其余与 slab/buddy 相关的联合字段这里省略
};flags存放页的状态,每一位表一种状态,能同时表达几十种。比如PG_locked表示页被锁、PG_uptodate表示页的数据已从块设备完整读入。_mapcount表示页表里有多少项指向本页,也就是本页被引用了多少次;一旦计数降为 -1,说明内核不再引用它,就可以拿去重新分配。virtual是页的内核虚拟地址;所谓"高端内存"并不永久映射进内核地址空间,此时该字段为 NULL,用到时再动态映射。
要注意的是 struct page 对应的是物理页,不是虚拟页;而且系统里每一个物理页都要分配一个这样的结构体。算一笔账:假设 struct page 约 40 字节、物理页 4KB、机器有 4GB 内存,那么共约 1048576(1 兆)个物理页,需要 40B × 1048576 ≈ 40MB 来描述——相对 4GB 内存只是很小一部分,代价可接受。页的大小其实是个权衡:页太大,页内剩余空间浪费(页内碎片);页太小,页数量多、页表长、还要频繁做页转换。所以适中最好,经验值 512B~8KB 之间,Linux/Windows 通常取 4KB。
页表自己也很占内存,于是有了多级页表
32 位系统,虚拟地址空间最大 4GB,页 4KB,那么一张完整的一级页表就需要 4GB/4KB = 1048576 个表项。每个表项存一个物理页的起始地址——注意一个物理页的地址天然是 4KB 对齐的(低 12 位全 0),所以表项其实只需记录高 20 位地址,但为了对齐,通常一个表项仍占 4 字节。于是这张页表本身要占 1048576 × 4B = 4MB,摊到物理内存上就是 1024 个页框。可麻烦来了:
- 当初分页的目的就是"不必用连续物理内存",可这一整张 4MB 的页表又要求连续的 1024 个页框,跟初衷拧巴了。
- 而且,按局部性原理,进程在一段时间内通常只会访问少数几页,没必要让整张页表所有逻辑页都常驻内存。
解决的思路是把页表"也当文件一样离散分配",即对页表再分页,于是有了多级页表。把那一张 1 兆表项的大页表,拆成 1024 张、每张 1024 项的小页表(每张占 4KB,正好一个页框)。这 1024 张小页表也需要被管理,于是再建一级表——页目录表(Page Directory)。这样从单级变成两级:虚拟页表(一级)→ 页目录(二级)。一个 10MB 的程序,按 4MB 倍数向上对齐取 12MB,只用 3 张小页表就够了,完全没有必要为它准备覆盖整个 4GB 的一张巨表。从"总字节数"看,两张方案都差不多是 4MB,但由虚到实、按需分配的潜力完全不同——这就是多级页表省内存、省连续空间的精髓。
页目录、CR3 与 MMU 的地址转换
页目录也放在物理内存里。谁指出页目录在物理内存的地址?一个专门的寄存器——CR3。它保存着当前任务的页目录地址,每次切换进程/任务都要换掉 CR3。所以操作系统加载一个程序时,不光要为程序内容分配物理内存,还要为它的页目录、页表分配物理内存。
整个地址转换的流程,属于硬件 MMU(Memory Management Unit,内存管理单元) 的常规工作。MMU 是一种硬件电路,速度很快,主要负责内存管理,地址转换只是它的一项业务。以 32 位 + 4KB 页为例,MMU 拿到一个虚拟地址后这样干活:
- 虚拟地址的低 12 位是页内偏移;剩下高 20 位分成两段,各 10 位(10+10),分别作为"一级页号"和"二级页号"。
- MMU 用 CR3 拿到页目录首地址,按一级页号查页目录表,找到下一级(该进程的某张)页表的物理位置。
- 再按二级页号查这张页表,找到最终想要访问的物理页框号。
- 把物理页框号和低 12 位页内偏移拼起来,得到物理地址,发到总线读内存。
MMU 要查两级表才能定位,相当于"N 级页表 = N 次检索 + 1 次读写"。页表级数越多,每次访存要多走几步,等待时间变长。这是多级页表的代价:省了连续空间和内存,却降低了每次查询的效率。
TLB:让页表查询变快的"快表"
计算机科学里的所有问题,都可以再套一层中间缓存解决。MMU 里也有一块缓存,叫 TLB(Translation Lookaside Buffer,转译后备缓冲器,俗称快表)。每次 CPU 给 MMU 一个新虚拟地址,MMU 先问 TLB:"这个映射你熟吗?" 熟就直接拿映射后的物理地址发总线,走人;不熟(Cache Miss)才去查页表,查到之后除了把地址发出去,还会顺手把这条虚实映射写进 TLB,方便下次直接命中。因为 TLB 容量小,命中率自然不如主存,但它确实是页表查询最主要的加速手段——这也引出一个和线程直接相关的性能点:切换地址空间会刷新 TLB,而线程间切换不必刷新,这正是后面"线程切换比进程切换快"的一个重要原因。
缺页异常
如果 MMU 在 TLB 和页表里都没找到目标虚拟页对应的物理页,怎么办?CPU 是暴脾气:拿不到数据就没法算,于是"罢工",触发缺页异常(Page Fault)。它是一个由硬件中断触发、却可以由软件逻辑纠正的错误——也就是说,它不一定表示程序有毛病,很多时候是操作系统安排的正常流程。
CPU 报告缺页后,进程会从用户态切换到内核态,把处理权交给内核的 Page Fault Handler(缺页处理函数)。处理方式因缺页类型而异:
- 硬缺页(Major / Hard Page Fault):物理内存里根本没有对应的物理页,需要 CPU 打开磁盘等块设备,把数据读进物理内存,再让 MMU 建立虚实映射。这是最"重"的一种。
- 软缺页(Minor / Soft Page Fault):物理内存里其实有对应的物理页,只是当前进程还不知道(比如别的进程已经把它调进来了),此时 MMU 只需补建一次映射即可,不用读磁盘。常见于多进程共享内存区域、或
fork后写时拷贝等场景。 - 无效缺页(Invalid Page Fault):这是"真出事了"的那一种。比如访问越界地址、对空指针解引用,内核会报
segmentation fault,把进程杀掉。
那么问题来了:代码段常见,你读到这,自然想问——我是怎么区分"这次缺页是正常的,还是我越界了?" 其实操作系统主要靠两条线索:
- 页号合法性检查:先看触发异常的虚拟地址的页号合不合法。页号合法但页不在内存 → 缺页(正常流程);页号非法 → 越界访问。
- 内存映射范围检查:再查这个虚拟地址是否落在当前进程合法的内存映射范围内。在范围内但页不在(或权限不够) → 缺页;不在范围内 → 越界访问。
再解开两个你学 C 时就积压的疑问:
第一,"越界了,一定会立即报错吗?" 不一定。只有当你访问到"既不在映射范围、又没有对应页"的地址(或违反页权限,如把只读当可写)时,才会触发无效缺页 → 段错误。而如果你越界访问的地址恰好还合法——比如数组越界几个字节、恰好踩到相邻的仍映射着的页——程序可能照样"正常运行",只是结果早已不可控。这正是不谈纸上谈兵、"越界一定崩"的真相。
第二,"new/malloc 申请内存到底在干什么?写时拷贝呢?" 申请内存,本质上是请求操作系统为进程建出映射(映射到空闲页框,或至少把一段地址空间标记为可用),而不是真的立刻把物理内存"填满"。内核惰性得很——常常先只给你一个虚拟地址范围,等你真正写到那一页时才真正分配物理页并建立映射,这就是"按需调页 + 写时拷贝(Copy On Write,COW)"的思想:fork 出的子进程先和父进程共享那些只读页,谁要写,才把这一页复制一份再改。malloc 到真正能摸到内存之间,隔了许多层这样的"占位 → 戳破"机制。
总结一句:只要把虚拟地址空间划分好,进程的"资源"就天然被划分好了;反过来,多个执行流共享同一份地址空间,也就天然共享了这份地址空间里的绝大部分资源——这正是"线程资源共享"的最底层真相。
线程的优点
费这么大劲造出线程,图什么?总的来说有这几条实打实的好处:
- 创建代价小:创建一个新线程,比创建一个新进程便宜得多。因为线程不必复制整套地址空间、页表、文件描述符等资源,只在已有进程内部加一条执行流、分一份栈就行。
- 切换开销低:线程之间的切换,操作系统要做的事比进程切换少很多。最显著的区别在于——线程切换时虚拟内存空间不变、页表不动,而进程切换要换一套地址空间。进程切换那种上下文切换,最伤的性能点是"把寄存器内容来回倒腾";还有一个更隐蔽的损失是它会扰乱处理器缓存:一旦切换上下文,处理器里已缓存的内存地址一瞬间作废,而改了虚拟内存之后 TLB 会被整体刷新,导致之后一段时间内存访问相当低效。线程切换没有这个刷新问题,所以切换更轻盈。
- 占用资源少:线程只多一份私有的栈和一小撮私有状态(见下节),比完整的进程轻得多。
- 能充分利用多处理器:在多个 CPU 核上,可以把计算拆成 N 个线程并行跑到 N 个核上。
- 在等待慢速 I/O 的同时干别的:一个线程阻塞在读磁盘/网络这种慢操作上时,别的线程可以继续算。这就是典型的 I/O 重叠。
顺着最后两条,业界把应用分成两类:
- 计算密集型(CPU-bound):为了在多核上跑得快,把一个大计算拆到多个线程并行执行。
- I/O 密集型(I/O-bound):为了提高吞吐,把多个 I/O 操作重叠起来——线程可以同时等待不同的 I/O 完成,等于把等待时间用并行吃掉了。
线程的缺点
优点有多亮,缺点就多扎心:
- 性能损失:一个很少被外部事件阻塞的计算密集型线程,往往没法跟其它线程分享同一个处理器。如果计算密集型线程的数量多于可用处理器核数,反而会有额外开销——多出来的只是同步和调度开销,可用资源却不变,净赚不到。
- 健壮性降低:多线程程序需要考虑得又全面又深。因为时间分配上的细微偏差、或者共享了不该共享的变量,都可能造成连锁的不良影响——线程之间是缺乏保护的,一个线程捅的娄子常常波及整个进程。
- 缺乏访问控制:进程是访问控制的基本粒度。在一个线程里调用某些 OS 函数(比如
exit、改工作目录、chmod之类影响面大的操作),会影响整个进程的所有线程。 - 编程难度提高:写和调试多线程程序,比单线程难一个量级。你要和无数的数据竞争、死锁、竞态打交道。
线程异常
这是个必须刻进脑子的点:单个线程出异常,进程通常会跟着崩。
具体说,如果某个线程出现了除零、野指针这类问题导致线程崩溃,进程往往会一起崩溃。原因是:线程是进程的执行分支,线程出异常,类同进程出异常——内核会通过信号机制(比如 SIGSEGV、SIGFPE)去终止进程;进程一旦终止,它内部的所有线程也就随之退出。所以别以为"只是我这条线程崩了,别的不受影响"——很遗憾,通常是全军覆没。
线程的用途
- 合理地用多线程,能提高 CPU 密集型程序的执行效率(多核并行)。
- 合理地用多线程,能提高 I/O 密集型程序的用户体验——比如生活中你一边写代码、一边后台下载开发工具,就是多线程运行的一种表现:下载那条执行流在慢慢等网络,而编辑界面那条依然响应你的输入。
进程与线程:哪些资源共享、哪些独占
现在回到最关键的一张地图:同在进程里,什么是我独有的,什么是大家共享的?
一句总纲:进程间具有独立性;线程共享进程的地址空间,所以也就共享了进程的资源;同时线程也有自己的一小部分私有数据。
进程是资源分配的基本单位,线程是调度的基本单位
这句几乎是定义级的话,要反复咀嚼:操作系统以进程为单位去分配、记账资源;但真正被调度器撵来撵去、占用 CPU 的,是最小调度单位——线程(在 Linux 里体现为"轻量级进程" LWP,下文会讲)。把这两顶帽子分开,你就能理解:线程共享了"资源分配"的集体户口,却各自有"被调度"的独立身份。
线程私有的数据
即使同处一个进程,每个线程也单独拥有:
- 线程 ID(每个线程的唯一标识)
- 一组寄存器,也就是线程的上下文(CPU 现场)
- 各自独立的栈
errno(每个线程一份,互不干扰——这点对写代码很重要,后面会说到)- 信号屏蔽字
- 调度优先级
特别提一下 errno:它是线程私有(其实是线程局部存储 TLS 的实现)的,这样 A 线程调用一个失败的系统调用后,不会被 B 线程的失败干扰。同理,这也是为什么 pthread 函数出错时不走 errno(走返回值),二者不冲突。
进程的多个线程共享什么
因为共享同一地址空间,所以 Text Segment(代码段)、Data Segment(数据段)天然是共享的:你定义一个函数,各线程都能调用;定义一个全局变量,各线程都能读写。此外各线程还共享:
- 文件描述符表(所有线程能看到同一套打开的文件、socket;其中一个线程关闭 fd,别的线程也"看得到")
- 每种信号的处理方式(
SIG_IGN、SIG_DFL或自定义的信号处理函数) - 当前工作目录
- 用户 ID 和组 ID
于是回头看以前学过的单进程程序:它就是一个只含一条线程执行流的进程。所谓"多进程 vs 多线程",本质区别就在于——多个进程间资源天然隔离(要通信得用 IPC),多个线程则天然共享资源(要隔离反而得费劲)。
POSIX 线程库
Linux 上写线程,用的是 POSIX 标准定义的线程接口,称为 pthread。字面上它来自"POSIX threads"(可移植操作系统接口之线程)。要记住三件事:
- 与线程有关的一大串函数,绝大多数都以
pthread_打头(pthread_create、pthread_join、pthread_exit……)。 - 使用它们,要在源码里
#include <pthread.h>。 - 编译链接必须带上线程选项:
gcc ... -pthread。经典教材常写作-lpthread,两者通常都行;但现代 glibc 更推荐-pthread(它除了链接libpthread,还会引入正确编译线程程序所需的一些宏与链接语义),我们全文统一用-pthread。
所以第一件事就是记住这个会不断出现的编译姿势:
# 把 thread.c 编译成可执行程序 app,-pthread 一定要带上,否则链接报错
gcc -o app thread.c -pthread如果你忘了 -pthread,链接时会报"对 pthread_create 未定义的引用"这类经典错误,等你敲代码遇到再回头对号入座。
创建线程:pthread_create
创建线程的函数原型长这样:
#include <pthread.h>
// 参数:thread 输出新线程ID,attr 线程属性(传 NULL 用默认),start_routine 线程入口函数,arg 传给入口函数的参数
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg);thread:指向pthread_t的指针,函数会把新线程的 ID 写回这里,作为输出参数。attr:线程属性。NULL表示全部用默认属性(我们通篇都传NULL,属性属于进阶话题)。start_routine:新线程启动后要执行的函数地址,也就是"线程入口函数"。它的签名固定是void *(*)(void *)——参数、返回值都是void *。arg:传给入口函数start_routine的实参。- 返回值:成功返回 0,失败返回错误码(注意是错误码,不是 -1,往下看)。
来个最简单的示例。让新线程和主线程各自死循环打印,你就能看到"两条流同时在跑":
// thread_create.c —— 演示 pthread_create
#include <stdio.h> // printf
#include <unistd.h> // sleep
#include <pthread.h> // pthread 库
// 新线程的入口函数:签名固定为 void *(*)(void *)
void *routine(void *arg)
{
// 新线程自己死循环打印
for (;;) {
printf("I am a new thread\n");
sleep(1); // 睡 1 秒,把 CPU 让出去一会儿
}
}
int main(void)
{
pthread_t tid; // 用来接收新线程的 ID
// 用默认属性(NULL)、不传参数(NULL)创建线程
// 返回值:成功 0,失败返回错误码
if (pthread_create(&tid, NULL, routine, NULL) != 0) {
perror("pthread_create"); // 失败打印错误(注意:这里用 perror 只是为了方便)
return 1;
}
// 主线程自己也死循环打印
for (;;) {
printf("I am main thread\n");
sleep(1); // 睡 1 秒
}
return 0; // 这行理论上到不了:主线程已经死循环
}编译并运行:
gcc -o thread_create thread_create.c -pthread
./thread_create你会看到二者的打印交替出现,且顺序并不固定:
I am main thread
I am a new thread
I am a new thread
I am main thread
...
这就是线程的第一个重要直觉:线程创建出来后,谁先跑到、谁先打印,是完全不确定的,取决于操作系统的调度,而不是你 pthread_create 写在哪一行。两个 for (;;) 谁都没让谁,sleep(1) 只是给双方轮流占用 CPU 喘息的机会,但次序仍然飘忽。
这里丢出本课第一道思考题,紧接着就揭晓答案。
思考题 1(有详细解答):既然主线程是死循环,为什么还能看到新线程打印?反过来,如果主线程先 return 0 退出了,新线程会怎样?
解答与详解:第一个问题——主线程死循环并不妨碍新线程运行,因为 CPU 有轮转调度(时间片),主线程被调度出去(或 sleep 让出 CPU)时,新线程就有机会分到 CPU 接着打印。所谓"两条执行流并发",对单核来说只是"分时交替推进",宏观上像同时进行。第二个问题——如果主线程从
main里return 0,等价于调用了exit,会直接终止整个进程。进程都没了,它地界里的所有线程(包括这个还活蹦乱跳的新线程)会立刻被一起清掉,不会等它打印完。这也呼应了前文"一个线程退出不等于进程退出,但进程退出等于所有线程退出"。所以一个常见的守则就是:想让程序在所有线程干完活之后再结束,就别让main提前 return;要么用pthread_join逐个等待(下文),要么让main阻塞/入睡直到各线程完成。
这个例子也暴露了本环节一个"不优雅"之处:新线程是可 join 的(默认),却无人 join 它,而且它永不终止。先别急,等我们把线程终止、等待、分离讲完,你会得到一个完整闭环。
关于 pthread 函数出错:返回错误码,而不是设置 errno
一个极重要的坑,趁现在讲掉。传统 POSIX 函数常见套路是"成功返回 0,失败返回 -1,并且设置全局 errno"。但 pthreads 系列函数不是这样:pthreads 函数出错时不会去设置全局 errno(而大部分其它 POSIX 函数会),而是把错误代码直接通过返回值返回给你。
这意味着写判断时不能照搬 perror 那套,而要用返回值 + strerror 转成可读文本。同时 pthreads 内部也提供线程私有的 errno 变量,以支持其它依赖 errno 的库代码;但对 pthread 函数自己的错误,官方建议直接看返回值——因为读返回值比读线程内 errno 开销更小。
所以更规范的错误检查写法是把返回值接住再翻译:
#include <stdio.h> // printf
#include <string.h> // strerror
#include <pthread.h> // pthread 库
void *routine(void *arg)
{
return NULL; // 新线程入口,这里什么都不做直接返回
}
int main(void)
{
pthread_t tid;
// 典型错误检查:接住返回值,成功为 0,失败是错误码
int ret = pthread_create(&tid, NULL, routine, NULL);
if (ret != 0) {
// strerror 把错误码翻译成人话;注意不要用 perror,因为 errno 没被设置
fprintf(stderr, "pthread_create failed: %s\n", strerror(ret));
return 1;
}
return 0;
}你能在错误输出里看到诸如 Resource temporarily unavailable(线程数超限)这一类基于错误码的文本,而不是"未知错误"。
线程标识:pthread_self 与 LWP
进程里有 getpid(),线程里怎么拿自己的 Thread ID?用 pthread_self():
#include <pthread.h>
// 返回调用者线程自己的"ID",类型为 pthread_t
pthread_t pthread_self(void);pthread_self() 返回的 pthread_t,到底是个什么东西?这里藏着一个最容易混的两套"线程 ID",务必要分清楚。
两套线程 ID:pthread_t(用户态)与 LWP(内核态)
打开课件反复强调的一张概念图,你会发现其实有两套 ID:
pthread_t的用户态线程 ID:这是 pthread 库给每个线程在进程内定义的唯一标识,由 pthread 库自己维护。因为每个进程有自己独立的内存空间,所以它的作用域是进程级,内核根本不认识它。在 Linux 的 NPTL 实现里,这个值本质上就是进程虚拟地址空间上的一个地址(指向描述该线程的 TCB,即struct pthread,我们最后一节再细看),你可以通过这个地址找回线程的 ID、栈、寄存器等属性。- 内核线程 ID(即 LWP):pthread 库底层也是通过系统调用(例如
clone)来创建线程的,而内核会给每个创建出的执行流一个系统全局唯一的 ID 来标识它——这个就是 LWP(Lightweight Process,轻量级进程),也就是ps -aL里那个真正的线程 ID,是内核调度器认得的对象。
一句话:pthread_self() 给你的是用户态进程内 ID(是个地址);ps -aL 的 LWP 才是内核眼里真正的线程 ID。 想看 LWP,把上面那个死循环程序的进程名改成便于 grep 的名字后运行,另开终端执行:
# -L 表示展示每个进程里的线程;head -1 先输出表头的一行
ps -aL | head -1
ps -aL | grep mythread你会看到类似:
PID LWP TTY TIME CMD
2711838 2711838 pts/235 00:00:00 mythread
2711838 2711839 pts/235 00:00:00 mythread
解读这两行:PID 列两边一样(都是 2711838),因为它们属于同一个进程;LWP 列一个是 2711838(和 PID 相同,这是主线程),另一个 2711839(新线程)。LWP 列里和 PID 相同的那一个线程就是主线程。 这也直观印证了:主线程的本质还是那个"进程的执行流",而新线程是内核里额外多出来的一个 LWP。
顺带一提:主线程的栈就放在进程虚拟地址空间的"栈区"(跟普通 C 程序一个栈);而其它线程的栈不在栈区,而是放在"共享区"(也就是文件映射区,位于堆和栈之间)——因为 pthread 库本身就在共享区,它用 mmap 在这里给每个新线程划一块私有栈(详情见"线程 ID 与进程地址空间布局"一节的线程栈讨论)。这也是 ps -aL 里"主线程栈在栈区、从属线程栈在共享区"的由来。
线程终止:pthread_exit 与 pthread_cancel
如果只想终止某一个线程而不终止整个进程,有三种方式:
- 从线程函数
return返回。这一招注意对主线程不适用:从main里return等价于调用exit,会干掉整个进程。 - 线程自己调用
pthread_exit终止自己。 - 一个线程调用
pthread_cancel去终止进程里的另一个线程。
先看自己终止自己:
#include <pthread.h>
// 终止调用它的线程;value_ptr 就是该线程的"退出状态/返回值"
void pthread_exit(void *value_ptr);pthread_exit 没有返回值——因为线程被终止了,无法返回到任何调用者(跟进程 exit 一样,结束即消失)。它唯一要交代的是 value_ptr。
这里就埋了一个非常著名的坑,也是本课反复强调的雷区。
思考题 2(有详细解答):给 pthread_exit(或让线程 return)传退出值的时候,为什么"绝对不要把 value_ptr 指向一个函数内部的局部变量"?
解答与详解:因为退出状态是在其它线程(配合
pthread_join的那位)手上读取的,而不是在本线程内读取的。局部变量活在"线程函数自己的栈帧"里,当这个线程return或pthread_exit之后,它的栈帧就已经被回收/销毁了,栈上那堆内存虽在,内容却已不可靠、甚至可能被后续调用覆盖。等另一个线程通过pthread_join拿到这个指针去读时,读到的是一块"悬垂的"、随时可能被复用的栈内存——轻则读到垃圾值,重则程序直接崩溃或产生未定义行为。只有当别的线程拿到这个返回指针时,那块内存还"好端端活着"才安全。所以退出值所指向的内存必须是:全局的,或通过malloc在堆上分配的;绝不能是线程函数自己的、会随函数返回而失效的局部栈变量。 凡是想把"函数内数组、普通局部变量"的地址传给pthread_exit/return的,都是自杀式写法。这在最后"新线程栈为什么危险"一节还会有一次更彻底的还原。
再看 pthread_cancel:终止"别人":
#include <pthread.h>
// 请求终止同一进程中指定 ID 的线程
int pthread_cancel(pthread_t thread);thread:要取消的线程的 ID。- 返回值:成功 0,失败返回错误码(pthread 一贯的返回错误码风格)。
需要注意,pthread_cancel 是"请求"取消,并非立即把目标线程从现场揪下来。默认情况下,目标线程会推迟到某个"取消点"(比如 sleep、printf、系统调用等)才实际执行取消。我们这节用它的默认行为即可(对一直 sleep/打印的线程,取消会很听话地生效)。
线程等待:pthread_join
为什么需要"等线程"?因为——
已经退出的线程,它的资源(退出状态、TCB 相关的资源)并没有被自动释放,仍然占据进程地址空间里的一块,是一个"僵尸而未被回收"的存在;而且,创建新线程不会自动复用刚才退出线程的这份资源。
换言之,退出的线程必须有人"收尸"(就像进程的 wait),否则资源越积越多,造成系统泄漏。
收尸的接口是 pthread_join:
#include <pthread.h>
// 阻塞等待 thread 这个线程结束,并把它的退出状态取出放到 *value_ptr
int pthread_join(pthread_t thread, void **value_ptr);thread:要等待其结束的那个线程的 ID。value_ptr:它指向一个void *指针变量;函数返回后,*value_ptr会被填成被等待线程的退出状态。如果对这个状态不感兴趣,直接传NULL。- 返回值:成功 0,失败返回错误码。
调用 pthread_join 的线程会挂起等待,直到 id 为 thread 的那个线程终止。那么"终止方式"不同,*value_ptr 里装的东西也不同,总结成一张表:
| 目标线程的终止方式 | 通过 pthread_join 得到的 *value_ptr |
|---|---|
通过 return 返回 | 存放的是线程函数的返回值 |
被别的线程 pthread_cancel | 存放的是常量 PTHREAD_CANCELED |
自己 pthread_exit 终止 | 存放的是传给 pthread_exit 的参数值 |
| 对结果不感兴趣 | 直接传 NULL 给 value_ptr 即可 |
下面的例子完整演示这三种终止方式,并逐一用 pthread_join 收尸、查看退出状态。请特别注意退出值全部用了 malloc 分配的堆内存,这正是上面思考题 2 的落地范式:
// thread_join.c —— 三种线程终止方式 + pthread_join 收尸
#include <stdio.h> // printf
#include <stdlib.h> // malloc / free
#include <unistd.h> // sleep
#include <pthread.h> // pthread 库
// 方式一:线程通过 return 返回
void *thread1(void *arg)
{
printf("thread 1 returning ...\n");
int *p = (int*)malloc(sizeof(int)); // 在堆上造退出值,不指向局部栈变量
*p = 1; // 把 1 存进去
return (void*)p; // 返回这个堆指针作为退出状态
}
// 方式二:线程自己调用 pthread_exit 结束
void *thread2(void *arg)
{
printf("thread 2 exiting ...\n");
int *p = (int*)malloc(sizeof(int));
*p = 2;
pthread_exit((void*)p); // 用 pthread_exit 结束,退出值同样是堆指针
// 后面的代码不会执行到
}
// 方式三:线程一直跑,等待被别人 pthread_cancel
void *thread3(void *arg)
{
// 无限循环,每 1 秒打印一次
while (1) {
printf("thread 3 is running ...\n");
sleep(1);
}
// 这里的 return 永远到不了,只是语法上让函数有值
return NULL;
}
int main(void)
{
pthread_t tid; // 复用同一个变量存放每次创建的新线程 ID
void *ret; // 用来接住 pthread_join 拿到的退出状态
// 1) 等待一个通过 return 返回的线程
pthread_create(&tid, NULL, thread1, NULL); // 创建
pthread_join(tid, &ret); // 阻塞等待它结束
printf("thread return, id %lu, exit code: %d\n",
(unsigned long)tid, *(int*)ret); // 取 *ret 解引用成 int 打印
free(ret); // 堆指针用完要释放
// 2) 等待一个通过 pthread_exit 结束的线程
pthread_create(&tid, NULL, thread2, NULL);
pthread_join(tid, &ret);
printf("thread exit, id %lu, exit code: %d\n",
(unsigned long)tid, *(int*)ret);
free(ret);
// 3) 一个死循环线程,由别人 cancel,再 join 看退出状态
pthread_create(&tid, NULL, thread3, NULL);
sleep(3); // 让 thread3 先打印几轮
pthread_cancel(tid); // 取消它(请求,随后在取消点生效)
pthread_join(tid, &ret); // 阻塞等它真正结束
if (ret == PTHREAD_CANCELED) // 被 cancel 的线程,退出值是这个常量
printf("thread canceled, id %lu, exit code: PTHREAD_CANCELED\n",
(unsigned long)tid);
else
printf("thread canceled, id %lu, exit code: NULL\n",
(unsigned long)tid);
return 0;
}编译运行:
gcc -o thread_join thread_join.c -pthread
./thread_join输出大致是:
thread 1 returning ...
thread return, id 5AA79700, exit code: 1
thread 2 exiting ...
thread exit, id 5AA79700, exit code: 2
thread 3 is running ...
thread 3 is running ...
thread 3 is running ...
thread canceled, id 5AA79700, exit code: PTHREAD_CANCELED
思考题 3(有详细解答):注意上面三次打印的线程 ID 都是同一个 5AA79700——这正常吗?是打印出错了吗?
解答与详解:正常,不是 bug。两层原因叠加:第一,主线程里复用了一个
pthread_t tid变量,每次创建新线程前它都会先经历"上一个线程 join 到——复用这块内存"的过程;第二,更本质的是,在 NPTL 实现里pthread_t是一个指向线程描述符(TCB)的地址,当某个 joinable 线程被pthread_join回收后、pthread 库创建下一个线程时,往往会复用同一块线程描述符/栈内存(因为一个进程内的线程数量有限、地址空间里那块区域常常重叠),于是打印出来的地址(被%X/%lu显示成5AA79700之流)看起来完全一样。所以线程 ID 相同不等于"是同一个线程活着"——它是被回收后重新启用的地址。真正要唯一区分"活着的某次线程",靠的是内核的 LWP(ps -aL),而不是空闲时会被复用的pthread_t地址。顺带一提:示例里对pthread_t用%lu/%X打印只是为了演示,实际工程里把pthread_t当"不透明明句柄"用就好,不必强求打印它的外观。
分离线程:pthread_detach
上一节的结尾留下一个责任问题:默认创建的线程是 joinable(可 join 的),它退出后,必须有人对它做一次 pthread_join,否则资源无法释放,造成系统泄漏。 换句话说:joinable 线程的"收尸义务"在别人(创建它的或任何愿意等的线程)手上。
可如果我就是不关心它的返回值、也不想要"等它"这个负担呢?有办法:在它身上调用 pthread_detach(分离),告诉系统"这个线程退出时你自己把资源回收掉吧"。
#include <pthread.h>
// 把指定线程设置为"分离"状态:退出时资源自动释放,无需也禁止再 join
int pthread_detach(pthread_t thread);分离操作可以"别人帮你分离",也可以"线程自己分离自己"——这句是最典型的自分离写法:
pthread_detach(pthread_self()); // 自己把自己设置成分离线程这里要强调一对硬性关系:joinable(可连接)和分离是互斥冲突的——一个线程不能既是 joinable 又是分离的。 分离之后,就不能再对它 pthread_join 了(对分离线程 join 会返回错误),因为它退出时资源自动释放,没有"状态可等";反之,joinable 线程若不 join 就会资源泄漏。两条路二选一,别脚踏两条船。
下面演示"线程自己分离自己",并观察在分离之后再去 join 会得到失败:
// thread_detach.c —— 线程自分离后,join 会失败
#include <stdio.h> // printf
#include <unistd.h> // sleep
#include <pthread.h> // pthread 库
// 新线程入口:先把"自己"分离掉,再打印一句话
void *thread_run(void *arg)
{
pthread_detach(pthread_self()); // 关键:自己把自己设为分离态(对自身线程 ID 操作)
printf("%s\n", (char*)arg); // 把传入的字符串参数打印出来
return NULL; // 分离线程返回,退出资源由系统自动回收
}
int main(void)
{
pthread_t tid;
// 创建线程;第二个参数传字符串字面量作为 arg
if (pthread_create(&tid, NULL, thread_run, "thread1 run...") != 0) {
printf("create thread error\n");
return 1;
}
sleep(1); // 很重要:让出时间让 thread_run 先完成"自分离"
// 否则主线程可能在这个新线程还没执行 pthread_detach 时就去 join,
// 时序不同会得到不同结果(这是个典型的竞争态)
// 对已经分离的线程做 join:会失败,返回错误码(不是 0)
if (pthread_join(tid, NULL) == 0) {
printf("pthread wait success\n");
return 0;
} else {
printf("pthread wait failed\n");
return 1;
}
}编译运行:
gcc -o thread_detach thread_detach.c -pthread
./thread_detach输出应当是:
thread1 run...
pthread wait failed
思考题 4(有详细解答):既然线程已经自分离了,为什么代码里还要 sleep(1) 那一下?如果去掉它会怎样?
解答与详解:这行是典型的"为了让演示结果稳定而加的同步"。线程创建出来后
thread_run里的第一件事才是pthread_detach(pthread_self());但主线程紧跟pthread_create之后就调用pthread_join,两者存在竞争(竞态):如果主线程的join抢在前面、而新线程的detach还没执行,那么此时 join 会成功(返回 0)——因为那一刻目标线程还处于 joinable 状态;如果detach先执行了,那么 join 就会失败。sleep(1)人为保证"让新线程先跑完detach",从而稳定复现"分离之后 join 必然失败"。去掉这行不一定会报错,但输出会在pthread wait success和pthread wait failed之间随机震荡——这本身就是多线程竞争状态最直观的教科书演示。真正的工程里,要避免这种不确定,绝不能靠 sleep 去"猜节奏",而要采用同步原语(互斥量、信号量等,后续课程讲)来保证先后关系。
线程 ID 与进程地址空间布局
如果前面还在概念层面给了"pthread_t 是个地址",这一节我们从数据上把它坐实,顺便回答"新线程的栈到底在哪、为什么危险"。
NPTL 与两种"线程 ID"再厘清
pthread_create 第一个参数会得到一个 pthread_t,这是用户态线程 ID,属于 NPTL 线程库的范畴;它其实指向进程虚拟地址空间里的一个内存单元——那个内存单元就是新线程的线程控制块(TCB)。线程库之后所有对该线程的操作,都是拿这个 ID(一块地址)去定位 TCB 再动手的。而内核调度用的 LWP(轻量级进程)完全是另一码事。不要混为一谈。
线程控制块(TCB,Thread Control Block) 是管理一个线程所需全部信息的数据结构。在 Linux 的 NPTL 实现里,它对应 glibc 里的 struct pthread(位于 nptl/pthread_create.c / nptl/descr.h),里面装着线程的 id、start_routine、参数、返回值 result、私有栈指针 stackblock 与大小 stackblock_size、调度参数等一长串"线程档案"。值得特别留意一个字段:
// 线程运行完毕的返回值就存在 TCB 的这个字段里
void *result;这解释了 pthread_join 为什么能拿到退出状态——它就是去读这个 TCB 的 result 字段。也顺势解释了"线程执行流可以退出,TCB 却可以暂时保留"这句话:线程代码跑完了,但描述它的 TCB 还留在进程地址空间里等人来 join 取走 result 后才能释放。这正是"退出线程要收尸"的物理层原因。
新线程的栈:mmap 出来的共享区固定大小栈
还记得前面说过"主线程的栈在栈区,其它线程的栈在共享区"吗?这里补全原理:内核虽然把线程和进程不加区分地统一用 task_struct 管理,但 Linux 对待"地址空间里的 stack"还是有区别的。
- 对进程(主线程),就是
main函数的栈:fork时拷贝父亲栈空间地址,靠写时拷贝以及按需动态向下增长。只要不超出ulimit规定的栈上限,栈可以随函数调用层级加深自动长;一旦超出上限,栈溢出就发段错误信号把进程干掉。"进程栈是唯一可以在访问未映射页时不一定立刻段错误"的这个特性,根源就在于它能动态申请新页。 - 对通过
pthread_create创建的子线程,它的栈不再是向下动态生长的,而是事先固定好的一块。它位于文件映射区(共享区)中,是 pthread 库调用mmap用系统调用划出来的一块匿名私有映射内存。在 glibc 的nptl/allocatestack.c里,allocate_stack就是这么干的:
// 简化示意:给新线程在共享区 mmap 一块固定大小的私有匿名栈
mem = mmap(NULL, size, prot,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);这个 size 你可以手动指定,也可以用默认值,默认通常就是 8MB(受 ulimit -s 影响)。关键区别是:这种栈不能动态增长,一旦用尽就没了——这和 fork 造出来的、可动态增长的进程栈完全不同。所以新线程如果在函数里递归得太深、或声明了超大局部数组,把这块固定栈用破,就会直接段错误崩掉(还可能触发保护页 guard page)。
思考题 5(有详细解答):为什么说"把栈地址传给新线程"非常危险?这里分两层,分别指"把调用者局部变量的地址作为参数传给线程"与"线程把栈上局部变量地址作为退出值返回",请一并分析。
解答与详解:两层都是"拿栈内存当跨线程的共享内存"的误区,根子是同一件事——栈内存的生命周期只属于它所在的函数帧,而函数一返回,那块内存就被当作可复用/可回收。
第一层(传给线程当参数):主线程或任一线程若把一个"自己函数里的局部变量"的地址作为
arg传给pthread_create,然后这个创建者函数立刻返回,那么这个局部变量所在的栈帧随之失效。可新线程可能这时候才姗姗来迟到arg指向的那块内存去读——它会读到一个"已被遗弃、可能被别的东西覆盖"的地址,轻则读到旧值或垃圾值,重则访问到不该访问的内存直接崩溃/未定义行为。这也是为什么跨线程传参要么传全局/静态,要么传堆上malloc出来的内存(并由调用方标明归属 free),要么用一个足够长的宏观生命周期的结构体。第二层(把栈局部变量地址当退出值返回):正对思考题 2 的翻版。线程函数里定义
int x = 42; return &x;,等另一个线程pthread_join拿到&x去*(int*)ret时,线程函数早已返回、x的栈帧早已作废——读到的值是"悬垂引用"。所以退出值必须指向malloc/全局等能活到读取那一刻的内存。最终心法一句话:栈内存是"函数私有、函数返回即失效"的,它既不该被发往其它线程当参数,也不该被送回其它线程当返回值;跨线程共享任何数据,请给它一个不依赖任何活着栈帧的住处(全局或堆)。
共享地址空间里,线程是怎么"既共享又不共享"的
走到这,把整条线串回最初的"资源共享"图景:同一进程的所有线程共享同一个地址空间,所以共享代码段共享、数据段共享、堆共享、文件描述符表共享、信号处理方式共享、当前目录与用户/组 ID 共享;而各自独占的是 TCB、寄存器现场、各自那块 mmap 出来的私有栈、errno、信号屏蔽字与调度优先级。共享的那部分,是"看同一块地址"的低成本红利;独占的那部分,是线程能独立被调度、独立调用的基础。也正是因为共享得这么彻底,才让"共享变量不加保护"成为最容易爆雷的地方——那块内存是公共的,谁都能改,谁改了谁负责。
从 glibc 源码看 create 的全貌
如果你愿意,追一眼源代码能让你记得更牢。pthread_create 在 glibc 里真正的形如 __pthread_create_2_1(老版本符号)的实现,关键步骤是这么几条(我按可读性重排了注释):
// glibc-2.4 nptl/pthread_create.c(高度简化的讲解版,只为看懂脉络)
int __pthread_create_2_1(pthread_t *newthread, const pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg)
{
const struct pthread_attr *iattr = (struct pthread_attr *)attr;
if (iattr == NULL)
iattr = &default_attr; // 没给属性就用默认
struct pthread *pd = NULL; // 传说中的 TCB(线程控制块)
int err = ALLOCATE_STACK(iattr, &pd); // 先为线程申请一大块空间(栈+TCB)
pd->start_routine = start_routine; // 把入口函数地址存进 TCB
pd->arg = arg; // 把参数存进 TCB
*newthread = (pthread_t)pd; // 把 TCB 的地址用作 pthread_t 返回出去
...
err = create_thread(pd, iattr, STACK_VARIABLES_ARGS); // 真正落到 clone 创建
...
return err;
}而 create_thread 拼出一组 CLONE_* 标志(CLONE_VM 共享地址空间、CLONE_FILES 共享文件描述符表、CLONE_FS 共享文件系统信息、CLONE_SIGHAND 共享信号句柄等),交给 ARCH_CLONE(即 __clone,一条汇封装下的 clone 系统调用)去内核创建轻量级进程。整个 allocate_stack 那段的关键就是:先在共享区用 mmap 划出栈,把 struct pthread(TCB)放在这块空间的顶部,把栈底的余下部分作为线程栈,并把 TCB 地址 + result 字段都实录下来,供 pthread_join 日后读取。
思考题 6(有详细解答):读完这两节,你能说出"用户线程"与"内核线程(LWP)"在 Linux 上到底是什么关系吗?为什么常说"线程调度是内核在做,pthread 只提供 API 封装"?
解答与详解:Linux 采用的是 1:1 线程模型(以现代 NPTL 实现为标准):一个"用户线程"(你用
pthread_create造出来的那个抽象执行流)在底层对应内核里的一个调度实体——一个轻量级进程/ LWP(内核里就是一份task_struct)。pthread 库负责的是"建 TCB、分配共享区栈、拼 clone 标志、把入口函数和参数填进 TCB、之后用 ID 去瞄准 TCB 做 join/detach"这些用户态的记账与调度辅助工作;而真正把线程"安排到某个 CPU 上跑、切换寄存器现场、按时间片轮转"的,都是内核在做——内核认的是task_struct/LWP,根本不认识 pthread 库那套pthread_t。所以:你可以在用户态用 pthread 接口轻松地"创建出很多条逻辑执行流",但它们每个都映射成一个内核实体,由内核统一调度。这跟另一种"纯用户态线程/协程(多个线程共享一个内核调度实体,即 M:1)"是截然不同的设计——那种模型里没有 LWP 一一对应,调度完全发生在用户态。理解这点,你就明白为什么ps -aL里能看到每个 pthread 线程都有自己的 LWP 行,也明白为什么线程切换比进程切换快(同一地址空间,TLB/页表不刷新)。
线程封装:把 pthread 藏进对象里
学完元语素,很多语言给你蒙上一层"线程类"的糖衣。以 C++ 为例,我们可以把上面这套 pthread_create/join/detach 封装进一个 Thread 类,代码可读性好很多。这类封装的内核思路非常简单:用一个静态成员函数作为真正的线程入口,把对象自己 this 当作参数传进去,在入口函数里再调用到对象的业务方法。
课堂的完整封装较长,我挑一个最讨巧、最原创的内核片段给你感受"对象化"是怎么绕开"pthread 入口必须是静态函数"这条限制的:
// 假装是 Thread 的一部分(核心思路,非完整文件)
namespace ThreadModule {
using threadfunc_t = std::function<void()>; // 可调用对象类型
class Thread {
// 线程真正的入口必须是静态(或自由)函数,因为 pthread_create 只收普通函数指针
static void *run(void *obj) {
Thread *self = static_cast<Thread *>(obj); // 参数还原成对象指针
self->_func(); // 再调真正的业务逻辑
return nullptr;
}
public:
void Start() {
// 把 this 塞进 arg,pthread_create 就会通过上面的 run 调回业务方法
::pthread_create(&_id, nullptr, run, this);
}
bool Join() { return ::pthread_join(_id, nullptr) == 0; }
private:
pthread_t _id; // 原生线程 ID
threadfunc_t _func; // 业务逻辑
};
}static void *run(void *obj) 这种"入口函数把 this 收回来再转发"的模式,几乎是所有 pthread 面向对象封装的通用模板,墙裂建议背下来。
新手最容易踩的四个坑:一页速查
收尾前,把全文反复出现的坑浓缩成一页,供你刷代码时对号入座:
- 坑 1:不
-pthread链接。链接时报"对pthread_*未定义的引用",多半就是这个。所有示例都记得gcc -o x x.c -pthread。 - 坑 2:
pthread_create谁先跑不确定。执行顺序不保证,别指望"先创建的一定先打印"。需要确定先后时靠同步原语,不靠代码顺序。 - 坑 3:把栈地址传给线程 / 把栈局部变量地址当退出值。前者是
arg的悬垂,后者是return/pthread_exit的悬垂,都会导致读取失效内存甚至崩溃(见思考题 2、5)。 - 坑 4:joinable 线程退出后不
pthread_join,资源不回收、越积越多造成泄漏;而分离之后又去join则会失败。资源回收的两条路(join / detach)必须明确选一条走。
结语
这一课我们把"线程到底是什么"从两个方向讲透了:方向上,它是进程虚拟地址空间里的一条执行流,共享进程的资源、各自私有小块状态;机制上,我们从页表、MMU、TLB、缺页一路讲到唯一的用户态线程 ID 就是 TCB 的地址,新线程的栈是共享区里 mmap 出来的固定大小区。
代码侧,你已经亲手用 pthread_create 造出了并行的两条路,用 pthread_self 认领自己,用 pthread_exit/return/pthread_cancel 三种姿势终结线程,用 pthread_join 给线程"收尸"、把三种终止的退出状态都捞回来比对,也用 pthread_detach 学会了"退出时自动回收"的甩手掌柜玩法。加上那张"四个坑"速查表,你已经具备了安全使用 pthread 的初阶内力。
但线程真正难的地方——共享变量怎么保护、多个线程怎么有序协作不打架——才刚刚露出冰山一角。下一课,我们就要进入线程安全与同步:互斥量、条件变量、信号量,看看怎样让抢着改同一块内存的线程们,从"互相踩踏"变成"排队上车"。
准备好了吗?
还没有评论 — 第一条由你来留。