欢迎光临
我们一直在努力

Linux 线程与线程地址空间与页表原理 pthread 系列函数

前言

这篇文章是 Linux 线程的完整学习笔记,从基础概念讲起,逐步深入到 Linux 的特殊实现、线程与进程的区别、地址空间与页表原理,再到 pthread 系列 API 的用法和经典的多线程竞态问题。内容以大白话 + 考点总结为主,适合初学者学习和复习备考时快速过一遍。

Linux 线程

核心一句话

进程 = 资源容器;线程 = 进程内部独立的代码执行分支(CPU 调度的最小单位)。 操作系统分配内存、文件描述符这类资源,是以进程为单位;CPU 调度代码跑起来,是以线程为单位。

1. 基础定义(课件红框)

线程:是进程内的一个执行分支。线程的执行粒度,要比进程更细。

  • 一个进程刚被创建出来的时候,自带主线程,此时进程内部只有一条执行流。这就是咱们以前学的普通进程。
  • 多线程:在同一个进程里面,再额外开出多条执行分支(多个线程),多条线程同时在这个进程内部跑代码。

2. Linux 的特殊实现(课件右上角重点)

别的操作系统(Windows): 内核区分进程和线程,线程有专门内核结构体 TCB(Thread Control Block,线程控制块)。

Linux:内核没有专门为线程单独设计 TCB。 Linux 的方案:线程本质就是轻量级进程,每一条线程在内核,都用 struct task_struct 描述。

关键点:CPU 调度器只认 task_struct,CPU 不区分这个 task_struct 代表的是传统进程,还是线程,一律当成可调度的执行流。

3. 两张图拆解

下图:单线程进程

1 个 task_struct(主线程) + 1 套虚拟地址空间 + 1 套页表 整个进程里面只有一条执行路线。代码从头到尾一条路往下跑。

上图:多线程进程

【一份地址空间、一份页表】,绑定了多个独立的 task_struct 多个 task_struct 就是多条线程,全部共用这个进程的资源容器。

