请添加图片描述
🔥铅笔小新z:个人主页
🎬博客专栏:Linux学习
💫滴水不绝,可穿石;步履不休,能至渊。
请添加图片描述


一、Linux 线程概念

在这里插入图片描述

1. 什么是线程

线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。

  • 通俗定义:在一个程序里的一个执行路线就叫作线程。更准确地说,线程是"一个进程内部的执行序列"
  • 基本事实:一切进程至少都有一个执行线程(主线程)
  • 本质:线程在进程内部运行,即线程在进程的地址空间内运行
  • CPU 视角:在 CPU 眼中,线程的 PCB 比传统进程更加轻量化

想要真正理解线程,就必须搞明白:内核是如何对进程资源进行划分的,尤其是代码和数据的划分方式。

本质上,只要把进程的虚拟地址空间进行划分,进程资源就天然被划分好了。

下面先理解分页式存储管理,这是理解线程资源划分的基础。


2. 分页式存储管理

2.1 虚拟地址和页表的由来

在这里插入图片描述

如果没有虚拟内存和分页机制,用户程序在物理内存中对应的空间必须是连续的。这会导致一个问题:随着程序的加载和退出,物理内存会被分割成许多离散的、大小不同的块——就像一块完整的蛋糕被切来切去,留下大量无法使用的蛋糕碎屑,这些就是内存碎片

为了解决这个问题,操作系统引入了虚拟地址空间分页机制

  • 虚拟地址空间:操作系统为每个正在执行的进程分配一个逻辑地址。在 32 位系统上,范围是 0 ~ 4G
  • 页框(物理页):把物理内存按固定长度分割,一般 32 位是 4KB,64 位是 8KB
  • :进程虚拟内存中的数据块
  • 页表:记录虚拟页和物理页框之间映射关系的表格

核心思想:把虚拟内存的逻辑地址空间分成若干,把物理内存空间分成若干页框,通过页表把连续的虚拟内存映射到不连续的物理内存页上。这样既解决了碎片问题,又给用户程序呈现了连续的内存空间。

在这里插入图片描述

注意: 对用户程序来说,它以为自己的数据是连续存放的(页 0、页 1、页 2)。但通过页表这个“翻译官”,这些数据其实被零散地丢在了物理内存的各个角落。

【用户进程(虚拟内存)】                【页表(翻译官)】            【真实物理内存】
(程序以为自己拥有连续空间)              (负责记录映射关系)            (现实中零散的物理页框)
+-------------------+               +----+------+               +-------------------+
|  页 0 (Page 0)    | ------------> | 页 | 页框 | ------------> | 页框 3 (空闲)     |
+-------------------+               +----+------+               +-------------------+
|  页 1 (Page 1)    | ----+         |  0 |  1   |               | 页框 1 (存了页 0) |
+-------------------+     |         |  1 |  4   |               +-------------------+
|  页 2 (Page 2)    | --+ |         |  2 |  2   |               | 页框 2 (存了页 2) |
+-------------------+   | |         +----+------+               +-------------------+
                        | |                                     | 页框 0 (空闲)     |
                        | +-----------------------------------> +-------------------+
                        +-------------------------------------> | 页框 4 (存了页 1) |
                                                                +-------------------+

📌 知识点总结:为什么要有虚拟地址和页表?直接操作物理内存不行吗?

不行!如果没有虚拟地址和分页机制,所有用户程序必须连续地存放在物理内存中。随着程序频繁地加载和退出,物理内存会留下大量碎片,导致明明总内存还有剩余,却无法加载新的程序(因为没有足够大的连续空间)。虚拟地址空间给每个进程一个 0~4G 的"假地址",页表负责把连续的假地址映射到离散的真地址上。这样一来,用户程序以为自己在一片连续的大空间里运行,操作系统却在背后把数据散乱地存到物理内存的各处,充分利用了每一块可用空间,完美解决了碎片问题。简单说就是:用户要连续,物理不给连续,页表来做"翻译官",大家相安无事。


2.2 物理内存管理

假设物理内存有 4GB,按 4KB 一个页框划分,共 1048576 个页框。

内核用 struct page 结构体表示系统中的每个物理页:

/* include/linux/mm_types.h */
struct page {
    unsigned long flags;        // 原子标志,记录页的状态(是否脏、是否锁定等)
    union {
        struct {
            struct list_head lru;               // 换出页列表
            struct address_space* mapping;      // 映射的地址空间
            pgoff_t index;                      // 在映射内的偏移量
            unsigned long private;              // 私有数据
        };
        struct {    /* slab, slob, slub 分配器 */
            union {
                struct list_head slab_list;
                struct {
                    struct page* next;
                    // ... 不同位数架构下的字段
                };
            };
            struct kmem_cache* slab_cache;
            void* freelist;                     // 第一个空闲对象
            union {
                void* s_mem;
                unsigned long counters;
                struct {
                    unsigned inuse : 16;        // 对象数目
                    unsigned objects : 15;
                    unsigned frozen : 1;
                };
            };
        };
    };
    union {
        atomic_t _mapcount;     // 页表中有多少项指向该页(引用计数)
        unsigned int page_type;
        unsigned int active;
        int units;
    };
    void* virtual;              // 内核虚拟地址(高端内存时为 NULL)
};

重要参数说明:

  • flags:存放页的状态(是否脏、是否锁定等),每一位表示一种状态
  • _mapcount:页表中有多少项指向该页(引用计数),为 -1 时表示当前内核未引用该页
  • virtual:页的虚拟地址。高端内存(不永久映射到内核地址空间的内存)下为 NULL

一个 struct page 约 40 字节。假设 4GB 物理内存、4KB 页框,共 1048576 个物理页,全部 page 结构体消耗约 40MB,相对 4GB 内存只是一小部分,这个代价是值得的。

📌 知识点总结:操作系统怎么管理物理内存的那么多页面?

操作系统为每个物理页框都创建了一个 struct page 结构体,这些结构体就像每页的"身份证",记录了该页的状态(是否被使用、是否脏数据、被几个页表指向等)。用一个简单的类比:就像图书馆管理员给每本书都建了一张索引卡,记录这本书在哪、被谁借了、状态如何。4GB 内存约 100 万个页框,管好这些"索引卡"大约只需要 40MB 的开销,完全在可接受范围内。


2.3 页表

在这里插入图片描述

在 32 位系统中,虚拟地址空间最大 4GB,页大小 4KB,需要 4GB / 4KB = 1048576 个页表项。每个页表项占 4 字节,页表总大小为 1048576 * 4 = 4MB

