一文理解 Linux 线程:线程、进程、LWP 到底是什么关系?

文章目录
- 引言
- 1. 从执行流开始理解 Linux 线程
- 2. 为什么理解线程必须先理解虚拟地址空间
- 3. Linux 线程的优点、缺点和异常
- 4. Linux 进程 VS 线程:哪些资源共享,哪些资源独占
- 5. POSIX 线程控制
- 6. 线程终止、等待和分离
- 7. 线程 ID、LWP 与地址空间布局
- 8. 用 C++ 封装一个简单线程类
- 9. 从 NPTL 思路理解 `pthread_create` 背后发生了什么
- 10. 线程栈:主线程栈和子线程栈不完全一样
- 11. 页表项、页目录和 `mm_struct`
- 12. 支持任意参数传递的线程封装 Demo
- 13. 自己调用 clone 观察轻量级执行流
- 14. 高频技术题 / 面试题
- 结语
引言
刚开始学线程的时候,我其实一直把它理解得很简单:线程不就是“一个进程里面多条执行路线”吗?好像只要会用 pthread_create 创建线程、会用 pthread_join 等待线程结束,就算学会了。
但后来继续往底层看,我发现这个理解只能算入门。真正难的地方不是“线程是什么”这句话本身,而是:
线程为什么比进程轻量?
线程到底共享了什么?
线程自己的东西又在哪里?
为什么一个线程崩了,整个进程也可能一起崩?pthread_t 和 Linux 里真正看到的 LWP 又是不是同一个东西?
这些问题一开始看起来很零散,但后来我慢慢发现,它们其实都绕不开一个核心:虚拟地址空间。
以前我学习进程的时候,经常说“进程 = 内核数据结构 + 代码和数据”。但到了线程这里才发现,如果一个进程的地址空间已经把代码段、数据段、堆、栈、共享区这些资源组织好了,那么所谓多个线程,本质上就是在同一份进程资源里面划分出多条执行流。
💡可以把进程理解成一个宿舍,宿舍里面有桌子、柜子、路由器、电源这些公共资源;线程就像宿舍里的几个人,每个人都有自己的书包和学习进度,但很多东西都是一起用的。
所以这篇博客主要是我学习 Linux 线程概念与控制之后的一次系统整理。我会从虚拟地址空间、分页、页表开始,再过渡到进程和线程的关系,最后结合 pthread 接口、线程封装和底层 clone 思路,把线程这部分尽量串起来。
1. 从执行流开始理解 Linux 线程
1.1 线程到底是什么
线程最直观的理解是:
线程是一个程序内部的一条执行路线。
更准确一点说:
线程是一个进程内部的控制序列。
也就是说,进程里面至少有一条执行流,这条默认存在的执行流就是主线程。当一个进程里面只有一条执行流时,我们平时写的普通程序看起来就是“单进程单线程”的。
但是当一个进程内部出现多条执行流时,这个进程就变成了多线程进程。
这里我一开始容易想错的地方是:我以为线程是进程外面额外创建出来的东西。后来才意识到,线程一定是在进程内部运行的,它本质上是在同一个进程地址空间里执行代码。
也就是说:
- 线程不是脱离进程单独存在的;
- 线程运行时依赖进程的虚拟地址空间;
- 一个进程至少有一个线程;
- Linux 内核调度时看到的是一个个轻量级执行流;
- 线程比传统进程更轻量,核心原因是很多资源不需要重新创建。
💡进程像一个正在运行的项目组,线程像项目组里的不同成员。成员可以同时干活,但他们共享同一个项目资料库、同一个办公空间,只是每个人手头正在做的任务不同。

1.2 仅仅说“线程是执行流”还不够
如果只是记住“线程是一个执行流”,其实还是停留在概念层面。
真正理解线程,我觉得至少要回答一个问题:
线程之间到底是怎么共享资源、又怎么拥有私有数据的?
这个问题最后会落到进程地址空间上。因为进程的大部分资源都可以通过虚拟地址空间看到,比如:
- 代码段;
- 数据段;
- 堆;
- 栈;
- 共享区;
- 文件映射区;
- 动态库映射区域。
当这些资源被合理分配给不同执行流时,就形成了线程执行流。
这里我觉得最关键的一句话是:
只要把虚拟地址空间划分清楚,进程资源天然就被划分好了。
所以在线程之前,必须先把虚拟地址空间和分页机制理解清楚。
2. 为什么理解线程必须先理解虚拟地址空间
2.1 如果没有虚拟内存会发生什么
刚开始学虚拟地址空间的时候,我经常会有一个疑问:
既然程序最终都是在物理内存里运行,那为什么不直接访问物理内存呢?
后来发现,直接访问物理内存会带来很多问题。比如每个程序的代码和数据长度都不一样,如果要求每个程序在物理内存中必须连续存放,那么物理内存很快会被切成各种大小不一的碎片。
程序 A 占一块,程序 B 占一块,程序 C 退出以后中间空出来一块。下一次程序 D 想进来,如果它需要的连续空间比这个空洞大,那就算总剩余内存够,也可能放不下。
💡这有点像自习室占座。如果每个小组都必须连续坐在一起,那么中间零零散散空了很多座位,也不一定能容纳一个新来的大组。
所以操作系统希望做到一件事:
用户程序看到的地址空间是连续的,但实际物理内存不一定连续。
这就是虚拟内存和分页机制出现的重要原因。
2.2 虚拟地址、物理内存和页框
在分页机制下,物理内存会被切成固定大小的页框。常见情况下,一个页框大小是 4KB。
这里要区分两个概念:
- 页框:物理内存中的固定大小存储区域;
- 页:虚拟内存中的固定大小数据块,可以映射到某个物理页框中,也可能暂时在磁盘中。
虚拟地址空间看起来是连续的,但是通过页表映射之后,可以对应到离散的物理页框。
也就是说:
连续的虚拟地址空间
↓ 页表映射
离散的物理内存页框
这样一来,程序以为自己拥有连续地址,但操作系统可以把它的内容分散放到物理内存不同位置。

![建议插入图片:虚拟地址到物理页框映射关系]
【图片来源建议】
建议使用简约黑白线框图。左侧是连续虚拟地址空间,中间是页表,右侧是离散物理内存页框,用箭头表示映射关系。
【Images2 生成提示词】
生成一张简约的虚拟地址空间到物理内存映射示意图,白色背景,黑色和灰色线条。左侧画连续的虚拟地址空间,被分成若干页;中间画页表;右侧画物理内存页框,页框位置可以不连续。用箭头从虚拟页经过页表指向不同物理页框。文字标注尽量少,只保留“虚拟地址空间”“页表”“物理内存”。整体像课堂讲义中的黑白结构图。
【插入目的】
帮助理解虚拟空间连续、物理空间不连续这一点。
2.3 操作系统如何管理每一个物理页
有了分页以后,操作系统还需要管理每一个物理页。比如:
- 哪些页正在使用;
- 哪些页是空闲的;
- 哪些页是脏页;
- 哪些页被锁定;
- 哪些页被映射了多少次。
Linux 内核中用 struct page 来描述系统中的每个物理页。这个结构体和“物理页”相关,而不是和“虚拟页”相关。
下面这段代码是理解物理页管理时非常重要的一段结构体代码,我在注释里把关键字段的作用补充了一下。
/* include/linux/mm_types.h */
/*
* struct page 用来描述一个物理页。
* 注意:它描述的是物理内存中的页框,而不是用户进程看到的虚拟页。
*/
struct page {
/*
* flags 用来保存页的各种状态。
* 例如:页是否被锁定、是否是脏页、数据是否最新等。
* 每一位可以表示一种状态,所以一个 flags 可以同时保存多个状态。
*/
unsigned long flags;
union {
struct {
/*
* lru 用于把页挂到 LRU 链表上。
* 操作系统进行页面回收时,会根据这些链表判断哪些页更适合被换出。
*/
struct list_head lru;
/*
* mapping 表示这个页属于哪个 address_space。
* 如果是文件映射页,可能会和 inode 的 address_space 有关。
* 如果是匿名页,可能会和 anon_vma 有关。
*/
struct address_space* mapping;
/*
* index 表示这个页在映射对象中的偏移。
* 比如一个文件被映射到内存,这个字段可以表示当前页对应文件中的哪一部分。
*/
pgoff_t index;
/*
* private 是映射私有数据。
* 在不同场景下含义不同:
* - PagePrivate:常用于 buffer_heads;
* - PageSwapCache:可能用于交换分区相关信息;
* - PG_buddy:可能用于伙伴系统中的阶。
*/
unsigned long private;
};
struct {
/*
* slab/slob/slub 分配器相关字段。
* 当物理页被用作小对象分配器管理时,这些字段会有对应含义。
*/
union {
struct list_head slab_list;
struct {
struct page* next;
#ifdef CONFIG_64BIT
int pages; /* 剩余页数量 */
int pobjects; /* 近似对象数量 */
#else
short int pages;
short int pobjects;
#endif
};
};
/*
* 指向 slab cache。
* 这说明同一个 struct page 在不同内存管理场景下会有不同解释。
*/
struct kmem_cache* slab_cache;
/*
* freelist 指向第一个空闲对象。
* 当该页被 slab/slub 用于对象分配时,这个字段很关键。
*/
void* freelist;
union {
void* s_mem; /* slab 中第一个对象 */
unsigned long counters; /* SLUB 计数器 */
struct {
unsigned inuse : 16; /* 已经使用的对象数量 */
unsigned objects : 15; /* 总对象数量 */
unsigned frozen : 1; /* 是否冻结 */
};
};
};
/*
* 这里省略的是内核中其他联合体布局。
* struct page 大量使用 union 的原因是节省内存。
* 因为系统中每个物理页都要有一个 struct page,如果字段过多,内存开销会变大。
*/
};
union {
/*
* _mapcount 表示有多少页表项映射到了这个物理页。
* 可以理解为这个物理页被引用了多少次。
*/
atomic_t _mapcount;
unsigned int page_type;
unsigned int active;
int units;
};
#if defined(WANT_PAGE_VIRTUAL)
/*
* virtual 表示该物理页在内核虚拟地址空间中的地址。
* 对于高端内存,有些页不会永久映射到内核空间,此时可能为 NULL。
*/
void* virtual;
#endif
};
2.3.1 flags 字段
flags 主要记录页状态。比如:
PG_locked:页是否被锁定;PG_uptodate:页中的数据是否已经从块设备读取并且没有错误;- dirty 标志:页是否被修改过。
这里我觉得很有意思的一点是:操作系统不是只管理“进程”,它还要细到管理每一个物理页。
如果一个系统有 4GB 物理内存,页大小是 4KB,那么页框数量就是:
4GB / 4KB = 1048576
也就是大约一百万个物理页。
假设一个 struct page 大约占 40 字节,那么所有物理页的描述结构大约消耗:
1048576 * 40B ≈ 40MB
相对于 4GB 内存来说,这个管理成本是可以接受的。
💡这有点像图书馆给每一本书都贴一个编号标签。标签本身也占空间,但如果没有标签,管理员根本不知道哪本书在哪、有没有被借走。
2.4 页表:虚拟地址和物理地址之间的桥
页表中的每一个表项,指向一个物理页的起始地址。
在 32 位系统中,虚拟地址空间最大是 4GB。如果页大小是 4KB,那么一共需要:
4GB / 4KB = 1048576 个页表项
如果一个页表项占 4 字节,那么单级页表大小就是:
1048576 * 4B = 4MB
看起来 4MB 也不算很大,但问题在于:
单级页表本身也需要连续空间。
这就有点尴尬了。我们引入分页机制本来是为了避免连续物理内存分配,可页表自己却又需要一大块连续空间。
所以后来引入了多级页表。
2.5 多级页表和页目录
多级页表的思路是:
不要一次性把整个大页表都创建出来,而是把页表本身也拆开管理。
在二级页表中,会有:
- 页目录表;
- 页表;
- 物理页。
其中:
- 页目录项指向页表;
- 页表项指向物理页;
CR3寄存器保存当前任务页目录的起始地址。

