【Linux进阶系列】:线程(上)

🔥 本文专栏:Linux

🌸作者主页:努力努力再努力wz

💪 今日博客励志语录 人生的每一次负重,都是心的选择。真正的强大,是看清了生活的真相,依然选择拥抱它,并让自己的肩膀成为他人的依靠。


引入

从本篇文章开始,我们将正式进入线程相关内容的学习。在介绍线程的概念之前,我们首先需要理解为什么需要线程,也就是线程存在的意义是什么。

在线程概念出现之前,我们知道 main函数中的内容构成了程序的执行上下文,main 函数中的行为实际上对应了进程的行为。在 main函数中,我们通常会调用其他已定义的函数,而这些函数之间可能存在依赖关系。例如,在调用 fun2 之前必须先调用 fun1,因为 fun2 依赖于fun1的执行结果。因此,fun2 必须等待 fun1 执行完毕后才能开始执行。在代码中,这体现为在 main 函数中先调用 fun1,再调用 fun2

cpp 复制代码
#include<iostream>

int main()
{
    ...
    int ret = fun1(); 
    ...
    fun2(ret);   
    ....       
}

这样的顺序执行本身没有问题。但现在假设 main 函数还需要调用 fun3,而 fun3fun1fun2之间没有任何依赖关系,这意味着 fun3可以独立执行,不需要等待 fun1fun2执行结束。

然而,在传统的单执行流程序中,main 函数是唯一的执行入口,程序只能依次顺序执行 main 函数其中的每一条指令。因此,即使 fun3 可以独立运行,它仍然必须等待 fun1fun2 依次执行完毕才能被调用。

我们可以通过一个比喻来理解这种情况:想象一个家庭中只有一位家庭成员,他需要完成多项家务以维持家庭整洁,例如扫地、拖地、洗衣服和洗碗等。在这些家务中,有些动作之间存在顺序依赖,比如必须先扫地才能拖地,这就像 fun1fun2的调用关系。然而,有些家务,比如洗衣服,并不依赖于扫地或拖地的完成,理论上可以同时进行。

问题在于,这个家庭中只有一个人。俗话说"一心不可二用",一个人无法在同一时刻既扫地又洗衣服。如果家庭中有多位成员,我们就可以将不同的家务分配给不同的人,每个人负责一项任务,从而实现多个任务在同一时刻并行执行。

通过对比这两种模型------单一成员的家庭与多成员的家庭,我们可以看出后者的优势:在相同的时间内能够完成更多任务,提高了效率。这正是线程产生的意义所在。

于是,我们可以正式引入线程的概念:线程是进程中的一个执行流分支。

我们知道,main 函数构成了程序的执行上下文,其中可以调用其他函数。在没有线程的情况下,这些函数只能被依次顺序调用,因为整个程序只有一个执行流(即 main 函数)。而引入线程之后,我们可以将那些能够独立执行、与 main 函数或其他被调函数之间没有依赖关系的函数封装成线程。这样一来,这些线程就能够与
main 函数及其调用的其他函数并发执行,从而显著提高程序的并发性。

线程

通过前文的介绍,读者应当已经了解了引入 线程 的意义。但对于 线程 这一概念本身,可能还不太清晰。接下来的内容,我们将聚焦于线程的本质,逐步揭开其神秘面纱,明确线程到底是什么。

在上文中,我简要给出了 线程 的定义: 线程 是程序执行流的一个分支。但仅凭这个抽象的定义,可能仍难以形成直观理解。为了帮助读者更好地把握 线程 的概念,我将继续结合前文提到的 多家庭成员模型 进行类比说明。

要清晰认识 线程 ,一个有效的方式是将其与进程进行比较。在学习 线程 之前,我们通常先接触进程的概念。我们之前了解到,一个进程主要由两部分构成:一是进程控制块(在 Linux 中体现为 task_struct结构体),二是进程的用户态数据。

创建一个进程,底层涉及两个主要步骤。首先,在内核层面,操作系统为有效管理进程,采取"先描述,再组织"的策略,即为新进程创建对应的 task_struct结构体,并为其分配必要的资源,例如建立进程地址空间、映射页表、设置文件描述符表(本质上是一个文件指针数组)以及分配共享内存等。随后,进程的用户态数据会从磁盘加载到内存中,从而完成进程的创建。

现在,我们引入了线程的概念,这要求我们以一个新的视角来重新理解进程。实际上,之前我们所学的进程,准确来说应称为"单线程进程",即整个进程中只有一个执行流,也就是 main函数的主执行流,或称"主线程"。这对应于"多家庭成员"模型中只有一个家庭成员的情况。在单线程进程中,CPU 会从 main 函数的入口开始,顺序执行其中的所有指令。

然而,在 main函数中,我们常常会调用其他函数。如果这些函数之间没有严格的依赖关系,能够独立执行,那么它们就可以被设计成不同的线程。由此我们可以理解,线程本质上就是一个定义在用户态的函数。

既然线程本质上是一个用户态函数,那么随之而来的问题是:线程存在的意义究竟是什么?正如前文强调的,线程的核心价值在于" 并发执行"。就像多家庭成员可以同时分担家务一样,多个线程能够并发执行,共同推进整体任务的完成,从而显著提升程序的执行效率和响应能力。

一个用户态函数通常包含两部分:执行上下文(即函数的代码段)和数据段(即函数运行时的栈帧,用于保存局部变量、参数和返回地址等)。在引入线程之前,我们所说的进程实际上是单线程进程。该进程拥有自己的执行上下文(包括 main函数及其所调用函数的代码段)和数据段(包括全局数据、堆区以及 main 函数的栈帧等)。

通过对比线程与进程,我们可以发现二者在结构上非常相似。但关键在于,线程的执行上下文和数据段都是其所属进程的一部分。因此,线程可被视为进程的一个子集,或者说是一个" 轻量级进程"(Lightweight Process)。

" 轻量级"体现在:一个进程中可以包含多个线程(每个可由一个独立的用户态函数构成),而系统中又同时存在多个进程。面对如此多的执行实体,操作系统自然需要有效管理它们,而其管理方式依然是熟悉的"先描述,再组织"。

那么,操作系统是否需要为线程单独设计一种称为"线程控制块"(TCB)的数据结构,以区别于进程控制块(PCB)呢?理论上可以,但在 Linux 的设计哲学中,并无必要。

因为线程是进程的子集,甚至主线程( main函数)本身也是一个执行流。所以,在 Linux 内核的视角下,并不严格区分进程和线程,而是将他们都视为可调度的执行实体(Task)。每个线程也用一个 task_struct来表征。这就好比小区门禁系统:物业可以为每个家庭的不同成员按照其所处的单元以及其门牌号制作不同的门禁卡(类似为每个线程创建独立的 TCB),但更高效的做法是,物业只需要为每一个家庭成员制作只认证住户属于本小区的门禁卡即可,意味着所有不同家庭的所以家庭成员的门禁卡都是复用同一套模版(类似 Linux 中线程复用进程的 task_struct),其无需精确到具体的单元以及其门牌号。那么站在小区门禁系统的视角下,那么其不会区分每一个住户是哪个单元以及门牌号,而只识别其是否属于本小区,类似地,在 Linux 系统中,进程与线程的边界被弱化:操作系统视角下仅有 执行流这一统一抽象。因此,Linux 复用 这种设计简化了管理,降低了开销。

因此,在 Linux 中,线程的控制块复用了进程的 task_struct结构体。相比之下,Windows 等操作系统则明确区分进程和线程,并为它们分别设计不同的数据结构。Linux 的这种"平等"看待所有执行流的哲学,使得操作系统的调度单位不再是进程,而是线程。

这一点更新了我们之前的很多认知。操作系统的调度是以线程为基本单位的。我们之前了解的时间片,实际上是分配给线程的。每个线程会分得一个时间片,当时间片用尽,调度器就会切换到下一个就绪线程。内核所维护的"就绪队列",其内部链接的每个 task_struct结构体,代表的是一个可执行的线程。

cpp 复制代码
就绪队列:task1(main) → task2(thread1) → task3(thread2) → ...
          ↑同进程        ↑同进程         ↑不同进程

最后,关于线程的并发实现:在单核CPU上,其严格来说应该叫并行 执行多个线程,因为单核CPU在同一个时刻只能执行一个线程的上下文,不能达到严格意义上的并发,但由于每一个线程都具有一个时间片,而线程的时间片是毫秒级别,所以线程之间的切换是很快的,所以站在我们上层用户的角度上来看就是一个并发的效果,而多核CPU的话,那么就能够真正实现实质性的并发 ,因为不同核心的CPU能够在同一个时刻下运行不同线程的上下文

我们已经了解了线程的基本实现方式:操作系统通过复用 task_struct结构体来描述线程。在之前的学习中我们知道,创建一个单线程进程时,内核会为该进程建立对应的 task_struct结构体,并设置进程地址空间以及页表映射。而对于线程来说,每创建一个线程,内核也一定会为其分配一个独立的 task_struct结构体,即每个线程都拥有自己独立的 task_struct

那么,对于描述进程地址空间的 mm_struct 结构体和页表,是否每个线程也拥有独立的一份呢?