也就是说,页表自己就占用了 1024 个连续物理页。这存在两个问题:

  1. 连续性问题:页表本身需要连续存放 1024 个页框,这和使用页表的初衷(避免连续存储)相矛盾
  2. 局部性原理:进程在一段时间内通常只需要访问少数几个页,没必要让所有页表都常驻内存

解决方案:把页表再分页,形成多级页表。把 1048576 个表项拆成 1024 个页表,每个页表有 1024 个表项。这样,一个只需要 10MB 的程序只需 3 个页表就能覆盖(每个页表覆盖 4MB)。

📌 知识点总结:单级页表有什么问题?为什么要有多级页表?

单级页表需要连续存放 1024 个物理页来存储所有页表项,这违背了分页机制的初衷(避免连续存储)。而且根据局部性原理,程序往往只会用到一小部分空间。多级页表的思路是"用多少就建多少"——把一个大页表拆成 1024 个小页表,程序用到的空间只需对应少量小页表。10MB 的程序只需要 3 个小页表,既避免了连续存储的尴尬,又节省了内存空间。这就是空间换时间的反面——用多一次查表的"时间"换取节省内存的"空间"。


2.4 页目录结构

在这里插入图片描述

1024 个页表也需要被管理,管理它们的表叫页目录表,形成二级页表结构:

  • 所有页表的物理地址被页目录表中的表项指向
  • 页目录的物理地址被 CR3 寄存器指向(该寄存器保存了当前正在执行任务的页目录地址)

操作系统加载用户程序时,不仅需要为程序内容分配物理内存,还需要为页目录和页表分配物理内存。

📌 知识点总结:页目录是什么?

页目录就是"管页表的表"。有了 1024 个页表之后,需要有一个总表来记录这 1024 个页表各自在物理内存的什么位置,这个总表就叫页目录。CPU 通过 CR3 寄存器找到页目录,页目录找到页表,页表再找到物理页,形成二级查找机制。


2.5 两级页表的地址转换

以 32 位处理器、4KB 页大小为例,虚拟地址被分为三部分:

  • 高 10 位:一级页号(页目录索引)
  • 中间 10 位:二级页号(页表索引)
  • 低 12 位:页内偏移量(4KB = 4096 字节)

在这里插入图片描述

地址转换流程:

  1. CR3 寄存器读取页目录起始地址
  2. 根据一级页号查页目录表,找到下一级页表在物理内存中的位置
  3. 根据二级页号查页表,找到最终要访问的物理页框号
  4. 结合页内偏移量得到完整的物理地址

以上是 MMU(内存管理单元) 的工作流程。MMU 是一种硬件电路,速度很快,主要负责内存管理,地址转换只是它的业务之一。

效率问题:页表级数越多,查询步骤越多,CPU 等待时间越长。

TLB 快表引入:为了提升效率,MMU 引入了 TLB(转译后备缓冲器),即页表的硬件缓存。当 CPU 传新虚拟地址给 MMU 后,MMU 先在 TLB 中查找:

  • TLB 命中:直接拿到物理地址,速度极快
  • TLB 未命中:走页表查询,拿到物理地址后把映射关系更新到 TLB 中

📌 知识点总结:两级页表怎样把虚拟地址转换成物理地址?为什么有 TLB?

把 32 位的虚拟地址拆成三截:高 10 位找页目录、中间 10 位找页表、低 12 位在页内定位具体字节。整个过程就像去图书馆找书——先查区域索引(页目录)、再查书架索引(页表)、最后在书架上找第几本(页内偏移)。

但是多一级查表就多一次内存访问,为了加快速度,CPU 引入了 TLB,它就像一个"小本本"记住最近用过的地址映射关系。下次再访问同一个地址时,直接从小本本上抄答案,省掉两次查表的功夫。因为程序的访问通常具有局部性(反复访问同一片区域),TLB 的命中率非常高,可以大幅提升效率。

在这里插入图片描述


2.6 缺页异常

当 CPU 给 MMU 的虚拟地址在 TLB 和页表中都找不到对应的物理页时,就会触发缺页异常(Page Fault)。这是一个由硬件中断触发、可以由软件逻辑纠正的错误。

缺页异常分三种类型:

类型 说明
硬缺页(Major Page Fault) 物理内存中没有对应页面,需要从磁盘读取到内存再建立映射
软缺页(Minor Page Fault) 物理内存中有对应页面(被其他进程加载过),只需建立映射,无需磁盘 I/O
无效缺页(Invalid Page Fault) 访问非法地址(越界、空指针解引用),触发段错误,进程被终止

在这里插入图片描述

如何区分缺页和越界?

  1. 页号合法性检查:虚拟地址的页号是否合法?合法但不在内存 → 缺页;非法 → 越界
  2. 内存映射检查:地址是否在进程的内存映射范围内?是但不在内存 → 缺页;否 → 越界

理解 newmalloc:申请内存时,操作系统只是在虚拟地址空间里"记账"(分配虚拟地址),物理内存的分配往往是在真正访问时才通过缺页机制触发。这就是为什么 new 了很大的空间但没访问时,实际物理内存占用可能很小。

📌 知识点总结:什么是缺页异常?缺页和越界怎么区分?

缺页异常就像你去图书馆借书,查了索引发现这本书有记录但书架上找不到——书被放在仓库里了,需要管理员去仓库取出来。硬缺页类似于书在远程仓库,需要花时间搬运(磁盘 I/O);软缺页像是书已经被别人借出来放在桌上,你直接就能用,省了跑仓库的时间。

区分缺页还是越界,核心就是看这个地址"合不合法":地址合法但内容不在内存 → 缺页(操作系统会帮你去磁盘加载);地址非法(越界)→ 报段错误,进程直接被干掉。记住一句话:缺页是"有这个人但不在家",越界是"根本没这个人"。


3. 线程的优点

  1. 创建代价小:创建新线程比创建新进程快得多
  2. 切换开销小:线程切换比进程切换轻量
    • 线程切换不需要改变虚拟内存空间(同进程内的线程共享地址空间)
    • 进程切换会刷新 TLB 快表,导致一段时间内内存访问效率降低
    • 线程切换不会刷新 TLB,也不会扰乱硬件缓存
  3. 占用资源少:线程占用的系统资源比进程少
  4. 充分利用多处理器:多个线程可以在多核 CPU 上并行执行
  5. 掩蔽 I/O 等待:一个线程等待慢速 I/O 时,其他线程可以继续执行计算任务
  6. 计算密集型应用:将计算分解到多个线程,在多处理器系统上运行
  7. I/O 密集型应用:线程可以同时等待不同的 I/O 操作,提高整体吞吐量

📌 知识点总结:为什么要用多线程?它比多进程好在哪里?