2.6 MMU 和 TLB
地址转换并不是 CPU 自己慢慢查表完成的,而是由硬件 MMU 参与完成。
MMU 的基本流程可以理解为:
- CPU 给出虚拟地址;
- MMU 根据页表进行地址转换;
- 检查权限;
- 得到物理地址;
- 访问物理内存。
但是多级页表会带来一个问题:页表级数越多,查询次数越多。为了解决这个效率问题,硬件中引入了 TLB。
TLB 可以理解成页表映射关系的缓存。
流程大概是:
CPU 给出虚拟地址
↓
MMU 先查 TLB
↓
命中:直接得到物理地址
↓
未命中:再查页表,并把结果缓存到 TLB
💡TLB 就像我去食堂吃饭时记住了常去窗口的位置。第一次要找地图,后面熟了就直接走过去,不用每次重新查。
2.7 缺页异常:申请内存到底是在干什么
这里我之前特别容易想错:调用 malloc 或者 new 之后,是不是物理内存马上就分配好了?
后来理解缺页异常之后,发现事情没那么简单。
当 CPU 给 MMU 一个虚拟地址,但 TLB 和页表里都找不到对应物理页时,就会产生缺页异常,也就是 Page Fault。
缺页大致可以分为三类:Hard Page Fault、Soft Page Fault、Invalid Page Fault。
2.7.1 Hard Page Fault
硬缺页表示物理内存里没有对应的物理页,需要从磁盘加载数据到物理内存,再建立映射。
这种情况开销比较大。
2.7.2 Soft Page Fault
软缺页表示物理页其实已经在内存里了,只是当前进程还没有建立映射。
这种情况不需要从磁盘读取,只需要建立映射。
2.7.3 Invalid Page Fault
无效缺页一般表示访问了非法地址,比如:
- 空指针解引用;
- 越界访问到非法区域;
- 权限不允许。
这种情况就可能导致 Segmentation fault。

3. Linux 线程的优点、缺点和异常
3.1 线程为什么比进程更轻量
线程比进程轻量,核心不是因为线程“代码少”,而是因为线程共享了很多进程资源。
创建新进程时,需要创建或复制更多资源,比如:
- 地址空间;
- 页表;
- 文件描述符表;
- 内核相关数据结构。
而创建线程时,同一进程内的线程共享地址空间,因此不需要重新准备一整套进程资源。
更关键的是上下文切换。
进程切换时,虚拟地址空间通常会改变,页表也会改变,TLB 可能需要刷新。线程切换时,因为还在同一个进程地址空间中,虚拟地址空间不变,开销自然小很多。
💡进程切换像换一个教室上课,桌椅、黑板、多媒体都可能不同;线程切换像同一个教室里换一个同学上台做题,环境不用重新布置。
3.2 线程的优点
线程的优点主要有:
- 创建线程比创建进程成本低;
- 线程切换比进程切换成本低;
- 线程占用资源少;
- 多核 CPU 下可以并行执行;
- 等待慢速 I/O 时,其他线程可以继续计算;
- I/O 密集型程序可以通过多线程提高体验;
- CPU 密集型程序可以把计算任务拆分到多个线程。
比如一边下载开发工具,一边继续写代码,这就是多线程思想在生活中的直观表现。
3.3 线程的缺点
线程也不是越多越好。
多线程的缺点主要有:
- 线程过多会增加调度开销;
- 多线程共享资源,容易出现数据竞争;
- 一个线程出错可能影响整个进程;
- 调试难度比单线程高;
- 多线程代码需要考虑同步、互斥、死锁等问题。
这里我最大的感受是:线程提高了效率,但也降低了隔离性。
进程之间天然隔离,而线程之间是共享地址空间的,所以一个线程如果乱写内存,可能会把其他线程也一起拖下水。
3.4 单个线程崩溃为什么可能导致整个进程崩溃
线程是进程内部的执行分支。
如果某个线程出现:
- 除零错误;
- 野指针;
- 非法地址访问;
- 栈溢出;
那么它可能触发信号机制,导致整个进程终止。
原因是:线程不是独立的资源容器,它属于同一个进程。进程终止后,进程内部所有线程自然都会退出。
4. Linux 进程 VS 线程:哪些资源共享,哪些资源独占
4.1 进程和线程的核心区别
我觉得可以先记住两句话:
- 进程是资源分配的基本单位。
- 线程是调度的基本单位。
也就是说:
- 进程更强调“资源”;
- 线程更强调“执行”。
线程共享进程数据,但也有自己的一部分私有数据。
4.2 线程私有的数据
线程私有的数据主要包括:
- 线程 ID;
- 一组寄存器;
- 线程上下文;
- 栈;
errno;- 信号屏蔽字;
- 调度优先级。
其中寄存器和栈非常关键。
因为每个线程都要有自己的执行现场。一个线程执行到哪里、函数调用到了哪一层、局部变量在哪里,都需要自己保存。
💡多线程像几个人共用一张实验桌,但每个人都有自己的草稿纸。实验器材是共享的,草稿纸上的推导过程是自己的。
4.3 线程共享的资源
同一进程内的多个线程共享同一地址空间,所以它们共享:
- 代码段
Text Segment; - 数据段
Data Segment; - 堆;
- 文件描述符表;
- 信号处理方式;
- 当前工作目录;
- 用户 ID 和组 ID。
如果定义一个函数,多个线程都可以调用。
如果定义一个全局变量,多个线程都可以访问。
这也是多线程容易出问题的原因:共享带来了方便,也带来了竞争。