答案是,操作系统确实可以这样做,但实际并没有必要。

我们知道,线程的执行上下文是一个用户态函数,而线程的"数据段"实际上就是该函数的栈帧------通常被称为" 线程栈 "。从理论上讲,操作系统可以为每个线程单独定义一个 mm_struct 结构体,仅描述该线程的代码段和线程栈,并建立对应的页表映射。

然而,线程的一个重要价值在于通过并发执行来高效完成任务。这不仅依赖于高并发性,还依赖于另一个关键特性------ 高共享性

我们可以借助"多家庭模型"来帮助理解:每个线程如同家庭中的一个成员,各自承担不同的家务,如扫地、拖地等。线程之间并非总是独立工作,有时需要协作完成某项任务,这就需要彼此了解对方的工作进展或结果。

这种场景在进程间通信中很常见。但线程与进程有一个根本区别:线程之间没有 隔离。如前所述,线程本质上就是一个用户态函数。就像函数可以访问局部变量和全局变量一样,线程也可以访问其内部的局部变量以及进程全局域中定义的全局变量或静态变量。由于每个可独立执行的用户态函数(包括 main 函数)都可以成为线程,因此线程之间通信的成本极低。相比之下,进程间通信往往需要借助管道、共享内存等机制,通过读写特定资源才能实现信息交换。

用户态函数能访问全局变量,说明线程之间可以看见并共享进程中的大部分数据。进一步说,线程不仅能访问同一份全局变量,还能共享进程所关联的资源,如打开的文件、共享内存等。如果操作系统为每个线程分配独立的地址空间和页表,就相当于在线程之间引入了隔离,这将违背线程高共享性的设计初衷。因为对全局变量的访问和修改应当针对同一份数据实例。如果每个线程拥有独立的地址空间和页表,其行为就更类似于 子进程:在写入全局变量时会触发写时拷贝(Copy-on-Write),内核会为其分配新的物理页并建立新映射,从而破坏共享性。

因此,我们可以得出结论:线程之间共享同一份进程地址空间和页表,这正是实现 高共享性的基础。通过直接读写全局变量即可实现线程间通信,显著降低了通信开销。不过,多个线程对同一共享资源进行修改时,可能引发 数据不一致的问题,我们将在后文讨论这一内容。

对操作系统而言,由于同一进程内的线程共享地址空间和页表,资源分配是以进程为单位进行的,而调度是以线程为单位的。操作系统将资源(如内存页、页表、打开的文件等)分配给进程,而不是直接分配给其中的单个线程。因为线程不仅会访问自己栈内的数据,也会访问进程中的公共资源(如全局变量)。因此,资源分配的责任在操作系统,而线程之间如何共享这些资源,则是由程序员来设计和控制的。

那么对于线程,我们知道它是一个轻量化进程,那么上文我们知道它的轻量化的其中一个体现,就是其执行的上下文是整个进程的一部分,那么除了这个轻量化之外,那么更关键就在于线程的一个切换,那么它的切换相比于进程的效率是更高,那么要理解这一点,那么我们首先得再来认识一下页表这个结构

再次认识页表

想必各位读者在学习进程概念时,已经初步接触过页表。不过当时我们对页表的理解可能还比较基础,仅知道其核心作用------将虚拟地址转换为物理地址。具体来说,当 CPU 执行一条寻址指令时,会将虚拟地址交给内存管理单元(MMU),再由 MMU 借助页表将其转换为物理地址,从而在内存中定位目标数据。

关于页表具体是如何完成这一转换过程的,部分读者可能尚不熟悉。没有关系,本文将系统介绍页表实现虚拟地址到物理地址转换的基本原理。

我们知道,操作系统为每个进程都设置了一个进程地址空间。在 32 位平台下,CPU 与内存之间通过 32 根地址线相连,因此地址总线宽度为 32 位,地址范围从 0x00000000 到 0xFFFFFFFF,共计 2³² 个地址,即 4GB 的地址空间。每个进程都看到这样一个 4GB 的连续内存空间。内核通过 mm_struct结构体来描述这整个地址空间布局:低地址的 3GB 为用户地址空间,高地址的 1GB 为内核地址空间。用户空间进一步划分为数据段、代码段等,其中数据段又包含栈、共享区、堆等部分。mm_struct结构体中会记录各个段的起始与结束地址,从而为进程提供统一的内存视角。

操作系统管理内存时,采用的是"先描述,再组织"的方式。它将 4GB 的物理内存划分为若干个 4KB 大小的内存页(page),并为每一个内存页定义了一个 struct page结构体,用于记录该页的属性(如引用计数等)。随后,操作系统通过数组或链表等方式将这些 struct page 结构体组织起来,从而实现对全部物理内存的系统管理。

页表作为虚拟地址与物理地址之间的转换桥梁,本质上是一种数据结构。不少读者容易将页表理解为一种一维的线性数组,但实际上,现代操作系统通常采用多级页表(分级页目录)的结构。下面我们将重点解析这种分级页表的实现机制。

首先要明确一个关键点:虚拟地址与物理地址之间的映射并非任意对应,这一点常被误解。有人会将其类比为 STL 中的 map 结构,即一种键值对(key-value)模型,认为虚拟地址与物理地址在数值上毫无关联。然而实际上,虚拟地址的编码方式本身就隐含了与物理地址的对应关系,这也是理解地址转换的关键所在。

在 32 位系统中,虚拟地址的 32 个比特位被划分为三个部分:最高 10 位、次高 10 位和最低 12位,即10+10+12的结构。解析顺序是从高到低依次进行。

🎯

cpp 复制代码
31              22 21              12 11              0
+----------------+------------------+------------------+
   页目录索引(10位)     页表索引(10位)      页内偏移(12位)

前文提到,操作系统以4KB 为单位对内存进行分页管理。4GB 的物理内存共有 4GB / 4KB = 2²⁰ 个内存页,这个数值 2²⁰ 非常重要,请读者留意,后文会再次提到。

分级页表的实现中,内核为每个进程维护一个一级页目录(Page Directory),它可以被理解为是一个长度为 2¹⁰ 的指针数组模型。

但本质上一级页表本身是一个占用一个内存页的空间,即 4KB。每个页目录项(Page Directory Entry, PDE)的大小为 4 字节,因此一个一级页目录(页表)中共有 4KB / 4B = 2¹⁰ 个页目录项。这正好与 32 位虚拟地址的高 10 位对应,该 10 位用作一级页目录内的索引。

每个 页目录项 的内容至关重要:其高 20 位存储的是二级页表(页表)的 物理基地址 。而页目录项的低 12 位则用于存储权限与控制位(如存在位、读/写位、用户/超级用户位等),而非虚拟地址的一部分。虚拟地址的最高 10 位即用于索引该页目录中的项(每个项占 4 字节)。CPU 的 CR3 寄存器中保存着一级页目录的起始地址。通过"CR3 + 高10位 × 4"即可定位到对应目录项(PDE)。

基本计算公式:

📊 一级页目录偏移量 =CR3+((虚拟地址 >> 22) & 0x3FF)*4

其中0x3FF对应最低10位全1的二进制数

一级页表项中存储的是二级页表(Page Table)的基地址。二级页表同样是一个具有 2¹⁰ 个项的数组,每一项占 4 字节。虚拟地址的中间 10 位用于在二级页表中索引,通过"二级页表基地址 + 中10位 × 4"可定位到页表项(PTE)。

基本计算公式:

📊 二级页目录偏移量 =页表基地址+((虚拟地址 >> 12) & 0x3FF)*4

其中0x3FF对应最低10位全1的二进制数

页表项的高 20 位对应物理内存页的起始地址(页框号)。由于共有 2²⁰ 个物理页,20 位刚好可以索引全部页框。每个物理页大小为 4KB,因此虚拟地址的最低 12 位用作页内偏移(offset),它表示目标数据在物理页内的具体位置(0--4095)。

cpp 复制代码
31                    12 11        0
+----------------------+------------+
   物理页框基地址(高20位)  标志位(低12位)

此外,页表项的低 12 位通常用于存储标志位(如读写权限、存在位等),控制该页的访问属性。

💡 低12位:控制标志位

位0: 存在位(P)- 页面是否在内存中
位1: 读写位(R/W)- 只读(0)或可写(1)
位2: 用户/管理员位(U/S)- 用户态(1)可访问
位3:写透位(PWT)
位4:缓存禁用位(PCD)
位5:访问位(A)- 页面是否被访问过
位6:脏位(D)- 页面是否被修改过
位7:页大小位(PS)- 4KB或4MB页
位9-11:保留给操作系统使用

🔢 总结虚拟地址到物理地址的转换过程如下:

  1. 从 CR3 获取一级页目录基地址;
  2. 用虚拟地址的高 10 位作为索引,找到对应的一级页目录项,获得二级页表的基地址;
  3. 用虚拟地址的中间 10 位作为索引,在二级页表中找到对应的页表项,从中提取物理页框基地址(高 20 位);
  4. 将物理页框基地址与虚拟地址的低 12 位(页内偏移)相加,得到最终的物理地址。