多进程就像开多家独立的公司,每家都有自己的办公室(地址空间)、文件柜(资源),开新公司要全套置办,公司间合并/拆分(通信/切换)成本极高。多线程就像在一家公司里设多个部门,大家共享同一间办公室、文件柜,开新部门只需要加一张桌子(创建线程快),部门之间切换(线程切换)也不需要换办公室。最核心的区别是:线程切换不改变虚拟地址空间,TLB 和 CPU 缓存不会被刷新,而进程切换会。这就好比你在同一个办公室里从工位 A 走到工位 B(线程切换),比从一座楼跑到另一座楼(进程切换)快得多,而且不用重新认路。


4. 线程的缺点

  1. 性能损失
    • 计算密集型线程数量超过 CPU 核心数时,增加了额外的同步和调度开销,但可用资源不变
  2. 健壮性降低
    • 多线程程序需要考虑更多并发问题,时间分配上的细微偏差或错误共享变量都可能导致问题
    • 线程之间缺乏保护,一个线程出问题可能拖累整个进程
  3. 缺乏访问控制
    • 进程是访问控制的基本粒度,线程中调用某些 OS 函数会影响整个进程
  4. 编程难度提高
    • 编写和调试多线程程序比单线程程序困难得多

📌 知识点总结:多线程有哪些缺点?什么时候不适合用多线程?

多线程的主要问题是"人多手杂":线程过多时,CPU 在它们之间来回切换会浪费性能(上下文切换开销);共享资源不加保护会导致数据混乱(如两个线程同时修改一个变量);最要命的是,一个线程崩溃(除零、野指针)会导致整个进程挂掉,所有线程跟着殉葬。适合单线程的场景包括:任务本身是顺序执行的、对稳定性要求极高不能容忍任何线程崩溃的、编程能力有限写不好同步逻辑的。多线程是一把双刃剑,用好了效率翻倍,用不好是灾难。


5. 线程异常

  • 单个线程出现除零野指针等问题导致崩溃时,整个进程也会跟着崩溃
  • 原因:线程是进程的执行分支,线程出异常就像进程出异常一样,会触发信号机制终止进程;进程终止后,该进程内的所有线程也随之退出

📌 知识点总结:一个线程崩溃会影响其他线程吗?

会!一个线程挂了,整个进程都得陪葬。因为线程是进程内部的执行流,它们共享同一个地址空间。当一个线程访问非法内存(野指针、数组越界)时,CPU 触发的段错误信号是发给整个进程的,而不是只发给那个线程。进程被终止,所有线程都活不了。这就是多线程健壮性差的原因——不像多进程,一个进程挂了不影响其他进程。


6. 线程用途

  1. 提高 CPU 密集型程序的执行效率:多线程让多个 CPU 核心同时干活,充分利用硬件资源
  2. 提高 I/O 密集型程序的用户体验:一边等待网络/磁盘 I/O,一边响应用户操作(比如一边写代码一边下载软件)

📌 知识点总结:多线程一般用在什么地方?

两种场景最常用:一是计算密集型应用,要把大任务拆成小块让多个 CPU 核心一起算(比如视频渲染、科学计算);二是I/O 密集型应用,要在等待磁盘/网络的时候不让程序卡死(比如下载工具同时下载多个文件、Web 服务器同时处理多个请求)。


二、Linux 进程 VS 线程

1. 进程和线程的核心区别

对比维度 进程 线程
基本角色 资源分配的基本单位 调度的基本单位
地址空间 每个进程有独立地址空间 同一进程的线程共享地址空间
独立性 进程间具有独立性(互不干扰) 线程共享进程资源(紧密耦合)
创建开销
切换开销 大(刷新 TLB)

2. 线程独有的"私有"数据

每个线程虽然共享进程资源,但也拥有自己的一部分私有数据:

  • 线程 ID:唯一标识线程
  • 一组寄存器:线程的上下文数据
  • :线程自己的栈空间
  • errno:错误码变量
  • 信号屏蔽字:屏蔽哪些信号
  • 调度优先级:线程的调度优先级

3. 线程共享的进程资源

同一进程的多个线程共享以下资源:

  • 代码段(Text Segment):定义的函数可以在各线程中调用
  • 数据段(Data Segment):全局变量在各线程中都可以访问
  • 文件描述符表:打开的文件被所有线程共享
  • 信号处理方式:SIG_IGN、SIG_DFL 或自定义处理函数
  • 当前工作目录
  • 用户 ID 和组 ID

4. 如何理解之前的单进程?

在这里插入图片描述

之前学的单进程,本质上是具有一个线程执行流的进程——即只有主线程的进程。

📌 知识点总结:进程和线程到底是什么关系?各自管什么?

用公司来类比:进程是公司,负责拥有资源(办公室、资产、营业执照);线程是员工,负责干活(执行代码)。一个公司(进程)可以有多个员工(线程),员工共享公司的办公室和资产(地址空间和资源),但每个员工有自己的工位(栈)、自己的记事本(寄存器)、自己的员工编号(线程 ID)。公司倒闭(进程终止),所有员工都得走人;一个员工闯大祸(线程异常),公司也可能被查封。

进程是资源分配的基本单位——操作系统给公司批办公室、批预算;线程是调度的基本单位——操作系统决定哪个员工先用电脑、干多久。


三、Linux 线程控制

1. POSIX 线程库

POSIX 线程库(pthread)是一套用于线程操作的标准化 C 语言接口:

  • 函数名以 pthread_ 打头
  • 头文件:<pthread.h>
  • 编译链接选项:-lpthread

错误检查注意事项

与传统函数的区别:

  • 传统函数:成功返回 0,失败返回 -1,通过全局变量 errno 获取错误码
  • pthread 函数:成功返回 0,失败直接返回错误码(不设置 errno)
  • 建议通过返回值判断 pthread 函数是否成功,因为读取返回值比读取线程内 errno 开销更小

2. 创建线程

函数原型
#include <pthread.h>

// 功能:创建一个新的线程
// thread:输出参数,返回线程 ID(pthread 库中的 ID)
// attr:线程属性,传 NULL 表示使用默认属性
// start_routine:线程启动后要执行的函数(回调函数)
// arg:传给线程启动函数的参数
// 返回值:成功返回 0,失败返回错误码
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void *), void *arg);
示例代码
#include <unistd.h>     // sleep 函数
#include <stdlib.h>     // exit 函数
#include <stdio.h>      // printf 函数
#include <string.h>     // strerror 函数
#include <pthread.h>    // POSIX 线程库

// 子线程要执行的函数
void *rout(void *arg) 
{
    while (1) 
    {
        printf("我是线程 1\n");
        sleep(1);       // 每秒打印一次
    }
}

