线程概念
什么是线程?
• 在⼀个程序⾥的⼀个执⾏路线就叫做线程(thread)。更准确的定义是:线程是"⼀个进程内部 的控制序列"
• ⼀切进程⾄少都有⼀个执⾏线程
• 线程在进程内部运⾏,本质是在进程地址空间内运⾏
• 在Linux系统中,在CPU眼中,看到的PCB都要⽐传统的进程更加轻量化
• 透过进程虚拟地址空间,可以看到进程的⼤部分资源,将进程资源合理分配给每个执⾏流,就形 成了线程执⾏流
一个进程内的所有线程共享资源,包括虚拟地址空间,时间片等

在Linux中的线程被叫做轻量级进程,本质还是用进程模拟的。但为了迎合大众,还是叫做线程
要真正理解线程,就必须搞清楚,内核是如何进行资源划分的,尤其是代码。
分页式内存管理
虚拟地址和⻚表的由来
思考⼀下,如果在没有虚拟内存和分⻚机制的情况下,每⼀个⽤⼾程序在物理内存上所对应的空间必 须是连续的,如下图:

因为每⼀个程序的代码、数据⻓度都是不⼀样的,按照这样的映射⽅式,物理内存将会被分割成各种 离散的、⼤⼩不同的块。经过⼀段运⾏时间之后,有些程序会退出,那么它们占据的物理内存空间可 以被回收,导致这些物理内存都是以很多碎⽚的形式存在。
怎么办呢?我们希望操作系统提供给⽤⼾的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分⻚便出现了,如下图所⽰:

核心概念:什么是分页?
操作系统将虚拟地址空间 和物理内存 都划分成大小固定的块,称为页 (Page)和页框 (Page Frame)。通常大小是4KB(在某些体系结构上可以是 4MB 或 2MB 等大页)。

页表
页表中的每⼀个表项,指向⼀个物理⻚的开始地址。在 32 位系统中,虚拟内存的最⼤空间是4GB, 这是每⼀个⽤⼾程序都拥有的虚拟内存空间。既然需要让4GB 的虚拟内存全部可⽤,那么⻚表中就需要能够表⽰这所有的4GB 空间,那么就⼀共需要 4GB/4KB = 1048576 个表项。如下图所⽰:

页表中的物理地址,与物理内存之间,是随机的映射关系,哪里可用就指向哪里(物理页)。虽然最终使用的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时,使用的都是线性地址,只要它是连续的就可以了,最终都能够通过页表找到实际的物理地址。
假设,在32位系统中,地址的长度是4个字节,那么页表中的每一个表项就是占用4个字节。所以页表占据的总空间大小就是:1048576*4=4MB的大小。也就是说映射表自己本身,就要占用
4MB/4KB=1024个物理页。这还是只是页号的记录,甚至没记录具体在页内的偏移量,那样就更大了。
但是根据局部性原理可知,很多时候进程在一段时间内只需要访问某几个页就可以正常运行
了。就比如我只让一个程序计算1+1等于多少,至于记录所有的页吗?因此也没有必要一次让所有的物理页都常驻内存。
解决需要⼤容量⻚表的最好⽅法是:把⻚表看成普通的⽂件,对它进⾏离散分配,即对⻚表再分⻚, 由此形成多级⻚表的思想。
页目录
到⽬前为⽌,每⼀个⻚框都被⼀个⻚表中的⼀个表项来指向了,那么这1024个⻚表也需要被管理起来。管理⻚表的表称之为⻚⽬录表,形成⼆级⻚表。如下图所⽰:

• 所有⻚表的物理地址被⻚⽬录表项指向
• ⻚⽬录的物理地址被CR3 寄存器 指向,这个寄存器中,保存了当前正在执⾏任务的⻚⽬录地址。
所以操作系统在加载⽤⼾程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为⽤来保存程序的⻚⽬录和⻚表分配物理内存。
两级页表的地址转换
一、为什么要搞两级页表?
先回忆下单级页表的痛点:
- 32 位系统下,每个进程的单级页表要存
4GB/4KB=1048576个页表项,每个项 4 字节,页表本身就要占4MB 内存,而且必须是连续的一块内存。 - 如果系统里跑几十个进程,光页表就要占几百 MB,内存开销太大,而且很难找到连续的 4MB 内存放页表。
于是操作系统把「页表本身也分页」,拆成两级结构:
- 第一级:页目录(Page Directory):记录所有页表的地址,只有 1024 个项,占 4KB(刚好一个页)。
- 第二级:页表(Page Table):每个页表对应虚拟地址的一部分,也占 4KB,只有用到的页表才需要加载到内存里。
这样一来,页目录永远只占 4KB,页表按需加载,大幅减少了内存占用。
二、32 位线性地址的拆分
32 位的虚拟地址(也叫线性地址)被分成三段:

补充一下位数的来源:
- 页内偏移 12 位:因为页大小是
4KB=2^12,所以一个页内最多有2^12个字节,需要 12 位来表示偏移。 - 页目录项 / 页表项各 10 位:因为页目录和每个页表都有
2^10=1024个项,需要 10 位来索引。
三、转换示例
下⾯以⼀个逻辑地址为例。将逻辑地址(0000000000,0000000001,11111111111 )转换为物理地址的过程:
- 在32位处理器中,采⽤4KB的⻚⼤⼩,则虚拟地址中低12位为⻚偏移,剩下⾼20位给⻚表,分成 两级,每个级别占10个bit(10+10)。
2.CR3 寄存器读取⻚⽬录起始地址,再根据⼀级⻚号查⻚⽬录表,找到下⼀级⻚表在物理内存中 存放位置。
-
根据⼆级⻚号查表,找到最终想要访问的内存块号。
-
结合⻚内偏移量得到物理地址。
-
注:⼀个物理⻚的地址⼀定是4KB 对⻬的(最后的 12 位全部为 0 ),所以其实只需要记录物理⻚地址的⾼20位即可。
-
以上其实就是MMU的⼯作流程。MMU(MemoryManageUnit)是⼀种硬件电路,其速度很快, 主要⼯作是进⾏内存管理,地址转换只是它承接的业务之⼀。
让我们现在总结一下**:单级页表对连续内存要求高,于是引入了多级页表,但是多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。**
有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加⼀个中间层来解决。MMU 引⼊了新武器,江湖⼈称快表的TLB(其实,就是缓存TranslationLookasideBuffer,学名转译后备缓冲器)
当 CPU 给 MMU 传新虚拟地址之后,MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,⻬活。但TLB 容量⽐较⼩,难免发⽣Cache Miss ,这时候 MMU 还有保底的⽼武器**⻚表** ,在⻚表中找到之后MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录⼀下刷新缓存。
缺页中断
现代操作系统都依赖虚拟内存机制运行。每个进程都拥有独立、连续的虚拟地址空间,而物理内存是所有进程共享的有限资源。CPU 中的 MMU(内存管理单元)负责完成虚拟地址到物理地址的转换,它的核心工作就是查询页表,实现地址映射。
当进程访问虚拟地址时,若 MMU 无法完成正常的地址转换 ,就会触发缺页中断(也叫缺页异常)。
触发缺页中断的三种典型场景
当发生以下三种情况时,会触发缺页中断:
- 页面未映射 :虚拟地址对应的页表项为空(
pte_none),说明这个地址从未被操作系统分配给进程,或是已经被进程通过munmap等系统调用释放。此时进程访问的是无权限的非法地址,操作系统会判定为访问错误。 - 页面被换出 :页表项存在,但
Present位(存在位)标记为 0,说明这个虚拟页对应的物理页,已经被操作系统交换到了磁盘的 Swap 分区(页面文件)中,当前物理内存里没有这份数据。 - 权限不足 :页表项存在,也标记为 "存在",但进程尝试执行的操作违反了页的权限限制。比如:试图写入一个标记为只读的页面、执行一个被禁止执行的页面(常见于
mmap的PROT_NONE保护,或是写时复制(COW)场景下的写操作)。
缺页中断的处理流程
当 CPU 捕获到缺页异常后,会立刻暂停当前进程,陷入内核态,执行操作系统内核的do_page_fault缺页中断处理函数。这个函数的核心工作流程是:
- 获取触发异常时的上下文数据(比如进程号、触发异常的虚拟地址、错误原因);
- 分析这次缺页的合法性:判断地址是否属于进程的合法虚拟地址范围、操作是否符合权限;
- 若是合法的缺页请求,为进程分配物理内存空间,将对应的页从磁盘加载到物理内存中;
- 更新页表项,建立虚拟地址到新物理地址的映射,并标记页面为 "存在";
- 恢复进程的运行,让进程重新执行刚才触发异常的指令,完成地址访问。
为什么要有缺页中断?
缺页中断的存在,本质上是为了解决计算机体系中的一个核心矛盾:物理内存的容量是有限且宝贵的,而应用程序需要的是足够大、连续、且互相隔离的虚拟地址空间。如果没有缺页中断,虚拟内存就只是一个无法落地的 "空壳",程序无法真正运行。
它的核心优势非常明显:
- 大幅节约物理内存:程序启动时,操作系统不需要将整个程序的代码和数据(比如几十 GB 的游戏、大型数据库)一次性读入物理内存,仅需建立虚拟地址到文件的映射关系即可。
- 按需加载,提升效率:只有当 CPU 真正执行到某段代码、访问某一页数据时,才会触发缺页中断,内核此时才从磁盘加载这一页。这大幅缩短了程序的启动时间,也减少了物理内存的占用,让大程序也能在有限的物理内存中正常运行。
- 实现内存隔离与权限控制:通过权限不足类的缺页中断,操作系统可以拦截非法访问、越界读写,实现进程间的内存隔离,也能支持写时拷贝、内存保护等高级机制。