补充一点:页表项中的高 20 位也可视为页框号(Page Frame Number, PFN)。由于系统使用 struct page结构体数组来管理所有物理页(数组大小为 2²⁰),该 PFN 也可直接作为数组索引,用于定位对应物理页的 struct page结构。


如果采用读者最初设想的一维线性数组模型,则需准备完整的 2²⁰ 个页表项,每个页表项至少为 4 字节。其中,高 20 位表示内存页的基地址,低 12 位用于存储控制信息。虚拟地址的前 20 位将作为一个整体,作为页表数组的索引,低 12 位则表示页内偏移量。此时,单个进程的页表大小为 4 MB。而系统总内存为 4 GB,其中用户可用空间仅为 3 GB,且系统中通常存在多个进程,每个进程都拥有独立的页表。因此,采用该模型将带来较大的内存开销。

相比之下,分页管理机制支持按需分配页表结构。每个进程首先需要一个 4 KB 的页目录(一级页表),但该页目录中的每一项并不一定都指向有效的页表(二级页表),因为进程通常不会完全使用全部 3 GB 的用户地址空间。因此,部分页目录项可能为空,从而显著节省内存占用。

其次,由于同一进程内的线程共享页表,在线程间切换时,CR3 寄存器的值无需改变。而若切换到另一进程的线程,则需更新 CR3 寄存器的值。此处还需补充 TLB (Translation Lookaside Buffer)机制。TLB 是 CPU 内部的一个小型硬件缓存,用于存储 MMU (Memory Management Unit)频繁访问的虚拟地址到物理地址的映射条目。

因此,在MMU 查找页表之前,会首先查询 TLB 中是否存在对应虚拟地址的映射。若命中,则无需访问内存中的页表;否则,必须进行页表查找。由于页表存储于内存中,而 CPU 访问缓存的速度远高于访问内存,TLB 的存在可显著提升地址转换效率。

需要注意的是,在同一进程内的线程切换时,TLB 中的内容通常不会被刷新 ;而进行进程切换时,TLB 中原有映射将被清除,并加载新进程的常用地址映射,这一过程将带来额外的时间开销。

cpp 复制代码
1. MMU接收虚拟地址
2. 首先查询TLB缓存
   ├─ 命中:直接获得物理地址(1-3个时钟周期)
   └─ 未命中:查询内存中的页表(几十到几百个时钟周期)
3. 将新映射加入TLB(替换算法)

此外,关于缓存(Cache)机制,由于 CPU 访问内存的速度远低于访问缓存,CPU 在读取数据时会利用局部性原理 。例如,在遍历数组的循环中,访问某一数据时,其邻近数据也可能被后续访问。在 32 位平台下,CPU 通过 32 位数据总线一次可读取 4 字节数据。为减少内存访问次数,CPU 通常会一次性加载以目标地址为中心的 64 字节数据块(即一个缓存行)至缓存。尽管该操作可能需要 8 次总线传输,但若后续多次访问该数据块内的其他数据,均可直接从缓存命中,从而避免频繁访问内存。

若不采用缓存,n 次数据访问将对应 n 次内存读取;引入缓存后,总开销变为 8 次内存访问(加载缓存行)加上 n 次缓存访问。由于缓存访问速度远高于内存,整体效率得到提升。

在同一进程的线程切换过程中,缓存内容通常得以保留;而进程切换时,缓存往往会被清空或部分失效,导致切换开销增加。

由此也可理解线程被称为"轻量级进程 "的另一层原因:其切换速度更快。


那么从上文的内容,相信各位读者已经知道线程是个什么呢,那么我们对于线程也不在陌生,那么光知道线程还不够,那么我们还得学会如何管理以及使用我们的线程,所以接下来的内容便是线程的管理

线程的管理

线程的创建

在理解了线程的基本概念后,读者最关心的问题自然是如何在代码层面实际创建一个线程。

创建线程背后主要涉及两个关键步骤:一是在内核中创建线程对应的 task_struct结构体,二是为其准备用户态函数和线程栈。需要注意的是,只有操作系统内核才有权限创建 task_struct结构体,因此创建线程必须通过调用操作系统提供的系统调用来完成。

这个系统调用就是cloneclone 的强大之处在于它赋予程序员很高的自由度,允许我们自定义线程的多种属性。这一点可以从其参数设置中看出:

  • 系统调用:clone
  • 头文件:<sched.h>
  • 函数声明:int clone(int (*fn)(void *),void *child_stack,int flags,void arg,
    ... /
    pid_t *parent_tid, void *tls, pid_t *child_tid */ );
  • 返回值:成功时,在父进程中返回子线程的 tid;失败时返回 -1,并设置 errno。

clone 系统调用包含多个参数,其中几个尤为关键:

  • 第一个参数是一个函数指针,指向用户自定义的函数,该函数即为线程的入口点。clone 系统调用对函数原型有明确限制,必须符合 int (*fn)(void *) 形式。
  • 第二个参数用于指定线程栈的起始地址。clone 允许程序员自行设置线程栈的存储位置,可以在栈上或堆上分配,但必须确保所提供地址指向合法、可读写且已分配的内存页。

clone 最核心的参数是第三个参数 flags ,它是一个位掩码(bitmask),用于控制线程之间共享资源的程度。通过设置不同的标志位,可以决定线程是否共享如打开的文件描述符、内存空间等资源。

💡

CLONE_VM : 父子进程共享地址空间(内存空间)

CLONE_FS : 共享文件系统信息(根目录、当前工作目录等)

CLONE_FILES : 共享打开的文件描述符表

CLONE_SIGHAND : 共享信号处理程序表

CLONE_SYSVSEM : 共享 System V 信号量撤销值

CLONE_THREAD : 将子进程置于父进程的线程组中(作为线程)

由此可见,clone 系统调用确实提供了极大的自由度,允许程序员定制线程栈的位置和资源共享行为。然而,权力越大责任越大,过度放开控制也可能带来风险。

例如,关于线程栈的设置:线程栈实质上是线程执行用户函数时的栈帧存储区域。有些读者可能会习惯在栈上分配线程栈,但这样做存在隐患。考虑以下场景:

当一个线程被调度执行时,其在用户栈上分配了对应的线程栈。假设在该线程执行过程中时间片耗尽,CPU 被切换去执行同一进程中的另一个线程。由于线程调度频繁,线程的上下文经常会被中断和恢复。

所以每个线程都拥有一个内核栈,这是由操作系统为每一个线程分配的一个或多个内核内存页,用于在上下文切换时保存线程的执行状态,内核栈的内容就会是CPU运行当前线程时各个寄存器的值(其中包括程序计数器、段寄存器等通用寄存器值)。当线程被切换出去时,其寄存器状态被压入内核栈;待重新被调度时,再将内核栈的值弹出到各个寄存器从而恢复之前保存的寄存器状态,确保线程能从被中断的位置继续执行,保证执行连续性。

关键在于,线程的执行具有独立性。与同一线程内部用户态函数之间的调用机制不同------在单线程环境中,若发生函数调用,被调用函数的栈帧总是位于调用者栈帧的下方,且彼此不会发生重叠;被调用函数执行完毕后,其栈帧会被正常销毁------线程之间的执行则是完全独立的。当操作系统调度到某一线程时,它会直接恢复该线程的上下文,具体来说,是将内核栈中保存的栈基址指针(ebp)和栈顶指针(esp)的值重新载入对应的寄存器。然而,内核在此过程中并不会检查该线程的栈空间是否已被其他线程占用。

因此,多个线程的栈空间可能存在重叠。这种情况可能导致一个线程的栈内容被另一个线程覆盖:例如,当某一线程在执行过程中被切换出去,其上下文被保存至内核栈;随后若另一个线程被调度执行,而两者的栈空间存在重叠,后一线程可能会覆盖前一线程栈中保存的数据。当前一线程再次被调度并恢复执行时,其栈内的局部变量等数据可能已被篡改,从而引发数据不一致程序行为异常

因此,尽管 clone 允许灵活设置线程栈,程序员仍需谨慎确保各线程栈彼此独立、互不干扰,通常建议在堆上动态分配线程栈以避免此类问题。

pthread_create在内部封装了 clone 系统调用,并创建符合 POSIX 标准的线程,即真正意义上的线程。这类线程在同一进程内高度共享资源,包括文件描述符表、地址空间以及共享内存等。
pthread_create在底层已预设好相应的 clone 标志位,以支持这些共享行为。

关于线程栈的分配,pthread_create 并不会在进程的栈区分配线程栈,而是选择在堆区分配。这是因为堆空间可以按需独立分配,确保每个线程拥有独立的堆空间,避免多个线程栈重叠。尽管线程执行上下文是一个用户态函数,其栈空间默认被视为具备栈属性(由 ebp/esp 等栈寄存器维护),实际物理位置却在堆区。堆的生长方向是从低地址向高地址,因此 malloc 返回的堆起始地址为低地址。pthread_create 在调clone 前会对该地址进行处理,将其调整到所分配堆空间的末尾(高地址处),以满足栈从高地址向低地址生长的约定。

