Linux多线程

1. Linux线程概念

1-1什么是线程

• 在⼀个程序⾥的⼀个执⾏路线就叫做线程(thread)。更准确的定义是:线程是"⼀个进程内部的控制序列"

• ⼀切进程⾄少都有⼀个执⾏线程

• 线程在进程内部运⾏,本质是在进程地址空间内运⾏

• 在Linux系统中,在CPU眼中,看到的PCB都要⽐传统的进程更加轻量化

• 透过进程虚拟地址空间,可以看到进程的⼤部分资源,将进程资源合理分配给每个执⾏流,就形成了线程执⾏流

一个进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表的创建,虚拟地址和物理地址就是通过页表建立映射的。

每个进程都有自己独立的进程地址空间和独立的页表,也就意味着所有进程在运行时本身就具有独立性。

但如果我们在创建"进程"时,只创建task_struct,并要求创建出来的task_struct和父task_struct共享进程地址空间和页表,那么创建的结果就是下面这样的:

1.其中每一个线程都是当前进程里面的一个执行流,也就是我们常说的"线程是进程内部的一个执行分支"。

2.同时我们也可以看出,线程在进程内部运行,本质就是线程在进程地址空间内运行,也就是说曾经这个进程申请的所有资源,几乎都是被所有线程共享的。

要真正理解线程,就必须搞清楚,内核是如何进⾏资源划分的,尤其是代码

后面再说

1-2分⻚式存储管理

所谓的虚拟地址空间,是操作系统为每⼀个正在执⾏的进程分配的⼀个逻辑地址,在32位机上,其范围从0~4G

所以有这么多字节的地址要存

4GB = 4 * 1024 * 1024 * 1024

那如果是按字节对应的,页表的大小为

假如页表只存虚拟地址与物理地址都需要8* 4G = 32G

这是不可能的!

物理地址是按照4kb为单位的页框划分的

假设⼀个可⽤的物理内存有4GB 的空间。按照⼀个⻚框的⼤⼩4KB 进⾏划分, 4GB 的空间就是

4GB/4KB = 1048576 个⻚框。有这么多的物理⻚,操作系统肯定是要将其管理起来的,操作系统需要知道哪些⻚正在被使⽤,哪些⻚空闲等等。

那我们要怎么管理?

先描述,在组织
其中⽐较重要的⼏个参数:

1.flags :⽤来存放⻚的状态。

  1. _mapcount :表⽰在⻚表中有多少项指向该⻚,也就是这⼀⻚被引⽤了多少次。当计数值变为-1时,就说明当前内核并没有引⽤这⼀⻚,于是在新的分配中就可以使⽤它。

要注意的是 struct page 与物理⻚相关,⽽并⾮与虚拟⻚相关。⽽系统中的每个物理⻚都要分配⼀个这样的结构体,有struct page pages[]来管理所有的物理⻚

那么对于struct page pages[],第0个下标对应第一个物理内存块,第1个下标对应第二个物理内存块,第2个下标对应第三个物理内存块

所以物理块起始地址 = 数组下标 * 4kb

⻚⽬录结构

每⼀个⻚框都被⼀个⻚表中的⼀个表项来指向了,那么这 1024 个⻚表也需要被管理起来。管理⻚表的表称之为⻚⽬录表,形成⼆级⻚表。如下图所⽰:

• 所有⻚表的物理地址被⻚⽬录表项指向

• ⻚⽬录的物理地址被 CR3 寄存器 指向,这个寄存器中,保存了当前正在执⾏任务的⻚⽬录地址。
两级⻚表的地址转换

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

对应 32 位虚拟地址拆分:

1.页目录索引(PDI,高 10 位):0000000000 → 十进制0

2.页表索引(PTI,中间 10 位):0000000001 → 十进制1

3.页内偏移(Offset,低 12 位):111111111111 → 十进制4095(0xFFF)

完整转换步骤

1.取页目录基地址:从 CR3 寄存器中,拿到当前进程页目录表的物理基地址PD_base。

2.定位页目录项:

页目录项物理地址 = PD_base + PDI × 4B

从该地址中,读取二级页表的物理基地址PT_base。

3.定位页表项:

页表项物理地址 = PT_base + PTI × 4B

从该地址中,读取物理页框的起始地址PFN_base(页框号 ×4KB)。

计算最终物理地址:

物理地址 = PFN_base + Offset(低 12 位直接复用)

总结

MMU 以 CR3 为起点,查页目录获取二级页表地址,再查页表获取物理页框号,最终结合页内偏移得到物理地址,完成转换。

细节 1:cr3 保存当前进程页表的基地址,物理地址

核心原理