线程VS进程
进程和线程都是操作系统调度执行的基本单元,核心区别在于资源共享方式,我们从关键维度来对比:
资源占用
- 进程 :拥有独立的虚拟地址空间、文件描述符、堆、栈、信号处理等系统资源。创建进程(如
fork())时,需要复制父进程的页表、文件描述符表,依赖写时拷贝(COW)机制,开销较大。 - 线程 :同一进程内的线程共享进程的地址空间、堆、全局变量、文件描述符;仅拥有独立的线程栈、寄存器和线程局部存储(TLS)。创建线程(如
pthread_create)只需分配线程栈和线程控制块(TCB),复用进程地址空间,开销远小于进程。
独立地址空间也带来了更强的隔离性:一个进程崩溃不会影响其他进程;但一个线程崩溃会导致整个进程终止。
切换开销
- 进程切换:需要切换页表、刷新 TLB(旁路转换缓存)、保存 / 恢复完整的 CPU 上下文,开销大、速度慢。
- 线程切换:同进程内的线程切换无需修改页表、无需刷新 TLB,仅需保存 / 恢复寄存器、栈指针,开销小、速度快。
通信方式
- 进程间通信(IPC):进程地址空间相互隔离,通信需要借助专门的 IPC 机制,如管道、消息队列、共享内存、信号、套接字等,实现复杂。
- 线程间通信:线程共享进程内存,可直接读写全局变量、共享内存,通信几乎无成本,但需要配合互斥锁、读写锁等同步机制保证安全。
数据同步
- 进程:数据天然隔离,无需显式加锁,减少了并发编程的复杂度;但 IPC 设计会带来额外开发成本。
- 线程:数据天然共享,交换成本极低,但必须使用同步原语(互斥锁、条件变量、自旋锁等)避免竞态条件,否则容易出现数据竞争问题。
对比总结
| 特性 | 进程 | 线程 |
|---|---|---|
| 资源开销 | 高 | 低 |
| 切换速度 | 慢 | 快 |
| 隔离性 | 强(独立地址空间) | 弱(共享进程资源) |
| 通信复杂度 | 高(需 IPC 机制) | 低(共享内存,需同步) |
| 编程难度 | 相对简单(无锁,隔离性好) | 较高(需精细同步设计) |
| 适用场景 | 稳定性优先、隔离性要求高 | 性能优先、高频数据交互 |
进程的优缺点
优点
- 稳定性与隔离性强:一个进程崩溃不会影响其他进程,适合高可靠性场景(如浏览器多进程架构、守护进程)。
- 安全性高:独立的地址空间让进程无法直接访问其他进程的内存,需通过内核授权的 IPC 通信,降低了安全漏洞扩散的风险。
- 多核 CPU 利用率高:多进程可充分利用多核处理器,且无需担心锁竞争带来的性能下降(仅进程间通信可能成为瓶颈)。
- 并发编程模型简单:进程间数据天然隔离,无需显式加锁,减少了并发编程的复杂度(代价是需要设计 IPC 机制)。
缺点
- 资源开销大:创建、销毁进程都需要申请和回收大量资源(内存页表、文件描述符等),进程切换的开销也远高于线程。
- 进程间通信效率低:多数 IPC 机制需要内核参与和数据拷贝(共享内存除外),性能低于线程间直接访问共享内存。
- 扩展性受限:进程数量过多时,系统调度开销、内存占用会显著增加,负载明显上升。
线程的优缺点
优点
- 轻量高效:线程创建、销毁和上下文切换速度快,资源占用少,适合高并发场景(如 Web 服务器、数据库服务)。
- 通信便捷:线程间天然共享进程的内存空间,数据交换几乎零成本,仅需注意同步控制即可。
- 资源利用率高:线程可共享进程内的资源(如打开的文件、内存池),避免了资源重复复制,也能高效利用多核并行执行任务。
缺点
- 稳定性差:一个线程因野指针、除零等错误崩溃时,整个进程(包括所有其他线程)都会被终止。
- 同步逻辑复杂:共享内存带来了竞态条件风险,必须引入互斥锁、条件变量等同步机制,易产生死锁、锁竞争等性能问题。
- 调试难度大:多线程程序的 bug(如数据竞争、死锁)多具有偶发性,难以复现和定位。
- 存在安全隐患:线程间直接共享内存,若某个线程存在漏洞(如缓冲区溢出),可能被攻击者利用来读写其他线程的敏感数据。
如何选择?
在实际开发中,选择进程还是线程,核心取决于场景对稳定性、性能、开发复杂度的需求:
优先选择多进程的场景
- 需要高稳定性、强隔离性的场景,如守护进程、关键服务、浏览器标签页等。
- 任务之间关联性低、通信需求少,无需频繁交换数据。
- 希望避免复杂的锁同步逻辑,降低并发编程的出错概率。
优先选择多线程的场景
- 需要高并发、高性能的场景,如 Web 服务器、高频数据交互服务。
- 任务间数据交互频繁,需要低成本的通信方式。
- 资源有限,需要控制内存和调度开销。
混合模型
实际大型系统中常采用多进程 + 多线程 混合模式。例如,主进程负责管理,多个工作进程各自内部使用线程池处理请求(如 Apache 的 prefork 和 worker 模式、Nginx 的多进程架构)。这样既利用进程隔离提高稳定性,又通过线程提高并发效率。
结论
1.线程的初步理解
结论1:Linux"线程"可以采用进程来模拟。
结论2:对资源的划分,本质是对进程地址空间虚拟地址范围的划分。
结论3:代码区划分?函数就是虚拟地址(逻辑地址)空间的集合,就是让线程来执行ELF程序的不同函数即可!
结论4:以前的进程是内部只有一个线程的进程!
结论5:Linux的线程,就是轻量级进程(PCB),或者用轻量级进程模拟实现的。
问题1:为什么要这么设计?
复用task_struct,用进行来模拟线程。进程内核代码,全部复用!这样代码更加健壮。
问题2:其他平台,比如Windows也是这样吗?有没有自己的实现方案?
Linux:线程 = 共享资源的轻量级进程 LWP,统一用 task_struct,1:1 内核映射,clone 创建;Windows:进程、线程内核结构体分离,原生线程设计,1:1 内核线程 + 可选用户态纤程。
2.从理性角度资源划分
可执行程序就是文件,文件就在磁盘存储。
可执行程序存储的时候,天然就是4KB单位存储的,无论属性还是内容。
物理内存与磁盘进行I/O交换时,是以4KB为单位的内存块,这种块也叫做页框或页帧。
4KB是OS划分的,不管是磁盘(文件系统),内存,OS都要管理页框4KB。->内核中就有struct page。
struct page mem1048576;转换成对数组的操作了,所以每个page都会有下标!每个page的起始物理地址就天然知道了。
具体物理地址=起始物理地址+页内(4KB)偏移。
申请物理内存是在做什么?
1.查数组改page
2.建立内核数据结构的对应关系
页表查找
第一阶段:先查找到虚拟地址对应的页框。
第二阶段:根据虚拟地址低几位,作为页内偏移,访问具体字节。
细节1:申请内存------>查找数组------>找到没有被使用的page------>page index------>物理页框地址。
细节2:写时拷贝,缺页中断,申请内存等等,背后都可能重新建立新的页表和建立新的映射关系。
细节3:进程,一张页目录+n张页表构建的映射体系,虚拟地址是索引,物理地址是目标,虚拟地址(低12)+页框地址=物理地址。
细节4:为什么是低12位数字1?
因为页框大小是4KB,0,4095。
执行流看到的资源本质是:在合法情况下,你有多少虚拟地址,虚拟地址是资源的代表!
虚拟地址空间mm_struct+mv_area_struct本质:进行资源的统计数据和整体数据。
页表是一张虚拟到物理的地图。
3.线程的深度理解
线程进行资源划分:本质是划分地址空间,获得一定范围的合法虚拟地址,再本质,就是划分页表!
线程进行资源共享:本质是对地址空间的共享,再本质,就是对页表的条目的共享。
4.线程周边
进程切换,会导致TLB和cahe失效,下次运行,需要重新缓存。这也是进程切换成本最大的。
而线程就不会导致大面积失效的问题。所以线程切换成本低。
线程控制
在 Linux 环境中,我们通过 POSIX 标准的 pthread 线程库 ,实现线程的创建、终止、等待与分离等操作。所有示例基于 C/C++ 编写,编译时需手动链接 pthread 库(编译指令:gcc -pthread test.c -o test)。
线程创建
核心 API:pthread_create
函数原型:
cpp
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg);
参数说明:
thread:输出参数,用于存储新创建线程的 ID。attr:线程属性配置,传入NULL表示使用默认属性。start_routine:线程入口函数,必须满足「返回值为void*、参数为void*」的格式。arg:传递给线程入口函数的参数,无参数时可传NULL。
返回值 :创建成功返回 0,失败返回非 0 错误码。
线程入口函数规范
线程启动后,会自动从 start_routine 指向的函数开始执行,标准格式如下:
cpp
void* thread_func(void* arg) {
// 线程执行逻辑
return NULL;
}
pthread_create 传入的 arg 参数,会自动传递给入口函数的 arg 形参。
测试示例
cpp
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
// 线程入口函数
void* thread_func(void* arg) {
int num = *(int*)arg;
printf("线程 %d 正在运行\n", num);
sleep(1); // 模拟线程执行耗时操作
printf("线程 %d 结束\n", num);
return NULL;
}
int main() {
pthread_t tid1, tid2;
int n1 = 1, n2 = 2;
// 创建两个线程
pthread_create(&tid1, NULL, thread_func, &n1);
pthread_create(&tid2, NULL, thread_func, &n2);
// 阻塞主线程,等待子线程执行完成
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
printf("主线程退出\n");
return 0;
}