✅ 同一个进程内,所有线程共享这些东西

  • 整个虚拟地址空间:代码段.text、全局变量.data、堆、加载的动态库
  • 页表
  • 文件描述符表(所有打开的 fd、对应的内核struct file、文件偏移全部共享)
  • 进程的信号处理规则、工作目录、用户 ID
  • ✅ 每个线程自己私有的东西(不共享)

  • 线程栈:保存函数局部变量、函数调用链
  • CPU 寄存器上下文(PC/EIP 程序计数器、通用寄存器。切换线程时保存 / 恢复)
  • 线程局部存储 TLS,线程私有的 errno
    (Thread Local Storage,线程局部存储)TLS:线程私有全局变量。线程创建就分配,线程退出才释放。生命周期:整个线程的一生。
    errno 就是典型 TLS 变量。TLS 给每个线程单独开辟私有全局变量,多线程之间互不干扰,解决普通全局变量竞争覆盖的问题。
  • 4. 两个思考题(课件上的)

    问题 1:为什么线程在进程 “内部” 运行,跑在进程的地址空间里?

    进程才是持有资源的容器,地址空间、内存资源属于进程。 线程仅仅是执行代码的分支,线程本身没有独立的内存资源,只能复用进程已经开好的地址空间。 好处:多个线程之间直接读写全局变量,交换数据非常方便。

    问题 2:为什么说线程执行粒度比进程更细?

    什么叫「执行粒度更细」

    粒度 = 操作系统调度的最小单元。
    大白话: 进程是一整套完整打包好的程序;线程只是这个程序里面拆分出来的一小段干活的分支。操作系统调度的时候,可以只调度这一小段分支,不用搬整个程序。所以粒度更小、更细。

  • 创建线程:不需要拷贝完整地址空间,只需要新建 task_struct + 分配一小块线程栈,开销很小。
  • 线程切换:不需要更换页表,不用刷新 TLB 缓存,切换成本低。
  • 对比:进程切换,必须替换整套页表,刷新 TLB,开销大很多。

    Translation Lookaside Buffer,地址转换后备缓冲器,是 CPU 硬件里面一小块高速缓存。
    TLB 缓存【虚拟地址→物理地址】的映射条目,放在 CPU 内部,速度极快。

    2、“线程执行进程代码的一部分”

    程序编译完成,代码放在.text代码段,代码段属于整个进程,所有线程共享这一份代码。 线程不是单独拥有一份代码,多个线程可以跑同一份代码里不同的片段。 例子: 进程代码有 A 函数、B 函数。主线程跑 A,子线程跑 B。 👉 子线程只是执行这个进程代码里的一部分,不是自己拥有一份全新程序。

    5. fork 和 pthread_create 的区分(仅这两个 API 对比)

  • fork():创建新进程。新建 task_struct,生成一份全新独立的虚拟地址空间,采用写时拷贝。两个进程资源隔离。
  • pthread_create():创建新线程。新建 task_struct,复用当前进程的地址空间和页表,只分配独立线程栈,大量资源共享。
  • 6. 易错点单独拎出来

  • Linux 内核里线程和进程都用struct task_struct,区分它们靠:是否共享地址空间。
  • 线程共享文件描述符:A 线程 open 得到 fd,B 线程直接可以用这个 fd 读写文件,文件偏移互相影响。
  • 线程栈是私有!局部变量存在线程栈,别的线程看不到。全局变量在堆 / 数据段,所有线程都能访问。
  • 重新定义线程和进程

    1、线程定义

    线程:操作系统调度 CPU 的基本单位 大白话:CPU 时间片分给谁,就看线程。内核调度器挑选线程去 CPU 上跑代码。 课件红字:线程是进程内部的执行流资源

    • 执行流 = 一条独立的代码执行路线,有自己的 PC 程序计数器、寄存器、私有栈。
    • 线程本身不拥有内存、文件描述符这些大块资源,它寄生在进程里面,直接复用进程的资源。

    2、进程定义(内核视角)

    进程:承担分配系统资源的基本实体 大白话:内存、fd、页表这些资源,操作系统是以进程为单位分配的。 公式:进程 = 内核数据结构(task_struct) + 代码和数据 Linux 特殊点:Linux 里面线程也有自己独立的task_struct,Linux 把线程叫做轻量级进程。 多个线程的 task_struct,共享同一套资源(地址空间、文件描述符);普通进程的 task_struct,资源是独立独占。

    3、课件提问:执行流是资源吗?

    答案

    执行流(线程)算一种特殊资源,但不是内存 / 文件这类存储资源。

    • 普通资源:内存、文件描述符、页表。是存放数据、文件的东西,由进程持有。
    • 执行流(线程):代表 “代码正在跑的这条路线”,描述 CPU 执行上下文(寄存器、栈、PC)。
    • 一句话区分: 进程拿着内存、文件这些资源;线程是使用这些资源去跑代码的执行流。

    小思考题

    问:Linux 里进程和线程都有 task_struct,怎么区分?

    答:看是否共享虚拟地址空间。 两个 task_struct 共享地址空间 → 属于同一个进程里的不同线程; 地址空间互相独立 → 两个独立进程。

    重讲进程地址空间第四讲

    32 位 Linux 虚拟地址空间 + 二级页表地址转换 的原理图。

    32 位:总虚拟地址 4GB,拆分:用户空间 3G,内核空间 1G。

    一、左边:进程虚拟地址空间(图上方大竖框)

    课件红字:线程目前分配资源,本质就是分配地址空间范围

  • 整个虚拟地址空间属于进程,是进程的资源。
    • 正文代码:程序指令
    • 初始化数据 / 未初始化数据:全局变量
    • 堆:malloc 分配,向上增长,所有线程共享
    • 共享区:动态库.so,所有线程共享
    • 栈:每个线程单独分配一段栈地址范围!
    • 命令行参数、环境变量
    • 最上面:内核空间(1G,所有进程内核区映射同一份内核)
  • 重点: ✅ 堆、代码、全局数据、共享库:同进程所有线程共享这片地址范围 ✅ 栈:线程私有,创建新线程,内核就在这个进程的虚拟地址空间里,划出一块新地址范围给线程当栈 👉 这就是那句话:给线程分配资源,本质是在进程已有的地址空间里分配一段地址范围,不是新建一整套地址空间。
  • 二、CPU 里两个寄存器 CR3、CR2

    • CR3:存放页目录的物理地址。每个独立进程,CR3 的值不一样。 进程切换的时候,CPU 会修改 CR3,换一套页表。

      同进程切换线程:CR3 不变!页表不变,TLB 不用刷新。

    • CR2:发生缺页异常的时候,CPU 把出错的虚拟地址放到 CR2,内核读 CR2 就知道是哪个地址缺页了。

    三、二级页表:32 位虚拟地址拆分 10 + 10 + 12

    前置基础

    内存分页:把物理内存切成固定大小小块,叫页框(4KB); 虚拟地址空间切成同样大小小块,叫页面(4KB)。

    虚拟页面 ↔ 物理页框,需要一张 “对照表” 记录映射关系,这张表就是页表。

    1. 单层页表(一级页表,先理解这个,才能懂二级)

    32 位虚拟地址总共有 4GB,如果只用一张巨大的一级页表: 每一条记录占 4 字节,4GB/4KB = 100 万条,整张表就要 4MB。 ✅ 能实现地址映射 ❌ 问题:就算程序只用一点点内存,也要一次性占用 4MB 物理内存放页表,浪费巨大! 程序可能只用到几十 KB,却要 4MB 页表,很亏。

    解决方案:拆成两级 → 二级页表

    2. 页目录(Page Directory)【第一级】

    页目录 = 一级目录,一个总目录表

    • 32 位二级分页里,页目录只有1024 个条目
    • 每一条(页目录项),保存:某一张二级页表的物理内存地址
    • 页目录本身,只占 1024 * 4B(指针大小,每一条:4 字节(4B),用来存一个物理地址(32 位地址刚好就是 4 字节)) = 4KB!很小!

    类比:一本书的目录页,目录页上写着每一章在哪一页。 页目录就是目录页,它不存页面和物理内存的映射,只存二级页表在哪里。

    3. 页表(Page Table,二级页表,第二级)

    二级页表,才是真正记录【虚拟页面 → 物理页框】的对照表

    • 每一张二级页表,同样 1024 条目,4KB 大小
    • 一张二级页表,可以管 1024 * 4KB = 4MB 的虚拟地址空间
    • 重点:二级页表按需创建!

    页大小:4KB = 2¹²,所以低 12 位 = 页内偏移 剩下 20 位,拆成高 10 位(页目录项)、中间 10 位(页表项)

  • 虚拟地址【高 10 位】→ 索引页目录,拿到二级页表的物理地址
  • 虚拟地址【中间 10 位】→ 索引二级页表,拿到物理页框起始地址
  • 虚拟地址【低 12 位】→ 页内偏移
  • 物理地址 = 物理页框起始地址 + 低 12 位偏移

    重要考点:页表不是一次性全部建好

    课件写:二级页表大部分情况都是不全的 只有程序真正访问某一块虚拟内存的时候,内核才会创建对应的页表项。不会一次性把 4G 空间的所有页表全部创建,节省物理内存。

    四、磁盘、虚拟地址、物理内存关系

    A.exe 在磁盘上。 程序运行加载:不是一次性全部读进物理内存。

    • 访问虚拟地址,如果对应的页面不在物理内存 → 缺页异常,内核把磁盘的页面读到物理内存,填充页表。
    • 图里面页表打叉:代表这个虚拟地址还没有映射物理内存。

    五、图下面小例子 int a=10;

    int a占 4 字节。&a拿到的是虚拟地址,不是物理地址。 X86 特点:地址 + 类型,就能算出偏移,找到变量。

    核心问答(图上的两个问号)

    1. 如何理解资源分配给线程?

    进程拥有整套虚拟地址空间。 创建新线程,不需要新建地址空间。内核在进程已有的虚拟地址空间里划分一段地址,作为这个线程私有栈。这就是给线程分配资源。

    2. 虚拟地址怎么转物理地址?

    32 位二级页表三步:

  • CR3 拿到页目录物理地址;用虚拟地址高 10 位找页目录项,得到二级页表物理地址
  • 中间 10 位,在二级页表找到物理页框首地址
  • 低 12 位是页内偏移,相加得到最终物理地址
  • 精简背诵版

  • 32 位虚拟地址空间:总共 4GB,用户空间 3G、内核空间 1G,地址空间是进程的资源。
  • 创建线程分配资源,本质是在进程的虚拟地址空间内划分一段地址范围作为线程私有栈,不新建独立地址空间。
  • 32 位二级分页,虚拟地址划分为 10 (页目录)+10 (页表)+12 (页内偏移),页大小 4KB。
  • CR3 存放页目录物理地址,进程切换会修改 CR3;线程切换 CR3 不变,页表不变。
  • CR2 在缺页异常时保存触发异常的虚拟地址。
  • 页表是按需创建,不会一次性全部构建,节省物理内存。
  • Linux线程周边的概念

    Linux 里线程本质就是轻量级进程,每一条线程都有自己独立的task_struct(PCB),同一个进程内所有线程共用同一份虚拟地址空间(mm_struct)。

    图里各个部件拆解

  • task_struct Linux 内核里,不管进程、线程,内核统一用task_struct描述,就是 PCB。
    • 一个进程 4 个线程 → 内核就有4 个 task_struct。
    • 线程的 task_struct 里面,不单独复制虚拟内存 mm_struct,直接共用主进程的 mm_struct。

    红字笔记:CPU 看到的虽说还是 PCB,但已经比传统的进程要更轻量化。

    2.mm_struct 描述整套虚拟地址空间,里面记录页目录地址、代码段、全局、堆、共享库等信息。 ✅ 同进程所有线程共享同一个 mm_struct 也就是:共用一套页表、同一个 CR3、同一份代码、全局变量、堆、动态库。 ❌ 每个线程只有自己私有栈,在这片共享地址空间里划出一段地址作为线程栈。

    重点:线程比进程轻量,2 个原因(课件 a、b 两点,考试简答)

    a. 创建、销毁更轻量(生死)

    • 创建进程:fork,复制 mm_struct,复制整套页表,开销大,物理内存消耗大。
    • 创建线程:pthread_create,不用复制地址空间、不用新建页表,只新建一个 task_struct + 在现有虚拟地址空间划分一小块栈。 销毁线程也一样,只回收线程栈、task_struct,地址空间保留给同进程其他线程。

    b. 线程切换更轻量(运行,图左侧红色笔记重点)

  • 同进程线程切换:CR3 不变! mm_struct 不变,页表不变,不用刷新 TLB。 CPU 缓存(cache)里面缓存的地址翻译、数据依然有效,不需要重新加载 cache 热数据。切换速度很快。
  • 不同进程之间切换 要更换 mm_struct,修改 CR3,必须刷新 TLB,CPU cache 里面缓存的内容全部失效,要重新加载数据,开销巨大。
  • 笔记原话:线程内的切换不需要重新 cache 数据

    右侧页表 & 缺页部分回顾

    • 页表记录:虚拟地址 → 物理地址,附带权限(RWX,用户 U / 内核 K 权限)
    • 如果访问虚拟内存,对应页面不在物理内存 → 缺页异常,内核把磁盘页面加载进物理内存,建立映射。

    同进程所有线程访问的都是同一套页表映射。

    表格总结(背诵用)

    表格

    对比项进程切换同进程线程切换
    mm_struct 更换,换新地址空间 不换,共用
    CR3 寄存器 修改 不变
    TLB 必须刷新 不用刷新
    Cache 缓存失效,要重加载 缓存保留,不用重加载
    开销 大 很小

    Linux 内核不存在专门的线程概念,内核只识别轻量级进程,内核没有直接创建线程的系统调用,提供clone系统调用用来创建轻量级进程。 pthread 线程库运行在应用层,封装 clone 系统调用,向用户提供多线程编程接口;编写多线程程序需要链接 pthread 库。

    易错坑(选择题经常考)

    ❌ 错误:pthread_create是系统调用 ✅ 正确:pthread_create是库函数,底层内部调用clone系统调用

    ❌ 错误:Linux 内核有专门的线程结构体 ✅ 正确:线程在内核中依然是 task_struct,属于轻量级进程

    pthread_create 函数

    一、函数原型

    int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
    void *(*start_routine) (void *), void *arg);

    逐个参数大白话解释:

  • pthread_t *thread:输出参数。创建成功之后,把新线程的用户态线程 ID写到这个变量里。

  • const pthread_attr_t *attr:线程属性,填nullptr就是使用默认属性。

  • void *(*start_routine) (void *):函数指针。新线程启动之后,会自动跑这个函数,就是线程入口函数。

    这个函数规定:参数是void*,返回值也必须是void*

  • void *arg:传给上面线程入口函数的参数。如果不需要传参,填nullptr。

  • 返回值

    • 成功:返回 0
    • 失败:返回错误码(注意!不是 – 1,直接返回错误编号),并且 thread 里面的值无效。

    ⚠️编译重点:gcc/g++ 编译多线程代码必须加 -pthread 作用:1. 链接 libpthread 库;2. 预处理增加线程相关宏。

    二、示例代码解读

    // 线程入口函数
    void *threadRoutine(void *args)
    {
    while (true)
    {
    cout << "new thread, pid: " << getpid() << endl;
    sleep(2);
    }
    }

    int main()
    {
    pthread_t tid;
    // 创建新线程
    pthread_create(&tid, nullptr, threadRoutine, nullptr);

    while (true)
    {
    cout << "main thread, pid: " << getpid() << endl;
    sleep(1);
    }
    return 0;
    }

    重点现象(考试高频)

    主线程 和 新建子线程,调用getpid()拿到的 PID 是【同一个】! 因为它们属于同一个进程,共享 mm_struct,在内核里是一组轻量级进程 LWP。Light Weight Process:轻量级进程,缩写 LWP

    三、命令行 ps -aL 截图解释

    24089 24089 pts/2 00:00:00 mythread
    24089 24090 pts/2 00:00:00 mythread

    • PID 列:都是 24089,代表同一个进程
    • LWP 列(Light Weight Process,轻量级进程 id):24089、24090,内核层面每个 LWP 有独立 LWP 编号

    ✅ 一句话考点: PID:进程 id,同进程内所有线程 PID 相同; LWP:内核看到的轻量级进程 ID,每个线程 LWP 不一样,对应独立 task_struct。

    区分两个 ID:

  • pthread_t tid:用户态线程 ID,pthread 库维护,只在本进程内有效
  • LWP:内核的轻量级进程 ID,内核 task_struct 里面存的,全局唯一
  • 四、核心考点总结(背诵)

  • pthread_create是库函数,底层调用clone系统调用。
  • 同一个进程内所有线程getpid()得到的 PID 相同,但是 LWP 不同。
  • 编译多线程代码,gcc/g++ 必须加 -pthread。
  • 线程入口函数格式固定:void* func(void* arg)。
  • 返回值:成功返回 0,失败返回错误码。
  • 五、易错题

    问:主线程和子线程 PID 一样吗?LWP 一样吗? 答:PID 相同,LWP 不同。内核把每一个线程看作独立 LWP,有自己的 task_struct。

    ps:process status,进程状态,查看进程的基础命令。

    • -a:all,显示所有用户的进程(默认只显示当前用户)
    • -L:Light weight process,LWP 轻量级进程,这个参数就是用来看线程!

    pthread_join 就是线程等待函数

    函数原型

    int pthread_join(pthread_t thread, void **retval);

    逐个参数大白话讲解

  • pthread_t thread:你要等待的目标线程 ID(pthread_create 输出的 tid)
  • 作用:指定你要等哪一个子线程。调用这个函数的线程会阻塞,一直等到这个子线程执行完毕,函数才返回。

  • void **retval:二级指针,用来接收子线程的返回值(这是这张图最难懂的地方)
    • 子线程入口函数返回值类型是 void*
    • pthread_join 要把这个 void * 返回值,拷贝到用户提供的变量里
    • 函数需要拿到void*变量的地址,所以参数类型就变成 void**

    简单类比: 如果想让函数修改int a,传&a(int*)
    如果想让函数修改void* x,传&x(void**)

    大白话解释:为啥线程入口函数要用 void*(void 星)

    void *(*start_routine) (void *)

    核心一句话:void* 是通用万能指针,可以代表任意类型的地址。pthread 库设计的时候,不知道你将来要传什么类型数据,所以用万能指针。

    1、参数 void* arg(传给线程的输入)

    你传给子线程的数据,类型五花八门: 可以是 int、数组、自定义结构体、对象…… 如果写成 int arg,那只能传整数; 写成 char* arg,就只能传字符串。

    用void*:不管你传什么变量的地址,都能塞进去。

    示例:

    int a = 100;
    pthread_create(…, &a); // 把int变量地址转成void*传给子线程

    子线程内部再强制转回原来类型:int val = *(int*)arg;

    2、返回值 void*(线程结束往外输出的数据)

    子线程跑完任务,想返回结果。这个结果也可以是任意类型的地址。 返回void*,就能返回任意数据的地址,后面pthread_join接收。

    void* threadFunc(void* arg)
    {
    int* p = new int(666);
    return (void*)p; // 返回int指针,包装成void*
    }

    主线程 join 之后,再强制转换,拿到 666。

    不能是 void代表:没有返回值。

    返回值

    成功返回 0;失败返回错误码。

    核心功能(必背考点)

  • 阻塞等待:调用pthread_join的线程会卡住不动,直到目标子线程执行结束。
  • 回收线程资源:子线程默认是可结合态 (joinable),线程退出后,它的内核资源(task_struct)不会自动释放,必须调用pthread_join回收。不 join 就会产生线程僵尸。
  • 获取子线程返回值:把子线程return void*的返回值,存入*retval。
  • 如果不想接收返回值,第二个参数直接传nullptr。

    图里指针逻辑拆解

    void *retval; // 一级指针,用来存放子线程返回的void*
    pthread_join(tid, &retval); // &retval 就是 void**,传给pthread_join

    • 子线程运行完,return 一个void* z
    • pthread_join库内部执行:*retval = z;
    • *retval 就是修改外面retval变量,把子线程返回的地址存进去。

    ⚠️ 易错坑(选择题高频)

  • 只能等待**可结合态 (joinable)**的线程;如果线程设置成分离态detach,不能 join,调用会失败。
  • 一个线程只能被 join 一次,多次 join 同一个 tid 直接报错。
  • void** retval 不是用来存普通数字!存的是地址。
  • ❌ 错误理解:retval 保存返回的整数
    ✅ 正确:retval 保存子线程 return 的指针地址;你自己再解引用拿到里面的数据。

  • 子线程如果被取消(pthread_cancel),join 拿到的返回值是 PTHREAD_CANCELED,本质是(void *)-1。
  • 极简示例代码

    #include <pthread.h>
    #include <iostream>
    using namespace std;

    void* threadFunc(void* arg)
    {
    int* p = new int(100);
    return (void*)p; // 返回void*指针
    }

    int main()
    {
    pthread_t tid;
    pthread_create(&tid, nullptr, threadFunc, nullptr);

    void* ret;
    // 等待子线程结束,拿到返回的void*
    pthread_join(tid, &ret);

    cout << *(int*)ret << endl; // 强转,取出里面的值100
    delete (int*)ret;
    return 0;
    }

    一句话背诵版

    pthread_join阻塞等待指定子线程结束,回收线程资源;第二个参数是二级指针,用来接收子线程入口函数返回的void*指针;不需要接收返回值填 nullptr。

    pthread_detach 讲解(大白话 + 考点)

    函数原型

    int pthread_detach(pthread_t thread);

    一句话:把目标线程设置成【分离态】

    核心作用

  • 分离态线程:线程退出之后,自动释放自己的内核资源(task_struct),不需要主线程调用 pthread_join 去回收。
  • 一旦 detach 成功,这个线程再也不能用 pthread_join 等待,再调用 join 直接报错。
  • 默认新建出来的线程是 joinable(可结合态):线程跑完退出,资源不会自动释放,必须 join 回收,不然产生线程僵尸。

    两种使用场景

    场景 1:你需要拿到子线程结果、要等它做完 → 用 pthread_join 场景 2:子线程自己干活,主线程不需要等它、不需要拿返回值 → pthread_detach

    示例代码片段:

    pthread_t tid;
    pthread_create(&tid,nullptr,threadFunc,nullptr);
    pthread_detach(tid); // 设置为分离态
    // 主线程不用join,子线程结束自动清理资源

    ⚠️ 高频易错考点

  • detach不会等待线程!只是标记属性,函数调用完立刻返回,不会卡住。
  • 一个线程不能同时 detach 和 join,二选一。
  • 分离态线程退出,返回值直接丢掉,无法获取。
  • pthread_cancel 大白话讲解

    函数原型:

    int pthread_cancel(pthread_t thread);

    一句话:向指定子线程发送取消请求,请求让这个子线程终止运行。

    重点(非常容易踩坑)

  • 不是立刻杀死线程! pthread_cancel只是发一个取消信号(请求标记),不是强制马上终止。 子线程不会瞬间死掉,要等到遇到取消点(cancel point),才会退出。
  • 什么是取消点:系统规定的一些库函数,比如 sleep、read、write。 子线程执行到这些函数的时候,会检查自己有没有收到取消请求。 如果收到了,就在这里终止线程。

  • 线程被取消退出后,主线程用pthread_join去获取返回值,拿到的就是 PTHREAD_CANCELED
  • #define PTHREAD_CANCELED ((void *)-1)

    本质就是把 – 1 强转为void*指针,用来标记:这个线程不是正常 return 结束,是被取消终止的。

    举个流程例子

    // 主线程
    pthread_cancel(tid); // 发取消请求,只是打个标记,子线程继续跑
    // … 子线程继续执行代码,直到碰到sleep()【取消点】
    // 碰到取消点 → 子线程退出
    pthread_join(tid, &ret);
    // ret == PTHREAD_CANCELED,代表是被取消终止

    考点总结

  • pthread_cancel:发送取消请求,不是直接杀线程。
  • 线程需要到达取消点才会退出。
  • 被取消的线程,pthread_join拿到返回值是PTHREAD_CANCELED。
  • 区分:
    • 正常结束:线程执行完入口函数 return xxx;
    • 被取消结束:收到 cancel 请求,在取消点退出
  • Linux pthread 线程原理

    核心一句话先记住

    Linux 内核没有 “线程” 这个概念!内核只有轻量级进程 LWP。pthread 线程库(用户层动态库 pthread.so)封装了 clone 系统调用,给我们提供线程 API(pthread_create、pthread_join)。

    1、内核层面

    课件原话:内核中有没有很明确的线程的概念呢?没有的。轻量级进程的概念 不会给我直接提供线程的系统调用,只会给我们提供轻量级进程的系统调用!

    • Linux 内核:只认识进程 / 轻量级进程 LWP,不存在专门叫 “线程” 的内核对象。
    • 创建轻量级进程用 clone() 系统调用。
    • clone 可以设置标志:共享虚拟地址空间、共享文件描述符、共享信号处理,这就是线程的本质。

    clone 函数原型

    int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, …);

    • fn:子任务要跑的函数(线程入口函数)
    • child_stack:子线程独立栈,必须用户层自己分配一块内存作为新线程栈
    • flags:标记哪些资源父子共享(创建线程就填:共享地址空间等标志)
    • arg:传给 fn 的参数

    重点:创建 LWP 的时候,必须手动给新线程准备独立栈!多个线程共享同一个虚拟地址空间,但是栈是独立的。
    clone 是 Linux 里通用创建任务的系统调用,fork、vfork 底层本质都是调用 clone,只是传不同 flags 标志。

    2、用户层:pthread 线程库 pthread.so(动态库)

    课件原话:pthread 线程库 – 应用层 – 轻量级进程接口进行封装。为用户提供直接线程的接口。 几乎所有 Linux 平台,默认自带这个库;写多线程代码,编译时要 -pthread。

    pthread 库做两件大事:

  • 封装clone,调用 clone 去内核创建轻量级进程(LWP)
  • 用户态维护线程管理结构:struct pthread(TCB,线程控制块,用户态 TCB!)
  • struct pthread(用户态 TCB)

    存在进程共享区(mmap 分配出来的内存),每个子线程对应一个:

    • 线程局部存储(TLS)
    • 线程栈
    • 各种线程属性、状态(是否退出、分离态、返回值)

    pthread_t tid:本质就是这个 struct pthread 结构体的起始地址! ❗ tid 不是内核 LWP 的 pid!tid 是用户态库层面的编号,内核 LWP id 是另外一回事。

    mmap

    mmap 是系统调用:在进程虚拟地址空间里,开辟一块新的虚拟内存区域。 这块区域可以映射磁盘文件,也可以映射成纯内存(匿名映射)。

    在 pthread 创建线程里,mmap 干什么(课件重点)

    pthread 库调用mmap做匿名映射:

  • 在当前进程的用户空间(共享区)申请一块内存
  • 这块内存同时放两样东西:
    • struct pthread 用户态 TCB(线程控制块)
    • 子线程独立栈
  • 主线程栈是操作系统默认分配在地址空间顶端;子线程栈不是默认栈,是 mmap 开辟出来的。

    对比

    • malloc:库函数,面向普通变量,拿堆内存,底层小的用 brk,大的内部 mmap。
    • mmap:系统调用,直接开辟虚拟内存;pthread 用它分配线程栈 + TCB,还能配置栈的警戒页,捕获栈溢出。

    3、进程虚拟地址空间布局(右边图)

    一个进程的 3G 用户空间,1G 内核空间:

    • 主线程:栈在进程默认栈位置(最顶部)
    • 其他子线程的栈:由 pthread 库用 mmap 开辟,放在共享区,不在主线程栈。

    课件红字:除了主线程,所有其他线程的独立栈,都在共享区;具体在 pthread 库中,tid 指向用户 TCB。

    资源共享总结(重点考点)

    ✅ 所有线程共享: 代码段、全局变量、堆、文件描述符表、信号处理方式、虚拟地址空间

    ❌ 每个线程独立: 线程栈、寄存器、TLS 线程局部存储、errno、信号屏蔽字

    4、整体流程:pthread_create 到底干了啥

  • pthread 库内部,用mmap开辟一块内存,这块内存包含:TCB (struct pthread) + 子线程栈
  • 调用clone()系统调用,告诉内核:创建轻量级进程 LWP,共享当前进程地址空间,新进程使用刚刚分配好的 child_stack
  • 内核创建 LWP,分配 task_struct(内核 PCB),返回。
  • 库保存 TCB,返回pthread_t tid(TCB 起始地址)给用户。
  • 子 LWP 调度运行,执行你写的线程入口函数。
  • 5、区分两个东西(考试超级高频坑)

  • 内核:task_struct(内核 PCB),对应 LWP(轻量级进程),内核调度单位
  • 用户 pthread 库:struct pthread(用户 TCB),管理线程属性、栈,库层面管理
  • 一个 pthread 线程,在内核侧对应一个独立 task_struct(LWP)。

    12.2 线程控制done

    线程栈、全局变量、TLS __thread

    核心一句话

    同一个进程里所有线程共享一套虚拟地址空间,所以:全局变量全部共享;但每个线程拥有独立栈。栈内存地址别的线程也能访问,只是正常情况下互不使用。如果想要「全局写法,但每个线程自己一份独立副本」,就用 __thread 线程局部存储 TLS。

    1、第一块:线程栈(右上角运行截图)

    课件原话:每一个线程都会有自己独立的栈结构! 其实线程和线程之间,几乎没有密码,线程的栈上的数据,也是可以被其他线程看到并访问的。

    看截图里的地址

    thread-0 &test_i: 0xbc7e3edc
    thread-1 &test_i: 0xbbfe2edc
    thread-2 &test_i: 0xbb7e1edc

    • test_i 是线程函数内部的局部变量,存在当前线程栈上
    • 三个线程,test_i 变量名字完全一样,但是地址完全不同!
    • 每个线程有独立栈,所以每个线程的局部变量,在自己栈上,互不干扰。

    重点坑: 栈只是逻辑上属于这个线程,虚拟地址空间是全部共享的。只要拿到栈上变量的地址,别的线程完全可以读写这块内存。 不是栈有隔离保护,只是约定好:每个线程只用自己的栈。

    2、第二块:普通全局变量(左下角截图)

    课件原话:全局变量是被所有的线程同时看到并访问的!

    代码:int g_val = 100; 普通全局变量,放在数据段

    thread-0 &g_val: 0x407174
    thread-1 &g_val: 0x407174
    thread-2 &g_val: 0x407174

    ✅ 三个线程,全局变量 g_val 的地址一模一样! 所有线程共用同一份,任何一个线程修改 g_val,全部线程都能看到修改后的结果。

    多线程竞争写全局变量,会产生线程安全问题(竞态条件)。

    问题来了:我想要一个【写起来像全局变量,但是每个线程各自独立一份】怎么办?

    普通全局变量大家共用,局部变量在线程栈(函数结束变量就销毁)。 需求:全局生命周期,但是每个线程单独副本 → TLS 线程局部存储。

    3、__thread TLS 线程局部存储(最下面)

    __thread:GCC 编译器扩展关键字,线程局部修饰符

    __thread int g_val = 100;

    课件原话:__thread 线程的局部存储!只能定义内置类型,不用来修饰自定义类型。

    运行截图地址

    thread-0 &g_val: 0x7f1c4e7a56fc
    thread-1 &g_val: 0x7f1c4d4fa6fc
    thread-2 &g_val: 0x7f1c4d7a36fc

    ✅ 变量名字相同,但是每个线程地址不一样!

    • 写法是全局变量,生命周期和线程绑定(线程创建分配,线程销毁释放)
    • 每个线程,有独立副本,A 线程修改自己的 g_val,不会影响 B 线程。

    TLS 底层原理(结合前面 pthread 课件)

    TLS:Thread Local Storage,线程局部存储
    TCB:Thread Control Block,线程控制块(课件里是用户态 struct pthread)
    这块__thread变量,就存在用户态 TCB(struct pthread)里面的 TLS 区域。 pthread 库,每个线程 TCB 里面预留 TLS 空间。访问__thread变量的时候,CPU 通过 TLS 寄存器,找到当前线程 TCB,拿到属于自己那份变量。

    限制(考试考点)

  • __thread 一般只能修饰内置基础类型(int、long 等)
  • 不能直接修饰带复杂构造函数的 C++ 自定义类(老编译器限制)
  • 编译 Linux 多线程程序,依旧需要 -pthread
  • 4、三者横向对比(背诵表格)

    表格

    变量类型存放位置每个线程是否独立生命周期
    线程函数局部变量 当前线程栈(mmap 开辟的栈) ✅独立 函数调用期间,函数返回销毁
    普通全局变量 数据段 ❌全部线程共享同一份 整个进程运行期间
    __thread TLS 变量 TCB 里面的 TLS 区域 ✅每个线程独立副本 和线程共存亡,线程创建分配,线程退出释放

    pthread 全称:POSIX thread

  • POSIX:可移植操作系统接口(一套系统 API 标准)
  • thread:线程
  • Linux gcc 里,像 -pthread 这种【编译 + 链接二合一的复合选项】,非常少见,pthread 是最典型、考试唯一常考的一个。 绝大多数库,都只有单纯 -lxxx(仅链接),没有配套的复合选项。

    1、-lpthread 是什么

    -lxxx 是链接选项:链接名叫 libxxx.so 的动态库 -lpthread = 链接 libpthread.so 线程库。

    -l 的规则:去掉前缀lib、后缀.so,中间部分写在-l后面。libpthread.so → -lpthread

    2、那 -pthread 干了两件事(重点!这就是为什么优先用它)

    -pthread 不止是帮你加上 -lpthread

  • 编译阶段:自动定义宏(-D_REENTRANT),开启可重入版本的库函数(比如 printf、malloc 变成线程安全版本)
  • 链接阶段:自动追加 -lpthread
  • ✅ 所以 -pthread = 编译参数 + 链接参数一次性配齐 ❌ 如果只写 -lpthread:只做链接,不会添加编译宏。某些老环境会出现库函数不是线程安全,产生奇怪 bug。

    抢票代码逐句大白话拆解(经典多线程竞态问题)

    现象

    总票数tickets = 1000,4 个线程抢票。 最后打印出来票数出现:0、-1、-2,票变成负数,逻辑出错。

    代码核心逻辑

    int tickets = 1000;
    void *getTicket(void *args)
    {
    while(true)
    {
    if(tickets > 0) // 判断还有票
    {
    usleep(1000); // 放大问题!模拟抢票处理耗时
    printf("who=%s, get a ticket: %d\\n", name, tickets);
    tickets–; // 票数减1
    }
    else
    {
    break;
    }
    }
    }

    课件原话:共享数据 → 数据不一致问题。 考题:多线程并发对全局变量做 ++ /–,线程不安全!

    为什么会出现负数(重点)

    tickets > 0 判断 和 tickets– 不是原子操作!中间可以被线程切换打断

    原子操作:一口气全部做完,中途不会切走 CPU。

    举一个最简场景(只剩最后 1 张票 tickets = 1)

  • 线程 1:执行if(tickets>0),判断1>0 → 条件成立。
  • 时间片轮转,CPU 切到线程 2。
  • 线程 2:执行if(tickets>0),此时tickets还是1,判断1>0,条件也成立! ✅ 两个线程,都成功进入 if 内部,都认为还有票。
  • 线程 2 usleep,然后执行tickets– → tickets 变成 0。
  • 切回线程 1,线程 1 继续往下跑,执行tickets– → tickets 变成 -1。
  • 👉 两个线程,只剩余 1 张票,但是两个线程都抢到票,票数减两次,出现负数。 usleep(1000) 是故意加上,放大这个问题,更容易复现 bug。

    额外:tickets–本身也不是一条 CPU 指令

    tickets– 拆成 3 步:

  • 把tickets的值,从内存读到 CPU 寄存器
  • 寄存器里面的值 -1
  • 把寄存器的值写回内存 这三步中间,随时可能发生线程切换,也会引发数据错乱。
  • 一句话考点

    if判断 和 tickets– 是两段独立代码,不具备原子性。多个线程同时访问共享全局变量,会出现竞态条件(race condition),导致数据不一致,票数出现负数。
    竞态条件
    多个执行流(线程 / 进程)并发访问共享资源,并且至少有一个是写操作;代码执行结果,依赖线程调度的时序,这种情况就叫竞态条件。

    两个前提,缺一不可:

  • 共享一块数据(这里就是全局变量tickets)
  • 至少一个线程对数据做修改(写)
  • 解决办法:加互斥锁(mutex)

    把【判断 + usleep + 减票】这一整块代码保护起来,同一时间只能有一个线程进入临界区。 伪代码:

    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

    if(true)
    {
    pthread_mutex_lock(&mutex); // 加锁
    if(tickets > 0)
    {
    usleep(1000);
    printf("who=%s, get a ticket: %d\\n", name, tickets);
    tickets–;
    pthread_mutex_unlock(&mutex); // 解锁
    }
    else
    {
    pthread_mutex_unlock(&mutex);
    break;
    }
    }

    判断题

  • tickets– 是原子操作?❌ 错,分为读、改、写三步。
  • 多个线程同时读取共享变量,不加锁,一定会出错?❌ 读没问题;一边读一边写,才会竞态。
  • 临界区就是访问共享资源的那一段代码,需要互斥保护。✅对。
  • tickets–汇编三步

    课件原句

    tickets–拆成 3 步汇编:

  • 将内存tickets的值,读到 CPU 寄存器(拷贝一份到寄存器,寄存器是线程自己的临时副本)
  • CPU 寄存器内部做 -1 运算
  • 把寄存器算好的新值,写回内存
  • 重点原话:寄存器里面的数据,只是拷贝出来的副本!寄存器≠内存里原始数据 线程把共享数据加载到寄存器,本质:拷贝一份,变成自己线程上下文里的私有临时数据。

    图的例子:tickets 初始 = 10

  • thread-1
    • 第一步:读内存 tickets=10 → 放到自己 CPU 寄存器(寄存器存了副本 10)
    • 第二步:寄存器里面 10-1 = 9 ✅ 此时内存里的 tickets 仍然还是 10! 还没写回去!
    • 第三步:把寄存器的 9 写回内存,内存tickets才变成 9

  • 竞态怎么发生(两个线程,tickets=10)

  • thread1:读内存,寄存器拿到副本10,准备减 1
  • 线程切换! 操作系统把 thread1 挂起(寄存器上下文保存),切到 thread2
  • thread2:内存此刻还是 10,thread2 读内存,自己寄存器拿到副本10
  • thread2:寄存器 10-1=9,写回内存,内存tickets=9
  • 切回 thread1,thread1 寄存器里还是旧副本10,执行 10-1=9,写回内存 👉 最后内存 tickets=9。 两个线程各自扣一张票,理论应该变成 8,结果只减到 9!数据丢失!
  • 原因:两个线程各自拷贝了一份旧数据到寄存器,互相看不见对方修改内存的操作。寄存器是线程私有的临时拷贝,不是实时同步内存。

    对应你代码里的坑(抢票)

    if(tickets>0) // 这一步也要从内存读tickets,放到寄存器判断
    {
    usleep(1000); // 这里休眠,线程挂起!寄存器副本失效,但代码已经判断完了!
    tickets–;
    }

    if判断的时候读到tickets=1,然后 usleep 休眠,CPU 切走。 别的线程也读到tickets=1,等休眠结束,两个线程都执行tickets–,直接变成负数。

  • 多线程并发修改共享变量,就会出现竞态条件,数据丢失 / 负数。
  • 互斥锁的作用:保证【读、判断、修改、写回】这一整块临界区代码,一次性跑完,中途不会被别的线程插队。
  • 赞(0)
    未经允许不得转载:171主机测评 » Linux 线程与线程地址空间与页表原理 pthread 系列函数
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址