前言
这篇文章是 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 就是多条线程,全部共用这个进程的资源容器。
✅ 同一个进程内,所有线程共享这些东西
✅ 每个线程自己私有的东西(不共享)
(Thread Local Storage,线程局部存储)TLS:线程私有全局变量。线程创建就分配,线程退出才释放。生命周期:整个线程的一生。
errno 就是典型 TLS 变量。TLS 给每个线程单独开辟私有全局变量,多线程之间互不干扰,解决普通全局变量竞争覆盖的问题。
4. 两个思考题(课件上的)
问题 1:为什么线程在进程 “内部” 运行,跑在进程的地址空间里?
进程才是持有资源的容器,地址空间、内存资源属于进程。 线程仅仅是执行代码的分支,线程本身没有独立的内存资源,只能复用进程已经开好的地址空间。 好处:多个线程之间直接读写全局变量,交换数据非常方便。
问题 2:为什么说线程执行粒度比进程更细?
什么叫「执行粒度更细」
粒度 = 操作系统调度的最小单元。
大白话: 进程是一整套完整打包好的程序;线程只是这个程序里面拆分出来的一小段干活的分支。操作系统调度的时候,可以只调度这一小段分支,不用搬整个程序。所以粒度更小、更细。
对比:进程切换,必须替换整套页表,刷新 TLB,开销大很多。
Translation Lookaside Buffer,地址转换后备缓冲器,是 CPU 硬件里面一小块高速缓存。
TLB 缓存【虚拟地址→物理地址】的映射条目,放在 CPU 内部,速度极快。
2、“线程执行进程代码的一部分”
程序编译完成,代码放在.text代码段,代码段属于整个进程,所有线程共享这一份代码。 线程不是单独拥有一份代码,多个线程可以跑同一份代码里不同的片段。 例子: 进程代码有 A 函数、B 函数。主线程跑 A,子线程跑 B。 👉 子线程只是执行这个进程代码里的一部分,不是自己拥有一份全新程序。
5. fork 和 pthread_create 的区分(仅这两个 API 对比)
6. 易错点单独拎出来
重新定义线程和进程
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 位(页表项)
物理地址 = 物理页框起始地址 + 低 12 位偏移
重要考点:页表不是一次性全部建好
课件写:二级页表大部分情况都是不全的 只有程序真正访问某一块虚拟内存的时候,内核才会创建对应的页表项。不会一次性把 4G 空间的所有页表全部创建,节省物理内存。
四、磁盘、虚拟地址、物理内存关系
A.exe 在磁盘上。 程序运行加载:不是一次性全部读进物理内存。
- 访问虚拟地址,如果对应的页面不在物理内存 → 缺页异常,内核把磁盘的页面读到物理内存,填充页表。
- 图里面页表打叉:代表这个虚拟地址还没有映射物理内存。
五、图下面小例子 int a=10;
int a占 4 字节。&a拿到的是虚拟地址,不是物理地址。 X86 特点:地址 + 类型,就能算出偏移,找到变量。
核心问答(图上的两个问号)
1. 如何理解资源分配给线程?
进程拥有整套虚拟地址空间。 创建新线程,不需要新建地址空间。内核在进程已有的虚拟地址空间里划分一段地址,作为这个线程私有栈。这就是给线程分配资源。
2. 虚拟地址怎么转物理地址?
32 位二级页表三步:
精简背诵版
Linux线程周边的概念
Linux 里线程本质就是轻量级进程,每一条线程都有自己独立的task_struct(PCB),同一个进程内所有线程共用同一份虚拟地址空间(mm_struct)。
图里各个部件拆解
- 一个进程 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. 线程切换更轻量(运行,图左侧红色笔记重点)
笔记原话:线程内的切换不需要重新 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:
四、核心考点总结(背诵)
五、易错题
问:主线程和子线程 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);
逐个参数大白话讲解
作用:指定你要等哪一个子线程。调用这个函数的线程会阻塞,一直等到这个子线程执行完毕,函数才返回。
- 子线程入口函数返回值类型是 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;失败返回错误码。
核心功能(必背考点)
如果不想接收返回值,第二个参数直接传nullptr。
图里指针逻辑拆解
void *retval; // 一级指针,用来存放子线程返回的void*
pthread_join(tid, &retval); // &retval 就是 void**,传给pthread_join
- 子线程运行完,return 一个void* z
- pthread_join库内部执行:*retval = z;
- *retval 就是修改外面retval变量,把子线程返回的地址存进去。
⚠️ 易错坑(选择题高频)
❌ 错误理解:retval 保存返回的整数
✅ 正确:retval 保存子线程 return 的指针地址;你自己再解引用拿到里面的数据。
极简示例代码
#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);
一句话:把目标线程设置成【分离态】
核心作用
默认新建出来的线程是 joinable(可结合态):线程跑完退出,资源不会自动释放,必须 join 回收,不然产生线程僵尸。
两种使用场景
场景 1:你需要拿到子线程结果、要等它做完 → 用 pthread_join 场景 2:子线程自己干活,主线程不需要等它、不需要拿返回值 → pthread_detach
示例代码片段:
pthread_t tid;
pthread_create(&tid,nullptr,threadFunc,nullptr);
pthread_detach(tid); // 设置为分离态
// 主线程不用join,子线程结束自动清理资源
⚠️ 高频易错考点
pthread_cancel 大白话讲解
函数原型:
int pthread_cancel(pthread_t thread);
一句话:向指定子线程发送取消请求,请求让这个子线程终止运行。
重点(非常容易踩坑)
什么是取消点:系统规定的一些库函数,比如 sleep、read、write。 子线程执行到这些函数的时候,会检查自己有没有收到取消请求。 如果收到了,就在这里终止线程。
#define PTHREAD_CANCELED ((void *)-1)
本质就是把 – 1 强转为void*指针,用来标记:这个线程不是正常 return 结束,是被取消终止的。
举个流程例子
// 主线程
pthread_cancel(tid); // 发取消请求,只是打个标记,子线程继续跑
// … 子线程继续执行代码,直到碰到sleep()【取消点】
// 碰到取消点 → 子线程退出
pthread_join(tid, &ret);
// ret == 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 库做两件大事:
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 到底干了啥
5、区分两个东西(考试超级高频坑)
一个 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,拿到属于自己那份变量。
限制(考试考点)
4、三者横向对比(背诵表格)
表格
| 线程函数局部变量 | 当前线程栈(mmap 开辟的栈) | ✅独立 | 函数调用期间,函数返回销毁 |
| 普通全局变量 | 数据段 | ❌全部线程共享同一份 | 整个进程运行期间 |
| __thread TLS 变量 | TCB 里面的 TLS 区域 | ✅每个线程独立副本 | 和线程共存亡,线程创建分配,线程退出释放 |
pthread 全称:POSIX 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
✅ 所以 -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 张票,但是两个线程都抢到票,票数减两次,出现负数。 usleep(1000) 是故意加上,放大这个问题,更容易复现 bug。
额外:tickets–本身也不是一条 CPU 指令
tickets– 拆成 3 步:
一句话考点
if判断 和 tickets– 是两段独立代码,不具备原子性。多个线程同时访问共享全局变量,会出现竞态条件(race condition),导致数据不一致,票数出现负数。
竞态条件
多个执行流(线程 / 进程)并发访问共享资源,并且至少有一个是写操作;代码执行结果,依赖线程调度的时序,这种情况就叫竞态条件。
两个前提,缺一不可:
解决办法:加互斥锁(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–拆成 3 步汇编:
重点原话:寄存器里面的数据,只是拷贝出来的副本!寄存器≠内存里原始数据 线程把共享数据加载到寄存器,本质:拷贝一份,变成自己线程上下文里的私有临时数据。
图的例子:tickets 初始 = 10
- 第一步:读内存 tickets=10 → 放到自己 CPU 寄存器(寄存器存了副本 10)
- 第二步:寄存器里面 10-1 = 9 ✅ 此时内存里的 tickets 仍然还是 10! 还没写回去!
- 第三步:把寄存器的 9 写回内存,内存tickets才变成 9
竞态怎么发生(两个线程,tickets=10)
原因:两个线程各自拷贝了一份旧数据到寄存器,互相看不见对方修改内存的操作。寄存器是线程私有的临时拷贝,不是实时同步内存。
对应你代码里的坑(抢票)
if(tickets>0) // 这一步也要从内存读tickets,放到寄存器判断
{
usleep(1000); // 这里休眠,线程挂起!寄存器副本失效,但代码已经判断完了!
tickets–;
}
if判断的时候读到tickets=1,然后 usleep 休眠,CPU 切走。 别的线程也读到tickets=1,等休眠结束,两个线程都执行tickets–,直接变成负数。