注:线程调度顺序由操作系统决定,"线程 1" 和 "线程 2" 的打印顺序可能互换。
线程终止
线程可以通过以下三种方式终止运行:
1. 主动调用 pthread_exit
cpp
void pthread_exit(void *retval);
retval:线程退出状态,可由其他线程通过pthread_join获取。- 调用后线程会立即终止,后续代码不会执行。
- 线程不能用exit()终止,exit()是终止进程的。
2. 被其他线程取消
cpp
int pthread_cancel(pthread_t thread);
线程被取消,退出结果为-1。
向目标线程发送取消请求,目标线程是否响应取决于其取消状态与类型。
3. 线程函数自然返回
线程函数执行 return 语句时,返回值会作为线程的退出状态。
测试示例
下面是一个演示 pthread_exit 与 pthread_join 的完整例子:
cpp
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
// 线程函数
void* worker(void* arg) {
int* p = (int*)malloc(sizeof(int));
*p = 42;
printf("工作线程准备退出,返回地址 %p 值为 %d\n", p, *p);
// 主动退出线程,并返回动态分配的内存地址
pthread_exit(p);
}
int main() {
pthread_t tid;
pthread_create(&tid, NULL, worker, NULL);
void* ret;
// 等待线程结束,并获取其返回值
pthread_join(tid, &ret);
printf("主线程获取到返回值:%d\n", *(int*)ret);
free(ret); // 释放线程分配的内存
return 0;
}