CR3 是 CPU 的控制寄存器,只存物理地址(绝对不能存虚拟地址,否则 MMU 会陷入无限递归),是 MMU 做地址转换的唯一起点。

进程切换时,内核只需把新进程的页目录基地址写入 CR3,MMU 就自动切换页表,实现进程虚拟地址空间隔离。

细节 2:虚拟地址的划分,这个过程编译器参与吗?不关心!

核心原理

虚拟地址的划分(10 位页目录 + 10 位页表 + 12 位偏移)是CPU 硬件 / 操作系统定义的,编译器完全不参与。

编译器只负责生成「虚拟地址」的机器码,不关心虚拟地址怎么拆、怎么转物理地址,对编译器来说,地址就是一个 32 位整数。

细节 3:高 20 位相同的地址,一定是连续存放在一个页框的!ELF->0~FF...

核心原理

虚拟地址的高 20 位 = 虚拟页号,低 12 位是页内偏移。高 20 位相同,说明属于同一个虚拟页,最终映射到同一个 4KB 物理页框。

ELF 文件加载时,代码段、数据段会按 4KB 页对齐加载,保证同一段代码 / 数据连续存放在物理页中。

细节 4:如果我知道任意一个虚拟地址,如何得到我所处页框?

核心原理

虚拟地址 → 页框的方法:

虚拟地址 & 0xFFFFF000(高 20 位),得到虚拟页号;

MMU 以 CR3 为起点,查页目录→页表,得到物理页框号;

物理页框号 << 12,得到物理页框起始地址。

细节 5:首次加载进程磁盘块的时候,OS 做什么?

核心原理

进程首次加载时,OS 只做 3 件事:

1.创建进程的虚拟地址空间(mm_struct、页目录表);

2.页表项标记为「不存在」,不分配物理页;

3.把 ELF 文件的代码 / 数据映射到虚拟地址,延迟分配物理页。

当进程第一次访问某页时,MMU 触发缺页中断,OS 才分配物理页、从磁盘加载数据到内存。

ELF 是磁盘上的可执行文件镜像。

加载时内核只为进程建立虚拟地址布局,不加载实际内容。

首次访问时触发缺页异常,

操作系统才分配物理页,并从磁盘把对应 ELF 内容读入物理内存。

细节 6:如果我们访问的是,一个 int?一个结构体?一个数组呢?一个类变量呢?--> 所有变量都只有一个地址!-->

开辟空间的最小字节的地址 (虚拟)

核心原理

不管是 int、结构体、数组还是类对象,在内存中都只有一个「起始虚拟地址」,变量的大小由类型决定。

不同类型不同偏移量

细节 7:如何重新理解写时拷贝?缺页中断?

fork → 父子共享物理页,页表设为只读

子进程写 → MMU 查页表发现不可写

MMU 触发缺页异常 → 进入内核

内核看到 page->count > 1 → 判定写时拷贝

拷贝一页新物理页 → 更新子进程页表为可写

重新执行指令,正常写入

细节 8:我们用 new, malloc 申请,我怎么申请的是 1,4,n 字节随意申请啊!!! 核心原理

内核只按4KB 页为单位分配物理内存,用户态的new/malloc是「中间商」:

malloc先通过brk/mmap系统调用,向内核申请 4KB 页,建立内存池;

用户申请 1/4/n 字节时,malloc从内存池中切出对应大小的块,分配给用户;

内存池耗尽时,malloc再向内核申请新的 4KB 页。

malloc 内部通过链表管理空闲内存块,以实现高效分配、回收与合并,避免频繁向操作系统申请页。

申请内存 = 抢占虚拟地址 + 建立页表映射关系
物理内存是用到时才给,不是申请时就给。

优化

多级⻚表也是⼀把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。有没有提升效率的办法呢?

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

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

1-3理解资源划分的的本质

进程页表本质是什么?是进程看到资源的窗口

页表就是进程的 "视野范围":

页表里有的虚拟地址 → 进程能看见、能访问

页表里没有的 → 进程看不见,也访问不到

谁拥有更多的虚拟地址,就拥有更多的物理内存资源

虚拟地址是 "资格",物理内存是 "实际资源"。

页表越大、映射越多 → 进程能使用的物理页越多。

对函数进行编址,让不同的执行流执行不同的函数

代码段、函数地址都是虚拟地址,

线程执行流(PC 指针)跳来跳去,全是在虚拟地址里跳。

本质就是让不同的线程通过拥有不同区域的虚拟地址,拥有不同的资源

进程 = 一套虚拟地址空间 + 一套页表

线程 = 共享这套页表,共享所有虚拟地址

所以线程之间天然共享资源,切换极快