5. POSIX 线程控制
5.1 POSIX 线程库
Linux 下使用线程,通常会用 POSIX 线程库,也就是 pthread 系列接口。
特点是:
- 函数名大多以
pthread_开头; - 需要包含头文件
<pthread.h>; - 编译链接时需要加
-lpthread或-pthread。
常见编译方式:
gcc mythread.c -o mythread -lpthread
或者:
gcc mythread.c -o mythread -pthread
5.2 创建线程:pthread_create
5.2.1 函数原型
#include <pthread.h>
/*
* 功能:
* 创建一个新的线程。
*
* 参数:
* thread:
* 输出型参数,用来保存 pthread 库层面的线程 ID。
*
* attr:
* 线程属性。如果传 NULL,表示使用默认属性。
*
* start_routine:
* 函数指针,表示线程启动后要执行的函数。
* 该函数参数类型必须是 void*,返回值也必须是 void*。
*
* arg:
* 传给线程入口函数 start_routine 的参数。
*
* 返回值:
* 成功返回 0;
* 失败返回错误码。
*
* 注意:
* pthread 系列函数失败时通常不会通过全局 errno 返回错误,
* 而是直接把错误码作为返回值返回。
*/
int pthread_create(
pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine)(void*),
void *arg
);
这里最容易注意的地方是 start_routine。
它是一个函数指针,类型是:
void *(*start_routine)(void*)
意思是:
- 参数是
void*; - 返回值也是
void*; - 新线程启动后会执行这个函数。
💡可以把
start_routine理解成给新同学安排的第一项任务。线程创建出来以后,不是随便乱跑,而是从这个函数开始执行。
5.2.2 创建线程示例
#include <unistd.h> // sleep 函数
#include <stdlib.h> // exit、EXIT_FAILURE
#include <stdio.h> // printf、fprintf
#include <string.h> // strerror
#include <pthread.h> // pthread_create、pthread_t
/*
* 线程入口函数。
*
* 参数:
* arg 是 pthread_create 的第四个参数传进来的。
* 当前例子没有使用参数,所以可以传 NULL。
*
* 返回值:
* 返回 void*,这里线程一直循环执行,所以不会正常 return。
*/
void *rout(void *arg)
{
int i;
/*
* 子线程不断打印信息。
* 这里的无限循环是为了观察主线程和子线程同时运行的现象。
*/
for (;;) {
printf("I'am thread 1\n");
sleep(1);
}
return NULL;
}
int main(void)
{
/*
* pthread_t 是 pthread 库层面的线程 ID。
* 注意:它不一定等于内核中的 LWP。
*/
pthread_t tid;
/*
* ret 用来保存 pthread_create 的返回值。
* pthread_create 成功返回 0,失败返回错误码。
*/
int ret;
/*
* 创建一个新线程:
* &tid:保存新线程 ID;
* NULL:使用默认线程属性;
* rout:新线程执行的入口函数;
* NULL:传给 rout 的参数。
*/
if ((ret = pthread_create(&tid, NULL, rout, NULL)) != 0) {
/*
* pthread_create 失败时,ret 本身就是错误码。
* strerror(ret) 可以把错误码转成可读字符串。
*/
fprintf(stderr, "pthread_create : %s\n", strerror(ret));
exit(EXIT_FAILURE);
}
int i;
/*
* 主线程也不断打印信息。
* 如果程序运行后两种输出交替出现,就说明主线程和子线程都在运行。
*/
for (;;) {
printf("I'am main thread\n");
sleep(1);
}
return 0;
}
5.2.3 代码理解
这段代码验证了一个现象:
创建线程之后,主线程和新线程会在同一个进程中并发执行。
主线程执行 main 中的循环,新线程执行 rout 中的循环。
这里不是 main 调用了 rout,而是 pthread_create 告诉线程库和内核:新建一个执行流,让它从 rout 函数开始运行。
这个区别很重要。
如果直接写:
rout(NULL);
那只是普通函数调用,并没有新线程。
但 pthread_create(&tid, NULL, rout, NULL) 创建的是新的执行流。
5.3 获取线程 ID:pthread_self
5.3.1 函数原型
#include <pthread.h>
/*
* 功能:
* 获取当前调用线程自己的 pthread_t ID。
*
* 参数:
* 无。
*
* 返回值:
* 返回当前线程的 pthread_t。
*
* 注意:
* 这是 pthread 库维护的线程 ID,
* 在 Linux NPTL 实现中,它本质上可能是进程地址空间中的一个地址。
*/
pthread_t pthread_self(void);
pthread_self 返回的是 pthread 库层面的线程 ID。
这个 ID 的作用范围主要在当前进程内部。也就是说,它是线程库为了管理线程而维护的标识。
5.4 用 ps -aL 查看线程
运行多线程程序后,可以使用:
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是进程 ID;LWP是轻量级进程 ID,也可以理解为内核看到的线程 ID;CMD是命令名。
如果一个进程里有两个线程,就可能看到同一个 PID 对应多个 LWP。
其中有一个 LWP 和 PID 相同,它通常就是主线程。