int main() 
{
    pthread_t tid;           // 存放线程 ID
    int ret;
    // 创建线程,传入 NULL 表示默认属性,NULL 表示不给参数
    if ((ret = pthread_create(&tid, NULL, rout, NULL)) != 0) 
    {
        fprintf(stderr, "pthread_create: %s\n", strerror(ret));
        exit(EXIT_FAILURE);
    }
    while (1)
    {
        printf("我是主线程\n");
        sleep(1);
    }
}
获取线程 ID
#include <pthread.h>

// 获取调用线程自身的 ID(pthread 库中的 ID)
// 返回值:当前线程的 ID,类型为 pthread_t
pthread_t pthread_self();

pthread 库的线程 ID 与内核线程 ID(LWP)的区别

  • pthread_self() 返回的是 pthread 库层面的线程 ID,本质上是一个虚拟地址(指向线程控制块 TCB 的指针,位于共享区内存中)
  • 内核为每个线程创建了一个全局唯一的 LWP(Light Weight Process,轻量级进程)ID

使用 ps -aL 命令查看两者区别:

$ ps -aL | head -1 && ps -aL | grep mythread
    PID     LWP TTY          TIME CMD
2711838 2711838 pts/235  00:00:00 mythread    ← 主线程,PID == LWP
2711838 2711839 pts/235  00:00:00 mythread    ← 子线程,LWP 不同
  • -L 选项:显示线程信息
  • PID:进程 ID
  • LWP:线程在内核中的真实 ID

主线程的栈在进程地址空间的栈区;其他线程的栈在堆栈之间的共享区(因为 pthread 库在共享区)。LWP 更像是"系统全局唯一身份证号",而 pthread_self 得到的更像是"公司内部工号",系统不认识但同一个进程内可以用。

📌 知识点总结pthread_create 怎么创建线程?pthread_self 返回的是什么?

pthread_create 做了三件事:① 在进程的共享区用 mmap 申请一块内存,作为新线程的栈和线程控制块(TCB);② 给 TCB 填入各种属性(要执行的函数、参数、栈地址等);③ 调用内核的 clone 系统调用,让内核创建一个轻量级进程(LWP)来执行线程函数。

pthread_self 返回的本质上是一个虚拟地址,指向内存中该线程的 TCB 结构体。而 ps -aL 看到的 LWP 才是内核真正用来调度的线程 ID。一个形象的比喻:LWP 是"身份证号"(系统全局唯一),pthread_t 是"工号"(公司内部用,本质是个地址值)。


3. 线程终止

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

方法 说明
从线程函数 return 对主线程不适用(main 函数 return 相当于 exit)
pthread_exit 线程主动终止自己
pthread_cancel 一个线程终止同一进程中的另一个线程
pthread_exit
#include <pthread.h>

// 功能:终止当前线程
// value_ptr:线程返回值指针(不要指向局部变量!)
// 返回值:无返回值(线程调用后直接终止,不返回调用者)
void pthread_exit(void *value_ptr);

重要注意事项pthread_exitreturn 返回的指针所指向的内存单元必须是全局变量用 malloc 分配的堆内存。不能是线程函数栈上的局部变量,因为其他线程拿到返回指针时,该线程函数已经退出了,局部变量已被销毁。

pthread_cancel
#include <pthread.h>

// 功能:取消同一进程中的另一个线程
// thread:要取消的目标线程 ID
// 返回值:成功返回 0,失败返回错误码
int pthread_cancel(pthread_t thread);

📌 知识点总结:线程怎么终止?和进程终止有什么区别?

线程终止有三种姿势:一是正常退出(函数 return 回家),二是自杀(调用 pthread_exit 自己了断),三是他杀(别人调用 pthread_cancel 把你干掉)。注意:主线程不能用 return 终止自己,因为 main 函数的 return 相当于调用 exit,整个进程都会退出。另外,pthread_exitreturn 返回的数据不能是局部变量的地址——线程都退出了,栈上的局部变量也就被释放了,别人拿到的是一个悬空指针。


4. 线程等待

为什么需要线程等待?
  • 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内
  • 如果不等待,已退出的线程资源会一直占用,产生类似"僵尸进程"的问题
  • 不等待的话,创建新线程不会复用已退出线程的地址空间
pthread_join
#include <pthread.h>

// 功能:等待线程结束,回收线程资源
// thread:要等待的目标线程 ID
// value_ptr:二级指针,接收线程的返回值(传 NULL 表示不关心返回值)
// 返回值:成功返回 0,失败返回错误码
int pthread_join(pthread_t thread, void **value_ptr);

调用 pthread_join 的线程会挂起等待,直到目标线程终止。不同终止方式对应的返回值:

终止方式 value_ptr 指向的内容
return 返回 线程函数的返回值
pthread_cancel 取消 常量 PTHREAD_CANCELED
调用 pthread_exit 传给 pthread_exit 的参数
不关心返回值 NULL 给 value_ptr
示例代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

// 线程1:通过 return 返回值
void *thread1(void *arg) 
{
    printf("线程1 正在 return...\n");
    int *p = (int*)malloc(sizeof(int));     // 堆上分配内存,线程退出后数据还在
    *p = 1;
    return (void*)p;                        // 返回堆内存地址
}

// 线程2:通过 pthread_exit 返回值
void *thread2(void *arg) 
{
    printf("线程2 正在 exiting...\n");
    int *p = (int*)malloc(sizeof(int));
    *p = 2;
    pthread_exit((void*)p);                 // 主动退出并返回值
}

// 线程3:无限循环,等待被取消
void *thread3(void *arg) 
{
    while (1) 
    {
        printf("线程3 正在运行...\n");
        sleep(1);
    }
    return NULL;
}