通过函数编译的方式进行了进程内部的资源划分

编译器把程序切成:

.text/.data/.bss/stack /heap

这些区域本质就是虚拟地址的不同区间,

页表给它们不同权限、不同映射,实现隔离。

进程页表的本质,是为进程构建一套独立的虚拟地址空间,
通过虚拟地址到物理页的映射,实现资源隔离、权限控制、按需分配物理内存,
让线程在统一虚拟地址空间内形成执行流,完成进程内部资源划分。

1-4linux中多线程的实现(内核角度)


线程 在进程内部运行
线程 在进程的虚拟地址空间里跑
Linux 线程叫 轻量级进程 LWP
让不同线程执行不同函数 = 执行不同虚拟地址
Linux 内核用 PCB 描述线程,复用进程结构

为什么 Linux 线程叫「轻量化进程」?

Linux 是:内核不区分进程和线程

内核眼里,只有一种东西:task_struct(PCB)

区别只在于:

新 PCB 独立页表 → 就是普通进程

新 PCB 共享页表 + 虚拟地址空间 → 就是线程

所以 Linux 线程本质就是:

一个共享了虚拟地址空间的进程。

开销小很多 → 所以叫 轻量级进程 Light Weight Process。

怎么做到 "让不同线程访问一部分资源"?

给不同线程,挂不同入口函数地址即可。

线程看到的虚拟地址空间完全一样,

但它们只去碰自己负责的那片地址。

Linux 内核到底怎么实现线程?

内核不创造新结构,直接复用 task_struct:

创建一个新 task_struct(PCB)

不创建新页表

直接 指向父进程的页表 + mm_struct

进程 = 独立 PCB + 独立页表
线程 = 独立 PCB + 共享页表

1-5Linux轻量级进程 + pthread 库

核心结论(Linux 线程的本质)

Linux 系统内核层面不存在 "线程" 这个独立概念,只存在轻量级进程(LWP, Light Weight Process)。

内核用统一的 task_struct(PCB)描述所有执行流,不区分进程和线程

普通进程:独立 task_struct + 独立页表 / 虚拟地址空间

轻量级进程(线程):独立 task_struct + 共享父进程的页表 / 虚拟地址空间

用户态通过 pthread 原生线程库,封装轻量级进程,给用户提供 "线程" 接口

二、图中三层结构拆解

  1. 用户层:只认进程,通过 pthread 库使用线程

    用户态程序只需要调用 pthread_create 等线程接口,就能创建、管理线程

    用户完全感知不到内核的 "轻量级进程" 实现,对用户来说就是 "线程"

    pthread 库是 Linux 系统自带的原生线程库,所有 Linux 发行版默认集成

  2. 库层:pthread 库封装 clone 系统调用

    pthread 库的核心作用:封装内核的 clone 系统调用,给用户提供线程接口

    clone 是 Linux 特有的轻量级进程创建系统调用,通过不同 flags 参数控制资源共享:

  3. 内核层:只提供轻量级进程,无线程概念

    内核只实现 clone 系统调用,只管理 task_struct,不区分进程和线程

    内核给用户提供的只有 "轻量级进程" 的系统调用,没有专门的 "线程" 系统调用

    这是 Linux 特有的设计:用进程模拟线程,复用进程的调度、管理逻辑,实现极简高效

三、为什么 Linux 用 "库 + 轻量级进程" 实现线程?

  1. 设计哲学:极简内核,用户态灵活扩展
    内核只做最核心的事:调度执行流、管理资源
    线程的复杂逻辑(同步、互斥、线程局部存储等)全部放到用户态 pthread 库实现内核无需修改,就能通过库升级支持新的线程特性,扩展性极强
  2. 性能优势:轻量级进程开销极低
    线程本质是共享资源的进程,创建、切换开销远低于普通进程
    复用进程的调度器,无需单独实现线程调度,性能损耗极小
    对比 Windows 等系统:内核实现进程 + 线程两套结构,逻辑复杂、开销更高
  3. 兼容性:统一进程模型
    所有执行流都用 task_struct 描述,进程、线程的调度、信号、资源管理逻辑完全统一开发者可以直接用进程的知识理解线程,学习成本更低

1-6线程的优缺点

优点

• 创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多

• 与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多

◦ 最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下⽂切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。

◦ 另外⼀个隐藏的损耗是上下⽂的切换会扰乱处理器的缓存机制。简单的说,⼀旦去切换上下⽂,处理器中所有已经缓存的内存地址⼀瞬间都作废了。还有⼀个显著的区别是当你改变虚拟内存空间的时候,处理的⻚表缓冲 TLB (快表)会被全部刷新,这将导致内存的访问在⼀段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件cache。

