linux 线程概念与控制

线程概念

什么是线程?

• 在⼀个程序⾥的⼀个执⾏路线就叫做线程(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 内存放页表。

于是操作系统把「页表本身也分页」,拆成两级结构

  1. 第一级:页目录(Page Directory):记录所有页表的地址,只有 1024 个项,占 4KB(刚好一个页)。
  2. 第二级:页表(Page Table):每个页表对应虚拟地址的一部分,也占 4KB,只有用到的页表才需要加载到内存里。

这样一来,页目录永远只占 4KB,页表按需加载,大幅减少了内存占用。

二、32 位线性地址的拆分

32 位的虚拟地址(也叫线性地址)被分成三段

补充一下位数的来源:

  • 页内偏移 12 位:因为页大小是4KB=2^12,所以一个页内最多有2^12个字节,需要 12 位来表示偏移。
  • 页目录项 / 页表项各 10 位:因为页目录和每个页表都有2^10=1024个项,需要 10 位来索引。
三、转换示例

下⾯以⼀个逻辑地址为例。将逻辑地址(0000000000,0000000001,11111111111 )转换为物理地址的过程:

  1. 在32位处理器中,采⽤4KB的⻚⼤⼩,则虚拟地址中低12位为⻚偏移,剩下⾼20位给⻚表,分成 两级,每个级别占10个bit(10+10)。

2.CR3 寄存器读取⻚⽬录起始地址,再根据⼀级⻚号查⻚⽬录表,找到下⼀级⻚表在物理内存中 存放位置。

  1. 根据⼆级⻚号查表,找到最终想要访问的内存块号。

  2. 结合⻚内偏移量得到物理地址。

  3. 注:⼀个物理⻚的地址⼀定是4KB 对⻬的(最后的 12 位全部为 0 ),所以其实只需要记录物理⻚地址的⾼20位即可。

  4. 以上其实就是MMU的⼯作流程。MMU(MemoryManageUnit)是⼀种硬件电路,其速度很快, 主要⼯作是进⾏内存管理,地址转换只是它承接的业务之⼀。

让我们现在总结一下**:单级页表对连续内存要求高,于是引入了多级页表,但是多级页表也是一把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。**

有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加⼀个中间层来解决。MMU 引⼊了新武器,江湖⼈称快表的TLB(其实,就是缓存TranslationLookasideBuffer,学名转译后备缓冲器)

当 CPU 给 MMU 传新虚拟地址之后,MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,⻬活。但TLB 容量⽐较⼩,难免发⽣Cache Miss ,这时候 MMU 还有保底的⽼武器**⻚表** ,在⻚表中找到之后MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录⼀下刷新缓存。

缺页中断

现代操作系统都依赖虚拟内存机制运行。每个进程都拥有独立、连续的虚拟地址空间,而物理内存是所有进程共享的有限资源。CPU 中的 MMU(内存管理单元)负责完成虚拟地址到物理地址的转换,它的核心工作就是查询页表,实现地址映射。

当进程访问虚拟地址时,若 MMU 无法完成正常的地址转换 ,就会触发缺页中断(也叫缺页异常)

触发缺页中断的三种典型场景

当发生以下三种情况时,会触发缺页中断:

  1. 页面未映射 :虚拟地址对应的页表项为空(pte_none),说明这个地址从未被操作系统分配给进程,或是已经被进程通过munmap等系统调用释放。此时进程访问的是无权限的非法地址,操作系统会判定为访问错误。
  2. 页面被换出 :页表项存在,但Present位(存在位)标记为 0,说明这个虚拟页对应的物理页,已经被操作系统交换到了磁盘的 Swap 分区(页面文件)中,当前物理内存里没有这份数据。
  3. 权限不足 :页表项存在,也标记为 "存在",但进程尝试执行的操作违反了页的权限限制。比如:试图写入一个标记为只读的页面、执行一个被禁止执行的页面(常见于mmapPROT_NONE保护,或是写时复制(COW)场景下的写操作)。
缺页中断的处理流程

当 CPU 捕获到缺页异常后,会立刻暂停当前进程,陷入内核态,执行操作系统内核的do_page_fault缺页中断处理函数。这个函数的核心工作流程是:

  1. 获取触发异常时的上下文数据(比如进程号、触发异常的虚拟地址、错误原因);
  2. 分析这次缺页的合法性:判断地址是否属于进程的合法虚拟地址范围、操作是否符合权限;
  3. 若是合法的缺页请求,为进程分配物理内存空间,将对应的页从磁盘加载到物理内存中;
  4. 更新页表项,建立虚拟地址到新物理地址的映射,并标记页面为 "存在";
  5. 恢复进程的运行,让进程重新执行刚才触发异常的指令,完成地址访问。
为什么要有缺页中断?

缺页中断的存在,本质上是为了解决计算机体系中的一个核心矛盾:物理内存的容量是有限且宝贵的,而应用程序需要的是足够大、连续、且互相隔离的虚拟地址空间。如果没有缺页中断,虚拟内存就只是一个无法落地的 "空壳",程序无法真正运行。

它的核心优势非常明显:

  • 大幅节约物理内存:程序启动时,操作系统不需要将整个程序的代码和数据(比如几十 GB 的游戏、大型数据库)一次性读入物理内存,仅需建立虚拟地址到文件的映射关系即可。
  • 按需加载,提升效率:只有当 CPU 真正执行到某段代码、访问某一页数据时,才会触发缺页中断,内核此时才从磁盘加载这一页。这大幅缩短了程序的启动时间,也减少了物理内存的占用,让大程序也能在有限的物理内存中正常运行。
  • 实现内存隔离与权限控制:通过权限不足类的缺页中断,操作系统可以拦截非法访问、越界读写,实现进程间的内存隔离,也能支持写时拷贝、内存保护等高级机制。

线程VS进程

进程和线程都是操作系统调度执行的基本单元,核心区别在于资源共享方式,我们从关键维度来对比:

资源占用

  • 进程 :拥有独立的虚拟地址空间、文件描述符、堆、栈、信号处理等系统资源。创建进程(如fork())时,需要复制父进程的页表、文件描述符表,依赖写时拷贝(COW)机制,开销较大。
  • 线程 :同一进程内的线程共享进程的地址空间、堆、全局变量、文件描述符;仅拥有独立的线程栈、寄存器和线程局部存储(TLS)。创建线程(如pthread_create)只需分配线程栈和线程控制块(TCB),复用进程地址空间,开销远小于进程。

独立地址空间也带来了更强的隔离性:一个进程崩溃不会影响其他进程;但一个线程崩溃会导致整个进程终止。

切换开销

  • 进程切换:需要切换页表、刷新 TLB(旁路转换缓存)、保存 / 恢复完整的 CPU 上下文,开销大、速度慢。
  • 线程切换:同进程内的线程切换无需修改页表、无需刷新 TLB,仅需保存 / 恢复寄存器、栈指针,开销小、速度快。

通信方式

  • 进程间通信(IPC):进程地址空间相互隔离,通信需要借助专门的 IPC 机制,如管道、消息队列、共享内存、信号、套接字等,实现复杂。
  • 线程间通信:线程共享进程内存,可直接读写全局变量、共享内存,通信几乎无成本,但需要配合互斥锁、读写锁等同步机制保证安全。

数据同步

  • 进程:数据天然隔离,无需显式加锁,减少了并发编程的复杂度;但 IPC 设计会带来额外开发成本。
  • 线程:数据天然共享,交换成本极低,但必须使用同步原语(互斥锁、条件变量、自旋锁等)避免竞态条件,否则容易出现数据竞争问题。

对比总结

特性 进程 线程
资源开销
切换速度
隔离性 强(独立地址空间) 弱(共享进程资源)
通信复杂度 高(需 IPC 机制) 低(共享内存,需同步)
编程难度 相对简单(无锁,隔离性好) 较高(需精细同步设计)
适用场景 稳定性优先、隔离性要求高 性能优先、高频数据交互

进程的优缺点

优点

  1. 稳定性与隔离性强:一个进程崩溃不会影响其他进程,适合高可靠性场景(如浏览器多进程架构、守护进程)。
  2. 安全性高:独立的地址空间让进程无法直接访问其他进程的内存,需通过内核授权的 IPC 通信,降低了安全漏洞扩散的风险。
  3. 多核 CPU 利用率高:多进程可充分利用多核处理器,且无需担心锁竞争带来的性能下降(仅进程间通信可能成为瓶颈)。
  4. 并发编程模型简单:进程间数据天然隔离,无需显式加锁,减少了并发编程的复杂度(代价是需要设计 IPC 机制)。

缺点

  1. 资源开销大:创建、销毁进程都需要申请和回收大量资源(内存页表、文件描述符等),进程切换的开销也远高于线程。
  2. 进程间通信效率低:多数 IPC 机制需要内核参与和数据拷贝(共享内存除外),性能低于线程间直接访问共享内存。
  3. 扩展性受限:进程数量过多时,系统调度开销、内存占用会显著增加,负载明显上升。

线程的优缺点

优点

  1. 轻量高效:线程创建、销毁和上下文切换速度快,资源占用少,适合高并发场景(如 Web 服务器、数据库服务)。
  2. 通信便捷:线程间天然共享进程的内存空间,数据交换几乎零成本,仅需注意同步控制即可。
  3. 资源利用率高:线程可共享进程内的资源(如打开的文件、内存池),避免了资源重复复制,也能高效利用多核并行执行任务。

缺点

  1. 稳定性差:一个线程因野指针、除零等错误崩溃时,整个进程(包括所有其他线程)都会被终止。
  2. 同步逻辑复杂:共享内存带来了竞态条件风险,必须引入互斥锁、条件变量等同步机制,易产生死锁、锁竞争等性能问题。
  3. 调试难度大:多线程程序的 bug(如数据竞争、死锁)多具有偶发性,难以复现和定位。
  4. 存在安全隐患:线程间直接共享内存,若某个线程存在漏洞(如缓冲区溢出),可能被攻击者利用来读写其他线程的敏感数据。

如何选择?

在实际开发中,选择进程还是线程,核心取决于场景对稳定性、性能、开发复杂度的需求:

优先选择多进程的场景

  • 需要高稳定性、强隔离性的场景,如守护进程、关键服务、浏览器标签页等。
  • 任务之间关联性低、通信需求少,无需频繁交换数据。
  • 希望避免复杂的锁同步逻辑,降低并发编程的出错概率。

优先选择多线程的场景

  • 需要高并发、高性能的场景,如 Web 服务器、高频数据交互服务。
  • 任务间数据交互频繁,需要低成本的通信方式。
  • 资源有限,需要控制内存和调度开销。

混合模型

实际大型系统中常采用多进程 + 多线程 混合模式。例如,主进程负责管理,多个工作进程各自内部使用线程池处理请求(如 Apache 的 preforkworker 模式、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_exitpthread_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:输出参数,用于保存线程的退出状态:
    • 若线程通过 returnpthread_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_joinpthread_cancel 等线程库函数。
  • 类型pthread_t(平台相关,glibc 中通常是 unsigned long,本质是指向线程控制块的指针)。
  • 获取方式 :通过 pthread_self() 函数获取当前线程的用户态 ID。
  • 特点 :仅在当前进程内唯一,不同进程的线程可以有相同的 pthread_t 值,系统层面无法通过它唯一标识线程。

内核态线程 ID(tid

tid 是 Linux 内核层面的线程 ID,对应 task_struct 结构体中的 pid 字段,也叫轻量级进程(LWP)ID。

  • 作用 :系统全局唯一标识线程,内核调度、系统工具(如 pstop)都使用这个 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 映射模型,关系可以概括为:

  1. 内核与用户态的对应关系 每个由pthread_create创建的用户态线程,都会在内核中对应一个独立的LWP(轻量级进程)。LWP 是内核真正参与 CPU 调度的单元,而用户态 pthread 线程本身不直接参与内核调度。

  2. 底层实现关系 pthread_create是用户态线程库提供的接口,其底层会调用clone()系统调用创建 LWP,用户线程的执行、调度最终都由对应的 LWP 来承载。

  3. 资源共享与标识区别

    • 同一进程内的所有 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创建的线程接口。

相关推荐
huaweichenai1 小时前
php 根据每个类型的抽签范围实现抽签功能
开发语言·php
8Qi81 小时前
LeetCode 75:颜色分类(荷兰国旗问题)—— Java 题解 ✅
java·算法·leetcode·指针·排序
zzhongcy2 小时前
@Transactional 同类内部调用失效 + 两种自代理解决方案
java
AutumnWind04202 小时前
【Intelij IDEA使用手册】
java·ide·intellij-idea
codeejun3 小时前
每日一Go-73、云原生成本优化 —— 资源限制 & 指标驱动扩容
开发语言·云原生·golang
就叫_这个吧3 小时前
Java注解、元注解、自定义注解定义及应用
java·开发语言·注解
Sam_Deep_Thinking3 小时前
聊聊Java中的of
java·开发语言·架构
NE_STOP4 小时前
Docker--管理监控平台的应用
java
爱吃羊的老虎5 小时前
【JAVA】python转java:Spring Boot 入门
java·spring boot·python