pthread_create 的参数可以看出,用户需要提供以下信息:

  • 第一个参数为 pthread_t类型的输出型参数,用于返回新线程的标识符。 pthread_t本质上是整型类型,在线程库中通过 typedef定义为线程 ID 类型。

    cpp 复制代码
    typedef unsigned long int pthread_t;

    每个线程都具有唯一标识符。在引入线程概念前,进程通过 task_struct中的 pid 字段唯一标识。而在 Linux 中,线程实质是轻量级进程,内核并不严格区分线程与进程。那么在线程语境下, pid的具体含义是什么?这一点我们稍后再作说明。

  • 第二个参数用于设置线程属性,如栈大小、调度策略等。该参数通常使用默认值即可,在大多数应用场景中可直接传入 NULL

  • 第三个参数是一个函数指针,指向线程的入口函数,即线程开始执行时的用户态函数。该函数必须符合规定的原型:返回类型为 void*,且仅接受一个 void*类型的参数。

  • 第四个参数将作为参数传递给第三个参数所指向的用户函数,调用时会被转换为 void*类型。

那么知道pthread_create的各个参数的含义之后,那么接下来我们就来尝试调用pthread_create接口,来创建一个线程,同时我们也可以观察运行结果来验证线程的相关特性

我编写了一个简单的代码示例。在主线程(即main函数)中,调用pthread_create接口创建一个新线程。新线程的执行上下文是一个用户定义的函数threadfun,该函数内部通过一个循环(设置为5次迭代)来打印新线程的运行信息。每次循环后,线程会休眠1秒。主线程在创建新线程后,会继续执行后续代码,进入一个无限循环,不断打印主线程的信息。

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

void* threadfun(void* args)
{
        int cnt=5;
         while(cnt--)
         {
                std::cout<<"Thread is running..."<<std::endl;
                sleep(1);
         }
         return NULL;
}
int main()
{
         pthread_t tid;
         pthread_create(&tid,NULL,threadfun,NULL);
        while(true)
        {
                std::cout<<"Main thread is running.."<<std::endl;
                sleep(1);
        }
        return 0;
}

运行上述代码后,可以注意到新线程在执行完5次循环后正常退出,而主线程会继续无限循环打印。这表明新线程和主线程的执行是相互独立的:主线程在创建新线程后不会阻塞或等待其结束,而是继续执行自己的逻辑;新线程则独立运行其函数体,执行完毕后自动终止。

需要注意的是,当前代码存在一个潜在问题:主线程没有通过等待新线程结束,这可能导致资源未正确清理。但由于这里我只介绍pthread_create的基本用法,后续将逐步补充其他线程管理接口来完善代码。

接下来,我们对代码进行修改,将新线程的函数threadfun也改为执行无限循环打印。运行修改后的代码,在终端中输入命令ps -aL,可以展示系统中用户有权限查看的线程的信息。输出中会显示两个与测试程序相关的线程条目。

观察ps -aL的输出,可以看到两个线程的PID(进程ID)相同,但LWP(轻量级进程ID)不同。这解释了Linux线程模型的关键点:同一进程中的多个线程共享相同的PID(即线程组ID,对应内核中的tgid字段),但每个线程有独立的LWP(对应内核中的pid字段,即线程ID)。操作系统通过维护这两个标识符来区分线程组内的个体线程。其中,主线程的LWP与PID值相同,而新线程的LWP则不同。这种设计使得操作系统既能以进程为单位管理资源,又能独立调度每个线程。

而之前上文我说过,那么pthread_create的第一个参数就是线程的标识符,作为输出型参数,那么这里pthread_t的标识符和内核给每一个线程的lwp是同一个内容吗,那么我们可以写一段简单的代码来验证一下

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

void* threadfun(void* args)
{
         while(1)
         {

         }
         return NULL;

}
int main()
{
         pthread_t tid;
         pthread_create(&tid,NULL,threadfun,NULL);
         std::cout<<"child thread id is "<<tid<<std::endl;
         while(1)
         {
                
         }
        return 0;
}

根据运行结果可以看出,线程库所返回的线程标识符与内核为线程分配的标识符并不相同。这种差异的产生是因为线程库为每一个线程维护了一个用户态的线程控制块(TCB),其本质是一个结构体,用于记录线程的相关属性,例如线程状态、线程标识符(tid)等。

cpp 复制代码
// 基于glibc的近似结构(实际实现包含更多细节)
struct pthread {
    /* 通用头部 */
    struct pthread *self;           // 指向自身的指针

/* 线程ID和描述符 */
pid_t tid;                      // 线程ID(内核视角)

/* 栈管理 */
void *stack_block;              // 分配的栈块起始地址
size_t stack_block_size;        // 栈块大小
size_t guardsize;               // 栈保护区域大小

/* 线程局部存储(TLS) */
dtv_t *dtv;                     // 动态线程向量
void *private_tp;               // 线程私有指针

/* 同步和状态 */
int multiple_threads;           // 多线程标志
int thread_terminated;          // 线程终止标志
int joinid;                     // 用于 pthread_join 的标识

/* 取消处理 */
struct pthread_cancelation_handler *cancelhandlers;
volatile int cancelhandling;    // 取消处理状态

/* 清理函数 */
struct _pthread_cleanup_buffer *cleanup;

/* 线程特定数据 */
void *specific_1stblock[PTHREAD_KEY_1STLEVEL_SIZE];
void **specific[PTHREAD_KEY_2NDLEVEL_SIZE];

/* 其他实现细节... */

};

线程库为每个线程定义的线程控制块中包含众多字段,我们无需掌握每一个字段的具体作用,但可以关注其中的关键字段,例如指向自身的指针(self)以及所分配的栈块指针(stack_block),即指向该线程栈空间的起始地址。

线程库返回的用户态线程标识符,本质上是该线程对应的线程控制块(TCB)的起始地址。这样设计的目的在于能够快速定位到对应线程的TCB,以便及时修改其中的属性信息。

有读者可能会产生疑问:既然 pthread_create底层封装了 clone系统调用,而该系统调用会为线程在内核中创建对应的 task_struct结构体,那么为何还需要在用户态额外维护一套线程属性集合?这是否会造成冗余?

需要明确的是,我们在操作线程时(例如线程的等待、退出等),通常不直接使用系统调用,而是调用线程库封装好的接口。这意味着线程的管理职责主要由线程库承担,而非由程序员直接面对内核。一个进程中可能创建多个线程,因此线程库必须维护每个线程的属性,以识别当前操作的是哪一个线程,以及该线程的当前状态。

我们调用线程库函数,相当于向线程库发出请求,而请求的具体实现(如线程创建、同步、退出等)由线程库在底层通过一系列系统调用完成。由于线程库负责管理线程的生命周期,它必须能够跟踪每个线程的状态。而内核通常不会向用户态暴露 task_struct 中的所有字段,因此线程库需要自行维护一套线程控制结构。

值得注意的是,所有对线程的操作都通过线程库提供的接口进行,这保证了线程库能够准确追踪线程状态,因为每次操作都会同步更新TCB中的相关属性。

创建一个线程不仅需要为其提供执行的上下文,还需要为其分配运行环境,即线程栈。该栈由用户态管理,在线程结束时,不仅需要在内核中销毁对应的 task_struct,还需要清理线程运行过程中打开的文件、在堆上分配的内存等资源。此外,由于线程栈本身也是在堆上分配的,线程退出时也需要释放相关内存并执行清理函数。关于线程退出时的资源清理过程,将在后续讨论线程退出时进一步展开。

因此,线程库采取的是分级管理策略:用户态线程控制块(TCB)主要负责线程生命周期的管理,包括创建、退出、等待和资源清理等;而内核态的 task_struct 则主要关注线程调度、上下文切换和系统资源分配。两者管理的侧重点不同,共同协作完成线程的全生命周期管理。

了解了这些基础后,我们便可以深入理解 pthread_create的底层原理。首先,pthread_create函数会为该线程创建一个线程控制块(Thread Control Block, TCB),并初始化 TCB 中的相关字段。接着,它会在堆上申请一段内存空间作为线程栈。需要注意的是,虽然线程栈通常由栈寄存器(如 espebp)维护,使其具有栈的属性(例如从高地址向低地址增长),但实际分配位置在堆中。由于通过 malloc等函数在堆上申请空间返回的起始地址是低地址,为了满足栈的增长特性,需要将指针调整到高地址位置。

在创建完 TCB 和线程栈后,函数会初始化线程的初始状态。这一过程包括设置关键寄存器的值,例如将程序计数器(PC)指向用户态函数的入口点,并初始化栈寄存器等,而其他大部分寄存器则保持为空值。随后,
pthread_create内部会使用调整后的高地址指针、接收到的用户态函数指针,以及预设的共享资源标记(如共享的进程地址空间、页表、文件描述符表和共享内存等)传递给 clone系统调用。clone系统调用的核心作用是在内核中为线程创建对应的 task_struct结构体和内核栈,并将当前 CPU 的寄存器状态保存到内核栈中。这大致构成了 pthread_create的实现原理。