• 线程占⽤的资源要⽐进程少

• 能充分利⽤多处理器的可并⾏数量

• 在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务计算密集型应⽤,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现

• I/O密集型应⽤,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

缺点

性能损失: 一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。

健壮性降低: 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说,线程之间是缺乏保护的。

缺乏访问控制: 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。

编程难度提高: 编写与调试一个多线程程序比单线程程序困难得多。

线程异常

• 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃

• 线程是进程的执⾏分⽀,线程出异常,就类似进程出异常,进⽽触发信号机制,终⽌进程,进程终⽌,该进程内的所有线程也就随即退出

线程⽤途

• 合理的使⽤多线程,能提⾼CPU密集型程序的执⾏效率

• 合理的使⽤多线程,能提⾼IO密集型程序的⽤⼾体验(如⽣活中我们⼀边写代码⼀边下载开发⼯具,就是多线程运⾏的⼀种表现)

2.Linux进程VS线程

• 进程间具有独⽴性

• 线程共享地址空间,也就共享进程资源

2-1进程和线程

• 进程是资源分配的基本单位

• 线程是调度的基本单位线程共享进程数据,但也拥有⾃⼰的⼀部分"私有"数据:

◦ 线程ID

◦ ⼀组寄存器,线程的上下⽂数据

◦ 栈

◦ errno

◦ 信号屏蔽字

◦ 调度优先级

2-2 进程的多个线程共享

因为是在同一个地址空间,因此所谓的代码段(Text Segment)、数据段(Data Segment)都是共享的:

如果定义一个函数,在各线程中都可以调用。

如果定义一个全局变量,在各线程中都可以访问到。

除此之外,各线程还共享以下进程资源和环境:

文件描述符表。(进程打开一个文件后,其他线程也能够看到)

每种信号的处理方式。(SIG_IGN、SIG_DFL或者自定义的信号处理函数)

当前工作目录。(cwd)

用户ID和组ID。

2-3进程和线程的关系

进程和线程的关系如下图:

如何看待之前学习的单进程?具有⼀个线程执⾏流的进程

2-4进程切换 vs 线程切换的性能差异本质

1. 线程切换,不需要切换 CR3 寄存器

原理:

CR3 寄存器存储的是当前进程页目录表的物理基地址,是虚拟地址→物理地址转换的起点。

同一进程内的所有线程,共享同一套页表和虚拟地址空间,因此线程切换时,页表完全不变,CR3 的值不需要修改。

而进程切换时,必须把新进程的页目录基地址写入 CR3,触发页表切换,开销极大。

对应知识点:Linux 线程是共享页表的轻量级进程,进程是独立页表的独立执行流。
2. TLB → 缓存虚拟到物理地址 → 线程间切换,TLB 不需要更新

原理:

TLB(Translation Lookaside Buffer,快表)是 CPU 内部的硬件缓存,专门缓存虚拟页号→物理页框号的映射关系,避免每次内存访问都查内存里的页表,大幅提升地址转换速度。

线程共享同一套页表,虚拟地址到物理地址的映射完全一致,因此线程切换时,TLB 中的缓存条目全部有效,不需要刷新、不需要更新。

进程切换时,页表完全不同,TLB 缓存全部失效,必须刷新 TLB,后续访问需要重新查表,性能暴跌。

性能影响:TLB 不刷新,线程切换后地址转换几乎零开销;进程切换后 TLB miss 率飙升,大量缺页中断,性能损耗巨大。
3. 进程内的多线程切换,cache 缓存,不用更新,但是进程间切换,就要重新把 cache 缓冲区 "热起来"

原理:

CPU Cache(L1/L2/L3 缓存)缓存的是物理内存中的数据 / 指令,提升内存访问速度(把物理内存常用的数据 / 指令缓存到cpu)。

同一进程内的线程,访问的是同一个虚拟地址空间对应的物理内存,因此线程切换时,Cache 中缓存的进程数据 / 指令依然有效,不需要刷新,直接命中。

进程切换时,访问的是完全不同的物理内存,Cache 中缓存的旧进程数据全部失效,需要重新加载新进程的数据到 Cache,这个过程叫Cache 预热(热起来),会产生大量 Cache miss,性能损耗极大。
4.上下文切换:线程仅需切换 PC、栈、寄存器等少量上下文;进程需要切换完整的进程上下文(虚拟地址空间、文件描述符等),开销极高。

3. Linux线程控制

3-1POSIX线程库

pthread线程库是应用层的原生线程库:

应用层指的是这个线程库并不是系统接口直接提供的,而是由第三方帮我们提供的。

原生指的是大部分Linux系统都会默认带上该线程库。