6. 线程终止、等待和分离
6.1 线程终止的三种方式
如果只想终止某个线程,而不是终止整个进程,一般有三种方式:
- 在线程函数中
return; - 线程调用
pthread_exit终止自己; - 其他线程调用
pthread_cancel取消目标线程。
需要注意:
- 主线程从
main函数return,相当于调用exit,会导致整个进程结束; - 子线程从入口函数
return,只是当前线程结束; pthread_exit可以显式终止当前线程;pthread_cancel可以请求取消另一个线程。
6.2 pthread_exit
6.2.1 函数原型
#include <pthread.h>
/*
* 功能:
* 终止当前线程。
*
* 参数:
* value_ptr:
* 当前线程的退出返回值。
* 其他线程可以通过 pthread_join 拿到这个值。
*
* 返回值:
* 无返回值。
*
* 注意:
* value_ptr 不要指向当前线程栈上的局部变量。
* 因为线程退出后,线程栈上的局部变量可能已经失效。
*/
void pthread_exit(void *value_ptr);
这里最容易踩坑的是:
不要返回局部变量地址。
错误示例:
void *thread_run(void *arg)
{
int x = 10;
return &x; // 错误:x 是局部变量,线程退出后这个地址不可靠
}
正确做法一般是:
- 返回全局变量地址;
- 或者使用
malloc在堆上申请空间; - 或者不关心返回值,直接返回
NULL。
6.3 pthread_cancel
6.3.1 函数原型
#include <pthread.h>
/*
* 功能:
* 请求取消一个正在执行的线程。
*
* 参数:
* thread:
* 要取消的目标线程 ID。
*
* 返回值:
* 成功返回 0;
* 失败返回错误码。
*/
int pthread_cancel(pthread_t thread);
pthread_cancel 不是直接粗暴地“杀死线程”,更准确说是向目标线程发出取消请求。
目标线程是否立刻退出,还和取消状态、取消类型、取消点有关。对于初学阶段,可以先理解成:用它可以请求终止同一进程中的另一个线程。
6.4 线程等待:pthread_join
6.4.1 为什么需要线程等待
线程退出后,如果它是 joinable 状态,那么它的相关资源不会立刻完全释放。
所以需要其他线程调用 pthread_join 来等待它结束,并回收它的退出信息。
否则可能造成类似“资源泄漏”的问题。
💡joinable 线程就像一个同学做完实验后,实验台还没收拾。人已经走了,但桌上的记录、工具还在,需要另一个人来确认并收拾。
6.4.2 函数原型
#include <pthread.h>
/*
* 功能:
* 等待指定线程结束,并获取线程返回值。
*
* 参数:
* thread:
* 要等待的线程 ID。
*
* value_ptr:
* 输出型参数。
* 它指向一个 void* 指针,用来接收线程的退出返回值。
* 如果不关心线程返回值,可以传 NULL。
*
* 返回值:
* 成功返回 0;
* 失败返回错误码。
*/
int pthread_join(pthread_t thread, void **value_ptr);
线程结束方式不同,pthread_join 拿到的结果也不同:
- 如果线程函数
return,得到的是return的值; - 如果线程调用
pthread_exit,得到的是pthread_exit的参数; - 如果线程被
pthread_cancel取消,得到的是PTHREAD_CANCELED; - 如果不关心返回值,可以传
NULL。
6.5 线程终止与等待示例
#include <stdio.h> // printf
#include <stdlib.h> // malloc、free
#include <string.h> // 字符串相关函数
#include <unistd.h> // sleep
#include <pthread.h> // pthread_create、pthread_join、pthread_exit、pthread_cancel
/*
* thread1:
* 通过 return 结束线程。
* 返回值是堆上申请的 int*,这样线程退出后仍然有效。
*/
void *thread1(void *arg)
{
printf("thread 1 returning ... \n");
/*
* 这里必须用 malloc。
* 如果使用局部变量 int x,然后 return &x,
* 在线程退出后 x 的生命周期结束,其他线程拿到的是无效地址。
*/
int *p = (int*)malloc(sizeof(int));
*p = 1;
/*
* return 的值可以被 pthread_join 获取。
*/
return (void*)p;
}
/*
* thread2:
* 通过 pthread_exit 结束线程。
*/
void *thread2(void *arg)
{
printf("thread 2 exiting ...\n");
/*
* 同样使用堆空间保存返回值。
*/
int *p = (int*)malloc(sizeof(int));
*p = 2;
/*
* pthread_exit 的参数也可以被 pthread_join 获取。
*/
pthread_exit((void*)p);
}
/*
* thread3:
* 一直运行,后面由主线程调用 pthread_cancel 取消。
*/
void *thread3(void *arg)
{
while (1) {
printf("thread 3 is running ...\n");
sleep(1);
}
return NULL;
}
int main(void)
{
pthread_t tid;
void *ret;
/*
* 第一种情况:
* 线程函数通过 return 返回。
*/
pthread_create(&tid, NULL, thread1, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
free(ret);
/*
* 第二种情况:
* 线程函数通过 pthread_exit 退出。
*/
pthread_create(&tid, NULL, thread2, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
free(ret);
/*
* 第三种情况:
* 线程被其他线程取消。
*/
pthread_create(&tid, NULL, thread3, NULL);
/*
* 让 thread3 先运行 3 秒。
*/
sleep(3);
/*
* 请求取消 thread3。
*/
pthread_cancel(tid);
/*
* 等待 thread3 退出,并获取退出状态。
*/
pthread_join(tid, &ret);
if (ret == PTHREAD_CANCELED) {
printf("thread return, thread id %X, return code:PTHREAD_CANCELED\n", tid);
} else {
printf("thread return, thread id %X, return code:NULL\n", tid);
}
return 0;
}
运行结果类似:
[root@localhost linux]# ./a.out
thread 1 returning ...
thread return, thread id 5AA79700, return code:1
thread 2 exiting ...
thread return, thread id 5AA79700, return code:2
thread 3 is running ...
thread 3 is running ...
thread 3 is running ...
thread return, thread id 5AA79700, return code:PTHREAD_CANCELED
6.5.1 代码理解
这段代码把线程退出的三种情况放在一起对比:
thread1:通过return返回;thread2:通过pthread_exit返回;thread3:通过pthread_cancel被取消。
最重要的细节是:
pthread_join 不只是“等待”,它还承担了“回收线程退出信息”的作用。
💡
pthread_join有点像等舍友做完实验后,把实验台收拾干净。如果不等、不收拾,东西可能一直占着位置。
6.6 分离线程:pthread_detach
6.6.1 为什么需要线程分离
默认情况下,新创建的线程是 joinable 的。
这意味着线程退出后,需要其他线程调用 pthread_join 回收资源。
但有些线程我并不关心它的返回值,比如后台日志线程、定时清理线程。这时如果还必须 join,就会很麻烦。
所以可以让线程进入分离状态。分离线程退出后,系统会自动释放相关资源。
6.6.2 函数原型
#include <pthread.h>
/*
* 功能:
* 将指定线程设置为分离状态。
*
* 参数:
* thread:
* 要分离的线程 ID。
*
* 返回值:
* 成功返回 0;
* 失败返回错误码。
*
* 注意:
* joinable 和 detached 是冲突的。
* 一个线程不能既被 join,又被 detach。
*/
int pthread_detach(pthread_t thread);
线程可以分离其他线程,也可以分离自己:
pthread_detach(pthread_self());
6.7 分离线程示例
#include <stdio.h> // printf
#include <stdlib.h> // 标准库函数
#include <string.h> // 字符串相关函数
#include <unistd.h> // sleep
#include <pthread.h> // pthread_create、pthread_detach、pthread_join、pthread_self
/*
* 子线程入口函数。
*/
void *thread_run(void *arg)
{
/*
* 线程自己把自己设置为分离状态。
* 设置之后,线程退出时资源会自动释放。
*/
pthread_detach(pthread_self());
/*
* arg 是 pthread_create 第四个参数传进来的。
*/
printf("%s\n", (char*)arg);
return NULL;
}
int main(void)
{
pthread_t tid;
/*
* 创建子线程。
*/
if (pthread_create(&tid, NULL, thread_run, "thread1 run...") != 0) {
printf("create thread error\n");
return 1;
}
int ret = 0;
/*
* 这里 sleep 很重要:
* 让子线程先运行,并执行 pthread_detach。
*/
sleep(1);
/*
* 子线程已经分离,此时再 pthread_join 会失败。
*/
if (pthread_join(tid, NULL) == 0) {
printf("pthread wait success\n");
ret = 0;
} else {
printf("pthread wait failed\n");
ret = 1;
}
return ret;
}
6.7.1 代码理解
这个例子说明:
分离线程不能再被 join。
如果一个线程已经处于 detached 状态,系统会在线程退出后自动回收资源,其他线程就不应该再尝试 pthread_join 它。
7. 线程 ID、LWP 与地址空间布局
7.1 pthread_t 不是内核线程 ID
这里是我一开始最容易混的地方。
pthread_create 产生的线程 ID 和 ps -aL 看到的 LWP 不是一个层面的东西。
pthread_t 属于线程库层面。LWP 属于内核调度层面。
在 Linux NPTL 实现中,pthread_t 本质上可以理解成进程地址空间中的一个地址,这个地址可以找到线程库维护的线程控制信息,比如:
- 线程 ID;
- 线程栈;
- 寄存器相关信息;
- 线程属性;
- 返回值;
- 分离状态。
而 LWP 是内核真正调度时看到的轻量级执行流 ID。
7.2 主线程和子线程栈的位置
在 ps -aL 中,如果一个线程的 LWP 和 PID 相同,一般可以理解为主线程。
主线程的栈位于进程虚拟地址空间中的栈区域。
其他线程的栈通常位于共享区附近,因为线程库需要为它们单独准备栈空间。
这就解释了为什么线程既共享地址空间,又拥有自己的栈。

8. 用 C++ 封装一个简单线程类
8.1 第一版:不考虑复杂参数传递
学习完 pthread_create 后,我觉得一个很好的练习是:把线程封装成一个 C++ 类。
这样做不是为了重复造轮子,而是为了理解:
- 线程对象应该保存哪些信息;
pthread_create的入口函数为什么必须是静态函数;- 如何把
this指针传给线程入口; - 如何在线程类中支持 join 和 detach。
8.2 Thread.hpp
// Thread.hpp
#pragma once
#include <iostream> // std::cout、std::endl
#include <string> // std::string
#include <functional> // std::function
#include <pthread.h> // pthread_create、pthread_join、pthread_detach
namespace ThreadModule
{
/*
* 计数器,用来生成线程名称。
* 注意:
* 这里为了理解封装思路,暂时没有加锁。
* 如果多个线程同时创建 Thread 对象,cnt++ 可能存在线程安全问题。
*/
std::uint32_t cnt = 0;
/*
* 线程要执行的函数类型。
* 这里不考虑参数传递,统一封装成 void()。
*/
using threadfunc_t = std::function<void()>;
/*
* 线程状态。
*/
enum class TSTATUS
{
THREAD_NEW, // 新建状态,还没有启动
THREAD_RUNNING, // 正在运行
THREAD_STOP // 已经停止
};
class Thread
{
private:
/*
* pthread_create 要求线程入口函数类型是:
* void* (*)(void*)
*
* 普通成员函数会隐含 this 指针,类型不匹配。
* 所以这里必须使用 static 静态成员函数作为线程入口。
*/
static void *run(void *obj)
{
/*
* obj 是 pthread_create 第四个参数传进来的 this 指针。
* 这里把 void* 转回 Thread*,就可以访问当前对象。
*/
Thread *self = static_cast<Thread *>(obj);
/*
* 设置线程名称,方便 ps -aL 或调试时观察。
* pthread_setname_np 是非标准接口,_np 表示 non-portable。
*/
pthread_setname_np(pthread_self(), self->_name.c_str());
/*
* 线程真正开始运行。
*/
self->_status = TSTATUS::THREAD_RUNNING;
/*
* 如果设置为非 join 模式,就在线程内部 detach 自己。
*/
if (!self->_joined)
{
pthread_detach(pthread_self());
}
/*
* 执行用户传入的函数。
*/
self->_func();
return nullptr;
}
/*
* 设置线程名称。
*/
void SetName()
{
/*
* 注意:
* 这里 cnt++ 在多线程环境下不是线程安全的。
* 后续如果追求严格正确,需要加锁或使用原子变量。
*/
_name = "Thread-" + std::to_string(cnt++);
}
public:
/*
* 构造函数:
* 默认线程处于 THREAD_NEW;
* 默认线程是 joinable;
* 保存用户传入的函数。
*/
Thread(threadfunc_t func)
: _status(TSTATUS::THREAD_NEW),
_joined(true),
_func(func)
{
SetName();
}
/*
* 开启分离模式。
* 只有在线程还没启动时设置才合理。
*/
void EnableDetach()
{
if (_status == TSTATUS::THREAD_NEW)
_joined = false;
}
/*
* 开启 join 模式。
*/
void EnableJoined()
{
if (_status == TSTATUS::THREAD_NEW)
_joined = true;
}
/*
* 启动线程。
*/
bool Start()
{
if (_status == TSTATUS::THREAD_RUNNING)
return true;
/*
* 创建线程:
* &_id:保存 pthread_t;
* nullptr:默认属性;
* run:静态入口函数;
* this:把当前对象传给线程入口。
*/
int n = ::pthread_create(&_id, nullptr, run, this);
if (n != 0)
return false;
return true;
}
/*
* 等待线程结束。
*/
bool Join()
{
if (_joined)
{
int n = pthread_join(_id, nullptr);
if (n != 0)
return false;
return true;
}
return false;
}
~Thread() {}
private:
std::string _name; // 线程名称
pthread_t _id; // pthread 库层面的线程 ID
TSTATUS _status; // 线程状态
bool _joined; // 是否需要 join
threadfunc_t _func; // 线程执行函数
};
}
8.3 测试代码
// main.cc
#include <iostream> // std::cout
#include <unistd.h> // sleep
#include <pthread.h> // pthread_self、pthread_setname_np、pthread_getname_np
#include <functional> // std::bind
#include "Thread.hpp"
void hello1()
{
char buffer[64];
/*
* 获取当前线程名称。
* sizeof(buffer) - 1 是为了预留字符串结尾的 '\0'。
*/
pthread_getname_np(pthread_self(), buffer, sizeof(buffer) - 1);
while (true)
{
std::cout << "hello world, " << buffer << std::endl;
sleep(1);
}
}
void hello2()
{
char buffer[64];
/*
* 获取当前线程名称。
*/
pthread_getname_np(pthread_self(), buffer, sizeof(buffer) - 1);
while (true)
{
std::cout << "hello world, " << buffer << std::endl;
sleep(1);
}
}
int main()
{
/*
* 设置主线程名称。
*/
pthread_setname_np(pthread_self(), "main");
/*
* 创建线程对象 t1,执行 hello1。
*/
ThreadModule::Thread t1(hello1);
t1.Start();
/*
* 创建线程对象 t2。
* 这里使用 std::bind,是为了演示函数对象也可以传入。
*/
ThreadModule::Thread t2(std::bind(&hello2));
t2.Start();
/*
* 等待两个线程结束。
* 由于 hello1 和 hello2 都是死循环,所以这里一般不会返回。
*/
t1.Join();
t2.Join();
return 0;
}
运行时可以用:
ps -aL
观察结果可能类似:
PID LWP TTY TIME CMD
195828 195828 pts/1 00:00:00 main
195828 195829 pts/1 00:00:00 Thread-0
195828 195830 pts/1 00:00:00 Thread-1
8.4 关于 pthread_setname_np 和 pthread_getname_np
这两个函数用于设置和获取线程名称。
注意:
_np表示non-portable,即非可移植;- Linux 上线程名称最大长度通常是 16 字符,包括结尾的
\0; - 线程名称主要用于调试和观察;
- 名字太长可能会被截断。
8.5 模板版本线程封装
这个版本支持传递一个参数。
#include <iostream> // std::cout
#include <string> // std::string
#include <functional> // std::function
#include <pthread.h> // pthread_create、pthread_join、pthread_cancel、pthread_detach
#include <unistd.h> // getpid
namespace ThreadModule
{
/*
* 静态编号,用来生成线程名。
*/
static int number = 1;
/*
* 线程状态枚举。
*/
enum class TSTATUS
{
NEW,
RUNNING,
STOP
};
/*
* 模板线程类。
* T 表示线程函数接收的数据类型。
*/
template <typename T>
class Thread
{
/*
* func_t 表示线程要执行的函数类型。
* 该函数接收一个 T 类型参数,返回 void。
*/
using func_t = std::function<void(T)>;
private:
/*
* pthread_create 的入口函数必须是静态函数。
*/
static void *Routine(void *args)
{
/*
* args 是 this 指针。
*/
Thread<T> *t = static_cast<Thread<T> *>(args);
/*
* 修改线程状态。
*/
t->_status = TSTATUS::RUNNING;
/*
* 执行用户传入的函数,并传入保存的数据。
*/
t->_func(t->_data);
return nullptr;
}
/*
* 设置为分离状态。
*/
void EnableDetach()
{
_joinable = false;
}
public:
/*
* 构造函数:
* func:线程执行函数;
* data:传给线程函数的数据。
*/
Thread(func_t func, T data)
: _func(func),
_data(data),
_status(TSTATUS::NEW),
_joinable(true)
{
_name = "Thread-" + std::to_string(number++);
_pid = getpid();
}
/*
* 启动线程。
*/
bool Start()
{
if (_status != TSTATUS::RUNNING)
{
/*
* 创建线程,入口函数是 Routine,参数是 this。
*/
int n = ::pthread_create(&_tid, nullptr, Routine, this);
if (n != 0)
return false;
return true;
}
return false;
}
/*
* 停止线程。
* 这里使用 pthread_cancel 请求取消线程。
*/
bool Stop()
{
if (_status == TSTATUS::RUNNING)
{
int n = ::pthread_cancel(_tid);
if (n != 0)
return false;
_status = TSTATUS::STOP;
return true;
}
return false;
}
/*
* 等待线程退出。
*/
bool Join()
{
if (_joinable)
{
int n = ::pthread_join(_tid, nullptr);
if (n != 0)
return false;
_status = TSTATUS::STOP;
return true;
}
return false;
}
/*
* 分离线程。
*/
void Detach()
{
EnableDetach();
pthread_detach(_tid);
}
/*
* 判断是否 joinable。
*/
bool IsJoinable()
{
return _joinable;
}
/*
* 获取线程名称。
*/
std::string Name()
{
return _name;
}
~Thread()
{
}
private:
std::string _name; // 线程名称
pthread_t _tid; // pthread 线程 ID
pid_t _pid; // 所属进程 ID
bool _joinable; // 是否可以被 join
func_t _func; // 线程执行函数
TSTATUS _status; // 线程状态
T _data; // 线程数据
};
}
8.5.1 封装理解
这里最关键的设计是:
pthread_create(&_tid, nullptr, Routine, this);
因为 Routine 是静态函数,可以满足 pthread_create 的函数指针类型要求。
而 this 被作为参数传进去后,在线程入口函数里再强转回 Thread<T>*,这样就能访问对象中的成员变量。
💡这就像把“对象自己的身份证”传给新线程。线程入口函数虽然是公共入口,但拿到 this 之后,就知道自己应该执行哪个对象里的任务。
9. 从 NPTL 思路理解 pthread_create 背后发生了什么
9.1 pthread_create 不只是一个普通函数
学到这里,我才发现 pthread_create 背后其实做了很多事。
从使用者角度看,我们只是写:
pthread_create(&tid, NULL, routine, arg);
但线程库内部大致要做:
- 读取线程属性;
- 分配线程栈;
- 创建线程控制块;
- 保存入口函数和参数;
- 设置线程状态;
- 调用底层
clone创建轻量级执行流; - 把线程控制块地址返回给上层作为
pthread_t。
所以 pthread_t 为什么可能是一个地址?因为线程库内部会创建一个描述线程的对象,然后把这个对象地址作为线程库层面的 ID 返回给用户。
9.2 pthread_create 核心源码理解
下面是一个经过注释整理的核心流程。
/*
* pthread_create 底层核心流程示意。
* 这里重点理解:
* 1. pthread 库先创建线程描述对象;
* 2. 再申请线程栈;
* 3. 把用户传入的函数和参数保存进线程控制块;
* 4. 最后调用 create_thread,进一步走向 clone。
*/
int __pthread_create_2_1(
pthread_t *newthread,
const pthread_attr_t *attr,
void *(*start_routine)(void *),
void *arg
)
{
STACK_VARIABLES;
/*
* 线程属性。
* 如果用户没有传 attr,就使用默认属性。
*/
const struct pthread_attr *iattr = (struct pthread_attr *)attr;
if (iattr == NULL)
iattr = &default_attr;
/*
* pd 可以理解成 pthread 库内部描述线程的 TCB。
*/
struct pthread *pd = NULL;
/*
* 申请线程栈,并准备 struct pthread 对象。
* 这里的 pd 会指向线程控制块。
*/
int err = ALLOCATE_STACK(iattr, &pd);
if (__builtin_expect(err != 0, 0))
return err;
#ifdef TLS_TCB_AT_TP
/*
* 设置 TCB 自引用。
*/
pd->header.self = pd;
pd->header.tcb = pd;
#endif
/*
* 保存线程入口函数和参数。
* 注意:
* 新线程并不是立即直接调用 start_routine,
* 而是先把它保存到线程描述结构里,
* 之后新线程启动时再取出来执行。
*/
pd->start_routine = start_routine;
pd->arg = arg;
/*
* 复制线程属性标志。
*/
struct pthread *self = THREAD_SELF;
pd->flags =
((iattr->flags & ~(ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET))
| (self->flags & (ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET)));
/*
* joinid 用来判断线程是否分离。
* 如果线程是 detached,则 joinid 指向自己。
*/
pd->joinid = iattr->flags & ATTR_FLAG_DETACHSTATE ? pd : NULL;
/*
* 继承调试事件、调度策略和调度参数。
*/
pd->eventbuf = self->eventbuf;
pd->schedpolicy = self->schedpolicy;
pd->schedparam = self->schedparam;
#ifdef THREAD_COPY_STACK_GUARD
THREAD_COPY_STACK_GUARD(pd);
#endif
#ifdef THREAD_COPY_POINTER_GUARD
THREAD_COPY_POINTER_GUARD(pd);
#endif
/*
* 如果用户设置了调度属性,需要检查调度策略和优先级是否合法。
*/
if (attr != NULL &&
__builtin_expect((iattr->flags & ATTR_FLAG_NOTINHERITSCHED) != 0, 0) &&
(iattr->flags & (ATTR_FLAG_SCHED_SET | ATTR_FLAG_POLICY_SET)) != 0)
{
INTERNAL_SYSCALL_DECL(scerr);
if (iattr->flags & ATTR_FLAG_POLICY_SET)
pd->schedpolicy = iattr->schedpolicy;
else if ((pd->flags & ATTR_FLAG_POLICY_SET) == 0)
{
pd->schedpolicy = INTERNAL_SYSCALL(sched_getscheduler, scerr, 1, 0);
pd->flags |= ATTR_FLAG_POLICY_SET;
}
if (iattr->flags & ATTR_FLAG_SCHED_SET)
memcpy(&pd->schedparam, &iattr->schedparam,
sizeof(struct sched_param));
else if ((pd->flags & ATTR_FLAG_SCHED_SET) == 0)
{
INTERNAL_SYSCALL(sched_getparam, scerr, 2, 0, &pd->schedparam);
pd->flags |= ATTR_FLAG_SCHED_SET;
}
/*
* 检查优先级范围。
*/
int minprio = INTERNAL_SYSCALL(sched_get_priority_min, scerr, 1,
iattr->schedpolicy);
int maxprio = INTERNAL_SYSCALL(sched_get_priority_max, scerr, 1,
iattr->schedpolicy);
if (pd->schedparam.sched_priority < minprio ||
pd->schedparam.sched_priority > maxprio)
{
err = EINVAL;
goto errout;
}
}
/*
* 关键点:
* 把 pd 转成 pthread_t 返回给用户。
* 所以上层拿到的 pthread_t,本质上可能就是线程控制块地址。
*/
*newthread = (pthread_t)pd;
/*
* 判断线程是否是分离状态。
*/
bool is_detached = IS_DETACHED(pd);
/*
* 真正创建线程。
*/
err = create_thread(pd, iattr, STACK_VARIABLES_ARGS);
if (err != 0)
{
if (!is_detached)
{
errout:
__deallocate_stack(pd);
}
return err;
}
return 0;
}
9.2.1 代码理解
这段代码最关键的地方是:
*newthread = (pthread_t)pd;
也就是说,用户拿到的 pthread_t 并不是内核中的 LWP,而是线程库内部线程描述对象的地址。
这也解释了前面的问题:
pthread_self()拿到的是 pthread 库 ID;ps -aL看到的是内核 LWP;- 二者不是一个层面的东西。
9.3 线程属性结构
/*
* pthread_attr 表示线程属性。
* 线程属性可以控制调度策略、栈大小、分离状态、CPU 亲和性等。
*/
struct pthread_attr
{
/*
* 调度参数,比如优先级。
*/
struct sched_param schedparam;
/*
* 调度策略。
*/
int schedpolicy;
/*
* 各种标志位,比如是否分离、调度策略是否显式设置等。
*/
int flags;
/*
* 栈保护区大小。
* 用于防止栈溢出破坏其他内存。
*/
size_t guardsize;
/*
* 用户指定的栈地址和栈大小。
* 如果用户不指定,就由线程库自己申请。
*/
void *stackaddr;
size_t stacksize;
/*
* CPU 亲和性相关设置。
*/
cpu_set_t *cpuset;
size_t cpusetsize;
};
9.4 线程控制块中几个重要字段
线程控制块很大,但我觉得理解线程时重点关注这些字段就够了:
/*
* struct pthread 是线程库内部描述线程的结构。
* 它保存了线程 ID、进程 ID、线程返回值、线程入口函数、线程栈等信息。
*/
struct pthread
{
/*
* tid 是内核层面的线程 ID。
*/
pid_t tid;
/*
* pid 是线程所属进程的 ID,也可以理解成线程组 ID。
*/
pid_t pid;
/*
* joinid 用来描述 join/detach 关系。
* 如果线程是 detached,joinid 可能指向自己。
*/
struct pthread *joinid;
#define IS_DETACHED(pd) ((pd)->joinid == (pd))
/*
* 线程函数执行结束后的返回值。
* pthread_join 获取线程返回值时,本质上就和这个结果有关。
*/
void *result;
/*
* 调度参数和调度策略。
*/
struct sched_param schedparam;
int schedpolicy;
/*
* 用户传入的线程入口函数和参数。
*/
void *(*start_routine)(void *);
void *arg;
/*
* 线程自己的栈空间起始位置和大小。
*/
void *stackblock;
size_t stackblock_size;
/*
* 栈保护区大小。
*/
size_t guardsize;
size_t reported_guardsize;
};
9.4.1 代码理解
线程控制块里面有几个字段特别重要:
start_routine:保存用户传入的线程函数;arg:保存传给线程函数的参数;result:保存线程退出结果;stackblock:保存线程栈位置;tid:保存内核层面的线程 ID;joinid:用于判断线程是否分离。
这也说明线程库并不是“魔法”,它本质上是在用户层维护了一套线程对象,再配合内核创建轻量级执行流。
9.5 create_thread 和 clone 标志
/*
* create_thread 会进一步调用 do_clone。
* clone_flags 决定新执行流和当前执行流共享哪些资源。
*/
static int create_thread(
struct pthread *pd,
const struct pthread_attr *attr,
STACK_VARIABLES_PARMS
)
{
/*
* 这些 CLONE 标志非常关键。
*/
int clone_flags =
(CLONE_VM /* 共享虚拟地址空间 */
| CLONE_FS /* 共享文件系统信息,比如当前工作目录 */
| CLONE_FILES /* 共享文件描述符表 */
| CLONE_SIGNAL /* 共享信号处理语义 */
| CLONE_SETTLS /* 设置 TLS 区域 */
| CLONE_PARENT_SETTID /* 内核把新线程 ID 写到父线程指定位置 */
| CLONE_CHILD_CLEARTID /* 线程退出时清理 TID */
| CLONE_SYSVSEM /* 共享 System V 信号量调整值 */
#if __ASSUME_NO_CLONE_DETACHED == 0
| CLONE_DETACHED
#endif
| 0);
/*
* 如果调试器需要线程创建事件,可能先让线程暂停启动。
*/
if (__builtin_expect(THREAD_GETMEM(THREAD_SELF, report_events), 0))
{
pd->stopped_start = true;
/*
* 通过 do_clone 创建线程。
*/
int res = do_clone(pd, attr, clone_flags, start_thread,
STACK_VARIABLES_ARGS, 1);
if (res == 0)
{
/*
* 设置线程创建事件信息。
*/
pd->eventbuf.eventnum = TD_CREATE;
pd->eventbuf.eventdata = pd;
/*
* 记录事件。
*/
do
pd->nextevent = __nptl_last_event;
while (atomic_compare_and_exchange_bool_acq(&__nptl_last_event,
pd,
pd->nextevent) != 0);
/*
* 通知调试器线程创建事件。
*/
__nptl_create_event();
/*
* 最后解锁,让新线程继续运行。
*/
lll_unlock(pd->lock);
}
return res;
}
/*
* 如果设置了 CPU 亲和性或者调度属性,也可能让线程先停止。
*/
bool stopped = false;
if (attr != NULL &&
(attr->cpuset != NULL ||
(attr->flags & ATTR_FLAG_NOTINHERITSCHED) != 0))
stopped = true;
pd->stopped_start = stopped;
/*
* 真正创建线程。
*/
int res = do_clone(pd, attr, clone_flags, start_thread,
STACK_VARIABLES_ARGS, stopped);
if (res == 0 && stopped)
lll_unlock(pd->lock);
return res;
}
9.5.1 CLONE_VM 为什么关键
CLONE_VM 表示共享虚拟地址空间。
这正是线程和进程的重要区别之一。
如果不共享地址空间,那更像创建一个新进程。
如果共享地址空间,再加上共享文件描述符表、信号处理等资源,就更符合线程语义。
💡
CLONE_VM就像几个人共用同一本实验记录本。每个人可以在不同位置写,但如果没有约定好,可能会互相覆盖。
9.6 do_clone 的核心流程
/*
* do_clone 进一步调用体系结构相关的 clone。
*/
static int do_clone(
struct pthread *pd,
const struct pthread_attr *attr,
int clone_flags,
int (*fct)(void *),
STACK_VARIABLES_PARMS,
int stopped
)
{
#ifdef PREPARE_CREATE
PREPARE_CREATE;
#endif
/*
* 如果需要让线程先暂停,就先加锁。
*/
if (stopped)
lll_lock(pd->lock);
/*
* 全局线程数量加一。
*/
atomic_increment(&__nptl_nthreads);
/*
* ARCH_CLONE 最终会走到体系结构相关的 clone 实现。
*/
if (ARCH_CLONE(fct,
STACK_VARIABLES_ARGS,
clone_flags,
pd,
&pd->tid,
TLS_VALUE,
&pd->tid) == -1)
{
atomic_decrement(&__nptl_nthreads);
/*
* 如果创建失败,并且线程是分离状态,需要释放 TCB 和栈。
*/
if (IS_DETACHED(pd))
__deallocate_stack(pd);
return errno;
}
/*
* 如果线程是 stopped 状态,还需要设置 CPU 亲和性和调度参数。
*/
if (__builtin_expect(stopped != 0, 0))
{
INTERNAL_SYSCALL_DECL(err);
int res = 0;
if (attr->cpuset != NULL)
{
res = INTERNAL_SYSCALL(sched_setaffinity, err, 3, pd->tid,
sizeof(cpu_set_t), attr->cpuset);
if (__builtin_expect(INTERNAL_SYSCALL_ERROR_P(res, err), 0))
{
INTERNAL_SYSCALL_DECL(err2);
err_out:
#if __ASSUME_TGKILL
(void)INTERNAL_SYSCALL(tgkill, err2, 3,
THREAD_GETMEM(THREAD_SELF, pid),
pd->tid, SIGCANCEL);
#else
(void)INTERNAL_SYSCALL(tkill, err2, 2, pd->tid, SIGCANCEL);
#endif
return (INTERNAL_SYSCALL_ERROR_P(res, err)
? INTERNAL_SYSCALL_ERRNO(res, err)
: 0);
}
}
if ((attr->flags & ATTR_FLAG_NOTINHERITSCHED) != 0)
{
res = INTERNAL_SYSCALL(sched_setscheduler, err, 3, pd->tid,
pd->schedpolicy, &pd->schedparam);
if (__builtin_expect(INTERNAL_SYSCALL_ERROR_P(res, err), 0))
goto err_out;
}
}
/*
* 标记当前进程已经是多线程状态。
*/
THREAD_SETMEM(THREAD_SELF, header.multiple_threads, 1);
return 0;
}
9.7 __clone 汇编中的系统调用
/*
* __clone 是 glibc 用汇编封装的 clone 系统调用。
* 这里不用死抠汇编细节,重点看它最终会 syscall 进入内核。
*/
ENTRY (BP_SYM (__clone))
/* 参数检查:函数指针不能为空 */
movq $-EINVAL,%rax
testq %rdi,%rdi
jz SYSCALL_ERROR_LABEL
/* 参数检查:栈指针不能为空 */
testq %rsi,%rsi
jz SYSCALL_ERROR_LABEL
/* 在新线程栈上预留空间 */
subq $16,%rsi
movq %rcx,8(%rsi)
/* 保存函数指针 */
movq %rdi,0(%rsi)
/* 准备系统调用参数 */
movq %rdx, %rdi
movq %r8, %rdx
movq %r9, %r8
movq 8(%rsp), %r10
/* 获取 clone 系统调用号 */
movl $SYS_ify(clone),%eax
/*
* syscall 陷入内核。
* 这一步要求内核创建轻量级执行流。
*/
syscall
testq %rax,%rax
jl SYSCALL_ERROR_LABEL
jz L(thread_start)
9.7.1 代码理解
这个地方我觉得只要理解一件事:
pthread_create 最终还是要通过系统调用进入内核。
线程库负责包装和管理,内核负责真正调度。
也就是说:
pthread_create
↓
NPTL 线程库
↓
分配线程栈和 TCB
↓
clone 系统调用
↓
内核创建轻量级执行流

10. 线程栈:主线程栈和子线程栈不完全一样
10.1 主线程栈
主线程栈可以简单理解为 main 函数对应的栈空间。
在 fork 的时候,子进程会复制父进程的地址空间结构,并通过写时拷贝机制减少真实复制开销。
主线程栈可以动态增长,但增长也有上限。如果超过上限,可能触发栈溢出,最终导致段错误。
10.2 子线程栈
子线程的栈通常不是像主线程栈那样动态增长,而是线程库提前通过 mmap 映射出一块固定大小的区域。
常见默认大小可能是 8MB。
相关思想可以用下面这段代码理解:
/*
* pthread 库内部可能通过 mmap 为线程申请栈空间。
*
* 参数说明:
* NULL:
* 让内核自动选择映射地址。
*
* size:
* 映射区域大小,也就是线程栈大小。
*
* prot:
* 映射区域权限,比如可读可写。
*
* MAP_PRIVATE:
* 私有映射。
*
* MAP_ANONYMOUS:
* 匿名映射,不关联具体文件。
*
* MAP_STACK:
* 表示该映射用于栈。
*
* -1 和 0:
* 匿名映射下不需要文件描述符和文件偏移。
*/
mem = mmap(NULL, size, prot,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
所以子线程栈本质上是在进程地址空间中映射出来的一块区域。
它原则上是线程私有的,但因为同一进程内线程共享地址空间,如果其他线程知道它的位置,也可能访问到。
这就是多线程危险的地方。
💡子线程栈像每个人自己的草稿本,但这些草稿本都放在同一个宿舍桌子上。如果别人非要翻,也不是完全做不到。
10.3 sys_clone 中的栈指针
/*
* sys_clone 会从寄存器中取出 clone_flags 和新栈指针。
* 这里可以看到,创建线程/轻量级进程时,新执行流需要自己的栈。
*/
int sys_clone(struct pt_regs *regs)
{
unsigned long clone_flags;
unsigned long newsp;
int __user *parent_tidptr;
int __user *child_tidptr;
/*
* 获取 clone 标志位。
*/
clone_flags = regs->bx;
/*
* 获取新栈指针。
* 对线程来说,这个栈通常来自 pthread 库前面 mmap 出来的区域。
*/
newsp = regs->cx;
parent_tidptr = (int __user *)regs->dx;
child_tidptr = (int __user *)regs->di;
/*
* 如果没有传新栈,就使用当前栈指针。
*/
if (!newsp)
newsp = regs->sp;
/*
* 最终进入 do_fork。
*/
return do_fork(clone_flags, newsp, regs, 0, parent_tidptr, child_tidptr);
}
11. 页表项、页目录和 mm_struct
11.1 页表项标志位
/*
* 页表项相关标志位。
* 这些标志位用来描述页是否存在、是否可写、是否可执行、是否脏页等。
*/
#define L_PTE_PRESENT (1 << 0) // 页是否存在
#define L_PTE_FILE (1 << 1) // 非 PRESENT 时表示文件映射
#define L_PTE_YOUNG (1 << 1) // 页是否最近被访问
#define L_PTE_BUFFERABLE (1 << 2) // 是否可缓冲
#define L_PTE_CACHEABLE (1 << 3) // 是否可缓存
#define L_PTE_USER (1 << 4) // 用户态是否可访问
#define L_PTE_WRITE (1 << 5) // 是否可写
#define L_PTE_EXEC (1 << 6) // 是否可执行
#define L_PTE_DIRTY (1 << 7) // 是否为脏页
#define L_PTE_COHERENT (1 << 9) // I/O 一致性相关
#define L_PTE_SHARED (1 << 10) // 是否在 CPU 间共享
#define L_PTE_ASID (1 << 11) // ASID 相关
11.2 页表项和页目录项类型
/*
* pte_t 表示页表项。
* pgd_t 表示页全局目录项。
*/
typedef struct { unsigned long pte; } pte_t;
typedef struct { unsigned long pgd; } pgd_t;
这段代码看起来很简单,但背后很重要。
它说明页表项和页目录项,本质上都是对底层整数的封装。只不过为了类型安全和语义清晰,内核把它们封装成不同类型。
11.3 分配页目录和页表
/*
* 分配页全局目录。
*/
pgd_t *pgd_alloc(struct mm_struct *mm)
{
pgd_t *ret;
pgd_t *init;
/*
* 申请一个空闲页作为页目录。
* GFP_KERNEL 表示内核正常内存分配。
* __GFP_ZERO 表示分配后清零。
*/
ret = (pgd_t *)__get_free_page(GFP_KERNEL | __GFP_ZERO);
/*
* 获取 init_mm 的页目录偏移。
*/
init = pgd_offset(&init_mm, 0UL);
if (ret) {
#ifdef CONFIG_ALPHA_LARGE_VMALLOC
memcpy(ret + USER_PTRS_PER_PGD,
init + USER_PTRS_PER_PGD,
(PTRS_PER_PGD - USER_PTRS_PER_PGD - 1) * sizeof(pgd_t));
#else
pgd_val(ret[PTRS_PER_PGD - 2]) = pgd_val(init[PTRS_PER_PGD - 2]);
#endif
/*
* 最后一个 PGD 项是自映射相关内容。
*/
pgd_val(ret[PTRS_PER_PGD - 1]) =
pte_val(mk_pte(virt_to_page(ret), PAGE_KERNEL));
}
return ret;
}
/*
* 分配一个内核页表。
*/
pte_t *pte_alloc_one_kernel(struct mm_struct *mm, unsigned long address)
{
/*
* 申请一个空闲页作为页表。
*/
pte_t *pte =
(pte_t *)__get_free_page(GFP_KERNEL | __GFP_REPEAT | __GFP_ZERO);
return pte;
}
11.4 mm_struct 中的页目录
/*
* mm_struct 描述一个进程的内存空间。
*/
struct mm_struct {
/*
* VMA 链表。
* 每个 vm_area_struct 描述一段虚拟内存区域。
*/
struct vm_area_struct *mmap;
/*
* VMA 红黑树。
* 用于快速查找某个虚拟地址属于哪个 VMA。
*/
struct rb_root mm_rb;
/*
* 最近一次 find_vma 的缓存。
*/
struct vm_area_struct *mmap_cache;
/*
* 查找未映射区域的函数指针。
*/
unsigned long (*get_unmapped_area)(
struct file *filp,
unsigned long addr,
unsigned long len,
unsigned long pgoff,
unsigned long flags
);
/*
* 取消映射区域的函数指针。
*/
void (*unmap_area)(struct mm_struct *mm, unsigned long addr);
/*
* mmap 区域基地址。
*/
unsigned long mmap_base;
/*
* 用户虚拟地址空间大小。
*/
unsigned long task_size;
/*
* 空洞缓存相关字段。
*/
unsigned long cached_hole_size;
unsigned long free_area_cache;
/*
* 页目录起始地址。
* 进程切换时,页目录变化是影响 TLB 的重要因素。
*/
pgd_t *pgd;
};
11.4.1 和线程的联系
同一进程内的线程共享地址空间,也就意味着它们共享同一个 mm_struct。
这也是线程切换比进程切换更轻量的重要原因之一。
线程切换时,不需要像进程切换那样切换整套地址空间。
12. 支持任意参数传递的线程封装 Demo
12.1 设计思路
前面的线程封装版本不支持任意参数传递。
如果想让线程类支持任意函数和任意参数,可以结合:
- 模板;
std::bind;std::function<void()>;std::shared_ptr;std::unique_ptr。
核心思想是:
把用户传入的函数和参数绑定成一个无参任务,然后在线程入口函数里统一执行这个任务。
12.2 完整代码
#include <iostream> // std::cout、std::cerr
#include <functional> // std::function、std::bind
#include <memory> // std::shared_ptr、std::unique_ptr
#include <pthread.h> // POSIX 线程库
#include <unistd.h> // sleep
class Thread {
public:
/*
* 构造函数。
* thread_id_ 初始化为 0;
* running_ 表示线程是否正在运行。
*/
Thread()
: thread_id_(0),
running_(false)
{
}
/*
* 析构函数。
* 如果对象析构时线程仍在运行,就 detach,避免资源泄漏。
*/
~Thread()
{
if (running_) {
pthread_detach(thread_id_);
}
}
/*
* 启动线程。
*
* Callable:
* 任意可调用对象,比如普通函数、lambda、函数对象等。
*
* Args:
* 可变参数模板,用来接收任意数量参数。
*/
template <typename Callable, typename... Args>
bool start(Callable&& func, Args&&... args)
{
if (running_) {
std::cerr << "Thread is already running!" << std::endl;
return false;
}
/*
* 将可调用对象和参数绑定为 std::function<void()>。
*
* std::bind:
* 把函数和参数提前绑定起来。
*
* std::forward:
* 完美转发,尽量保持参数原本的左值/右值属性。
*
* shared_ptr:
* 保证任务对象在线程执行期间依然有效。
*/
auto task = std::make_shared<std::function<void()>>(
std::bind(std::forward<Callable>(func),
std::forward<Args>(args)...)
);
/*
* 创建线程。
*
* 注意:
* pthread_create 的第四个参数只能传 void*。
* 所以这里把 shared_ptr 再放到堆上,然后传递指针。
*/
if (pthread_create(&thread_id_,
nullptr,
&Thread::threadEntry,
new std::shared_ptr<std::function<void()>>(task)) != 0) {
std::cerr << "Failed to create thread!" << std::endl;
return false;
}
running_ = true;
return true;
}
/*
* 等待线程结束。
*/
void join()
{
if (running_) {
pthread_join(thread_id_, nullptr);
running_ = false;
}
}
private:
pthread_t thread_id_; // 线程 ID
bool running_; // 线程是否正在运行
/*
* 线程入口函数。
*
* 参数:
* arg 是 pthread_create 第四个参数传进来的任务对象指针。
*/
static void* threadEntry(void* arg)
{
/*
* 使用 unique_ptr 管理堆上分配的 shared_ptr 指针。
* 这样 threadEntry 结束时可以自动释放这层 new 出来的对象。
*/
std::unique_ptr<std::shared_ptr<std::function<void()>>> task_ptr(
static_cast<std::shared_ptr<std::function<void()>>*>(arg)
);
/*
* 取出真正的任务对象。
*/
auto task = *task_ptr;
/*
* 执行任务。
*/
(*task)();
return nullptr;
}
};
/*
* 测试函数。
*/
void printMessage(const std::string& message, int value, int a, int b, int c)
{
std::cout << "Message: " << message << ", Value: " << value << std::endl;
std::cout << "a:" << a << std::endl;
std::cout << "b:" << b << std::endl;
std::cout << "c:" << c << std::endl;
/*
* sleep 用来让线程保持一段时间,方便观察 ps -aL。
*/
sleep(10);
}
int main()
{
Thread thread;
/*
* 启动线程,并传递多个参数。
*/
thread.start(printMessage, "Hello, World!", 42, 1, 2, 3);
/*
* 等待线程结束。
*/
thread.join();
return 0;
}
运行结果类似:
$ ./a.out
Message: Hello, World!, Value: 42
a:1
b:2
c:3
查看线程:
$ ps -aL
PID LWP TTY TIME CMD
923509 923509 pts/1 00:00:00 a.out
923509 923510 pts/1 00:00:00 a.out
12.2.1 代码理解
这个封装最重要的是把“任意函数 + 任意参数”统一转换成:
std::function<void()>
这样线程入口函数就不需要关心用户到底传了什么函数、几个参数、参数类型是什么。
💡这就像快递打包。不同形状的东西先统一装进箱子里,运输流程只需要处理“箱子”,不用关心里面具体是什么。
13. 自己调用 clone 观察轻量级执行流
13.1 为什么要自己调用 clone
pthread_create 最终会走到底层 clone,所以自己调用一次 clone,可以更直观地理解:
- 新执行流需要自己的栈;
clone_flags决定共享哪些资源;CLONE_VM会让新执行流共享虚拟地址空间;SIGCHLD表示子执行流结束后给父进程发送信号。
13.2 示例代码
#define _GNU_SOURCE // 开启 GNU 扩展,使用 clone 需要
#include <sched.h> // clone、CLONE_VM
#include <stdio.h> // printf、perror
#include <stdlib.h> // malloc、free、exit
#include <sys/wait.h> // waitpid
#include <unistd.h> // getpid
/*
* 给 clone 创建的子执行流准备 1MB 栈空间。
*/
#define STACK_SIZE (1024 * 1024)
/*
* 子执行流执行的函数。
*
* 参数:
* arg 是 clone 最后一个参数传进来的。
*
* 返回值:
* 返回 0 表示正常结束。
*/
static int child_func(void *arg)
{
printf("Child process: PID = %d\n", getpid());
return 0;
}
int main()
{
/*
* clone 要求调用者提供栈空间。
* 这里使用 malloc 在堆上申请 1MB 空间。
*/
char *stack = (char*)malloc(STACK_SIZE);
if (stack == NULL) {
perror("malloc");
exit(EXIT_FAILURE);
}
/*
* clone 创建子执行流。
*
* child_func:
* 子执行流入口函数。
*
* stack + STACK_SIZE:
* 栈顶地址。
* 栈通常向低地址增长,所以传入申请空间的末尾。
*
* CLONE_VM:
* 共享虚拟地址空间。
*
* SIGCHLD:
* 子执行流结束时通知父进程。
*
* NULL:
* 传给 child_func 的参数。
*/
pid_t pid = clone(child_func,
stack + STACK_SIZE,
CLONE_VM | SIGCHLD,
NULL);
if (pid == -1) {
perror("clone");
free(stack);
exit(EXIT_FAILURE);
}
printf("Parent process: PID = %d, Child PID = %d\n", getpid(), pid);
/*
* 等待子执行流结束。
*/
if (waitpid(pid, NULL, 0) == -1) {
perror("waitpid");
free(stack);
exit(EXIT_FAILURE);
}
/*
* 释放栈空间。
*/
free(stack);
return 0;
}
13.2.1 代码理解
这段代码和 pthread_create 最大的区别是:这里我们自己显式准备栈空间,并直接指定 clone_flags。
如果加上更多 CLONE_ 标志,就可以更接近线程语义。
pthread_create 其实就是帮我们把这些底层细节封装好了。
14. 高频技术题 / 面试题
14.1 线程和进程的区别是什么?
进程是资源分配的基本单位,线程是调度的基本单位。
进程拥有独立地址空间,而同一进程内的多个线程共享地址空间。
线程共享代码段、数据段、堆、文件描述符表、信号处理方式等资源,但每个线程也有自己的栈、寄存器上下文、线程 ID、errno、信号屏蔽字和调度优先级。
所以线程比进程轻量,但线程之间隔离性更差。
常见误区是只回答“线程比进程小”。这样太表面。真正的原因应该落到地址空间和资源共享上。
14.2 为什么线程切换比进程切换更轻量?
因为同一进程内线程共享虚拟地址空间。
进程切换时,通常需要切换地址空间,页表会变化,CR3 可能变化,TLB 可能被刷新,缓存命中率也会受到影响。
线程切换时,地址空间不变,只需要切换线程上下文,比如寄存器、栈指针等,开销更小。
所以线程切换轻量的核心不是“线程名字短”,而是它复用了进程的大量资源。
14.3 pthread_t 和 LWP 是同一个东西吗?
不是。
pthread_t 是 pthread 库维护的线程 ID,在 Linux NPTL 实现中,它本质上可能是进程地址空间中的一个地址,指向线程库内部的线程控制块。
LWP 是内核调度看到的轻量级进程 ID。
可以用:
ps -aL
查看进程中的多个 LWP。
14.4 为什么一个线程崩溃,整个进程可能会崩溃?
因为线程共享同一个进程地址空间。
如果某个线程出现非法内存访问、除零、野指针等问题,可能触发信号。信号处理如果导致进程终止,那么该进程内所有线程都会退出。
线程不是独立进程,它只是进程内部的执行流,所以一个线程出严重异常时,影响范围可能是整个进程。
14.5 pthread_join 的作用是什么?
pthread_join 主要有两个作用:
- 等待指定线程结束;
- 获取线程退出返回值并回收相关资源。
如果线程是 joinable 状态,但一直没有被 join,线程退出后的部分资源可能无法释放。
如果不关心线程返回值,可以传 NULL。
14.6 pthread_detach 和 pthread_join 能同时使用吗?
不能。
一个线程要么是 joinable,要么是 detached。
joinable 线程退出后需要被 pthread_join 回收。
detached 线程退出后由系统自动回收资源,不能再被 pthread_join。
所以一个线程不能既 detach 又 join。
14.7 malloc 申请内存后,物理内存一定立刻分配了吗?
不一定。
很多情况下,malloc 可能只是让虚拟地址空间中出现了一段可用区域,真正物理页可能在第一次访问时通过缺页异常建立映射。
所以“申请内存”不一定等于“马上占用对应物理内存”。
这和虚拟内存、页表、缺页异常都有关系。
14.8 CLONE_VM 的作用是什么?
CLONE_VM 表示新创建的执行流和当前执行流共享虚拟地址空间。
这是实现线程语义的重要标志之一。
如果没有 CLONE_VM,新执行流就不会共享地址空间,更接近进程。
如果设置了 CLONE_VM,多个执行流可以访问同一份代码段、数据段、堆等资源。
14.9 子线程的栈在哪里?
主线程栈通常在进程地址空间的栈区域。
子线程栈通常由 pthread 库通过 mmap 在共享区附近映射出来,大小一般是固定的,比如默认可能是 8MB。
子线程栈原则上是线程私有的,但因为同一进程的线程共享地址空间,如果其他线程知道地址,也可能访问到。
14.10 为什么多线程程序更难调试?
因为多线程存在并发执行,线程调度顺序具有不确定性。
同一个 bug 可能这次出现,下次不出现。
常见问题包括:
- 数据竞争;
- 死锁;
- 资源泄漏;
- 野指针;
- 线程退出顺序错误;
- join/detach 使用错误。
所以多线程程序不仅要关心代码逻辑,还要关心执行时序。
结语
学完线程这一部分之后,我最大的感受是:线程并不是孤立的一个 API 知识点,而是把进程地址空间、页表、调度、信号、栈、系统调用这些内容串在了一起。
刚开始我只是觉得线程就是“多条执行路线”,会创建、会等待就差不多了。后来真正往底层看,才发现线程为什么轻量、为什么共享资源、为什么又有自己的栈,背后其实都和虚拟地址空间有关。
尤其是 pthread_t 和 LWP 这一块,让我意识到 Linux 线程并不是单纯由一个库函数“变出来”的。pthread 库在用户层维护线程控制块、线程属性和线程栈,而真正的执行流创建和调度,最终还是要交给内核完成。
再回过头看 pthread_create,它就不再只是一个函数调用了,而是一条完整链路:
用户调用 pthread_create
↓
线程库准备线程属性、线程栈和 TCB
↓
保存入口函数和参数
↓
通过 clone 创建轻量级执行流
↓
内核参与调度
这也是我觉得 Linux 学习比较有意思的地方:很多一开始看起来只是“会用就行”的接口,背后其实都有完整的操作系统机制在支撑。
线程这一部分学完之后,再去看线程池、互斥锁、条件变量、生产者消费者模型、协程,都会更容易理解一些。因为这些东西本质上都是围绕“多个执行流如何共享资源、如何安全协作”展开的。
更多推荐




所有评论(0)