pthread_exit传入的retval必须保证在线程退出后依然有效,因此示例中使用了动态分配的内存。- 如果直接返回栈上变量的地址,主线程拿到的会是无效数据。
线程等待
为什么需要线程等待?
- 线程退出后,如果没有被等待回收,它的栈、寄存器状态等资源不会被系统释放,仍然占用进程地址空间,成为僵尸线程。
- 后续创建新线程时,系统不会复用这些僵尸线程的地址空间,造成内存泄漏。
cpp
int pthread_join(pthread_t thread, void **retval);
参数说明
thread:要等待的目标线程 ID。retval:输出参数,用于保存线程的退出状态:- 若线程通过
return或pthread_exit返回,这里会保存返回值的地址; - 若线程被取消,这里会被设置为
PTHREAD_CANCELED。
- 若线程通过
核心行为
- 调用线程会被阻塞,直到目标线程执行终止。
- 自动回收目标线程的资源(栈、寄存器等),避免僵尸线程。
- 一个线程只能被
pthread_join一次,且目标线程必须处于可连接(joinable)状态。
测试示例(求两个数的乘积)
cpp
#include <pthread.h>
#include <stdio.h>
#include <cstdlib>
// 线程函数:计算两个数的乘积
void* multiply(void* arg) {
int* nums = (int*)arg;
int* result = (int*)malloc(sizeof(int));
*result = nums[0] * nums[1];
return result;
}
int main() {
pthread_t tid;
int nums[] = {6, 7}; // 测试数据:6 × 7
pthread_create(&tid, nullptr, multiply, nums);
void* ret;
// 等待线程结束,并获取返回值
pthread_join(tid, &ret);
printf("计算结果:%d × %d = %d\n", nums[0], nums[1], *(int*)ret);
free(ret); // 释放线程动态分配的结果内存
return 0;
}