cpp 复制代码
// 伪代码示意
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void*), void *arg) {
    // 1. 分配线程控制块(TCB)和线程栈
    thread_control_block_t *tcb = malloc(sizeof(thread_control_block_t));
    void *stack_base = malloc(STACK_SIZE);  // 在堆上分配
    
    // 2. 调整栈指针(栈从高地址向低地址增长)
    void *stack_top = stack_base + STACK_SIZE;
    
    // 3. 初始化TCB字段
    tcb->stack_base = stack_base;
    tcb->stack_size = STACK_SIZE;
    tcb->start_routine = start_routine;
    tcb->arg = arg;
    
    // 4. 设置线程上下文(初始状态)
    ucontext_t *uc = &tcb->context;
    uc->uc_stack.ss_sp = stack_base;
    uc->uc_stack.ss_size = STACK_SIZE;
    uc->uc_link = ...;  // 线程退出后的处理
    
    // 设置程序计数器指向线程启动函数
    makecontext(uc, thread_start_function, ...);
    
    // 5. 调用clone系统调用进入内核
    clone(....)
    }

需要注意的是,线程控制块(TCB)和线程栈通常被分配在连续的内存区域中。一般情况下,TCB 位于高地址段,而线程栈位于低地址段。两者之间可能存在线程局部存储(Thread-Local Storage, TLS)区域。事实上,TCB 本身可被视为 TLS 的一部分。TLS 是线程私有的数据区域,用于存储仅属于该线程的变量。

cpp 复制代码
高地址:
    TCB(pthread结构)
    TLS数据区域(包括__thread变量)
    保护页
    线程栈(从高地址向低地址增长)
低地址:

关于线程数据的共享性,线程间可以高效访问全局变量,而线程私有数据(如用户态函数中定义的局部变量)通常存储在线程栈中。由于线程栈在堆上分配且对用户不透明,线程栈的地址并不直接暴露给用户,因此这些局部变量看似是线程私有的。然而,通过全局指针(例如定义一个全局指针变量,在线程函数中令其指向局部变量),其他线程仍可能间接访问到这些数据。这表明线程间几乎没有绝对的隐私边界,只要通过指针操作,线程的"私有"数据也可能被外部访问。

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

int* ptr1=NULL;
void* threadfun(void* args)
{
    int* var = (int*)malloc(sizeof(int));
     *var=20;
    ptr1 = var;
    return NULL;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, threadfun, NULL);
    while (true)
    {
        if (ptr1 != NULL)
        {
            std::cout << "value of var in child thread is " << *ptr1 << std::endl;
            break;
        }
    }
    return 0;
}

基于上述机制,读者可能会疑问:是否存在一种方法,使线程能真正拥有其他线程无法访问的私有数据?答案是肯定的,这需要通过_thread关键字来实现。

_thread关键字用于修饰全局变量或静态变量,其作用是将这些变量的存储位置从静态区转移至线程局部存储(TLS)区域。每个线程都拥有独立的 TLS 段,因此被 _thread 修饰的变量在每个线程中都有独立的实例。那么,CPU 如何确定该访问哪个线程的 TLS 段呢?这涉及到 %fs段寄存器的作用。

pthread_create的完整流程中,除了创建 TCB 和线程栈,还会设置 TLS 区域和内存保护页,并将 TCB 的地址写入 %fs 寄存器。而保护页(Guard Pages)则是以防止栈溢出破坏相邻内存。由于 TCB 通常位于 TLS 的首部,%fs寄存器的值即指向当前线程的 TCB。这样,当访问 _thread变量时,系统通过 %fs 寄存器加上固定偏移量来定位变量在 TLS 中的位置,而非通过栈寄存器寻址。

以下代码示例演示了 _thread 变量的行为:两个线程分别打印同一 _thread变量的值和地址,结果会显示不同地址,证实变量存储于各自的 TLS 区域。

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

__thread int var1 = 10;
void* threadfun(void* args)
{
    std::cout << "child thread var1: " << var1 << " address is: " << &var1 << std::endl;
    return NULL;
}

int main()
{
    pthread_t tid1;
    pthread_t tid2;
    pthread_create(&tid1, NULL, threadfun, NULL);
    pthread_create(&tid2, NULL, threadfun, NULL);
    while (1) {}
    return 0;
}

运行结果会显示两个线程中 var1 的地址不同,验证了 _thread变量存储于各线程独立的 TLS 区域。访问这些变量时,底层通过 %fs寄存器定位 TLS 基址,再加上偏移量实现,而非依赖栈寄存器。

线程的等待与退出

通过上文的介绍,读者应当已经了解了如何创建一个线程。我们知道,主线程创建新线程的主要目的之一,是委托其完成特定任务模块,从而加速整体目标的实现。因此,主线程通常需要获取新线程的执行结果。而新线程本质上是一个用户态函数,既然作为函数,就必然具备返回值。主线程便可通过获取该用户态函数的返回值,来得知新线程的执行情况。

由于线程是独立的执行流,一个线程若要获取另一个线程的返回值,就涉及到线程间的同步机制------类似于父进程等待子进程的方式,需要通过"线程等待"来实现。线程等待通常通过调用 pthread_join 函数完成。在深入介绍 pthread_join 的使用方法与实现原理之前,我们先来看一下该函数的原型:

  • 系统调用:pthread_join
  • 头文件:<pthread.h>
  • 函数声明:int pthread_join(pthread_t thread, void **retval);
  • 返回值:成功返回 0,失败返回错误码(不设置 errno)。

pthread_join 的第一个参数 thread表示要等待的线程的用户态标识符,即该线程的 ID。如前文所述,该标识符实际对应用户口态线程控制块(TCB)的起始地址。第二个参数retval 是一个二级指针,属于输出型参数,用于接收目标线程的返回值。

当某线程调用 pthread_join 等待另一个线程时,该线程会进入阻塞状态,直到目标线程执行结束。

接下来我们探讨 pthread_join的底层实现机制。该函数首先会根据传入的线程 ID 定位到对应线程的 TCB。TCB 中通常包含一个指针,指向因等待该线程退出而阻塞的线程所构成的等待队列。该队列本质上是一个链表,每个节点指向一个正在等待的线程的 TCB。

之前提到,CPU 的 %fs 寄存器中存储的是当前线程的 TCB 地址。因此,pthread_join 可通过该寄存器获取当前调用线程的 TCB,并将其加入目标线程的等待队列。同时,还会设置目标线程的等待标志,表明有线程正在等待其结束。

线程通常通过 return语句退出,此时返回值会被写入其 TCB 中的特定字段(如退出码字段)。当某个线程调用 pthread_join 时,调用者需预先准备一个变量(如 void* ret),并将其地址以 &ret的形式作为retval参数传入。

cpp 复制代码
// 简化的线程控制块结构
struct pthread_ {
    pthread_t tid;
    // ......其他字段
    int state;                        // 状态: READY, RUNNING, ZOMBIE等
    void* return_value;               // 返回值存储
    struct cleanup_handler* cleanup; // 清理函数链表
    struct wait_queue_node* waiters; // 等待队列
    // ... 其他字段
};

pthread_join内部会从目标线程的 TCB 中取出退出码,并将其写入 retval所指向的内存,从而使调用线程获得目标线程的返回值。

为了熟悉pthread_join接口的使用,我们编写一个简单的示例代码。代码逻辑如下:主线程创建一个新线程,新线程的执行函数中循环打印一条语句,循环5次后返回一个值。主线程创建新线程后,调用pthread_join函数接收新线程的返回值,获取返回值后打印该值。

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

void* threadfun(void* args)
{
    int cnt = 5;
    while(cnt--)
    {
        std::cout << "child thread is running " << std::endl;
        sleep(1);
    }
    return (void*)1;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, threadfun, NULL);
    int ret;
    pthread_join(tid, (void**)&ret);
    std::cout << "child thread exit code: " << ret << std::endl;
    return 0;
}

运行结果显示,主线程确实被阻塞,直到新线程执行结束后才继续执行打印语句,这验证了pthread_join的阻塞等待特性。

pthread_join函数用于获取线程的退出状态,即线程的返回值。除了使用return返回,线程是否也可以调用exit系统调用来退出呢?我们在原有代码基础上修改,将新线程函数中的return改为调用exit,观察运行结果:

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

void* threadfun(void* args)
{
    int cnt = 5;
    while(cnt--)
    {
        std::cout << "child thread is running " << std::endl;
        sleep(1);
    }
    exit(1);
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, threadfun, NULL);
    int ret;
    pthread_join(tid, (void**)&ret);
    std::cout << "child thread exit code: " << ret << std::endl;
    return 0;
}

根据运行结果,主线程的打印语句并未执行,说明新线程执行exit后整个进程都退出了。这是因为exit是进程级的系统调用:当任一线程调用exit时,操作系统会通过进程标识符(tgid)定位到该进程的所有线程,并终止整个进程。若只想终止单个线程而不影响其他线程,应使用pthread_exit接口。

  • 系统调用:pthread_exit
  • 头文件:<pthread.h>
  • 函数声明:void pthread_exit(void *retval);
  • 返回值:无

pthread_exit的参数即为线程的退出码。pthread_join获取的返回值可以来自线程函数的return语句,也可以来自pthread_exit调用时传入的参数。

读者可能会好奇pthread_exit如何实现仅终止当前线程而不影响其他线程。下面将探讨其底层实现原理:

每个线程都有一个用户态的线程控制块(TCB)。当线程调用pthread_exit时,首先通过%fs寄存器定位到当前线程的TCB,然后修改TCB的字段:将pthread_exit的参数写入exit_code字段,并修改状态字段。接着,遍历因等待该线程而阻塞的线程等待队列,唤醒所有等待的线程。随后进行线程资源清理,包括调用清理函数处理线程关联的资源(如打开的文件、堆上分配的内存等)。清理完成后,释放线程栈和TLS(线程本地存储)区域,但保留TCB。当等待的线程被唤醒后,会访问该TCB,读取exit_code字段并写入到pthread_join的输出型参数中。最后,释放TCB,并通过系统调用释放内核中对应的task_struct结构体。

cpp 复制代码
void pthread_exit(void *retval)
{
    // 1. 获取当前线程的TCB(线程控制块)
    pthread_t self = pthread_self();
    struct pthread *pd = (struct pthread *)self;
    
    // 2. 检查线程是否已经退出
    if (pd->exited) {
        // 线程已退出,直接返回或终止
        return;
    }
    
    // 3. 设置退出码和退出状态
    pd->exit_value = retval;     // 保存退出值
    pd->exited = true;           // 标记为已退出
    pd->joinid = NULL;           // 清除join标识
    
    // 4. 执行线程清理处理程序(通过pthread_cleanup_push注册的)
    while (pd->cleanup_stack != NULL) {
        struct cleanup_handler *handler = pd->cleanup_stack;
        pd->cleanup_stack = handler->next;
        handler->routine(handler->arg);  // 执行清理函数
        free(handler);
    }
    
    // 5. 销毁线程特定的数据(TLS析构函数)
    for (int i = 0; i < PTHREAD_KEYS_MAX; i++) {
        if (pd->specific[i] != NULL && __pthread_keys[i].destructor != NULL) {
            void *value = pd->specific[i];
            pd->specific[i] = NULL;
            __pthread_keys[i].destructor(value);
        }
    }
    
    // 6. 唤醒所有等待此线程的线程
    if (pd->joiners != NULL) {
        // 遍历等待队列,唤醒所有等待的线程
        struct joiner *joiner = pd->joiners;
        while (joiner != NULL) {
            pthread_t waiter = joiner->thread;
            // 将退出值传递给等待线程
            if (waiter->join_result != NULL) {
                *(void **)waiter->join_result = retval;
            }
            // 唤醒等待线程
            __futex_wake(&waiter->join_state, 1);
            joiner = joiner->next;
        }
    }
    
    // 7. 如果是分离的线程,立即释放资源
    if (pd->detached) {
        __free_tcb(pd);  // 释放TCB
    } else {
        // 非分离线程:标记为僵尸状态,等待pthread_join来回收
        pd->zombie = true;
    }
    
    // 8. 线程栈清理(延迟或立即,取决于实现)
    if (pd->stack_need_free) {
        __deallocate_stack(pd);
    }
    
    // 9. 调用底层系统调用实际终止线程
    __exit_thread();  // 通常是clone系统调用的特殊标志或exit系统调用的线程版本
    
   
}

读者可能还存在一个疑问:对于主线程而言,当其中执行了 return 语句时,不仅主线程会终止,整个进程的其他线程也会随之终止。这是因为 return 语句在底层实际会调用 exit 系统调用,导致整个进程退出。而在讨论
pthread_exit 之前,我们知道线程通常是通过 return 语句终止的。但这里出现了一个矛盾:新创建的线程执行 return 语句后,仅自身终止,并未影响其他线程。这又是如何实现的呢?

实际上,pthread_create 函数库对用户提供的线程函数进行了封装。我们向 pthread_create传递的函数指针指向用户定义的函数,也就是用户态的线程上下文。然而,线程实际真正执行的入口并不是用户函数本身,而是线程库内部封装的一个入口函数。该封装函数首先会执行线程的初始化工作,包括设置运行环境,然后才调用用户定义的函数。用户函数执行完毕后,封装函数会继续执行线程的收尾工作,其功能与 pthread_exit等效,包括清理线程状态并传递返回值。因此,即使线程执行 return 语句,其终止过程也被限制在线程内部,不会触发进程级别的退出,从而允许其他线程继续运行,并且其他线程仍可通过 pthread_join获取其返回值。

cpp 复制代码
// pthread_create 的内部实现简化
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start)(void *), void *arg) {
    // 实际传递给内核的并不是用户函数,而是封装函数
    return clone(thread_wrapper, stack, flags, start, arg);
}

// 封装函数确保线程安全退出
static void thread_wrapper(void *data) {
    struct thread_data *td = data;
    

// 线程特定的初始化
__pthread_init(td);

// 执行用户函数
void *result = td->start_routine(td->arg);

// 线程特定的清理(不会影响其他线程)
__pthread_exit(result);

}

线程的分离

根据上文的讨论,我们了解到,如果新创建的线程没有被其他线程等待(即未调用 pthread_join),那么在线程结束时(无论是通过 return 语句还是调用 pthread_exit),系统会先执行清理函数来释放该线程所管理的资源(例如线程栈和线程局部存储区域),但线程控制块(Thread Control Block, TCB)会仍然保留。如果线程始终未被等待以获取其返回值,则对应的线程控制块无法被释放,从而造成内存泄漏。这一问题类似于进程中的"僵尸进程"现象。

当然,线程的典型应用场景是作为主线程的辅助单元,协助完成特定子任务,以提升整体执行效率。在这种情况下,主线程通常需要获取新线程的执行结果,因此主线程会主动等待新线程结束。但在某些设计场景下,主线程与新线程之间并无依赖关系,主线程可能并不关心新线程的执行结果。此时,主线程在创建新线程后,双方可独立执行,直至各自退出。

这种模式本身是合理的,但关键问题在于如何确保新线程在退出时能够自动释放所有关联资源,包括线程栈、线程局部存储以及线程控制块,从而避免资源泄漏。为此,我们需要使用 pthread_detach函数:

  • pthread_detach
  • 头文件:<pthread.h>
  • 函数声明:int pthread_detach(pthread_t thread);
  • 返回值:成功时返回 0,失败时返回错误码(注意,不会设置 errno)。

pthread_detach 函数接受一个参数,即用户态的线程标识符 pthread_t,其本质是线程控制块的内存地址。通过该参数,pthread_detach能够快速定位到目标线程的控制块,并修改其中的状态字段。与线程等待机制类似,线程控制块中设有一个标记字段,用于表示线程是否处于分离状态。调用 pthread_detach会设置该字段,表明线程已被标记为分离状态。

cpp 复制代码
struct pthread{
    int detached;           // 分离标记:0=可连接,1=已分离
    int state;              // 线程状态
    void* return_value;     // 返回值
    // ... 其他字段
};

当线程调用 pthread_exit 或执行 return 语句时,系统会检查该分离标记字段。若字段有效(即线程处于分离状态),线程将直接执行清理操作:释放线程关联的资源、回收线程栈和线程局部存储区域,并销毁线程控制块本身,最后通过系统调用接口释放内核中对应的 task_struct 结构体。

需要注意的是,如果尝试使用 pthread_join 等待一个已处于分离状态的线程,pthread_join 会在内部检查线程控制块中的分离标记。若标记有效,函数会立即返回错误码,表示无法等待已分离的线程。

那么我们如果想要新创建的线程分离,那么我们可以在主线程中,通过 pthread_create 的第一个输出型参数传递给 pthread_detach 来让该线程分离,也可以在线程的上下文中,也就是线程对应的用户态函数,先调用 pthread_self 函数,那么这个函数的返回值就是当前线程对应的用户态标识符,然后传递给 pthread_detach ,来实现线程的自我分离

线程的互斥

通过前文的介绍,读者应当已经理解了线程的基本概念,并掌握了如何创建和管理线程,即线程库相关函数的使用方法。接下来,我们将重点讨论与线程安全相关的问题。

在前文中我们多次强调,线程之间具有高度共享性。它们几乎不存在任何隔离,共享同一进程的地址空间和页表。即便是我们通常认为存储在线程栈中、属于线程私有数据的局部变量,也可以通过一个全局指针指向线程用户态函数中的局部变量来进行访问,从而打破这种私有性。

当多个线程同时访问同一数据或共享资源时,必然会引发数据不一致的问题。下面我们通过一个具体场景来帮助理解什么是数据不一致性,以及它可能导致的后果。

相信大家都有过抢票的经历,无论是火车票还是电影票。我们可以简单模拟一个抢票系统:用户抢票的行为实际上是通过调用一个函数来实现的,每个用户对应一个独立的执行流。由于用户之间的抢票操作相互独立、互不依赖,因此每个用户可被视作一个线程,其执行上下文即为抢票函数。我们定义一个全局变量 tickets表示剩余票数。抢票函数的逻辑非常简单:只要 tickets 大于 0,就不断执行抢票操作(即对 tickets 进行减一操作),直到票数归零后线程退出。