int main() 
{
    pthread_t tid;
    void *ret;

    // --- 测试 return ---
    pthread_create(&tid, NULL, thread1, NULL);  // 创建线程1
    pthread_join(tid, &ret);                    // 等待线程1结束
    printf("线程1 返回, 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("线程2 返回, thread id %X, return code:%d\n", tid, *(int*)ret);
    free(ret);

    // --- 测试 pthread_cancel ---
    pthread_create(&tid, NULL, thread3, NULL);
    sleep(3);                                    // 让线程3运行 3 秒
    pthread_cancel(tid);                         // 主线程取消线程3
    pthread_join(tid, &ret);                     // 等待线程3结束
    if (ret == PTHREAD_CANCELED)
        printf("线程3 返回, thread id %X, return code:PTHREAD_CANCELED\n", tid);
    else
        printf("线程3 返回, thread id %X, return code:NULL\n", tid);
}

运行结果:

线程1 正在 return...
线程1 返回, thread id 5AA79700, return code:1
线程2 正在 exiting...
线程2 返回, thread id 5AA79700, return code:2
线程3 正在运行...
线程3 正在运行...
线程3 正在运行...
线程3 返回, thread id 5AA79700, return code:PTHREAD_CANCELED

📌 知识点总结:为什么要调用 pthread_join 等待线程?

就像进程有"僵尸进程"一样,线程也有"僵尸线程"。线程虽然执行完了,但它的资源(TCB、栈空间)还在进程地址空间里没被释放。pthread_join 就是用来"收尸"的——它让调用者挂起等待目标线程结束,然后告诉内核可以释放资源了。更重要的是,通过 pthread_join 还能拿到线程的返回值。如果不 join,线程退出后的空间就无法被回收,也无法被新创建的线程复用,造成内存泄漏。


5. 分离线程

分离的原因
  • 默认情况下,新创建的线程是 joinable(可连接)
  • 线程退出后,必须调用 pthread_join 回收资源,否则会造成资源泄漏
  • 如果不关心线程返回值,每次都要 join 是一个负担
  • 分离后,线程退出时系统自动释放资源,无需手动 join
pthread_detach
#include <pthread.h>

// 功能:将指定线程设置为分离状态
// thread:要分离的目标线程 ID
// 返回值:成功返回 0,失败返回错误码
int pthread_detach(pthread_t thread);

两种使用方式:

// 方式1:其他线程分离目标线程
pthread_detach(thread_id);

// 方式2:线程自己分离自己(常用方式)
pthread_detach(pthread_self());

重要规则:joinable 和分离是互斥的——一个线程不能既是 joinable 又是分离的。

示例代码
#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() 
{
    pthread_t tid;
    // 创建线程,传入字符串 "thread1 run..."
    if (pthread_create(&tid, NULL, thread_run, "thread1 run...") != 0) 
    {
        printf("创建线程出错\n");
        return 1;
    }
    int ret = 0;
    sleep(1);  // 很重要!让线程先完成分离,再尝试 join
    if (pthread_join(tid, NULL) == 0) 
    {
        printf("线程等待成功\n");
        ret = 0;
    } 
    else 
    {
        printf("线程等待失败\n");
        ret = 1;
    }
    return ret;
}

sleep(1) 很重要:必须确保线程已经在自己内部调用 pthread_detach 完成了分离,否则主线程调用 pthread_join 仍然可能成功。

📌 知识点总结:分离线程是什么?什么场景下用?

分离线程就是告诉系统:"这个线程跑完就自动收拾干净,不用等我收尸。“默认创建的线程是 joinable 的,类似于"管杀管埋”——你创建了就得负责 join 回收。如果线程执行完就完事了,不关心返回值,每次还要 join 挺麻烦,就可以设置分离状态,让系统自动回收资源。

joinable vs 分离:就像借书——joinable 是"你借了就必须亲自还",分离是"放自助还书箱,图书馆自己处理"。两者只能选一个,不能既要自动回收又要手动 join。


四、线程 ID 及进程地址空间布局

在这里插入图片描述
在这里插入图片描述

1. 两个"线程 ID"的区别

名称 来源 作用域 本质
LWP(轻量级进程 ID) 内核分配 系统全局 唯一标识线程,供内核调度
pthread_t pthread 库分配 进程内部 虚拟地址,指向 TCB 结构体

pthread_create 的第一个参数指向一个虚拟内存单元,该地址即为新创建线程的 pthread 线程 ID,属于 NPTL(Native POSIX Thread Library) 线程库的范畴。线程库后续的操作(如 pthread_joinpthread_cancel)都是根据这个 ID 来操作线程的。

2. pthread_t 的本质

对于 Linux 目前实现的 NPTL 实现而言,pthread_t 类型的线程 ID,本质就是一个进程地址空间上的一个地址

#include <pthread.h>

// 获取当前线程自身的 ID
// 返回值:pthread_t 类型,本质是一个虚拟地址
pthread_t pthread_self();

3. 线程地址空间布局

  • 主线程:栈在进程的栈区(向下生长)
  • 子线程:栈在进程地址空间的共享区(堆和栈之间),使用 mmap 系统调用分配,大小固定(默认 8MB),不能动态增长

📌 知识点总结:pthread_t 的线程 ID 到底是什么?

pthread_t 就是一个虚拟地址,指向进程共享区中该线程的 TCB(线程控制块)。这个 TCB 是 pthread 库用 mmap 在共享区申请的一块内存,放在这块内存开头的位置。因为它是进程中的一个地址,所以只能在同一个进程内用来标识线程。而 ps -aL 看到的 LWP 才是内核全局唯一的线程 ID,供内核调度使用。


五、线程封装(C++ 面向对象封装)

1. 基于 std::function 的封装

// Thread.hpp
#pragma once
#include <iostream>     // 输入输出流
#include <string>       // 字符串
#include <functional>   // std::function 和 std::bind
#include <pthread.h>    // POSIX 线程库

namespace ThreadModule
{
    // 原子计数器,方便生成线程名称(如 Thread-0, Thread-1...)
    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 的回调函数
        // 因为 pthread_create 的第三个参数必须是 void* (*)(void*) 类型
        // 所以我们需要一个静态方法来接收 this 指针
        static void *run(void *obj)
        {
            // 把参数转换回 Thread 对象指针
            Thread *self = static_cast<Thread *>(obj);
            // 设置线程名称(便于调试)
            pthread_setname_np(pthread_self(), self->_name.c_str());
            // 更新状态为运行中
            self->_status = TSTATUS::THREAD_RUNNING;
            // 如果设置了分离,则自动分离
            if (!self->_joined)
            {
                pthread_detach(pthread_self());
            }
            // 执行线程函数
            self->_func();
            return nullptr;
        }

        // 生成线程名称
        void SetName()
        {
            // 后期需要加锁保护 cnt(多线程并发时会有问题)
            _name = "Thread-" + std::to_string(cnt++);
        }

    public:
        // 构造函数:传入线程要执行的函数
        Thread(threadfunc_t func)
            : _status(TSTATUS::THREAD_NEW), _joined(true), _func(func)
        {
            SetName();
        }

        // 开启分离模式(必须在 Start 之前调用)
        void EnableDetach()
        {
            if (_status == TSTATUS::THREAD_NEW)
                _joined = false;  // false 表示不需要 join,即分离
        }

        // 开启可连接模式(默认)
        void EnableJoined()
        {
            if (_status == TSTATUS::THREAD_NEW)
                _joined = true;
        }

        // 启动线程
        bool Start()
        {
            if (_status == TSTATUS::THREAD_RUNNING)
                return true;
            // 调用 pthread_create,传入 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;  // 分离状态不能 join
        }

        ~Thread() {}

    private:
        std::string _name;           // 线程名称
        pthread_t _id;               // 线程 ID
        TSTATUS _status;             // 线程状态
        bool _joined;                // 是否需要 join(true=可连接,false=分离)
        threadfunc_t _func;          // 线程要执行的函数
    };
}