- 如果不关心线程的返回值,可以将
retval设为NULL,例如pthread_join(tid, NULL);,此时仅回收线程资源,不获取状态。 - 线程默认是可连接状态,如果设置了分离属性(
pthread_detach),则无法再使用pthread_join等待该线程。
分离线程
为什么需要分离线程?
- 默认创建的线程是需要被等待的,线程退出后,必须调用
pthread_join才能回收其栈、寄存器等资源,否则会造成资源泄漏。 - 如果主线程不关心线程的返回值,也不需要同步等待,
pthread_join就成了不必要的负担。此时可以把线程设置为分离(detached)状态,让线程退出时自动释放资源。
cpp
int pthread_detach(pthread_t thread);
- 作用:将目标线程设置为分离状态。
- 核心特点:
- 分离线程终止时,系统会自动回收其资源,无需也不允许其他线程调用
pthread_join。 - 线程可以在创建时通过属性设置为分离,也可以在运行中由自身或其他线程调用
pthread_detach分离。
- 分离线程终止时,系统会自动回收其资源,无需也不允许其他线程调用
分离的线程,依旧在进程的地址空间中,进程的所有资源,被分离的线程,依旧可以访问,可以操作。只不过主线程不等待新线程,等待(join)就出错。
测试示例(倒计时分离线程)
主线程无需 join,线程退出后资源自动回收:
cpp
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
// 线程函数:模拟倒计时任务
void* countdown_task(void* arg) {
printf("分离线程开始倒计时...\n");
for (int i = 3; i > 0; i--) {
printf("倒计时:%d秒\n", i);
sleep(1);
}
printf("倒计时结束,分离线程退出,资源自动回收\n");
return NULL;
}
int main() {
pthread_t tid;
pthread_create(&tid, NULL, countdown_task, NULL);
// 将线程设置为分离状态
pthread_detach(tid);
printf("主线程继续运行,无需等待线程结束\n");
sleep(4); // 让主线程晚一点退出,确保分离线程能执行完
printf("主线程退出\n");
return 0;
}

