在所有操作系统的话题里,"线程"大概是最容易让人以为自己已经懂了的那一个。
背下 pthread_create 的四个参数只要二十分钟,可只要往下多问一层------线程的栈是谁分配的?pthread_t 打印出来为什么像个地址?一个线程抛了异常,凭什么整个进程跟着陪葬?------那点"懂了"的感觉就会立刻漏气。
这一篇要做的,就是把漏气的地方全部补回来。而补完之后会有一个相当过瘾的收获:Linux 对线程的处理方式有一种近乎吝啬的聪明------它压根没有为线程发明任何新东西,只是把进程的零件拆开,让它们共用。 这个"抠门"的答案,比教科书上那句"线程是进程内的执行单元"有意思得多。
从这里出发,把镜头从操作系统的大全景一路推近,直到看清 pthread_create 括号里的每一个参数。
hello hello 呀,大家好 ,提前祝大家中秋快乐 这是"线程三部曲"的第一篇。
三篇各有各的视角:上篇是内核视角 ,我们把镜头从操作系统的大全景一路推近,直到看清
pthread_create那四个参数到底是什么;中篇是工程视角 ,讲怎么把 pthread 装进 C++ 的盒子,以及共享资源第一次翻车的事故报告;下篇是协同视角,讲一群线程怎么从"各抢各的"变成"排好队干活"。本篇不写一行"能跑起来"的多线程代码,但读完回过头再看那条
pthread_create(&tid, NULL, threadRun, NULL),会知道括号里每一个参数在内核里对应着什么。
本篇目录
- 一、先说清楚我们为什么要绕这么大一圈
- 二、宏观全景:三层视角,一张地图
- 三、第一层拆解:一个"正在运行的程序"到底占用了什么
- 3.1 从"进程"的定义说起
- 3.2 task_struct:一个执行流的全部档案
- 3.3 内存视野这一项,值得单独展开:mm_struct
- 四、第二层拆解:执行流自己,需要什么私有财产
- 4.1 一次思想实验:两兄弟共用一间房
- 4.2 私有的三样东西
- 4.3 一张表讲清楚:共享什么,私有什么
- 五、第三层拆解:Linux 的选择------不造线程,复用进程
- 5.1 一句话捅破窗户纸
- 5.2 pid 和 tgid:两个编号,各管一摊
- 5.3 三个可以立刻动手验证的实验
- 六、第四层拆解:内核给得太素,用户层来补
- 6.1 pthread 库存在的理由
- 6.2 库里的档案:TCB 是怎么被设计出来的
- 6.3 一个反直觉的结论:线程 ID 是一个地址
- 七、最底层:clone ------ 一切的起点
- 7.1 为什么不是 fork
- 7.2 用标志位"点菜"
- 八、回到起点:pthread_create 的四个参数到底是什么
- 8.1 第一个多线程程序:把理论跑出来
- 8.2 承上启下的收尾:两个被埋下的雷
- 九、本篇速查表
- 十、下一篇预告
一、先说清楚我们为什么要绕这么大一圈
线程的入门路径往往是这样:打开教程,抄一段 pthread_create,编译跑通,看到两行字交替打印,然后点点头------"哦,线程就是能同时跑两段代码的东西"。
这个理解没错,但是它薄。薄在哪里?薄在它只记住了现象 ,没有建立因果。于是接下来会遇到一连串想不通的问题:
- 为什么两个线程里
getpid()打印出来是同一个数字? - 为什么我在线程里改了全局变量,另一个线程能看见?
- 为什么任何一个线程崩了,整个程序都跟着死?
- 为什么
pthread_t打印出来是一个奇奇怪怪的大数(地址),而不是 1、2、3? - 为什么线程的栈要单独分配,而全局变量不用?
fork()和创建线程,底层到底差在哪?
这些问题,靠背 API 是回答不了的。它们的答案不在 pthread.h 里,而在内核里。所以这一篇我们换个讲法------不从 API 讲起,从操作系统讲起。
打个比方。要理解"一个城市的某条地铁线为什么这么走",光看地铁图是不够的,还得先知道这个城市的地形、人口分布、规划规则。线程就是那条地铁线,操作系统就是那座城市。
本篇的主线只有一条:由大到小,一层一层往下拆。
操作系统怎么看待"正在运行的程序" → 进程里到底装了什么 → 那些东西怎么被描述 → 执行流本身需要什么 → Linux 为什么说"我没有线程" → 用户层怎么把它补回来 → 最后一个系统调用怎么把这一切串起来。
提前说一句:这条路走完就会发现,Linux 的线程实现方案其实非常"抠门"------它没有为线程发明任何新东西,而是把进程的零件拆开,让大家共用。这个"抠门"的设计,正是后面所有并发问题的总源头。
二、宏观全景:三层视角,一张地图
在往下拆之前,先站远一点,看一眼全貌。
一个多线程程序,从应用层写下 std::thread t(f); 到 CPU 真的开始执行 f,中间要穿过三个世界。这三个世界各自说自己的语言,各自有各自的概念:
┌─────────────────────────────────────────────────────┐
│ ① 应用层(开发者代码) │
│ 语言:C++ / std::thread、std::function、lambda │
│ 概念:任务、回调、"我要并发做几件事" │
├─────────────────────────────────────────────────────┤
│ ② 用户库层(pthread 库,libpthread / glibc 内置) │
│ 语言:POSIX 线程接口(pthread_*)、TCB │
│ 概念:线程对象、线程ID、独立栈、join/detach │
├─────────────────────────────────────────────────────┤
│ ③ 内核层(Linux) │
│ 语言:task_struct、clone、调度器 │
│ 概念:轻量级进程 LWP、地址空间、页表、上下文 │
└─────────────────────────────────────────────────────┘
这张图是本篇的骨架。上篇要做的事情,就是把这张图从上往下、再从下往上,走通一遍。
走得通的标志是什么?是看到 pthread_create(&tid, nullptr, threadRun, nullptr) 时,脑子里能自动分层展开:
- 第 4 个参数
nullptr,最终会变成内核里一个新task_struct的某个寄存器初值; - 第 3 个参数
threadRun的地址,会变成那个新执行流的第一条指令地址(也就是 PC 初值); - 第 1 个参数
&tid,会被用户库填上一个地址------那个地址指向库里的一个结构体; - 而这一切的动作,最终落到一个系统调用 上:
clone。
全景看完了。现在从最外层开始拆。
三、第一层拆解:一个"正在运行的程序"到底占用了什么
3.1 从"进程"的定义说起
课本上对进程的定义通常是"程序的一次执行过程"。这个定义不算错,但它对写代码的人没什么帮助------没法拿这句话去理解 fork() 为什么慢,也没法理解线程为什么快。
我们换一个更有操作性的定义:
进程 = 内核里的一组数据结构 + 这份程序自己的代码和数据。
这句话是本篇的地基,值得拆开看。"内核里的一组数据结构"意味着:进程不是一个虚的东西,它是内核里实实在在的一堆 C 结构体,能被遍历、能被计数、能被销毁。"自己的代码和数据"意味着:这些东西是从磁盘上的可执行文件加载进来的,放在一块属于它的内存里。
那么问题来了。内核要描述一个进程,需要记下哪些事?
我们来当一回内核设计师,从需求倒推:
- 调度器要能在成百上千个进程里挑一个来跑,那就得知道每个进程现在处于什么状态(在跑?在睡?已停止?)。
- 调度器切走一个进程、过会儿再切回来,得能接着上次的位置继续跑 ,那就得保存CPU 上下文的现场(寄存器、程序计数器)。
- 用户执行
ps要看到一个编号,那就得给进程编号(pid)。 - 进程有自己的内存视野,那就得有一个东西描述它能看到的内存。
- 进程打开的文件、当前工作目录、收到的信号怎么处理......这些也都得记着。
需求列出来了,接下来才是结构体登场------这就是"先讲问题、再贴结构"的意思。内核把这些需求捏成了一个结构体,叫 task_struct ,也就是我们常说的 PCB(进程控制块)。
3.2 task_struct:一个执行流的全部档案
下面是一段简化过的 task_struct 片段(字段名以 Linux 5.x/6.x 为准,不同版本会有出入,但主干一致)。注意看注释------每个字段的存在,都对应上面列出的某一条需求:
struct task_struct {
/* ---- 需求 1:调度器要挑人 ---- */
volatile long state; // 执行流当前状态:TASK_RUNNING(就绪/运行)、
// TASK_INTERRUPTIBLE(可中断睡眠)等
int prio; // 动态优先级,调度器排序用
unsigned int policy; // 调度策略:普通分时?实时?
/* ---- 需求 2:切走了要能切回来 ---- */
struct thread_struct thread; // CPU 上下文:通用寄存器、栈指针、PC 等,
// 切换执行流时保存/恢复的就是它
/* ---- 需求 3:要有个身份编号 ---- */
pid_t pid; // 这个 task_struct 自己的编号
pid_t tgid; // 它所属"线程组"的编号(后面细说,关键!)
/* ---- 需求 4:要有自己的内存视野 ---- */
struct mm_struct *mm; // 指向地址空间描述符
struct mm_struct *active_mm; // 内核线程没有 mm,借用别人的
/* ---- 需求 5:各种附属资源 ---- */
struct fs_struct *fs; // 当前工作目录、根目录
struct files_struct *files; // 打开的文件描述符表
struct signal_struct *signal; // 信号相关
struct sighand_struct *sighand; // 信号处理函数表
/* ---- 其他 ---- */
void *stack; // 内核栈指针
struct list_head thread_group;// 同组的兄弟执行流,串成一条链
struct task_struct *parent; // 父进程
};
这里有一个必须现在就点破的伏笔:
上面这个结构体,我们习惯称它为"进程控制块 "。但仔细数一数------它描述的其实是一个独立的执行流 需要的一切:状态、上下文、身份、内存视野、附属资源。它描述的对象是"一条执行流的全部档案",而不是"一个程序的全部资源"。
这个区别,就是后面 Linux 实现线程的全部秘密。先记着,第三节末尾我们回来收这个伏笔。
3.3 内存视野这一项,值得单独展开:mm_struct
task_struct 里那个 struct mm_struct *mm,是整篇里最值得细看的一个字段,因为线程和进程的分野,就画在这个字段上。
还是先问问题:内核要给进程描述内存,具体要描述什么?
写过 C 程序的人都知道,一个进程的虚拟地址空间长这样:低地址是代码段、只读数据段、已初始化数据段、未初始化数据段,然后往上堆生长,中间一大片空着,然后栈从高地址往下生长;最顶上还有一块留给内核。另外还有命令行参数和环境变量,它们也在栈区附近。
内核要能回答这些问题:这段内存是代码还是数据?堆现在涨到哪了?栈顶在哪?程序名和参数放哪儿了?
所以内核设计了 mm_struct,也就是"地址空间描述符"。同样,字段和需求一一对应:
struct mm_struct {
/* ---- 需求:虚拟地址怎么变成物理地址? ---- */
pgd_t *pgd; // 页全局目录(Page Global Directory)基址。
// 这块内存就是"页表"的根,MMU 靠它做地址翻译
/* ---- 需求:各段的边界在哪? ---- */
unsigned long start_code, end_code; // 代码段起止
unsigned long start_data, end_data; // 数据段起止
unsigned long start_brk, brk; // 堆的起点、堆当前顶端
unsigned long start_stack; // 栈的起始位置
/* ---- 需求:命令行参数和环境变量 ---- */
unsigned long arg_start, arg_end; // argv 区域
unsigned long env_start, env_end; // envp 区域
/* ---- 需求:这片地址空间被多少人在用? ---- */
atomic_t mm_users; // 有多少个 task_struct 正在使用它 ★★★
atomic_t mm_count; // 主引用计数
/* ---- 需求:那些零散的映射(mmap 出来的)怎么记? ---- */
struct vm_area_struct *mmap; // 虚拟内存区域链表
};
请把目光停在 mm_users 这个字段上,多停几秒。
一个"普通进程"的 mm_users 是 1------只有它自己在用这片地址空间。
现在设想另一种情况:如果我把同一个 mm_struct 指针,赋给两个不同的 task_struct 呢?
那这两个执行流就会看同一片内存 :同一个代码段、同一个堆、同一批全局变量。它们各自有自己的状态、自己的上下文(thread_struct)、自己的身份编号(pid),但是共享同一个"内存视野"。
......这不就是我们想要的线程吗?
是的。答案就在这里。Linux 造线程的方法,不是发明一个新结构体,而是让多个 task_struct 共享同一个 mm_struct。
这个想法一旦成立,剩下的事情就顺理成章了:files_struct 也可以共享、fs_struct 也可以共享、sighand_struct 也可以共享。内核要做的,只是提供一种"创建执行流时,选择哪些零件共享、哪些零件拷贝"的机制。
那个机制叫 clone。我们第六节见它。
四、第二层拆解:执行流自己,需要什么私有财产
4.1 一次思想实验:两兄弟共用一间房
上一节我们得出了一个漂亮的结论:共享 mm_struct 就能得到线程。
但先不急着下结论,这里可以做一次思想实验。
假设有两个执行流共享了同一片地址空间、同一个堆。现在第一个执行流正在执行 f() 函数,f() 里定义了一个局部变量 int a = 10;。紧接着调度器把第一个执行流切走,让第二个执行流上 CPU------第二个执行流也调用了 f(),也定义了 int a = 20;。
请问:这两个 a 是同一个 a 吗?
必须不是。如果是同一个,那 f() 这个函数就没法被两个线程同时调用了------第一个线程算到一半,第二个线程把它的临时变量改了,结果必然错乱。
这就引出了一个问题:共享地址空间之后,哪些东西必须保持私有?
答案是:凡是描述"我这条执行流跑到哪儿了"的东西,都必须是私有的。
4.2 私有的三样东西
具体是哪三样?
第一样:CPU 的硬件上下文。 包括通用寄存器、栈指针(SP)、程序计数器(PC/EIP/RIP)。这是"我执行到哪一条指令了"的唯一凭证。两个执行流如果共用一套寄存器,那就不是两条执行流了,那是一条------因为它们会互相覆盖对方的进度。
在内核里,这份上下文就存在 task_struct 的 thread_struct thread 字段里(第一节见过)。这是最核心的私有财产。
第二样:栈。 栈是函数调用链的载体------每个函数的局部变量、返回地址都压在栈上。两条执行流的函数调用链必然不同(一个可能在 f() 里,另一个可能在 g() 里),所以必须各自有各自的栈。
这一点值得多说一句,因为它解释了一个常见困惑:线程的栈从不在代码里显式管理,但它确实存在,这是为什么? 答案是:用户线程的栈,是 pthread 库在创建线程时替线程在堆上(或者 mmap 区)分配好的一块内存 。默认大小一般是 8MB,可以用 pthread_attr_setstacksize 改,也可以用 pthread_getattr_np + pthread_attr_getstack 把它查出来。它是地址空间里的一块普通内存,只是库把它登记成了"这个线程的栈",然后把栈指针指过去。
第三样:线程局部存储(TLS)。 这是最容易被忽略的一样。
举个例子。errno 在单线程程序里当全局变量用得好好的,但它在 glibc 里其实是每个线程一份的------否则线程 A 的出错码会被线程 B 覆盖掉。
再比如课程痕迹文件 TestThread.cc 里躺着这么一段注释代码:
// 线程局部存储: 只能用来局部存储内置类型,常见的是整形。
// 可以让不同的线程用同样的变量名,访问不同的内存块,各自访问各自的局部存储!
__thread pid_t id = 0; // GCC 扩展关键字;C++11 的标准写法是 thread_local
__thread(C++11 起是 thread_local)的作用就是:同一个变量名,每个线程看到的是一份独立的存储 。上面这段代码里,主线程把 id 设成自己的 LWP 号,新线程把 id 设成它的 LWP 号,两边互不干扰------因为它们在物理上是两块内存。
这三样东西(上下文、栈、TLS),构成了执行流的"私有财产清单"。
4.3 一张表讲清楚:共享什么,私有什么
把前面几节的内容合起来,就是这张表。这张表是本篇的题眼,也是后面所有并发问题的总源头------出了问题的地方,永远在"共享"那一栏:
| 资源 | 进程内线程之间 | 内核里的载体 |
|---|---|---|
| 地址空间(代码、全局变量、堆) | 共享 | mm_struct |
| 页表 | 共享(同一个 mm 就同一张页表) | mm_struct->pgd |
| 打开的文件描述符表 | 共享 | files_struct |
| 当前工作目录、根目录 | 共享 | fs_struct |
| 信号处理函数表 | 共享 | sighand_struct |
| CPU 上下文(寄存器、PC、SP) | 私有 | task_struct->thread |
| 栈 | 私有 | pthread 库分配的独立栈 |
| 线程局部存储(TLS、errno) | 私有 | 库 + TLS 机制 |
| 身份编号 | 私有(各有一个 pid) | task_struct->pid |
看完这张表,前面那些"想不通的问题"就自动有答案了:
- 为什么两个线程
getpid()一样? 不,它们内核里的pid其实不一样,是getpid()这个函数特意返回了tgid(线程组 ID),我们第五节就讲这个。 - 为什么改全局变量另一个线程能看见? 因为全局变量在代码/数据段里,属于
mm_struct管的地址空间,是共享的。 - 为什么线程要有自己的栈? 见 4.2。
- 为什么
fork()慢、创建线程快? 因为fork()要拷贝一整套(页表、fd 表......),而线程只创建了一个新task_struct加一个栈,剩下的全是指针指过去。
到这里,"由大到小"的拆解已经走过了三层:操作系统 → 进程 → 地址空间/执行流。再往下,就是内核最底层的抉择了。
五、第三层拆解:Linux 的选择------不造线程,复用进程
5.1 一句话捅破窗户纸
现在我们把前面的伏笔收掉。
我们已知:
task_struct描述的是一条执行流的全部档案;- 多个
task_struct共享一个mm_struct,就得到了线程。
那么请问:Linux 内核里,到底有没有一个叫"线程"的实体?
答案是:没有。
课上的原话是这样的:
在 Linux 当中,在 CPU 视角里看到的一个个的执行流,不叫进程,我们也不把它叫线程,我们把它叫做轻量级进程(LWP,Light Weight Process)。
因为很显然,它在执行的时候,加载的 PCB 可能是进程的唯一一份,也可能是多份当中的一份------所以我们把它叫做轻量级进程。
这段话值得逐字读三遍。它的意思是:
内核根本不在乎它被叫作什么。内核眼里只有 task_struct。一个 task_struct 就是一个可以被调度器挑选的执行流 ,这是调度层看到的东西。至于这些 task_struct 背后共享了多少资源------共享了 mm_struct 就是"线程",不共享就是"进程"------那是资源层看到的东西。
Linux 把"调度"和"资源"这两件事彻底拆开了,然后用同一个结构体描述前者。 这个设计也解释了为什么 Linux 内核里找不到 thread_struct 这样一个"线程结构体"(注意:task_struct 里那个字段虽然叫 struct thread_struct thread,但它存的是 CPU 寄存器上下文,不是"线程对象")。
于是我们得到了一个非常重要的等式:
进程 = 一个或多个轻量级进程(LWP) + 它们共用的 PCB 与虚拟地址空间、页表、代码数据
反过来说:我们以前学的"进程",本质上是一个"内部只有一个执行流的进程"------单执行流进程。
5.2 pid 和 tgid:两个编号,各管一摊
既然一个进程里可以有多个 task_struct,每个 task_struct 又都有自己的 pid 字段,那么用户敲 kill 1234 的时候,杀的是哪一个?
这就轮到 task_struct 里那个被我们标了"关键"的字段出场了:
pid_t pid; // 这条执行流自己的编号 ------ 对线程而言就是它的 LWP 号
pid_t tgid; // thread group id ------ "线程组"的编号,等于主线程的 pid
内核用这两个编号分工:
pid是调度层的编号。区分执行流,靠的是它。 在多线程程序里,每个线程的这个值都不一样。tgid是资源层的编号。一个进程里的所有线程,tgid全都相同 ,而且等于主线程的pid。
而 getpid() 这个函数,返回的是 tgid 而不是 pid 。这就是"两个线程打印出来的 pid 一模一样"的全部答案------不是它们的 pid 相同,是 getpid() 压根没返回 pid。
课程里有一个非常直观的实验观察记录:
两个执行流名字都叫"线程",它们两个的 pid 竟然是一样的;而它们两个还有一个 LWP(轻量级进程)编号,很显然,一个叫 205,一个叫 206,轻量级进程的 pid 不一样。
那么这两个执行流,哪一个是主线程呢?LWP 的值和 pid 的值相等的那个,就是主线程。
5.3 三个可以立刻动手验证的实验
理论讲完了,验证一下。以下三个实验都值得亲自跑一遍,眼见为实。
实验一:看见 LWP。 用课程痕迹里的 lesson43/2.code/testThread.c,编译运行后另开一个终端:
ps -aL | grep testThread
# -a 显示所有终端上的进程
# -L 关键!显示"线程"------也就是把每个 LWP 单独列成一行
可以看到同一个 PID 名下,出现了两行不同的 LWP 。第一行的 PID 和 LWP 相同,那就是主线程;第二行 LWP 不同而 PID 相同,那就是我们用 pthread_create 创建的新线程。这行输出就是"多线程"这件事在内核里的真身。
实验二:杀进程会连坐。 把上面那个程序跑起来,然后用 kill <pid> 杀它。这节课的原话是:
那么依赖进程资源生存的所有线程,自然也会被杀掉。因为进程一旦被杀掉,进程的 PCB、页表、还有代码和数据全部都没有了。
这句话反过来读,就是"线程为什么轻"的代价:线程的"轻",是建立在"寄人篱下"之上的。它不拥有地址空间,所以它必须依赖进程活着。
实验三:一个线程崩溃,全进程陪葬。 课程痕迹里有一段被注释掉、但专门留着的实验代码:
void *Routine(void *args)
{
int cnt = 5;
while (cnt) {
std::cout << "新线程在运行: " << cnt-- << std::endl;
sleep(1);
int a = 10;
a /= 0; // 故意制造除零异常
}
return nullptr;
}
跑起来可以看到:新线程一除零,主线程也跟着一起死了,整个进程直接挂掉。
为什么?还是因为共享。除零会触发一个信号,而信号处理函数表(sighand_struct)是进程内所有线程共享的 ;更要命的是,一个未处理的致命信号是发给整个进程的,内核会终止线程组里的所有执行流。共享地址空间让线程"轻",也让它"一损俱损"。
------这也是为什么痕迹文件的注释里会写:新线程出异常,进程全部都会退出,根本没机会 join 成功,所以 pthread_join 获取到的退出信息里没有退出信号,不需要关心异常。这句话看着像抱怨,其实是精确的内核行为描述。
六、第四层拆解:内核给得太素,用户层来补
6.1 pthread 库存在的理由
到现在为止,内核给我们的东西是:一个 clone 系统调用,能造出一个共享了部分资源的 task_struct,也就是一个 LWP。
对内核来说这够用了。但对写程序的人来说,这东西太素了:
- 它没有"线程"这个名字,只有"轻量级进程"这个语义模糊的说法;
- 它没有线程 ID 的概念(
task_struct->pid是内核编号,而且会被回收复用,不能长期持有); - 它没有线程返回值------线程干完活要带个结果回来,往哪儿放?
- 它没有"等待一个线程结束"的原语------
wait()是给进程用的,而且线程退出并不产生可wait的僵尸进程状态; - 它没有"分离线程"的概念------我希望这个线程自己跑完自己清理,不要人来收尸;
- 它没有取消(cancel)机制------我想让一个死循环线程停下来。
所以,Linux 在用户层提供了一套库,把这堆裸露的 LWP 包装成"线程"这个概念 。这套库就是 pthread 库(原生线程库,glibc 里已经内置)。它实现了 POSIX 线程标准定义的那套接口:
用户层:线程(pthread 库封装出来的概念)
↓ 底层封装
系统调用:clone(创建轻量级进程,可以不用拷贝页表和虚拟地址空间)
↓
内核层:轻量级进程 LWP
这张三层图,就是本篇开头那张全景图的下半部分,现在算是补全了。
6.2 库里的档案:TCB 是怎么被设计出来的
进程有 PCB(task_struct),线程也得有自己的"档案",这份档案叫 TCB(Thread Control Block)。
但这里有一个关键区别:线程的 TCB 不在内核里,而在 pthread 库里。 内核只管 LWP,LWP 之外的线程语义(ID、返回值、分离状态、清理函数),全部由库自己记账。
那么,这份账要记哪些项目?还是老规矩,从需求倒推:
- 每个线程要有自己的独立栈------要记下栈的起始地址和大小(第 4.2 节讲过为什么必须有独立栈)。
- 线程函数可以
return一个void*作为退出结果,这个结果要存着 ,等别人join的时候交出去。 - 线程可以被 join 或 detach,库得记住它当前是哪种状态。
- 内核给的 LWP 号也要存一份,方便调试和系统调用。
- 线程可能注册了线程局部存储 (
pthread_key_create那套),这些也要有个地方放。 - 线程退出时可能有清理函数 要回调(
pthread_cleanup_push),需要一个栈来记录。
把这些需求捏起来,就得到了课程里给出的简化模型(对应的是 glibc 源码里的 struct pthread,位于 nptl/descr.h):
struct thread { // pthread 库中的线程控制块(简化模型)
pid_t tid; // 轻量级进程 ID(LWP),即对 clone 等系统调用的封装结果
void *stack; // 线程独立栈的起始地址
size_t stack_size; // 线程独立栈的大小
void *retval; // 线程的返回值(return 回来的那个 void* 存在这)
/* ... 还有描述线程是否分离的相关指针字段 ... */
};
对照真实的 glibc struct pthread,可以看到这些字段几乎一一对应:tid(LWP 号)、stackblock / stackblock_size(独立栈)、result(返回值)、joinid(谁在等我,对应分离状态)、specific[](线程局部存储数组)、cleanup(清理函数栈)、cancelhandling(取消状态)。
顺带一个有意思的细节:glibc 里那份真实结构体的名字不叫
struct thread,叫struct pthread。而它的第一个字段通常是一个tcbhead_t header------这个"放在最前面"的安排不是随手写的,TLS 机制要求线程控制块的地址能直接被一段特殊寄存器定位到,所以它必须在结构体的固定偏移上。这也是为什么这份结构体不能随便改字段顺序。
6.3 一个反直觉的结论:线程 ID 是一个地址
现在可以回答那个经典问题了------pthread_t 到底是什么?
很多人的直觉是:它是个编号,就像 pid 那样,1、2、3 递增。
不是。
线程 ID,本质上是 pthread 库映射到当前进程虚拟空间之后,该线程的线程控制块(TCB)在库内部的起始地址。
也就是说,pthread_t 装的是一个虚拟地址,它指向这个线程的 TCB。只要拿到这个地址,就能顺着它找到该线程的控制块、线程局部存储、以及独立栈。
这件事有三个可观察的推论:
- 代码里
printf("tid: 0x%lx", tid)打印出来的会是一个像地址的大数 ,不是 1、2、3。课程痕迹里就有这么一行printf("new thread id: 0x%lx\n", tid); // lwp??------那个问号问得好,答案是:它既不是 LWP 号,也不是序号,是地址。 pthread_self()返回的也是这个地址。- 内核眼里的 LWP 号和它完全不是一回事 。想拿内核的 LWP 号,得用
syscall(SYS_gettid)去问内核(痕迹文件的注释里就留了这个函数:pid_t GetTid() { return syscall(SYS_gettid); })。
换句话说,一个线程身上挂着两个"ID":
| 名字 | 谁给的 | 是什么 | 怎么拿 |
|---|---|---|---|
pthread_t |
pthread 库 | TCB 的起始地址 | pthread_self() |
| tid / LWP 号 | 内核 | task_struct->pid |
syscall(SYS_gettid),或 ps -L 看 LWP 列 |
调试的时候用哪个?两个都用得上:pthread_t 用来在库里定位线程,tid 用来在 ps -L、top -H、/proc/<pid>/task/ 里定位线程。课程里 pthread_setname_np 改的是内核里那个 LWP 的名字 (所以 ps -L 能看到名字变了),这件事我们中篇会细说。
七、最底层:clone ------ 一切的起点
7.1 为什么不是 fork
故事讲到这里,只剩最后一个问题:那个"选择性共享零件"的机制,到底长什么样?
先看为什么不能用 fork()。
fork() 的语义是"克隆一个几乎一模一样的进程":子进程有自己独立的 地址空间(写时拷贝,但语义上是独立的)、独立的 文件描述符表、独立的信号处理。这正好是线程的反面。
我们需要的是一个能"点菜"的创建接口:我要共享地址空间、共享 fd 表、共享信号处理,但是要一个独立的执行流和独立的栈。
这个接口就是 clone。
7.2 用标志位"点菜"
clone 的接口大致是:
#define _GNU_SOURCE
#include <sched.h>
int clone(int (*fn)(void *), // 新执行流从哪个函数开始跑
void *stack, // 新执行流的栈(用户态自己准备)
int flags, // ★ 最关键:一张"共享还是拷贝"的点菜单
void *arg, // 传给 fn 的参数
... // 还有若干可选参数(父/子 TID 写到哪等)
);
那个 flags 参数是一堆按位或的标志位,每一项都在回答"这个零件,新的执行流是共用 还是自己拷贝一份"。挑几个和线程最相关的:
| 标志位 | 含义 | 不设置时(即 fork 的行为) |
|---|---|---|
CLONE_VM |
共享虚拟地址空间(共用 mm_struct) |
拷贝一份地址空间 |
CLONE_FS |
共享文件系统信息(fs_struct:cwd、根目录) |
各自一份 |
CLONE_FILES |
共享文件描述符表(files_struct) |
各自一份 |
CLONE_SIGHAND |
共享信号处理函数表(sighand_struct) |
各自一份 |
CLONE_THREAD |
放进同一个线程组 (于是 tgid 相同) |
独立成组 |
CLONE_SYSVSEM |
共享 System V 信号量的 undo 信息 | 各自一份 |
CLONE_SETTLS |
为新执行流设置 TLS 区域 | --- |
CLONE_CHILD_CLEARTID |
线程退出时清零一块内存并唤醒等待者(join 的底层机制之一) | --- |
现在可以一眼看穿这张表的规律了:
把
CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS | ...这一串全点上,得到的就是一个"线程";一个都不点,得到的就是fork出来的"进程"。
这就是 Linux 那句著名的说法的由来:线程和进程在内核里没有本质区别,区别只在 clone 的标志位上。
而 pthread 库干的事情,说白了就是:准备好栈,拼好这一串标志位,然后调 clone。 课程里那句总结非常到位:
我们写的所有线程代码,本质上都是对
clone系统调用的封装。
八、回到起点:pthread_create 的四个参数到底是什么
绕了一大圈,现在我们从内核爬回用户层,重新看这条语句:
pthread_t tid;
pthread_create(&tid, NULL, threadRun, NULL);
以前看它,是四个"规定要这么写"的参数。现在我们把它们逐个拆开,看它们分别对应着前面哪一层的知识。
// 1. 输出型参数:创建成功后,库把新线程 TCB 的地址填进来
// (不是序号,是地址 ------ 见 6.3 节)
pthread_t *tid
// 2. 线程属性:NULL 表示全用默认
// 属性里能改的东西包括:栈大小、是否分离、调度策略......
// 想想 4.2 节:栈是库替我们分配的,所以"栈多大"这个属性才有意义
const pthread_attr_t *attr
// 3. 线程入口函数:新执行流从哪个函数开始跑
// 它会变成内核里新 task_struct 的 PC 初值 ------ 第一节需求 2 讲过的上下文
// 类型必须是 void *(*)(void *),这是 pthread 的约定
void *(*start_routine)(void *)
// 4. 传给入口函数的参数:会成为新执行流第一次执行 start_routine 时,
// 寄存器里传进去的那个值(对应 4.2 节"私有上下文")
void *arg
课程里对这个函数有句总结,把它的两件事说得很清楚:
pthread_create干两件事:1. 返回唯一 tid 编号,方便后续线程操作;2. 分配线程执行任务。
现在可以补上第三件事和第四件事了:3. 在堆上分配一块栈;4. 拼好一整串 CLONE_* 标志位,调用 clone。
8.1 第一个多线程程序:把理论跑出来
理论说完了,跑一个最小验证。这份代码对应课程痕迹 lesson43/2.code/testThread.c:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void *threadRun(void *args) // 签名必须是 void* f(void*)
{
while (1) {
printf("new thread is running, pid: %d\n", getpid());
sleep(1); // 睡一下,让两条流交替打印,肉眼可见
}
return NULL;
}
int main()
{
pthread_t tid; // 装 TCB 地址
pthread_create(&tid, NULL, threadRun, NULL); // 造一个新 LWP
while (1) {
printf("main thread is running, pid: %d\n", getpid());
sleep(1);
}
}
编译要链接线程库(老系统必需,因为 pthread 曾经是独立库):
gcc testThread.c -o testThread -lpthread
运行后可以看到两行打印交替出现,而 pid 完全相同 ------这正是第 5.2 节讲的:getpid() 返回 tgid,两条执行流同属一个线程组。
课程里对这个现象的评语是:"两个线程里 getpid() 打印出来的是同一个进程 id------这是本阶段最重要的观察点。"现在可以看出,这句话背后是 tgid 和 pid 的分工。
顺手做一个进阶观察: 运行时另开终端执行 ps -aL | grep testThread,可以看到同一个 PID 下挂着两条 LWP 记录。再把 syscall(SYS_gettid) 打印出来,就能看出:主线程的 LWP 号 == getpid(),新线程的 LWP 号 != getpid()。"LWP 号和 pid 号相等的那个是主线程"------第 5.2 节的结论,闭环了。
8.2 承上启下的收尾:两个被埋下的雷
上篇到这里,整条"由大到小"的故事线走完了:
操作系统(调度单位是执行流)
└─ 进程(= 一个或多个 LWP + 共用的 mm_struct/页表/代码数据)
└─ mm_struct(地址空间描述符,mm_users 记录几个人在用)★线程的题眼
└─ 页表(pgd 指向的翻译表,共享同一份)
└─ 执行流(task_struct:状态/上下文/pid/tgid)
└─ Linux 复用 task_struct 造 LWP,不发明"线程"实体
└─ pthread 库补上线程语义:TCB、线程ID、独立栈
└─ clone + 一串 CLONE_* 标志位
└─ pthread_create 的四个参数
但是,这条线走完的同时,我们其实埋下了两颗雷。它们是后两篇的全部素材:
第一颗雷:共享是双刃剑。 第 4.3 节那张"共享 / 私有"表里,"共享"那一栏有多长,出问题的面就有多广。全局变量被两个线程同时改会怎样?fd 表被两个线程同时用会怎样?这正是中篇 要处理的事故现场------我们会先亲手把程序跑出一个负数票号,再回头看那些"判断也是运算""++ 不是原子操作"的细节。
第二颗雷:谁来收尸。 进程退出有 wait 收尸(课程第一阶段专门讲过 SIGCHLD 和僵尸进程)。那么线程退出呢?如果主线程不管它,会不会留下"僵尸线程"?答案是会------线程也有和僵尸进程一模一样的问题 ,解法叫 join。
而且,第二颗雷还牵出了更麻烦的一层:原生 pthread 太难用了。
再看上面那段 testThread.c:函数指针、void* 强转、tid 要自己声明和保管、-lpthread 忘了加就报错、参数只能传一个 void*......这还只是一个什么都不干的线程。真实项目里主线程要管理几十上百个线程,全靠这套 C 风格接口裸写,代码会变成什么样?
------这就是中篇 要解决的事:把 pthread 装进一个 C++ 的盒子里,让它像对象一样被创建、启动、等待、终止;然后用这个盒子,去正面撞上第一颗雷。
九、本篇速查表
| 问题 | 答案 |
|---|---|
| 进程的操作性定义? | 内核里的一组数据结构 + 这份程序自己的代码和数据 |
| PCB 是哪个结构体? | task_struct,描述一条执行流的全部档案 |
| 内核描述地址空间用哪个结构体? | mm_struct(地址空间描述符) |
| 线程的题眼在哪个字段? | task_struct->mm;多个 task_struct 共享一个 mm_struct 就是线程 |
| 内核怎么表示页表? | mm_struct->pgd(页全局目录基址),MMU 靠它做地址翻译 |
| 地址空间被几个人用,记在哪? | mm_struct->mm_users(原子计数) |
| 执行流必须私有的三样东西? | CPU 上下文(寄存器/PC/SP)、栈、线程局部存储 |
| 执行流共享哪些资源? | 地址空间、页表、fd 表(files_struct)、cwd(fs_struct)、信号处理表(sighand_struct) |
| Linux 内核里有"线程"实体吗? | 没有。CPU 视角看到的执行流叫轻量级进程 LWP |
| 进程 = ? | 一个或多个 LWP + 共用的 PCB/虚拟地址空间/页表/代码数据 |
| 我们以前学的"进程"本质是什么? | 一个内部只有一个执行流的进程,即单执行流进程 |
pid 和 tgid 分工? |
pid 区分执行流(线程各自的 LWP 号);tgid 标识线程组,组内全相同 |
为什么所有线程 getpid() 一样? |
因为 getpid() 返回的是 tgid,不是 pid |
| 怎么看主线程? | LWP 号 == pid 号的那条就是主线程 |
| 看线程的命令? | ps -aL(列出所有 LWP)、top -H、/proc/<pid>/task/ |
| 线程的 TCB 在哪? | 在 pthread 库里 (glibc 的 struct pthread,nptl/descr.h),不在内核 |
| TCB 里记什么? | 栈地址与大小、返回值 result、分离状态、LWP 号、TLS 数组、清理函数栈 |
pthread_t 是什么? |
不是序号,是 TCB 在进程虚拟空间里的起始地址 |
| 线程身上的两个 ID? | pthread_t(库给的,TCB 地址,用 pthread_self() 取);tid/LWP 号(内核给的 task_struct->pid,用 syscall(SYS_gettid) 取) |
| 线程的栈谁分配? | pthread 库在创建时分配(默认约 8MB),可用 pthread_attr_setstacksize 调整 |
clone 和 fork 的区别? |
clone 用 flags 点菜决定哪些资源共享;fork 相当于一个标志位都不点 |
| 哪些标志位拼出一个线程? | CLONE_VM、CLONE_FS、CLONE_FILES、CLONE_SIGHAND、CLONE_THREAD、CLONE_SYSVSEM、CLONE_SETTLS 等 |
| 线程代码的本质? | 对 clone 系统调用的封装(换成轻量级进程,省掉拷贝页表和虚拟空间的开销) |
pthread_create 四个参数分别是什么? |
①出参,存 TCB 地址 ②属性(NULL=默认)③入口函数,签名必须 void*(*)(void*) ④传给入口函数的参数 |
pthread_create 到底干了哪几件事? |
①填 tid ②记录任务 ③分配独立栈 ④拼好 CLONE_* 标志位调 clone |
| 一个线程崩溃会怎样? | 整个进程挂掉。因为信号处理表共享,且致命信号发给整个线程组 |
kill 杀进程时线程会怎样? |
全部陪葬。PCB、页表、代码数据全没了,依赖进程资源的线程自然全死 |
| 线程的"轻"代价是什么? | 寄人篱下:不拥有地址空间,必须依赖进程活着 |
pthread_join 拿到的退出信息里有异常信号吗? |
没有。线程出异常整个进程就没了,根本没机会 join 成功,只需关心正常退出 |
上一节课提的 __thread 是什么? |
线程局部存储,同一变量名在每个线程各有一份独立内存;C++11 标准写法是 thread_local |
十、下一篇预告
本篇我们把镜头一路推到底,看清了线程在内核里的真身:一个和进程共享地址空间的 task_struct,一个被 pthread 库精心包装过的 LWP。
但这个"包装"在 API 层面还是很粗糙。中篇要做的,是把这堆 C 风格的 pthread_xxx 收进一个 C++ 类里,让它用起来像对象:
- 怎么让"线程入口函数"这个要求(必须是
void*(*)(void*))和一个 C++ 成员函数 握手?答案是static入口 +this指针回传------这是所有线程库封装的标准套路。 - 为什么"构造对象"和"启动线程"要拆成两件事?为什么
start()之后立刻detach()会失败?这是一个真实的竞态,我们会看它怎么被一个"谁来写状态"的分工解决掉。 - 线程要传参数怎么办?课程给了三个方案,前两个都是坑,第三个方案会顺手把"线程模块"和"任务模块"彻底解耦。
- 以及,我们要亲手撞上第一颗雷:10000 张票,会抢成负数。然后从汇编层面看清楚那几个字节到底发生了什么,最后请出互斥锁,并用 RAII 把它也装进盒子。
带着上篇那句"共享是线程的题眼",去中篇看看它惹的祸。
(本篇为线程三部曲之上篇。中篇《把线程装进盒子里》讲封装、传参与互斥;下篇《让线程排好队》讲同步、生产者消费者与线程池。)