2. 使用示例

// main.cc
#include <iostream>
#include <unistd.h>
#include "test.hpp"

// 线程函数1
void hello1()
{
    char buffer[64];
    // 获取当前线程名称
    pthread_getname_np(pthread_self(), buffer, sizeof(buffer) - 1);
    while (true)
    {
        std::cout << "hello world, " << buffer << std::endl;
        sleep(1);
    }
}

// 线程函数2
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,执行 hello2(使用 std::bind)
    ThreadModule::Thread t2(std::bind(&hello2));
    t2.Start();

    // 等待线程结束
    t1.Join();
    t2.Join();
    return 0;
}

查看运行结果:

$ ps -aL
    PID     LWP TTY          TIME CMD
 195828  195828 pts/1    00:00:00 main          ← 主线程,名称为"main"
 195828  195829 pts/1    00:00:00 Thread-0      ← 子线程1
 195828  195830 pts/1    00:00:00 Thread-1      ← 子线程2

3. 模板版本的封装(支持任意参数)

// 模板形式的线程封装,支持传递任意类型数据
namespace ThreadModule
{
    static int number = 1;

    enum class TSTATUS
    {
        NEW,
        RUNNING,
        STOP
    };

    template <typename T>
    class Thread
    {
        using func_t = std::function<void(T)>;   // 带参数的函数类型

    private:
        // 静态方法:pthread_create 的回调
        static void *Routine(void *args)
        {
            // 把参数转换为 Thread 对象指针
            Thread<T> *t = static_cast<Thread<T> *>(args);
            t->_status = TSTATUS::RUNNING;
            t->_func(t->_data);     // 执行线程函数,传入数据
            return nullptr;
        }

        void EnableDetach() { _joinable = false; }

    public:
        // 构造函数:传入函数和数据
        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)
            {
                int n = ::pthread_create(&_tid, nullptr, Routine, this);
                if (n != 0)
                    return false;
                return true;
            }
            return false;
        }

        // 取消线程
        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);
        }

        bool IsJoinable() { return _joinable; }
        std::string Name() { return _name; }
        ~Thread() {}

    private:
        std::string _name;       // 线程名称
        pthread_t _tid;          // 线程 ID
        pid_t _pid;              // 进程 ID
        bool _joinable;          // 是否可连接(默认是)
        func_t _func;            // 线程函数
        TSTATUS _status;         // 线程状态
        T _data;                 // 传递给线程的数据
    };
}

4. 支持可变参数的线程封装(更现代的 C++ 方式)

#include <iostream>
#include <functional>    // std::function 和 std::bind
#include <memory>        // std::shared_ptr 和 std::unique_ptr
#include <pthread.h>     // POSIX 线程库
#include <unistd.h>

class Thread 
{
public:
    Thread() : thread_id_(0), running_(false) {}
    ~Thread() 
    {
        if (running_) {
            pthread_detach(thread_id_);    // 析构时分离,避免资源泄漏
        }
    }

