什么是线程?
线程的朴素理解
线程,最直观的理解就是"一个程序内部的一条执行路线"。
你可以把进程想象成一个工厂,线程就是工厂里的生产线。一个工厂至少有一条生产线(主线程),也可以有多条生产线同时工作(多线程)。
更精确的定义是:线程是进程内部的一个控制序列。
Linux视角下的线程
在Linux系统中,线程在CPU眼中其实就是一个PCB(进程控制块)。
内核并不区分"进程"和"线程",它们都被抽象为task_struct结构体。区别在于:
-
进程:拥有独立的虚拟地址空间
-
线程:共享同一个进程的虚拟地址空间
这就是为什么说线程是"轻量级进程"------创建线程不需要复制整个地址空间,只需要在已有空间里划出一块区域给新线程用。
虚拟地址空间与分页
要真正理解线程,必须先搞懂虚拟地址空间和分页机制。
为什么要虚拟地址?
想象你是一个大型图书馆的管理员 ,而正在运行的程序(如浏览器、游戏)是借阅者。
-
物理内存(RAM)是真实书架:空间有限,且位置固定(0号架、1号架...)。
-
直接使用物理地址的灾难:
- 冲突:如果程序A占用了0-100号架,程序B也想用0号架,系统立刻崩溃。
- 碎片化:程序运行完后释放空间,书架出现很多空洞,新来的大程序可能塞不进去。
- 安全漏洞:程序A可以随意读取程序B书架上的书(数据)。
- 编译噩梦:程序员在写代码时,必须硬编码"我的变量放在物理内存的第1024号格子",这完全不可行。
解决方案 :给每个"借阅者"发一张专属地图(虚拟地址空间) 。在这张地图上,每个程序都认为自己拥有从0x0000到0xFFFF的整栋图书馆(完整地址空间)。管理员(操作系统+硬件)负责在背后把"地图上的书架号"映射到"真实物理书架号"。
分页(Paging)------ 映射的核心机制
既然虚拟地址这么大,物理内存(比如8GB)这么小,如何映射?
核心思想 :分而治之,按"页"映射,按需调入。
1. 基本单位
-
页(Page) :虚拟地址空间被切成固定大小的块,通常为 4KB(也可配置为2MB或1GB大页)。
-
页框(Page Frame):物理内存被切成同样大小的块(4KB)。
-
映射关系:虚拟页号(VPN) → 物理页框号(PPN)。
虚拟地址空间(连续) 物理内存(离散)
┌─────────────┐ ┌─────────────┐
│ 虚拟页 0 │───────┐ │ 页框 5 │
├─────────────┤ ├──▶├─────────────┤
│ 虚拟页 1 │───┐ │ │ 页框 2 │
├─────────────┤ │ └──▶├─────────────┤
│ 虚拟页 2 │ │ │ 页框 8 │
├─────────────┤ └──────▶├─────────────┤
│ 虚拟页 3 │ │ 页框 1 │
└─────────────┘ └─────────────┘
关键点:虚拟地址是连续的,物理地址可以是离散的。程序只需要连续的逻辑地址,剩下的由硬件MMU和操作系统搞定。
页表(Page Table)------ 映射字典
每个进程都有一个页表,保存在物理内存中。页表就像一个字典,记录了该进程每个虚拟页对应哪个物理页框。
页表的每一项,称为页表项(PTE,Page Table Entry) 。PTE的核心字段就是页框号(PFN,Page Frame Number)。
页表项(PTE)的关键字段:
-
物理页框号:指向真实内存地址。
-
有效位 :该页是否在物理内存中?(如果为0,访问会触发缺页中断)。
-
读/写/执行权限:控制访问保护(如代码段不可写)。
-
脏位:该页是否被修改过(用于换出时判断是否需要写回磁盘)。
-
访问位:用于内存淘汰算法(LRU)。
工作机制:
-
CPU发出一个虚拟地址(例如
0x0000_7FFF_FFFF_0123)。 -
MMU(内存管理单元)提取出虚拟页号(VPN)。
-
用VPN作为索引,在页表中查表,找到对应的PTE。
-
从PTE中读出物理页框号(PFN)。
-
将PFN与虚拟地址的页内偏移(低12位) 拼接,形成最终的物理地址,访问RAM。
多级页表(解决空间爆炸问题)
如果使用单级页表,64位系统有 2^64 / 2^12 = 2^52 个页表项,每个页表项8字节,单个进程页表需要 32PB 内存------根本不现实。
解决方案:多级页表(如x86-64的4级页表)
-
虚拟地址被拆分成 4个索引字段 + 页内偏移。
-
每一级页表都是一个数组,指向下一级页表的物理地址。
-
巨大优势 :如果某个中间级的页表不存在(进程没使用该区域),就不分配下一级页表,极大地节省内存。
CR3寄存器 ──▶ 页目录(1024项)
├──▶ 页表1(1024项)──▶ 物理页
├──▶ 页表2(1024项)──▶ 物理页
└──▶ 页表3(1024项)──▶ 物理页
一个程序可能只需要几十个页表就能覆盖全部内存需求,而不用一次性分配全部4MB。
我们以一个进程从诞生到运行的过程为例:
1. 进程创建(fork/exec)
-
内核为新进程分配一个
mm_struct(内存描述符)。 -
内核分配 1 个物理页框 作为 PML4表(顶级页表),将其物理地址写入CR3寄存器。
-
PML4表中的所有条目初始化为 "无效(Present=0)"。
-
此时只占用 1 个页框(4KB)。
2. 加载可执行文件(exec)
-
内核读取ELF文件的段(代码段、数据段),在虚拟地址空间中建立VMA(虚拟内存区域)。
-
但此时并不分配下级页表,只是记录"将来需要映射哪些虚拟地址范围"。
3. 程序开始执行(访问虚拟地址)
-
当CPU执行第一条指令(访问虚拟地址
0x400000)时:-
MMU查PML4表 → 对应的PML4条目无效 → 触发缺页中断。
-
内核检查该虚拟地址是否在VMA中(合法)。
-
如果合法,内核分配 1 个物理页框 作为 PDP表,并将该页框的PFN填入PML4条目中(置有效位=1)。
-
继续查PDP表 → 对应条目无效 → 再分配 PD表。
-
继续查PD表 → 对应条目无效 → 再分配 PT表。
-
继续查PT表 → 对应条目无效 → 分配 数据页框,从磁盘加载代码/数据,填入PTE。
-
-
此时,这条访问路径上分配了 PML4 + PDP + PD + PT + 数据页 = 5 个物理页框(20KB)。
4. 访问另一个不同区域的地址(如栈 0x7FFF_FFFF_0000)
-
PML4表已经存在(无需再分配)。
-
查PML4表,找到对应的条目(可能索引不同)→ 如果无效,分配新的 PDP表(另一个物理页框)。
-
继续分配新的 PD表 、新的 PT表 、新的 数据页框。
地址转换流程(MMU的工作)
以32位系统为例,虚拟地址被分成三部分:
-
高10位:页目录索引
-
中10位:页表索引
-
低12位:页内偏移
虚拟地址: [页目录索引(10bit)] [页表索引(10bit)] [偏移(12bit)]
↓ ↓ ↓
查页目录表 查页表 物理页内偏移
↓ ↓ ↓
得到页表地址 得到物理页地址 ────┘
↓
物理地址 = 物理页地址 + 偏移
MMU(内存管理单元)是硬件电路,专门做这件事,速度极快。
TLB------加速地址转换的缓存
多级页表节省了内存,但带来了一个新问题:地址转换变慢了。
-
单级页表:1 次内存访问(查页表)。
-
4级页表:需要 4 次内存访问( x86-64 4级分页下**)** (查 PML4、PDP、PD、PT)+ 1 次数据访问 = 5 次内存访问。
硬件救星:TLB(Translation Lookaside Buffer,快表)
-
TLB 是 CPU 内部的一个小型硬件缓存 ,专门缓存最近用过的 "虚拟页号 → 物理页框号" 映射。
-
如果 TLB 命中,地址转换在 1 个时钟周期 内完成,无需遍历多级页表。
-
只有当 TLB 未命中时,CPU 才"慢速"遍历 4 级页表(约需几十到几百个时钟周期),并将结果存入 TLB。
缺页异常(Page Fault)
当CPU访问的虚拟地址在TLB和页表中都找不到对应物理页时,触发缺页异常。
缺页异常分三种:
| 类型 | 含义 | 处理方式 |
|---|---|---|
| Hard Page Fault(硬缺页) | 物理内存中没有对应页 | 从磁盘读取到内存 |
| Soft Page Fault(软缺页) | 物理内存有,但映射关系未建立 | 只需建立映射 |
| Invalid Page Fault(无效缺页) | 访问了非法地址 | 触发段错误,进程崩溃 |
思考 :malloc和new分配内存时,真的立刻就分配物理内存了吗?
答案 :不是的!malloc只是在虚拟地址空间中划了一块区域,真正分配物理内存是在第一次访问时触发缺页异常才发生的。这就是惰性分配(Lazy Allocation)。
同一进程下的两个线程映射物理内存的全过程
第一部分:场景设定
-
系统:32位 x86(2级页表,简化但原理完全一致)。
-
进程 :
p,已启动,PID = 100。 -
线程 :
main(主线程)和worker(工作线程)。 -
物理内存状态 :一开始,进程刚创建,只分配了 1 个物理页框 (4KB)作为页目录(Page Directory),所有条目无效。
第二阶段:创建线程------内核干了什么?
当调用 pthread_create 时,内核执行 clone(CLONE_VM | CLONE_FILES | ...):
-
不创建新的
mm_struct:直接将子线程(worker)的task_struct->mm指针指向主线程的mm_struct。 -
不复制页目录 :worker 的
CR3寄存器值 等于 main 的CR3值(指向同一个物理页框)。 -
分配线程栈的虚拟地址 :在虚拟地址空间中,用
mmap预留一块区域(比如0x7FFF_0000~0x7FFF_FFFF,大小4MB),插入一个VMA(虚拟内存区域)。 -
页表状态 :此时,页目录和页表完全没有变化,VMA 只是一个"将来要用的规划"。
此刻物理内存占用 :只有 1 个页框 (页目录)+ 线程控制块(TCB)等内核数据结构,没有为线程栈分配任何物理页框。
第三阶段:主线程访问全局变量(共享数据)
主线程执行:int g_val = 42;(全局变量,虚拟地址 0x0001_0000)
第1步:CPU 的 MMU 查页表
-
虚拟地址
0x0001_0000:高20位(VPN)=0x00010,中10位(PT索引)=0x000,低12位偏移=0x000。 -
CR3 → 页目录物理地址(假设
0x2000_0000)。 -
页目录条目:
PD[0x00010](因为VPN=0x00010)→ 发现present = 0(无效)。
第2步:触发缺页中断(#PF)
-
CPU 保存上下文,跳转到内核的
do_page_fault()。 -
内核检查
0x0001_0000是否在合法的 VMA 中 → 是(全局数据段)。 -
内核执行
handle_mm_fault():a. 分配页表(PT)页框:
-
从伙伴系统分配 1 个空闲物理页框(假设 PFN =
0x100,物理地址0x1000_0000)。 -
在页目录 中,将
PD[0x00010]填入该页框的物理地址0x1000_0000,置present=1。
b. 分配数据页框:
-
再分配 1 个物理页框(假设 PFN =
0x200,物理地址0x2000_0000)。 -
在页表 (位于
0x1000_0000)中,用中10位索引0x000找到 PTE,填入 PFN=0x200,置present=1。
c. 从磁盘加载数据:
- 将可执行文件中
.data段对应的 4KB 数据(g_val=42)读入物理页框0x2000_0000。
-
第3步:返回用户态,重试指令
-
MMU 再次查表:这回页目录→页表→PTE 全部有效,得到物理地址
0x2000_0000+ 偏移0 → 成功读取42。 -
同时,CPU 自动将 VPN
0x00010→ PFN0x200的映射存入 TLB。
此时物理内存 :页目录(1页框)+ 页表(1页框)+ 数据页(1页框)= 3 个页框(12KB)。
第四阶段:工作线程(worker)访问同一个全局变量
工作线程执行:printf("%d", g_val);(访问 0x0001_0000)
第1步:MMU 查 TLB
-
由于主线程刚刚访问过,TLB 中已经有了
0x00010→0x2000_0000的缓存。 -
TLB 命中! CPU 直接得到物理地址
0x2000_0000,完全不需要遍历页表。
关键点:
-
Worker 和 Main 共享同一个虚拟地址
0x0001_0000。 -
它们共享同一份页表(因为 CR3 相同),所以 TLB 缓存的映射对 worker 同样有效。
-
最终访问的是同一个物理页框
0x2000_0000→ 读到相同的42。
物理内存无变化,因为页表已经存在,数据页已经分配。
第五阶段:工作线程写自己的栈(私有数据)
工作线程执行:int local = 100;(栈上分配,虚拟地址 0x7FFF_0000)
第1步:MMU 查页表
-
虚拟地址
0x7FFF_0000:高20位(VPN)=0x7FFF0,中10位(PT索引)=0x000,偏移=0。 -
CR3 → 页目录
0x2000_0000→ 查PD[0x7FFF0]→ 发现present=0(因为主线程从未访问过这个区域)。
第2步:触发缺页中断
-
内核检查 VMA:发现
0x7FFF_0000属于 worker 的栈 VMA。 -
内核执行缺页处理:
a. 分配新的页表(PT)页框:
-
从伙伴系统分配另一个空闲物理页框(假设 PFN =
0x300,物理地址0x3000_0000)。 -
在页目录中,将
PD[0x7FFF0]填入0x3000_0000,置present=1。 -
注意 :这个页表是新的,与主线程用的页表(PFN=0x100)不同。
b. 分配栈数据页框:
-
再分配一个物理页框(假设 PFN =
0x400,物理地址0x4000_0000)。 -
在新的页表 (位于
0x3000_0000)中,用中10位索引0x000找到 PTE,填入 PFN=0x400,置present=1。 -
将该页框内容清零(栈初始化)。
-
第3步:MMU 重试
- 页目录→页表→PTE 全部有效,得到物理地址
0x4000_0000,写入100。
此时物理内存:
-
页目录:1 页框(保持不变)
-
主线程数据页表:1 页框(PFN=0x100)
-
主线程数据页:1 页框(PFN=0x200)
-
工作线程栈页表:1 页框(PFN=0x300)
-
工作线程栈数据页:1 页框(PFN=0x400)
-
总共 5 个页框(20KB) ,全都通过共享的页目录被索引到。
线程与进程的关系------共享与独有
一句话总结
-
进程是资源分配的基本单位
-
线程是CPU调度的基本单位
线程之间共享什么?
同一进程内的所有线程共享:
-
虚拟地址空间(代码段、数据段、堆)
-
文件描述符表
-
信号处理方式
-
当前工作目录
-
用户ID和组ID
线程独有什么?
每个线程拥有自己的:
-
线程ID
-
寄存器上下文(PC、SP等)
-
栈(栈空间独立)
-
errno变量
-
信号屏蔽字
-
调度优先级
重要图示
进程地址空间
┌────────────────────────────────┐
│ 内核空间 │ ← 所有线程共享
├────────────────────────────────┤
│ 栈 │ ← 主线程的栈
├────────────────────────────────┤
│ ↓ │
│ ↑ │
├────────────────────────────────┤
│ 堆 │ ← 所有线程共享
├────────────────────────────────┤
│ 共享库/映射区 │ ← 子线程的栈在这里
├────────────────────────────────┤
│ 数据段(.data) │ ← 所有线程共享
├────────────────────────────────┤
│ 代码段(.text) │ ← 所有线程共享
└────────────────────────────────┘
关键理解:子线程的栈为什么在共享区?
因为pthread_create内部通过mmap在共享区(堆和栈之间的区域)申请了一块匿名内存作为线程栈。而主线程的栈在虚拟地址空间的栈区。
线程的优缺点
线程的核心优势
线程最本质的特点是轻量级 和共享性,这带来了四个决定性的优势:
1. 创建和切换开销极低(速度优势)
-
创建 :创建一个进程需要复制父进程的页表、文件描述符表、环境变量等(即使使用
fork()+COW,也要复制页表)。而创建一个线程,只需分配一个线程控制块(TCB) 和一个内核栈,几乎不涉及内存管理单元的TLB刷新。 -
切换(上下文切换) :切换进程时,CPU需要切换虚拟地址空间 (CR3寄存器),导致TLB全部失效(成本极高)。而切换线程时,虚拟地址空间保持不变(因为属于同一进程),只需切换寄存器和程序计数器,开销小一个数量级。
量化对比 :在Linux上,进程切换耗时约 1~2微秒 ,线程切换约 0.1~0.3微秒。
2. 高效的数据共享(通信优势)
-
进程间通信(IPC):管道、消息队列、共享内存,需要经过内核,涉及数据拷贝和同步,复杂且慢。
-
线程间通信 :由于所有线程共享进程的堆(Heap) 和全局数据段,它们可以直接通过指针读写同一块内存。
-
效果:数据传递是零拷贝的,延迟接近内存访问速度,这是高性能服务器(如Nginx、Redis)采用多线程(或多进程配合共享内存)的根本原因。
3. 充分利用多核CPU(并行优势)
-
单线程程序在一个时刻只能运行在一个CPU核心上。
-
多线程可以将任务拆解(如并行计算、同时处理多个网络请求),让多个核心同时工作。
4. 编程模型的便利性(I/O阻塞场景)
- 对于需要同时处理多个I/O操作(如网络读写、文件读写)的程序,使用多线程可以将每个请求分配给独立线程。当一个线程因I/O阻塞时,CPU可以立即切换到其他就绪线程,从而提高CPU利用率(相比单线程轮询,编程逻辑更直观)。
线程的致命缺点
共享是一把双刃剑。线程的缺点几乎全部源于资源共享带来的副作用。
1. 数据竞争(Race Condition)与同步噩梦(最大痛点)
-
问题 :两个线程同时修改同一个全局变量(如
count++),在汇编层面需要"读-改-写"三步。如果线程A读到5,线程B也读到5,A写回6,B也写回6,最终结果是6而非7。 -
代价 :为了修复这个问题,必须引入互斥锁(Mutex) 、读写锁(RWLock) 、信号量(Semaphore)。
-
副作用:
-
性能下降:加锁解锁本身有开销,且导致CPU缓存行(Cache Line)频繁失效。
-
死锁(Deadlock):线程A持有锁1等待锁2,线程B持有锁2等待锁1,两败俱伤。
-
优先级反转:低优先级线程持锁,高优先级线程等待,导致实时性崩溃。
-
编程复杂度飙升:编写正确且高性能的并发代码,难度远超单线程。
-
2. 健壮性极差(一个线程崩溃,全家陪葬)
-
进程隔离:进程A访问非法内存(如空指针),操作系统回收进程A的物理内存,进程B毫发无损。
-
线程隔离 :所有线程共享同一虚拟地址空间 。如果线程1解引用空指针,触发
Segmentation Fault,操作系统会直接杀死整个进程,所有线程瞬间消亡。
3. 调试与复现极其困难
-
单线程程序错误是确定性的(输入相同,输出相同)。
-
多线程的Bug(特别是死锁和竞态条件)往往依赖于CPU指令执行的交错时序,这在毫秒级甚至纳秒级变化。可能运行1000次才出现一次,且难以通过日志还原现场。
4. 资源消耗并非"零成本"
-
每个线程都有独立的栈空间(默认Linux下8MB)。创建1000个线程就需要约8GB的虚拟地址空间(虽然物理内存按需分配,但虚拟地址空间本身是有限的,尤其在32位系统)。
-
调度开销:当线程数量远大于CPU核心数(如1000个线程争抢8个核),操作系统调度器频繁进行上下文切换,开销会非线性增长。
5. 无法利用多机分布式资源
- 线程受限于单台物理机的CPU和内存上限。如果需要处理海量数据(如PB级),必须使用多进程或分布式架构。
线程控制------创建、终止、等待
POSIX线程库
Linux的线程操作遵循POSIX标准,使用pthread库:
#include <pthread.h>
// 编译时链接:-lpthread
所有函数名以pthread_开头。
创建线程 - pthread_create
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine)(void*),
void *arg);
-
thread:输出参数,返回线程ID(pthread库维护的) -
attr:线程属性,NULL表示默认 -
start_routine:线程执行的函数 -
arg:传递给函数的参数
错误检查方式不同:
-
传统系统调用:返回-1,设置
errno -
pthread函数:直接返回错误码
示例:
void* thread_func(void* arg) {
printf("Hello from thread!\n");
return NULL;
}
int main() {
pthread_t tid;
int ret = pthread_create(&tid, NULL, thread_func, NULL);
if (ret != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(ret));
exit(1);
}
// ...
}
两种线程ID
重要概念区分:
| 类型 | 来源 | 作用域 | 本质 |
|---|---|---|---|
| pthread_t | pthread_create返回,pthread_self()获取 |
进程内唯一 | 虚拟地址空间中的一个地址 |
| LWP(轻量级进程ID) | 内核分配,ps -aL可见 |
系统全局唯一 | 内核task_struct的pid |
pthread_t本质上是一个指向struct pthread(线程控制块TCB)的指针,这个结构体在堆上分配。
当调用 pthread_create 时,glibc 的 pthread 库在堆(Heap) 上分配了一块内存,用于存放该线程的控制块 ------struct pthread。
// glibc/nptl/descr.h 简化定义
struct pthread {
pid_t tid; // 内核线程ID(由内核分配)
void *stack; // 线程栈地址
int lock; // 锁
void *(*start_routine)(void*); // 入口函数
// ... 还有大量用于异常处理、TLS、清理函数等的字段
};
pthread_self() 返回的值 本质上是一个指针(内存地址) ,指向该线程独有的 struct pthread 结构体。
pthread_t 是"用户态身份证",内核不认;TID 是"内核态身份证",用户态很少直接操作。
两种线程ID分别属于用户态和内核态两个层面。
-
用户态线程ID(
pthread_t) :由 glibc 的 pthread 库管理,本质是一个指向struct pthread结构体的指针(或编码值),作用域仅限当前进程,用于pthread_join、pthread_mutex_lock等用户态同步操作。 -
内核态线程ID(TID) :由 Linux 内核分配,存储在
task_struct->pid中,系统全局唯一,用于内核调度、信号发送、资源统计,通过syscall(SYS_gettid)获取。
两者通过 clone 系统调用建立映射:pthread 库在创建线程时保存内核返回的 TID 到 struct pthread 中,以便在需要时(如 pthread_kill)将用户态 ID 转换为内核态 ID。不能混用 ,例如不能用 pthread_t 直接调用 kill 系统调用。
查看线程信息
ps -aL | grep program_name
输出:
PID LWP TTY TIME CMD
2711838 2711838 pts/0 00:00:00 program ← 主线程,PID == LWP
2711838 2711839 pts/0 00:00:00 program ← 子线程,PID不同
PID相同的线程属于同一个进程,LWP是内核的线程ID。
线程终止
三种方式:
1. 从线程函数return
void* thread_func(void* arg) {
return (void*)123; // 返回退出码
}
主线程不能这样用,main返回相当于整个进程exit。
2. pthread_exit
void pthread_exit(void *value_ptr);
void* thread_func(void* arg) {
int* p = malloc(sizeof(int));
*p = 123;
pthread_exit(p);
}
value_ptr不能指向局部变量(栈上的),因为线程退出后栈被销毁。
"栈被销毁"这个说法,更严谨的表述是:栈上局部变量的生命周期结束了,那块内存随时可能被复用或释放,你不能再依赖它。
但关键问题:pthread_exit 的参数什么时候被读取?
看这段代码:
void* thread_func(void* arg) {
int local = 123;
pthread_exit(&local); // 错误:传了局部变量地址
}
int main() {
pthread_t tid;
void* ret;
pthread_create(&tid, NULL, thread_func, NULL);
pthread_join(tid, &ret); // 这里才去读 ret 指向的内容
printf("%d\n", *(int*)ret); // 读到的是什么?
}
时间线是这样的:
t0: 主线程调用 pthread_create,创建子线程
t1: 子线程执行 thread_func
t2: 子线程执行 pthread_exit(&local)
→ 把 &local 这个地址存到 TCB 的 result 字段里
→ 子线程退出,栈帧销毁
t3: 主线程 pthread_join 返回
→ 主线程拿到 ret = &local
→ 主线程去读 *(int*)ret
问题出在 t2 和 t3 之间。
当子线程执行完 pthread_exit 后,它的栈帧(stack frame)已经出栈了。那块内存虽然可能还在(因为栈空间整体还没释放),但:
-
这块内存不再被这个线程"拥有",可能被其他函数调用覆盖。
-
如果线程是 detached 的,或者已经被 join 过,整个栈空间可能已经被
munmap释放,访问它就是野指针。
所以主线程在 t3 读 *(int*)ret 时,读到的是一块已经失效的内存 ,结果是未定义行为(可能读到 123,也可能读到垃圾值,也可能段错误)。
一句话记住:
线程函数的返回值,必须放在生命周期长于线程本身的内存里------全局变量或堆内存,绝不能是栈上的局部变量。
3. pthread_cancel
int pthread_cancel(pthread_t thread);
pthread_cancel(tid); // 取消另一个线程
线程等待 - pthread_join
为什么要等待线程?
-
已退出的线程占用的资源(TCB、栈)不会自动释放
-
不等待会造成资源泄漏
int pthread_join(pthread_t thread, void **value_ptr);
三种退出方式的返回值:
| 退出方式 | value_ptr指向的值 |
|---|---|
return |
线程函数的返回值 |
pthread_exit |
传给pthread_exit的参数 |
pthread_cancel |
PTHREAD_CANCELED(常量) |
示例:
void* thread1(void* arg) {
int* p = malloc(sizeof(int));
*p = 1;
return p;
}
int main() {
pthread_t tid;
void* ret;
pthread_create(&tid, NULL, thread1, NULL);
pthread_join(tid, &ret);
printf("返回值: %d\n", *(int*)ret);
free(ret);
return 0;
}
分离线程 - pthread_detach
如果不需要等待线程的返回值,可以让线程自动释放资源:
int pthread_detach(pthread_t thread);
// 线程自己分离
pthread_detach(pthread_self());
注意 :一个线程不能既是joinable又是detached的。
深入理解 pthread_t 的本质
让我们看一段glibc源码,揭开pthread_t的神秘面纱。
pthread_create 核心流程
// nptl/pthread_create.c
int __pthread_create_2_1(...) {
// 1. 分配线程控制块(TCB)和栈空间
struct pthread *pd = NULL;
int err = ALLOCATE_STACK(iattr, &pd);
// 2. 填充TCB
pd->start_routine = start_routine; // 要执行的函数
pd->arg = arg; // 函数的参数
pd->result = NULL; // 存放返回值
// 3. 把TCB地址作为线程ID返回
*newthread = (pthread_t)pd; // 本质是一个地址!
// 4. 调用clone系统调用创建轻量级进程
err = create_thread(pd, ...);
}
线程控制块 struct pthread
struct pthread {
// ... 大量字段 ...
void *(*start_routine)(void *); // 用户函数
void *arg; // 用户参数
void *result; // 返回值(join时读取)
void *stackblock; // 栈地址
size_t stackblock_size; // 栈大小
pid_t tid; // 内核线程ID(LWP)
pid_t pid; // 所属进程ID
// ...
};
pthread_t就是一个指向struct pthread的指针,所以在虚拟地址空间里它看起来是一个很大的数字(地址)。
线程栈的分配
// nptl/allocatestack.c
static int allocate_stack(...) {
// 使用mmap分配匿名内存作为线程栈
mem = mmap(NULL, size, prot,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK,
-1, 0);
// TCB放在栈的底部(对于向下增长的架构)
pd = (struct pthread *)((char *)mem + size - coloring) - 1;
// 记录栈信息
pd->stackblock = mem;
pd->stackblock_size = size;
*pdp = pd;
return 0;
}
线程栈通过mmap分配在共享区,不能动态增长,用尽就会栈溢出。
clone系统调用------线程的本质
Linux线程的底层是clone系统调用。
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...);
clone和fork的区别在于flags参数:
// 创建线程的关键flags
int clone_flags = CLONE_VM | // 共享地址空间
CLONE_FS | // 共享文件系统信息
CLONE_FILES | // 共享文件描述符
CLONE_SIGNAL | // 共享信号处理
CLONE_SETTLS; // 设置线程本地存储
当CLONE_VM标志位被设置时,新进程(线程)和父进程共享同一个虚拟地址空间------这就是线程的本质。
线程封装(C++风格)
为了方便使用,可以将pthread线程封装成C++类:
class Thread {
public:
using Func = std::function<void()>;
Thread(Func func) : _func(func), _joined(true) {
_name = "Thread-" + std::to_string(_count++);
_status = TSTATUS::NEW;
}
bool Start() {
if (_status == TSTATUS::RUNNING) return true;
int n = pthread_create(&_tid, nullptr, Run, this);
if (n != 0) return false;
_status = TSTATUS::RUNNING;
return true;
}
bool Join() {
if (_joined) {
int n = pthread_join(_tid, nullptr);
if (n != 0) return false;
return true;
}
return false;
}
void Detach() {
if (_status == TSTATUS::NEW) {
_joined = false;
}
}
private:
static void* Run(void* obj) {
Thread* self = static_cast<Thread*>(obj);
self->_func();
return nullptr;
}
std::string _name;
pthread_t _tid;
Func _func;
bool _joined;
TSTATUS _status;
static int _count;
};
使用:
void hello() {
while (true) {
std::cout << "Hello from thread!" << std::endl;
sleep(1);
}
}
int main() {
Thread t1(hello);
t1.Start();
t1.Join();
return 0;
}
核心要点速记
-
Linux不区分进程和线程 ,都用
task_struct表示,区别在于是否共享地址空间 -
线程共享:地址空间、文件描述符、信号处理、工作目录
-
线程独有:栈、寄存器上下文、线程ID、errno、信号屏蔽字
-
pthread_t本质 :指向
struct pthread(TCB)的指针,是一个虚拟地址 -
LWP :内核级线程ID,通过
ps -aL查看 -
子线程栈 :通过
mmap分配在共享区,不能动态增长 -
pthread_join的必要性:释放TCB和栈资源,否则内存泄漏
-
线程底层实现 :
pthread_create→ 分配TCB和栈 →clone系统调用(带CLONE_VM标志)