与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以"pthread_"打头的。

要使用这些函数库,要通过引入头文件<pthreaad.h>。

链接这些线程函数库时,要使用编译器命令的"-lpthread"选项。

3-2创建线程

功能:创建⼀个新的线程

原型:

bash 复制代码
 int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
(*start_routine)(void*), void *arg);

参数:

thread:返回线程ID

attr:设置线程的属性,attr为NULL表⽰使⽤默认属性

start_routine:是个函数地址,线程启动后要执⾏的函数

arg:传给线程启动函数的参数

返回值:成功返回0;失败返回错误码

错误检查:

• 传统的⼀些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指⽰错误。

• pthreads函数出错时不会设置全局变量errno(⽽⼤部分其他POSIX函数会这样做)。⽽是将错误代码通过返回值返回

全局 errno 是多线程共享的,会产生竞争,导致错误码混乱,所以 pthread 函数不设置 errno。

• pthreads同样也提供了线程内的errno变量,以⽀持其它使⽤errno的代码。对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要⽐读取线程内的errno变量的开销更⼩

让主线程创建一个新线程

当一个程序启动时,就有一个进程被操作系统创建,与此同时一个线程也立刻运行,这个线程就叫做主线程。

主线程是产生其他子线程的线程。

通常主线程必须最后完成某些执行操作,比如各种关闭动作。

下面我们让主线程调用pthread_create函数创建一个新线程,此后新线程就会跑去执行自己的新例程,而主线程则继续执行后续代码。

cpp 复制代码
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>