    // 模板方法:接受任意可调用对象和任意参数
    template <typename Callable, typename... Args>
    bool start(Callable&& func, Args&&... args) 
    {
        if (running_) 
        {
            std::cerr << "线程已在运行中!" << std::endl;
            return false;
        }

        // 把函数和参数打包成一个 std::function<void()> 对象
        // 使用 std::bind 绑定函数和参数,std::forward 完美转发
        auto task = std::make_shared<std::function<void()>>(
            std::bind(std::forward<Callable>(func), std::forward<Args>(args)...)
        );

        // 在堆上创建 shared_ptr,确保执行期间任务对象有效
        // 创建线程,传入任务指针
        if (pthread_create(&thread_id_, nullptr, &Thread::threadEntry,
                new std::shared_ptr<std::function<void()>>(task)) != 0) 
        {
            std::cerr << "创建线程失败!" << 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_;           // 线程是否在运行

    // 静态线程入口函数
    static void* threadEntry(void* arg) 
    {
        // 用 unique_ptr 管理 shared_ptr 的指针,确保资源释放
        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 << ", 数值: " << value << std::endl;
    std::cout << "a:" << a << std::endl;
    std::cout << "b:" << b << std::endl;
    std::cout << "c:" << c << std::endl;
    sleep(10);
}

int main() 
{
    Thread thread;
    // 启动线程,可以传递任意函数和任意参数
    thread.start(printMessage, "你好, 世界!", 42, 1, 2, 3);
    thread.join();
    return 0;
}

运行结果:

$ ./a.out
消息: 你好, 世界!, 数值: 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

📌 知识点总结:为什么要把线程封装成 C++ 类?封装思路是什么?

把线程封装成类有三大好处:① 对象化管理——每个线程就是一个对象,创建、启动、停止、等待都通过方法调用,代码更清晰;② 自动资源管理——析构函数里自动处理分离/回收,不容易忘;③ 类型安全——可以用模板支持任意参数类型,避免裸的 void* 转换。

封装的核心理念是:pthread_create 需要一个 void* (*)(void*) 类型的函数,但我们想要的是面向对象的方法调用。所以技巧就是——把 this 指针作为参数传给 pthread_create 的静态包装函数,在包装函数里再把 this 转回来调用真正的成员函数。


六、附录:源码阅读(glibc pthread_create 源码分析)

1. pthread_create 主流程(glibc-2.4 / nptl/pthread_create.c)

// 版本:__pthread_create_2_1
// GLIBC_2.1 版本的 pthread_create 实现

int __pthread_create_2_1(newthread, attr, start_routine, arg)
    pthread_t *newthread;                           // 输出:线程 ID
    const pthread_attr_t *attr;                     // 线程属性
    void *(*start_routine)(void *);                 // 线程函数
    void *arg;                                      // 参数
{
    STACK_VARIABLES;
    // 重点1:获取线程属性,如果用户没设置就用默认属性
    const struct pthread_attr *iattr = (struct pthread_attr *)attr;
    if (iattr == NULL)
        iattr = &default_attr;

    // 重点2:pd(thread descriptor)是线程控制块 TCB 的指针
    struct pthread *pd = NULL;

    // 重点3:ALLOCATE_STACK 申请栈空间和 TCB 内存
    // 它会在进程的共享区申请一大块内存,struct pthread 在这块内存的开头
    int err = ALLOCATE_STACK(iattr, &pd);
    if (__builtin_expect(err != 0, 0))
        return err;

    // 初始化 TCB(线程控制块)
#ifdef TLS_TCB_AT_TP
    pd->header.self = pd;           // 自引用指针
    pd->header.tcb = pd;            // TLS 自引用
#endif

    // 重点4:把要执行的方法和参数保存到 TCB 中
    pd->start_routine = start_routine;
    pd->arg = arg;

    // 复制线程属性标志
    struct pthread *self = THREAD_SELF;
    pd->flags = ...;

    // 初始化 joinid(用于判断是否分离)
    pd->joinid = iattr->flags & ATTR_FLAG_DETACHSTATE ? pd : NULL;

    // ... 各种调度参数、安全设置的复制 ...

    // 重点5:把 pd(TCB 地址)作为线程 ID 返回给用户
    // 所以用户在用户态拿到的 pthread_t 本质上就是一个虚拟地址!
    *newthread = (pthread_t)pd;

    // 重点6:检查线程是否是分离状态
    bool is_detached = IS_DETACHED(pd);

    // 重点7:调用 create_thread 真正创建线程
    err = create_thread(pd, iattr, STACK_VARIABLES_ARGS);
    if (err != 0) 
    {
        if (!is_detached)
            __deallocate_stack(pd);     // 失败时回收栈
        return err;
    }
    return 0;
}

// 版本信息:把 __pthread_create_2_1 导出为 pthread_create 符号
versioned_symbol(libpthread, __pthread_create_2_1, pthread_create, GLIBC_2_1);

2. 线程属性结构体

// 线程属性结构体
struct pthread_attr
{
    struct sched_param schedparam;    // 调度参数和优先级
    int schedpolicy;                  // 调度策略
    int flags;                        // 各种标志(分离状态、作用域等)
    size_t guardsize;                 // 警戒区大小
    void *stackaddr;                  // 栈地址(用户可自定义)
    size_t stacksize;                 // 栈大小
    cpu_set_t *cpuset;                // CPU 亲和性掩码
    size_t cpusetsize;                // 亲和性掩码大小
};

3. struct pthread —— 线程控制块 TCB

// 线程描述符数据结构(核心)
struct pthread
{
    union {
#if !TLS_DTV_AT_TP
        tcbhead_t header;             // TLS 线程本地存储相关的头部
#else
        struct {
            int multiple_threads;     // 是否有多个线程
        } header;
#endif
        void *__padding[16];          // 填充字节
    };

    list_t list;                      // 在 stack_used 或 __stack_user 链表上的链接
    pid_t tid;                        // 线程 ID(内核的 LWP ID)
    pid_t pid;                        // 进程 ID

    // ... 稳健互斥锁相关 ...

    struct _pthread_cleanup_buffer *cleanup;           // 清理函数缓冲区
    struct pthread_unwind_buf *cleanup_jmp_buf;        // 异常展开信息
    int cancelhandling;                                // 取消处理的标志位

    // 线程特有数据
    struct pthread_key_data specific_1stblock[...];
    struct pthread_key_data *specific[...];
    bool specific_used;

    bool report_events;               // 是否报告事件
    bool user_stack;                  // 是否使用用户提供的栈
    bool stopped_start;               // 启动时是否先暂停
    lll_lock_t lock;                  // 同步锁

    struct pthread *joinid;           // 等待 join 的线程 ID
    // 如果 joinid == pd 自己,表示是分离状态
    // 实际判断宏:#define IS_DETACHED(pd) ((pd)->joinid == (pd))

    int flags;                        // 标志位
    void *result;                     // 线程函数的返回值(pthread_join 读取的就是它)

    struct sched_param schedparam;    // 调度参数
    int schedpolicy;                  // 调度策略

    void *(*start_routine)(void *);   // 用户指定的线程函数
    void *arg;                        // 用户指定的参数

    // ... 调试状态 ...

    void *stackblock;                 // 线程栈的起始地址
    size_t stackblock_size;           // 线程栈的大小
    size_t guardsize;                 // 警戒区大小
    // ...
};

4. allocate_stack —— 栈的分配

// 路径:nptl/allocatestack.c
// 实际调用的是 allocate_stack 函数

static int
allocate_stack(const struct pthread_attr *attr, struct pthread **pdp,
               ALLOCATE_STACK_PARMS)
{
    struct pthread *pd;
    size_t size;
    size_t pagesize_m1 = __getpagesize() - 1;

    // 获取栈大小(用户设置或默认值,默认通常是 8MB)
    size = attr->stacksize ?: __default_stacksize;

    // 如果用户已经设置了栈地址,直接使用(但一般我们不设置,走默认流程)
    if (__builtin_expect(attr->flags & ATTR_FLAG_STACKADDR, 0))
    {
        // ... 用户提供栈的处理逻辑 ...
    }
    else
    {
        // ---- 我们关心的默认流程 ----

        // 优先尝试从 pthread 的缓存中获取栈
        pd = get_cached_stack(&size, &mem);
        if (pd == NULL)
        {
            // 缓存中没有,用 mmap 在共享区申请匿名内存
            // MAP_PRIVATE | MAP_ANONYMOUS 表示私有匿名映射
            mem = mmap(NULL, size, prot,
                       MAP_PRIVATE | MAP_ANONYMOUS | ARCH_MAP_FLAGS, -1, 0);
            if (__builtin_expect(mem == MAP_FAILED, 0))
                return errno;
        }

        // 重点:把 struct pthread(TCB)放在栈空间的末尾位置
        // 这样栈向下生长,TCB 在栈的"天花板"处
        // pd 就是 TCB 的地址
#if TLS_TCB_AT_TP
        pd = (struct pthread *)((char *)mem + size - coloring) - 1;
#elif TLS_DTV_AT_TP
        pd = (struct pthread *)((((uintptr_t)mem + size - coloring
               - __static_tls_size) & ~__static_tls_align_m1) - TLS_PRE_TCB_SIZE);
#endif

        // 记录栈块的信息
        pd->stackblock = mem;               // 栈起始地址
        pd->stackblock_size = size;         // 栈大小

        // ... 其他初始化 ...
    }

    // 通过二级指针返回 TCB 地址
    *pdp = pd;
    return 0;
}

5. create_thread —— 调用 clone 系统调用

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 // 子线程退出时清除 ID
        | CLONE_SYSVSEM       // 共享 System V 信号量
        // | CLONE_DETACHED   // 早期版本的分离标志
        | 0
    );

    // 调用 do_clone,最终调用 ARCH_CLONE(即 __clone)
    int res = do_clone(pd, attr, clone_flags, start_thread,
                       STACK_VARIABLES_ARGS, stopped);
    return res;
}

6. do_clone 和 ARCH_CLONE

static int
do_clone(struct pthread *pd, const struct pthread_attr *attr,
         int clone_flags, int (*fct)(void *), STACK_VARIABLES_PARMS,
         int stopped)
{
    // 增加全局线程计数
    atomic_increment(&__nptl_nthreads);

    // 调用 ARCH_CLONE(即 __clone,用汇编封装 clone 系统调用)
    if (ARCH_CLONE(fct, STACK_VARIABLES_ARGS, clone_flags,
                   pd, &pd->tid, TLS_VALUE, &pd->tid) == -1)
    {
        atomic_decrement(&__nptl_nthreads);
        if (IS_DETACHED(pd))
            __deallocate_stack(pd);
        return errno;
    }
    return 0;
}

ARCH_CLONE__clone,这是一个用汇编实现的函数(sysdeps/unix/sysv/linux/x86_64/__clone.S):

ENTRY (BP_SYM (__clone))
        // 参数检查
        movq        $-EINVAL,%rax
        testq       %rdi,%rdi           // 检查函数指针是否为 NULL
        jz          SYSCALL_ERROR_LABEL
        testq       %rsi,%rsi           // 检查栈指针是否为 NULL
        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
        movl        $SYS_ify(clone),%eax    // 设置系统调用号为 clone