在本示例中,我们将票数初始化为 100,并创建 1000 个线程模拟抢票场景。为了管理这些线程,我们使用 STL 中的vector 容器。主线程在创建新线程后需要调用pthread_join 等待其结束,因此需记录每个新线程的用户态标识符。此外,每个线程会打印个性化信息(如线程编号),为此我们定义一个类来封装这些信息,并将该类对象作为参数传递给抢票函数,并且还会打印当前剩余的票数。以下是具体代码实现:

cpp 复制代码
#include<iostream>
#include<unistd.h>
#include<vector>
#include<pthread.h>
#include<string>
#define num 1000

class Thread {
public:
        Thread(int  number)
        {
                threadname = "childthread " + std::to_string(number);
        }
public:
        std::string threadname;
};

int ticket = 100;
void* getticket(void* args)
{
        Thread* my_thread = static_cast<Thread*>(args);
        while (true)
        {
                if (ticket > 0)
                 {
                        usleep(1000);
                        ticket--;
                        std::cout << my_thread->threadname.c_str() << " get a ticket "<<ticket << std::endl;
                        usleep(1000);
                }
                else
                {
                        break;
                }
        }
       return NULL;
}
int main()
{
        std::vector<pthread_t> thread;
        std::vector<Thread> thread_obj;
        thread_obj.reserve(num);
        for (int i = 0; i < num; i++)
        {
                pthread_t tid;
                Thread obj(i);
                thread_obj.push_back(obj);
                pthread_create(&tid, NULL, getticket, (void*)(&thread_obj[i]));
                thread.push_back(tid);
        }
        for (int i = 0; i < num; i++)
        {
                pthread_join(thread[i], NULL);
        }

  return 0;

}    

运行以上代码后,我们观察到剩余票数竟出现了负数,这显然不符合预期。这种现象的根源在于数据不一致性。下面我们来分析其具体原因。

问题的核心在于全局变量 tickets被多个线程同时访问和修改。观察线程执行上下文(即 getticket 函数),其中访问该全局变量的代码只有两行:一是条件判断语句(检查 tickets 是否大于 0),二是对
tickets的减减操作。这两行代码是函数中唯一涉及共享资源访问的部分。

虽然条件判断和减减操作在代码中各占一行,但它们对应的底层指令并不唯一。例如,条件判断语句在编译后可能包含从内存加载 tickets值到寄存器的指令,以及比较指令;而减减操作可能涉及加载、递减和写回内存三条指令。

cpp 复制代码
; if (ticket > 0)
mov eax, [ticket]     ; 1. 从内存加载ticket值到寄存器
cmp eax, 0            ; 2. 比较是否大于0
jle end               ; 3. 如果不大于0就跳转

; tickets--
mov eax, DWORD PTR [tickets]  ; 1. 从内存加载tickets值到eax寄存器
sub eax, 1                    ; 2. 寄存器值减1
mov DWORD PTR [tickets], eax  ; 3. 将结果存回内存

CPU 以指令为单位执行程序,每条指令的处理分为三个阶段:取指(从内存获取指令)、译码(解析指令操作码和操作数)和执行(完成指令操作)。这三个阶段是不可中断的,CPU 只有在完成当前指令的所有阶段后,才会响应硬件中断(如时钟中断)。这确保了单条指令执行的原子性

原子性这一概念源于化学中"原子不可再分"的特性。在计算机科学中,原子操作指的是不可被中断的一个或一系列操作,其状态只能是"已执行"或"未执行",不存在"执行中"的中间状态。因此,CPU 执行单条指令是原子的,一旦开始执行,就必须连续完成。

回到共享资源访问的问题,条件判断和减减操作对应的指令均不止一条,因此这两个操作都不具备原子性。非原子操作意味着执行过程中可能存在"正在执行"的中间状态。由于每个线程有自己的时间片,当时间片耗尽时,线程可能在任何指令阶段被切换。

考虑以下场景:假设当前 tickets 值为 1。线程 A 执行条件判断指令,将内存中的值 1 加载到寄存器后,时间片耗尽被切换。此时线程 A 的上下文(如程序计数器和寄存器值)被保存,此时对于该线程来说,那么它判断 tickets是否大于0这个动作的状态就被定格为执行中。线程 B 被调度后,其可能已完成减减操作,tickets 值写回内存(变为 0)。当线程 A 再次被调度时,它继续执行条件判断(基于寄存器中的旧值 1),误认为票数仍大于 0,进而执行减减操作,导致 tickets被减为 -1。这正是数据不一致性问题的典型表现。

cpp 复制代码
线程A: 读取ticket=1 → 时间片用完被挂起
线程B: 读取ticket=1 → 减1 → 写回ticket=0 → 完成
线程A: 恢复执行 → 继续执行减1操作 → 写回ticket=-1

💡 由此可见,数据不一致性问题是由于多个线程并发访问同一共享资源,且访问操作不具备原子性所导致的。那么,如何解决这一问题呢?根据上述分析,关键在于确保对共享资源的访问操作是原子的。

有读者可能会想:是否可以将 " tickets>0" 和 " tickets--" 这两个操作合并为一个原子操作?遗憾的是,这在实际中难以实现。CPU 的指令集是硬件层面预先定义的,高级语言代码最终都会被编译为这些指令。除非我们能够自定义 CPU 指令集,在其中增加一个同时完成判断和减减的原子指令,否则无法直接通过一条指令实现这两个操作。显然,这种方法在通用计算环境中是不现实的。

而这里真正的解决方式,想必读者在学习之前也有所耳闻,那就是通过锁(lock)机制来解决。为了帮助读者理解锁的基本概念,我将首先举一个例子:假设图书馆有一个自习室,该自习室规定同一时刻只能供一名学生使用。由于有多名学生都需要使用,可以采取"先到先得"的规则。第一位到达自习室的学生可以独占该空间。为了确保其他学生在此期间无法进入并访问自习室内的资源,该学生可以向图书管理员申请一把锁和对应的钥匙。在使用期间,学生将门锁上,这样其他人便无法进入,也就无法使用自习室内的资源。只有持有锁(即在自习室内)的学生才能使用资源。当该学生使用完毕后,出门把锁解开,然后将锁挂在门口,让需要使用该自习室的学生再遵循"先到先得"的原则去取锁获得自习室的使用权。

上述例子实际上体现了锁的核心思想。在多线程编程中,我们需要分析线程上下文中访问共享资源的代码片段,这一片段有一个专业术语,称为临界区(Critical Section)。临界区就类似于上文中的自习室,我们需要对其加锁以保证互斥访问。

具体到实现,锁通常对应一个类型为 pthread_mutex_t的变量。
pthread_mutex_t本质上是一个结构体类型,其内部字段记录了锁的属性和状态。以下是一个简化的结构定义(实际实现可能因系统而异):

cpp 复制代码
/* 主要的互斥锁结构定义 */
typedef union
{
  struct __pthread_mutex_s
  {
    int __lock;                     /* 锁状态:0=未锁,1=已锁 */
    unsigned int __count;           /* 递归锁的计数 */
    int __owner;                    /* 当前持有锁的线程ID */
    unsigned int __nusers;          /* 使用该锁的线程数 */
    int __kind;                     /* 锁的类型 */
    short __spins;                  /* 自旋计数 */
    short __elision;                /* 优化相关 */
    __pthread_list_t __list;       /* 等待队列 */
  } __data;

  /* 确保大小正确的填充字段 */
  char __size[__SIZEOF_PTHREAD_MUTEX_T];
  long int __align;
} pthread_mutex_t;

在实际使用中,我们无需掌握所有字段的细节,目前只需重点关注两个关键字段:锁状态(如 __lock)和等待队列(如 __list)。前者表示锁是否被持有,后者管理因获取锁而阻塞的线程。

定义锁变量后,我们需要对其进行初始化,这类似于向图书管理员指定锁的规格。初始化方式分为静态初始化和动态初始化两种:

  • 静态初始化:适用于全局变量或静态变量(即在全局作用域定义的或被 static修饰pthread_mutex_t变量)。通过宏 PTHREAD_MUTEX_INITIALIZER 实现,该宏会将锁的各个字段初始化为默认值(通常为0),相当于获取一个标准规格的锁。
  • 动态初始化:通过函数pthread_mutex_init实现,既可初始化局部变量,也可用于全局或静态变量。与静态初始化不同,动态初始化允许自定义锁的属性(如设置递归锁等),通过第二个参数 attr 指定。那么第二个参数 attr 可以设置多种属性,但 attr 参数最常见,也是最重要的应用就是设置锁的类型,比如 递归锁或者 错误检查锁等, 若无需特殊属性,可将 attr 设为 NULL 。函数原型如下:
  • pthread_mutex_init
  • 头文件:<pthread.h>
  • 函数声明:int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
  • 返回值:成功时返回 0,失败时返回错误码(不设置 errno)。

初始化完成后,我们需要在临界区加锁。加锁通过函数 pthread_mutex_lock实现:

  • pthread_mutex_lock
  • 头文件:<pthread.h>
  • 函数声明:int pthread_mutex_lock(pthread_mutex_t *mutex);
  • 返回值:成功时返回 0,失败时返回错误码(不设置 errno)。