- 分离线程无法再被
pthread_join,如果对分离线程调用pthread_join会直接失败。 - 主线程退出会导致整个进程终止,所以示例中加了
sleep(4),让主线程等待分离线程执行完,否则主线程退出时,分离线程还没执行完就会被杀死。 - 也可以在创建线程时直接设置分离属性,不用单独调用
pthread_detach。
线程 ID
在 Linux 多线程编程中,存在两种不同维度的线程 ID:用户态线程 ID(pthread_t)和 内核态线程 ID(tid)。二者一一对应但值不同,作用域和用途也完全不同。
用户态线程 ID(pthread_t)
pthread_t 是 POSIX 线程库定义的线程句柄,仅在进程内有效。
- 作用 :在当前进程内唯一标识线程,用于调用
pthread_join、pthread_cancel等线程库函数。 - 类型 :
pthread_t(平台相关,glibc 中通常是unsigned long,本质是指向线程控制块的指针)。 - 获取方式 :通过
pthread_self()函数获取当前线程的用户态 ID。 - 特点 :仅在当前进程内唯一,不同进程的线程可以有相同的
pthread_t值,系统层面无法通过它唯一标识线程。
内核态线程 ID(tid)
tid 是 Linux 内核层面的线程 ID,对应 task_struct 结构体中的 pid 字段,也叫轻量级进程(LWP)ID。
- 作用 :系统全局唯一标识线程,内核调度、系统工具(如
ps、top)都使用这个 ID。 - 类型 :
pid_t(即int类型)。 - 获取方式 :glibc 未直接封装,需通过系统调用
syscall(SYS_gettid)获取。 - 特点 :系统全局唯一,在
/proc/[pid]/task/目录下可见;主线程的tid等于进程的 PID。
测试示例:同时获取两种线程 ID
cpp
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>
// 封装获取内核态 tid 的系统调用
pid_t get_kernel_tid() {
return syscall(SYS_gettid);
}
void* thread_func(void* arg) {
// 子线程:同时获取用户态和内核态 ID
pthread_t user_tid = pthread_self();
pid_t kernel_tid = get_kernel_tid();
printf("子线程 - 用户态ID: %lu, 内核态ID: %d\n", user_tid, kernel_tid);
sleep(1);
return NULL;
}
int main() {
pthread_t tid;
pthread_create(&tid, NULL, thread_func, NULL);
// 主线程:同时获取两种 ID(主线程的内核态ID = 进程PID)
pthread_t main_user_tid = pthread_self();
pid_t main_kernel_tid = get_kernel_tid();
printf("主线程 - 用户态ID: %lu, 内核态ID(进程PID): %d\n", main_user_tid, main_kernel_tid);
pthread_join(tid, NULL);
return 0;
}