        syscall                             // 陷入内核,创建轻量级进程
        testq       %rax,%rax
        jl          SYSCALL_ERROR_LABEL
        jz          L(thread_start)         // 子线程从这里开始执行

📌 知识点总结:pthread_create 底层到底干了什么?glibc 源码能告诉我们什么?

读 glibc 源码让我们看到了线程创建的完整拼图:

  1. 分配 TCB 和栈allocate_stackmmap 在进程的共享区申请一块匿名内存(默认 8MB),struct pthread(TCB)放在这块内存的顶部,栈从 TCB 往下生长
  2. 填充 TCB:把线程函数、参数、各种属性填进 TCB
  3. 返回 TCB 地址作为 ID*newthread = (pthread_t)pd——所以用户拿到的 pthread_t 就是 TCB 的地址
  4. 调用 clone 系统调用ARCH_CLONEsyscall(SYS_ify(clone)),最关键的是传入了 CLONE_VM 标志——表示新创建的轻量级进程和父进程共享地址空间,这也就是"线程"的本质

整个过程就是:pthread 库在用户态搭好台子(申请内存、填好 TCB),然后一个 clone 系统调用让内核上场干活(创建共享地址空间的轻量级进程来执行线程函数)。


7. 线程栈的特殊性

进程/主线程的栈

  • 在进程地址空间的栈区,向下生长
  • 可以动态增长(通过缺页异常自动扩充)
  • 超出上限会报段错误

子线程的栈

  • 在进程地址空间的共享区(堆和栈之间的文件映射区)
  • 通过 mmap 系统调用分配,大小固定(默认 8MB)
  • 不能动态增长,一旦用完就没了
// glibc 中线程栈的申请
mem = mmap(NULL, size, prot,
           MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);

之后底层调用 sys_clone 系统调用,把 mmap 得到的栈指针传给内核:

int sys_clone(struct pt_regs *regs)
{
    unsigned long clone_flags;
    unsigned long newsp;          // 线程的栈指针(来自 mmap)

    clone_flags = regs->bx;
    newsp = regs->cx;             // 获取 mmap 得到的线程栈指针
    if (!newsp)
        newsp = regs->sp;         // 如果没有指定,使用当前栈
    return do_fork(clone_flags, newsp, regs, 0, parent_tidptr, child_tidptr);
}

8. 自己调用 clone 系统调用的示例

#define _GNU_SOURCE
#include <sched.h>       // clone 函数
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>    // waitpid
#include <unistd.h>      // getpid

#define STACK_SIZE (1024 * 1024)  // 1MB 的栈空间

// 子进程(轻量级进程)执行的函数
static int child_func(void *arg) {
    printf("子进程: PID = %d\n", getpid());
    return 0;
}

int main() {
    // 为子进程分配栈空间
    char *stack = (char*)malloc(STACK_SIZE);
    if (stack == NULL) {
        perror("malloc");
        exit(EXIT_FAILURE);
    }

    // 使用 clone 创建子进程(子线程)
    // CLONE_VM 标志表示共享地址空间——这就成了"线程"而不是"进程"
    // 栈要指向栈顶(x86 栈向下生长,所以指向栈空间末尾)
    pid_t pid = clone(child_func, stack + STACK_SIZE, CLONE_VM | SIGCHLD, NULL);
    if (pid == -1) {
        perror("clone");
        free(stack);
        exit(EXIT_FAILURE);
    }

    printf("父进程: PID = %d, 子进程 PID = %d\n", getpid(), pid);
    waitpid(pid, NULL, 0);     // 等待子进程结束
    free(stack);
    return 0;
}

📌 知识点总结:线程栈和主线程栈有什么不同?为什么子线程的栈不能动态增长?

主线程的栈在地址空间的栈区,由编译器自动管理,可以动态增长——缺页了内核就自动分配新页面。而子线程的栈是 mmap 在共享区申请的一块固定大小的内存(默认 8MB),它背后没有"随时可以扩充"的虚拟地址空间支持,用完了就是完了。这就像主线程的栈是"有无限扩张可能的自留地",而子线程的栈是"面积固定的租赁房"。

这也解释了为什么 pthread 创建时要指定栈大小(用户可以自定义),以及为什么递归层数太深或局部变量太大时子线程更容易栈溢出。


9. 页表与页表项

页表在 Linux 内核中的表示:

// 页表标志位
#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)

// 页表项和页目录项的类型定义
typedef struct { unsigned long pte; } pte_t;    // 页表项
typedef struct { unsigned long pgd; } pgd_t;    // 页全局目录项

// 进程的内存描述符中包含页目录
struct mm_struct {
    struct vm_area_struct *mmap;    // 虚拟内存区域链表
    // ...
    pgd_t *pgd;                     // 页目录起始地址(指向第一级页表)
};

总结:线程概念与控制的完整图景

主题 一句话总结
线程是什么 进程内部的执行流,共享进程的地址空间和资源
分页管理 虚拟地址通过多级页表映射到离散的物理内存,TLB 加速转换
缺页异常 物理内存中没有对应页面时触发,操作系统负责从磁盘加载
线程优缺点 创建/切换快但健壮性差,一个线程崩溃拖垮整个进程
进程 vs 线程 进程管资源分配,线程管调度执行
线程控制 create/exit/cancel/join/detach 五件套
pthread_t 本质 一个指向 TCB 的虚拟地址
线程栈 子线程的栈在共享区通过 mmap 分配,固定大小不能增长
底层实现 pthread 库填充 TCB → clone 系统调用(CLONE_VM 标志)→ 内核创建 LWP
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