该函数接收一个指向 pthread_mutex_init 的指针,第一个执行到该函数的线程将获得锁,并进入临界区执行。其他试图加锁的线程会被阻塞,从而将并行执行转为串行,确保互斥访问。需需要注意的是,应仅对临界区代码(即所有访问共享资源的代码段)加锁,非共享资源访问部分不应加锁,以尽量减少锁的持有时间,保证程序的并发性能。因此,临界区应尽可能小,但又必须完整覆盖所有对共享资源的操作。

临界区执行完毕后,必须及时解锁,以唤醒阻塞的线程。解锁通过函数 pthread_mutex_unlock 实现:

  • pthread_mutex_unlock
  • 头文件:<pthread.h>
  • 函数声明:int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 返回值:成功时返回 0,失败时返回错误码(不设置 errno )。

基于上述原理,我们可以修改之前的"抢票"示例代码,将对共享变量 ticket 的访问置于锁保护下。临界区包括判断 ticket>0 和执行 ticket-- 的操作,具体修改如下:

cpp 复制代码
#include<iostream>
#include<unistd.h>
#include<vector>
#include<pthread.h>
#include<string>
#define num 100
int ticket = 1000;
pthread_mutex_t mutex;
class Thread
{
public:
        Thread(int i, pthread_mutex_t* mtx = &mutex)
        {
                threadname = "child thread " + std::to_string(i);
                _mutex = mtx;
        }
public:
        std::string threadname;
        pthread_mutex_t* _mutex;
};

void* getticket(void* args)
{
        Thread* ptr = (Thread*)args;
        while (true)
        {
                pthread_mutex_lock(ptr->_mutex);  // 进入临界区前加锁
                if (ticket > 0)
                {
                        std::cout << ptr->threadname.c_str() << " get ticket: " << ticket << std::endl;
                        ticket--;
                        pthread_mutex_unlock(ptr->_mutex);  // 操作完成后立即解锁
                        usleep(1000);
                }
                else
                {
                        pthread_mutex_unlock(ptr->_mutex);  // 退出前必须解锁,避免死锁
                        break;
                }
        }
        return NULL;
}

int main()
{
        pthread_mutex_init(&mutex, NULL);  // 动态初始化锁
        std::vector<pthread_t> thread;
        std::vector<Thread> thread_obj;
        thread_obj.reserve(num);
        for (int i = 0; i < num; i++)
        {
                pthread_t tid;
                Thread obj(i);
                thread_obj.push_back(obj);
                pthread_create(&tid, NULL, getticket, (void*)(&thread_obj[i]));
                thread.push_back(tid);
        }
        for (int i = 0; i < num; i++)
        {
                pthread_join(thread[i], NULL);
        }
        pthread_mutex_destroy(&mutex);  // 销毁锁,释放相关资源
        std::cout << "main thread quit" << std::endl;
        return 0;
}

🎯 关键注意事项:

  1. else 分支中调用 break 前必须解锁,否则锁无法释放,会导致死锁(其他线程永久阻塞)。
  2. 使用动态初始化(pthread_mutex_init)时,需在程序结束前调用 pthread_mutex_destroy 销毁锁,以释放可能关联的系统资源(如等待队列)。静态初始化的锁无需此操作,因其未关联动态资源。
  3. 加锁和解锁应成对出现,且确保所有执行路径(包括条件分支)都能正确释放锁。

通过以上机制,我们可以安全地管理多线程对共享资源的访问,平衡并发效率与数据一致性。

根据上文,读者会发现pthread_mutex_t类型中维护了一个字段,用于记录因加锁失败而陷入阻塞的线程。可能会有读者好奇为什么要维护这样一个字段。

这里需要说明的是,如果不维护这个字段,那么一旦锁被释放,所有之前被阻塞的线程都将被唤醒,并尝试获取对临界区的访问权。然而,由于临界区的访问权遵循"先到先得"的规则,最终只有一个线程能够成功进入临界区。这个过程会唤醒多个线程,但仅有一个线程能实际获得锁,其余线程在唤醒后由于未能获取锁,只能再次进入阻塞状态。

将线程唤醒、重新放入就绪队列,再移回阻塞队列,这些操作都会带来上下文切换的开销。如果每次释放锁都唤醒所有等待线程,而实际上只有一个线程能够继续执行,就会造成大量无效的唤醒操作,带来不必要的性能损耗。操作系统的设计原则是避免任何低效或无用的工作,因此通常不会采用这种"全部唤醒"的策略。

实际上,pthread_mutex_t中会维护一个等待队列。当线程调用pthread_mutex_lock时,会检查锁的状态字段。如果该字段值为1,表示锁已被占用,当前线程便会进入阻塞状态,并被加入到该锁对应的等待队列中。当锁被释放时,系统只会将等待队列中队首的线程唤醒并授予锁的使用权,从而避免不必要的线程唤醒,减少上下文切换次数,同时也能防止线程饥饿(starvation)或者也可以叫做惊群效应(thundering herd problem)的情况发生。

通过上文的介绍,读者应已了解锁的基本使用方法及其相关接口。接下来,读者可能更关心的是锁如何实现互斥------即如何确保临界区的访问权被独占。这便涉及到pthread_mutex_lock的底层实现机制。

首先需要明确的是,锁本身也是一种共享资源。前文提到的抢票函数之所以出现数据竞争,是因为多个线程同时访问并修改了同一全局变量,且该修改操作不是原子的。

而锁作为一个全局变量,同样会被多个线程访问和修改。但它能够保证不出现数据不一致的问题,这说明对锁的修改操作本身是原子的。接下来我们将深入分析 pthread_mutex_lock 的底层实现。

pthread_mutex_lock 的底层实现包含多条指令。首先,该函数会将 eax 寄存器的值初始化为 1。随后执行一条关键指令------xchg(exchange)指令,该指令将内存中锁的状态字段与 eax 寄存器中的值进行交换。初始时锁的值为 0,交换后 eax 变为 0,而锁的值变为 1。接着,线程会判断 eax 的值:若为 0,则继续执行;若不为 0,则线程会被加入等待队列并进入阻塞状态。

cpp 复制代码
; pthread_mutex_lock 的简化汇编实现
mov eax, 1          ; 将1存入eax寄存器
xchg [mutex], eax   ; 原子交换:将mutex的值与eax交换
cmp eax, 0          ; 检查交换前mutex的值
jz acquire_success  ; 如果原来是0,获取锁成功
; 否则进入等待队列

我们进一步分析整个过程。由于每条指令的执行是原子的,且所有线程访问的是同一把锁,因此首先执行 xchg指令的线程会将内存中锁的状态字段置为 1。后续线程在执行时,会先将 eax 设为 1,再执行 xchg指令。但此时内存中锁的状态字段的值已为 1,交换后 eax 的值为 1,线程便会判断为加锁失败,进入阻塞状态。正是 xchg 指令的原子性,保障了锁状态的修改不会出现竞争,从而确保临界区的互斥访问。

在单核 CPU 中,同一时刻只能执行一个线程的上下文,因此 xchg指令的执行必然有先后顺序。最先执行该指令的线程成功加锁,后续线程因锁已被置 1 而加锁失败并阻塞。在多核 CPU 中,不同核可同时执行不同线程的上下文。此时,CPU 通过地址总线连接内存,并由仲裁器(arbiter)决定哪个线程可优先访问内存,其余线程则被阻塞。这保证了即使在多核环境下,xchg指令仍能被一个线程优先执行。

cpp 复制代码
// 单核环境的时间线
时间点1: 线程A执行 xchg 指令 → 获取锁成功
时间点2: 线程A在临界区执行
时间点3: 发生时钟中断,调度器切换到线程B
时间点4: 线程B执行 xchg 指令 → 发现锁已被占用 → 阻塞
时间点5: 切换回线程A,完成临界区并释放锁


多核环境同时竞争
核心1(线程A): 执行 xchg 指令
核心2(线程B): 同时执行 xchg 指令

// 硬件保证:内存总线仲裁
// 只有一个核心的 xchg 操作能先访问内存
// 其他核心的 xchg 会被阻塞,直到前一个完成

因此,无论是单核还是多核架构,xchg指令均能确保对锁的修改操作具备原子性,从而实现互斥进入临界区。

思维导图

结语

那么这就是本文的全部内容,那么线程我打算分两期来介绍完,那么我的下一期博客便是 线程(下) ,我会持续更新,希望你能够多多关注,如果本文有帮组到你,还请三连加关注,你的支持,就是我创作的最大动力!

相关推荐
仟千意2 小时前
C++:类和对象---初级篇
c++
极客柒2 小时前
Unity 协程GC优化记录
java·unity·游戏引擎
我要去腾讯2 小时前
Springcloud核心组件之Sentinel详解
java·spring cloud·sentinel
czhc11400756632 小时前
Java117 最长公共前缀
java·数据结构·算法
java 乐山2 小时前
蓝牙网关(备份)
linux·网络·算法
2301_803554522 小时前
面试后查缺补漏--cmake,makefiles,g++,gcc(自写精华版)
linux·运维·服务器
Brianna Home2 小时前
现代C++:从性能泥潭到AI基石
开发语言·c++·算法
煤球王子2 小时前
浅学任务调度
linux
吃着火锅x唱着歌2 小时前
LeetCode 2016.增量元素之间的最大差值
数据结构·算法·leetcode