void* Routine(void* arg)
{
    char* msg = (char*)arg;
    while(1)
    {
        printf("I am %s\n",msg);
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    pthread_create(&tid,NULL,Routine,(void*)"thread 1");
    while(1)
    {
        printf("I am main thread!\n");
        sleep(2);
    }
    return 0;
}

运行代码后可以看到,新线程每隔一秒执行一次打印操作,而主线程每隔两秒执行一次打印操作。

当我们用ps axj命令查看当前进程的信息时,虽然此时该进程中有两个线程,但是我们看到的进程只有一个,因为这两个线程都是属于同一个进程的。

使用ps -aL命令,可以显示当前的轻量级进程。

默认情况下,不带-L,看到的就是一个个的进程。

带-L就可以查看到每个进程内的多个轻量级进程。

其中,LWP(Light Weight Process)就是轻量级进程的ID,可以看到显示的两个轻量级进程的PID是相同的,因为它们属于同一个进程。

注意: 在Linux中,应用层的线程与内核的LWP是一一对应的,实际上操作系统调度的时候采用的是LWP,而并非PID,只不过我们之前接触到的都是单线程进程,其PID和LWP是相等的,所以对于单线程进程来说,调度时采用PID和LWP是一样的。

让主线程创建一批新线程

上面是让主线程创建一个新线程,下面我们让主线程一次性创建五个新线程,并让创建的每一个新线程都去执行Routine函数,也就是说Routine函数会被重复进入,即该函数是会被重入的。

cpp 复制代码
#include<stdio.h>
#include<stdlib.h>
#include<pthread.h>
#include<unistd.h>
#include<sys/types.h>

void* Routine(void* arg)
{
    char* msg = (char*)arg;
    while(1)
    {
        printf("I am %s...pid: %d,ppid: %d\n",msg,getpid(),getppid());
        sleep(1);
    }
}


int main()
{
    pthread_t tid[5];
    for(int i =0;i<5;i++)
    {
        char*buffer = (char*)malloc(64);
        sprintf(buffer,"thread %d",i);
        pthread_create(&tid[i],NULL,Routine,buffer);
    }
    while(1)
    {
        printf("I am main thread...pid: %d,ppid: %d\n",getpid(),getppid());
        sleep(2);
    }
    return 0;
}

运行代码,可以看到这五个新线程是创建成功的。

因为主线程和五个新线程都属于同一个进程,所以它们的PID和PPID也都是一样的。

此时我们再用ps -aL命令查看,就会看到六个轻量级进程。

获取线程ID

常见获取线程ID的方式有两种:

创建线程时通过输出型参数获得。

通过调用pthread_self函数获得。

pthread_self函数的函数原型如下:

cpp 复制代码
pthread_t pthread_self(void);

调用pthread_self函数即可获得当前线程的ID,类似于调用getpid函数获取当前进程的ID。

例如,下面代码中在每一个新线程被创建后,主线程都将通过输出型参数获取到的线程ID进行打印,此后主线程和新线程又通过调用pthread_self函数获取到自身的线程ID进行打印。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>

void* Routine(void* arg)
{
	char* msg = (char*)arg;
	while (1){
		printf("I am %s...pid: %d, ppid: %d, tid: %lu\n", msg, getpid(), getppid(), pthread_self());
		sleep(1);
	}
}
int main()
{
	pthread_t tid[5];
	for (int i = 0; i < 5; i++){
		char* buffer = (char*)malloc(64);
		sprintf(buffer, "thread %d", i);
		pthread_create(&tid[i], NULL, Routine, buffer);
		printf("%s tid is %lu\n", buffer, tid[i]);
	}
	while (1){
		 printf("I am main thread...pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
		 sleep(2);
	}
	return 0;
}

运行代码,可以看到这两种方式获取到的线程的ID是一样的。

注意: 用pthread_self函数获得的线程ID与内核的LWP的值是不相等的,pthread_self函数获得的是用户级原生线程库的线程ID,而LWP是内核的轻量级进程ID,它们之间是一对一的关系。

pthread_self() → 返回 pthread_t(用户态 ID)

ps -T 看到的 → LWP(内核态 ID)

它们完全不一样!

用户态 ID:pthread 库自己管理

内核态 ID(LWP):Linux 内核管理

3-3线程等待

为什么需要线程等待?

• 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。

• 创建新的线程不会复⽤刚才退出线程的地址空间。

pthread_join函数的函数原型如下:

cpp 复制代码
int pthread_join(pthread_t thread, void **retval);

参数说明:

thread:被等待线程的ID。

retval:线程退出时的退出码信息。

返回值说明:

线程等待成功返回0,失败返回错误码。

调用该函数的线程将挂起等待,直到ID为thread的线程终止,thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的。

总结如下:

1.如果thread线程通过return返回,retval所指向的单元里存放的是thread线程函数的返回值。

2.如果thread线程被别的线程调用pthread_cancel异常终止掉,retval所指向的单元里存放的是常数PTHREAD_CANCELED。

3.如果thread线程是自己调用pthread_exit终止的,retval所指向的单元存放的是传给pthread_exit的参数。

4.如果对thread线程的终止状态不感兴趣,可以传NULL给retval参数。

下面我们来看看如何获取线程退出时的退出码,为了便于查看,我们这里将线程退出时的退出码设置为某个特殊的值,并在成功等待线程后将该线程的退出码进行输出。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>

void* Routine(void* arg)
{
    char* msg = (char*)arg;
    int count = 0;
    while(count<5)
    {
        printf("I am %s...pid: %d, ppid: %d, tid: %lu\n", msg, getpid(), getppid(), pthread_self());
		sleep(1);
		count++;
    }
    return (void*)2026;
}

int main()
{
    pthread_t tid[5];
    for(int i =0;i<5;i++)
    {
        char* buffer = (char*)malloc(64);
        sprintf(buffer,"thread %d",i);
        pthread_create(&tid[i],NULL,Routine,buffer);
        printf("%s tid is %lu\n",buffer,tid[i]);
    }
    printf("I am main thread...pid: %d.ppid: %d,tid: %lu\n",getpid(),getppid(),pthread_self());
    for(int i =0;i<5;i++)
    {
        void* ret = NULL;
        pthread_join(tid[i],&ret);
        printf("thread %d[%lu]...quit,exitcode: %ld\n",i,tid[i],(long)ret);
    }
    return 0;
}

注意: pthread_join函数默认是以阻塞的方式进行线程等待的。

为什么线程退出时只能拿到线程的退出码?

如果我们等待的是一个进程,那么当这个进程退出时,我们可以通过wait函数或是waitpid函数的输出型参数status,获取到退出进程的退出码、退出信号以及core dump标志。

那为什么等待线程时我们只能拿到退出线程的退出码?难道线程不会出现异常吗?

线程在运行过程中当然也会出现异常,线程和进程一样,线程退出的情况也有三种:

1.代码运行完毕,结果正确。

2.代码运行完毕,结果不正确。

3.代码异常终止。

因此我们也需要考虑线程异常终止的情况,但是pthread_join函数无法获取到线程异常退出时的信息。因为线程是进程内的一个执行分支,如果进程中的某个线程崩溃了,那么整个进程也会因此而崩溃,此时我们根本没办法执行pthread_join函数,因为整个进程已经退出了。

3-4线程终止

如果需要只终止某个线程而不是终止整个进程,可以有三种方法:

1.从线程函数return。

2.线程可以自己调用pthread_exit函数终止自己。

3.一个线程可以调用pthread_cancel函数终止同一进程中的另一个线程。

return退出

在线程中使用return代表当前线程退出,但是在main函数中使用return代表整个进程退出,也就是说只要主线程退出了那么整个进程就退出了,此时该进程曾经申请的资源就会被释放,而其他线程会因为没有了资源,自然而然的也退出了。

例如,在下面代码中,主线程创建五个新线程后立刻进行return,那么整个进程也就退出了。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>

void* Routine(void* arg)
{
	char* msg = (char*)arg;
	while (1){
		printf("I am %s...pid: %d, ppid: %d, tid: %lu\n", msg, getpid(), getppid(), pthread_self());
		sleep(1);
	}
	return (void*)0;
}
int main()
{
	pthread_t tid[5];
	for (int i = 0; i < 5; i++){
		char* buffer = (char*)malloc(64);
		sprintf(buffer, "thread %d", i);
		pthread_create(&tid[i], NULL, Routine, buffer);
		printf("%s tid is %lu\n", buffer, tid[i]);
	}
	printf("I am main thread...pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());

	return 0;
}

运行代码,只能看到1条新线程执行的打印操作,因为主线程的退出导致整个进程退出了。

pthread_exit函数

pthread_exit函数的功能就是终止线程,pthread_exit函数的函数原型如下:

cpp 复制代码
void pthread_exit(void *retval);

参数说明:

retval:线程退出时的退出码信息。

说明一下:

该函数无返回值,跟进程一样,线程结束的时候无法返回它的调用者(自身)。
pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其他线程得到这个返回指针时,线程函数已经退出了。

例如,在下面代码中,我们使用pthread_exit函数终止线程,并将线程的退出码设置为6666。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>

void* Routine(void* arg)
{
	char* msg = (char*)arg;
	int count = 0;
	while (count < 5){
		printf("I am %s...pid: %d, ppid: %d, tid: %lu\n", msg, getpid(), getppid(), pthread_self());
		sleep(1);
		count++;
	}
	pthread_exit((void*)6666);
}
int main()
{
	pthread_t tid[5];
	for (int i = 0; i < 5; i++){
		char* buffer = (char*)malloc(64);
		sprintf(buffer, "thread %d", i);
		pthread_create(&tid[i], NULL, Routine, buffer);
		printf("%s tid is %lu\n", buffer, tid[i]);
	}
	printf("I am main thread...pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
	for (int i = 0; i < 5; i++){
		void* ret = NULL;
		pthread_join(tid[i], &ret);
		printf("thread %d[%lu]...quit, exitcode: %ld\n", i, tid[i], (long)ret);
	}
	return 0;
}

运行代码可以看到,当线程退出时其退出码就是我们设置的6666。

注意: exit函数的作用是终止进程,任何一个线程调用exit函数也代表的是整个进程终止。

pthread_cancel函数

线程是可以被取消的,我们可以使用pthread_cancel函数取消某一个线程,pthread_cancel函数的函数原型如下:

cpp 复制代码
int pthread_cancel(pthread_t thread);

虽然线程可以自己取消自己,但一般不这样做,我们往往是用于一个线程取消另一个线程,比如主线程取消新线程。

3-5分离线程

• 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进⾏pthread_join操作,否则⽆法释放资源,从⽽造成系统泄漏。

• 如果不关⼼线程的返回值,join是⼀种负担,这个时候,我们可以告诉系统,当线程退出时,⾃动释放线程资源。

• 可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。

• joinable和分离是冲突的,一个线程不能既是joinable又是分离的。

分离线程的函数叫做pthread_detach

参数说明:

thread:被分离线程的ID。

返回值说明:

线程分离成功返回0,失败返回错误码。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

void *thread_run( void * arg )
{
    pthread_detach(pthread_self());
    printf("%s\n", (char*)arg);
    return NULL;
}

int main( void )
{
    pthread_t tid;
    // 方案1:显式强转
    if ( pthread_create(&tid, NULL, thread_run, (void*)"thread1 run...") != 0 ) {
        printf("create thread error\n");
        return 1;
    }

    int ret = 0;
    sleep(1);
    if ( pthread_join(tid, NULL ) == 0 ) {
        printf("pthread wait success\n");
        ret = 0;
    } else {
        printf("pthread wait failed\n");
        ret = 1;
    }
    return ret;
}

4. 线程ID及进程地址空间布局

• pthread_create函数会产⽣⼀个线程ID,存放在第⼀个参数指向的地址中。该线程ID和前⾯说的线程ID(LWP)不是⼀回事。

• 前⾯讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最⼩单位,所以需要⼀个数值来唯⼀表⽰该线程。

• pthread_create函数第⼀个参数指向⼀个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。

• 线程库NPTL提供了pthread_self函数,可以获得线程⾃⾝的ID:

cpp 复制代码
pthread_t pthread_self(void);

pthread_t 到底是什么类型呢?取决于实现。对于Linux⽬前实现的NPTL实现⽽⾔,pthread_t类型的线程ID,本质就是⼀个进程地址空间上的⼀个地址。

左侧:进程地址空间(内核空间 + 用户空间)

右侧:libpthread.so 动态库在 mmap 区域分配的线程控制块(TCB)

每个线程对应一个 struct pthread 结构体,这就是 pthread_t 本质:指向该结构体的指针!
struct pthread 核心成员

NPTL 的 struct pthread 是线程的用户态控制块,核心成员如下(对应你提到的两个关键项):

  1. lwp_t tid(内核 LWP / 线程 ID)

    作用:存储该线程对应的内核轻量级进程 ID(LWP),也就是操作系统调度用的线程 ID

    对应关系:pthread_t(用户态 ID)→ struct pthread → lwp_t tid(内核 ID)

    用途:线程库通过这个 ID 和内核交互,比如 pthread_cancel 最终会通过 LWP 向线程发信号

  2. void *result(线程返回值 / 退出码)

    作用:存储线程的退出值,也就是 pthread_exit(retval) 或 return retval 传入的指针

    对应关系:pthread_join 拿到的 value_ptr,本质就是从这个成员里读出来的

    生命周期:线程退出后,这个值会被保留,直到 pthread_join 读取后,结构体才会被释放

3.线程局部存储(TLS,Thread Local Storage)

每个线程独有的数据区,比如 __thread 修饰的变量、C++ 的thread_local线程栈是函数调用栈,TLS 是线程私有全局变量,两者分离多线程下,每个线程的 TLS 互不干扰,实现线程私有数据

  1. 线程栈
    每个线程独立的栈空间,用于函数调用、局部变量存储
    线程退出后,栈销毁,不能返回栈地址
    栈大小可通过 pthread_attr_setstacksize 设置,默认通常为 8MB

LWP、pthread_t、返回值的完整链路

pthread_create() → 分配struct pthread → 内核创建LWP → 把LWP写入struct pthread的tid成员

线程执行 → pthread_exit(retval) → 把retval写入struct pthread的result成员

pthread_join(tid, &ret) → 通过tid(struct pthread*)找到result成员 → 把值赋给ret

总结

1.pthread_t 是用户态 NPTL 线程库的线程 ID,本质是 struct pthread* 指针,仅用于用户态线程操作,内核不识别;

2.LWP 是内核轻量级进程 ID,是操作系统调度的最小单位,存储在 struct pthread 的 tid 成员中,内核通过它调度线程;

3.struct pthread 是线程的用户态控制块,核心成员包括 LWP、线程返回值result、线程局部存储、线程栈等。


pthread.so 动态库是如何在多进程间共享的?

1.系统将 pthread.so 加载到物理内存,通过 mmap 映射到每个进程的虚拟地址空间的 mmap 共享区;

2.代码段在物理内存中只有一份,所有进程共享执行,节省内存;

3.线程私有数据(如 struct pthread、线程栈)存储在进程私有虚拟地址空间,通过写时复制保证隔离;

4.虚拟内存实现了进程间隔离,同时共享了库代码,兼顾了效率与安全。

pthread_create 创建线程的本质是:

1.用户态 pthread 库分配线程控制块 struct pthread、线程栈、TLS。

2.库向内核发起 clone 系统调用。

3.内核创建一个轻量级进程(LWP),共享进程资源,拥有独立执行上下文。

4.内核设置线程栈、指令指针,完成线程初始化。

5.调度器调度线程执行,进入用户态运行线程函数。

相关推荐
开心码农1号2 小时前
RabbitMQ 生产运维命令大全
linux·开发语言·ruby
IMPYLH2 小时前
Linux 的 nl 命令
linux·运维·服务器·bash
咖喱o2 小时前
路由策略
linux·服务器·网络
南境十里·墨染春水2 小时前
linux学习进展 主函数的参数
linux·运维·学习
淮北4942 小时前
obsidian管理自己的计划
linux·学习·kanban·obsidian
YYYing.2 小时前
【Linux/C++网络篇(一) 】网络编程入门:一文搞懂 TCP/UDP 编程模型与 Socket 网络编程
linux·网络·c++·tcp/ip·ubuntu·udp
jiayong232 小时前
第 7 课:第三轮真实重构,拆出新增任务弹窗
服务器·前端·重构
魔都吴所谓2 小时前
【Ubuntu】22.04安装 CMake 3.24
linux·运维·ubuntu
齐潇宇3 小时前
Rsync+sersync 实现数据实时同步故障排查
linux·自动化·rsync·排障·数据同步排障