线程库函数(如 pthread_join)只能使用用户态 pthread_t,系统工具(如 ps -eLf 中的 LWP 列)显示的是内核态 tid,二者不可混用。
主线程的内核态 tid 与进程 PID 相同,这也是为什么进程 ID 本质上就是主线程的内核 ID
LWP(轻量级进程)
一个task_struct里有PID和LWP。

LWP是Linux 内核中真正参与 CPU 调度的最小执行单元,是内核态的线程实现。
- 本质是共享资源的内核进程:它共享了所属进程的地址空间、文件描述符等资源,创建和切换开销比传统独立进程小,因此被称为 "轻量级"。
- 用户线程与内核的桥梁:现在的 POSIX 用户线程(pthread)通常采用 1:1 映射模型 ------ 一个用户线程对应一个内核 LWP,内核只通过 LWP 来调度执行,用户态线程本身不直接参与内核调度。
- 标识与查看 :每个 LWP 有独立的内核 ID(LWP ID,可通过
gettid()系统调用获取,用ps -L/top -H命令查看);同一进程内的所有 LWP 共享进程 ID(PID,getpid()获取),因此内核能通过 LWP ID 区分不同线程。
LWP与pthread_create创建的线程之间的关系
在 Linux 系统中,二者采用1:1 映射模型,关系可以概括为:
-
内核与用户态的对应关系 每个由
pthread_create创建的用户态线程,都会在内核中对应一个独立的LWP(轻量级进程)。LWP 是内核真正参与 CPU 调度的单元,而用户态 pthread 线程本身不直接参与内核调度。 -
底层实现关系
pthread_create是用户态线程库提供的接口,其底层会调用clone()系统调用创建 LWP,用户线程的执行、调度最终都由对应的 LWP 来承载。 -
资源共享与标识区别
- 同一进程内的所有 LWP(对应不同 pthread 线程)共享进程的地址空间、文件描述符等资源,因此被称为 "轻量级"。
- 用户态线程 ID(
pthread_self()获取)由线程库管理,而 LWP ID(gettid()获取)由内核分配,是线程在系统中的唯一标识,同一进程内的 LWP 共享同一个进程 PID。
进程ID和轻量级进程ID的区别
- 进程 ID(PID) :是进程的全局唯一标识,同一进程内的所有线程共享同一个 PID,用
getpid()获取。 - 轻量级进程 ID(LWP ID) :是内核调度单元(线程)的标识,同一进程内每个线程都有独立的 LWP ID,用
gettid()获取,用于区分进程内不同线程。
进程地址空间布局
进程虚拟地址空间(Virtual Address Space)是操作系统为每个进程提供的独立内存视图,多线程环境下所有线程共享同一地址空间,但每个线程拥有独立的栈、寄存器上下文和线程局部存储(TLS)。


多线程下的地址空间变化
1. 线程共享部分
所有线程共享进程的:
- 代码段、数据段、堆
- mmap 映射的共享库、文件描述符
- 全局变量(无锁访问会有竞态问题)
2. 线程独立部分
每个线程拥有自己的:
- 线程栈 :创建子线程时,pthread 库会通过
mmap分配一块内存(默认 8MB)作为线程栈,存放局部变量、函数调用上下文,线程间栈地址相互独立。 - 寄存器上下文:线程切换时保存各自的寄存器状态。
- 线程局部存储(TLS) :用
__thread修饰的变量,每个线程有独立副本,逻辑上隔离但位于共享地址空间。
核心机制:页表映射
进程的虚拟地址通过页表转换为物理地址,由 MMU 硬件完成:
- 每个进程有独立的页表,实现地址空间隔离。
- 虚拟地址的不同段,通过页表映射到物理内存的不同页帧。
关于pthread线程库
pthread库,把创建轻量级进程封装起来,为用户提供一批创建线程的接口!
Linux的线程实现,是在用户层实现的,我们称之为:用户线程!pthread:原生线程库,Linux只有原生线程库。 Windows 创建线程的原生用户态 API:CreateThread。
C++11的多线程,在Linux下,本质是封装了pthread库。在Windows下,C++11封装了Windows创建的线程接口。