Linux 线程深度解析:概念、机制与实践
Linux-线程理解
1.线程概念
-
什么是进程?
-
进程 = 内核数据结构 + 自己的代码和数据。
-
进程是承担分配系统资源的基本实体。
-
这些资源 (内核数据结构) 包括:
- task_struct结构体(可能会有多个)
独立的虚拟地址空间
文件描述符表
独立的页表
-
什么是线程?
-
线程是进程内部的一个执行分支(执行流)。
-
线程是CPU调度的基本单位。
-
- 创建一个新的线程,在内核层面上本质就是创建了一个新的
task_struct,但这个新的task_struct与创建它的那个task_struct共享绝大部分的资源(虚拟地址空间、页表、文件描述表)。

一些结论和问题:
-
Linux并不严格区分进程和线程。
-
Linux的线程使用进程来模拟的。
-
Linux的线程就是轻量级进程。
-
当多个
task_struct共享一个内存地址空间和其他资源时,在这个视角下被称为线程。线程本质上是对虚拟地址空间的共享。 -
决定一个
task_struct是用户眼中的进程还是线程,关键在于它是否与另一个task_struct共享资源,尤其是内存地址空间。-
独立的
task_struct:它既是进程也是唯一的线程(这个线程也可以被称为主线程)。 -
多个
task_struct共享资源:其中每一个task_struct,都是这个进程的一个线程。
-
-
进程强调独占,部分共享(比如通信时),线程强调共享,部分独占。
为什么Linux不单独设计线程,而复用进程的代码呢?
Linux选择不单独设计线程,而是用进程来实现线程,这是一个直指Linux内核设计的哲学核心。
-
一个数据结构搞定一切:如果为线程和进程分别设计两套不同的内核管理结构(比如一个
PCB和一个TCB),会导致内核代码变得复杂、冗余,容易出错。 -
统一的调度器:调度器不需要关心它是进程还是线程,它只处理统一的
task_struct。每个task_struct都是平等的调度单元,这极大地简化了调度算法和内核代码(调度算法只需一套即可)。 -
统一的机制: 比如信号处理、资源管理等所有机制,只需要针对
task_struct这一种内核数据结构设计和实现即可。例如,向一个线程发送信号和向一个进程发送信号,在内核里是同一套逻辑。
这已经不是一种简单的复用,而是一种极具智慧且经过实践检验的、优雅的工程解决方案。
2.理解虚拟地址空间和页表
2.1 理解虚拟地址空间
简单来说,它们的主要目的是:提供内存抽象、隔离和保护,并实现更高效的内存管理。
假设并不存在它们会怎么样?(物理内存的直接映射)
假设没有虚拟地址空间,程序直接操作物理内存地址(比如0x00000000到0xFFFFFFFF),会带来灾难性的后果:
-
安全问题(缺乏隔离):
-
一个恶意或存在Bug的程序可以随意读写其他程序甚至操作系统内核的内存数据。比如,你的浏览器可以修改正在运行的Word文档的内容,或者直接让操作系统崩溃。
-
结论:系统毫无安全性和稳定性可言。
-
-
可靠性问题:
-
每个程序都需要知道哪块物理内存是可用的,这几乎不可能。一个程序很可能意外地覆盖掉另一个程序的关键数据。
-
结论:无法同时运行多个程序。
-
-
内存管理问题:
-
程序必须被加载到一块连续的、足够大的物理内存中才能运行。随着程序的运行和结束,物理内存中会产生大量难以利用的内存碎片。
-
结论:内存利用率极低。
-
为了解决上述的问题,操作系统为我们提供了一层软件层(引入虚拟地址空间和页表)来解决:
虚拟地址空间:每个进程都认为自己独占了整个内存(比如在32位系统上是4GB的连续空间)。这个空间是线性的、从0开始的,并且被划分为固定的区域(代码区、数据区、堆、栈等)。程序的所有操作都基于这个虚拟地址。
页表:这是连接虚拟世界和物理世界的地图。它由CPU中的内存管理单元(MMU) 来自动查询(MMU是一个集成在CPU中的硬件)。
这样做的好处:
-
进程之间具有独立性(进程A无法感知其他进程的存在,也无法访问对方的内存)。
-
解决内存碎片化问题(虚拟地址空间是连续的,但页表可以将其映射到物理内存中任何不连续的物理页框中)。
-
无序变有序(进程的代码和数据在物理内存可能是乱序的,但是在进程地址空间中则是有序的)。
2.2 物理内存的理解
物理内存的理解:
-
我们在Linux文件系统中学习知道,扇区是磁盘的基本单位 (通常一个扇区大小是512Byte),而操作系统一次IO通常是读取8个扇区,也就是4KB,磁盘中的4KB被称为块。
-
物理内存中与之对应的、大小通常也为4KB的单位,被称为页框或页帧或页。
物理内存的管理:
假设一个可用的物理内存有 4GB 的空间。按照一个页框的大小 4KB 进行划分,4GB 的空间就是 4 GB / 4 KB = (4 * 1024 * (1024KB)) / 4 KB = 1,048,576 个页框,在内核中操作系统使用 struct page 结构描述系统中的每个物理页,出于节省内存的考虑,struct page 使用了大量的联合体。所以,struct page 的主要目的是描述和控制物理内存页(通常是4KB)的状态和元数据,而不是存储实际的数据。
你可以这样理解:内核为物理内存中的 每一个页框(通常为4KB) 都创建了一个对应的 struct page 对象。这些所有的 struct page 对象组成了一个巨大的数组,称为 page_map 数组。通过这个数组,内核可以知道系统中任何一块物理页框的状态和信息。(page_map[0]对应的就是物理内存的第一个4KB,以此类推…)
struct page_map[1048576];数组的下标就被称为页框号(PFN)。
-
假设每个
struct page的大小是 64 字节。-
1,048,576 * 64 Bytes = 67,108,864 Bytes -
67108864 / 1024 / 1024 = 64MB
-
结论:对于一个拥有 4GB 物理内存的系统,为了管理这些内存而产生的 struct page 结构体的总内存开销大约是 64 MB。
而 struct page 结构体中通常并没有一个直接指向其代表的那4KB物理内存的指针字段,因为 struct page 对象和它代表的物理内存之间存在着固定且可计算的数学关系,保存一个指针将是巨大的冗余。
-
PFN(数组中的下标)=物理地址/4KBPFN(数组中的下标) = 物理地址 / 4KBPFN(数组中的下标)=物理地址/4KB
-
物理地址=PFN(数组中的下标)∗4KB物理地址=PFN(数组中的下标)*4KB物理地址=PFN(数组中的下标)∗4KB
所以假设已知一个 struct page 的指针 ,根据数组的首元素地址,通过指针 - 指针的方式算出数组的下标,然后通过下标 * 4KB计算出物理地址即可。
page根本不需要申请,操作系统启动初期,就已经一次性将page_map数组全部初始化完毕了。
struct page {/* 原⼦标志,有些情况下会异步更新 */ unsigned long flags;// flags 中的位表示页的各种状态,例如:// PG_locked - 页是否被锁定// PG_dirty - 页是否被写入过(脏)// PG_uptodate - 页的内容是否有效(与磁盘同步)// PG_lru - 页是否在LRU链表上(用于页面回收)// PG_swapbacked - 页是否可被换出到交换分区union {struct {/* 换出⻚列表,例如由zone->lru_lock保护的active_list */struct list_head lru;/* 如果最低为为0,则指向inode* address_space,或为NULL* 如果⻚映射为匿名内存,最低为置位* ⽽且该指针指向anon_vma对象*/struct address_space* mapping;/* 在映射内的偏移量 */pgoff_t index;/** 由映射私有,不透明数据* 如果设置了PagePrivate,通常⽤于buffer_heads* 如果设置了PageSwapCache,则⽤于swp_entry_t* 如果设置了PG_buddy,则⽤于表⽰伙伴系统中的阶*/unsigned long private;};// ...};//...union {/* 内存管理⼦系统中映射的⻚表项计数,⽤于表⽰⻚是否已经映射,还⽤于限制逆向映射搜索*/atomic_t _mapcount; //表⽰在⻚表中有多少项指向该⻚,也就是这⼀⻚被引⽤了多少次。当计数值变为-1时,就说明当前内核并没有引⽤这⼀⻚,于是在新的分配中就可以使⽤它unsigned int page_type;unsigned int active; /* SLAB */int units; /* SLOB */};// ...
};
2.3 页表的理解
如果页表是单纯的映射比如虚拟地址和物理地址的映射,那么按地址4字节来算,一个虚拟地址映射就是8字节,4GB的虚拟地址映射就是32GB,所以根本不可能。
实际上:操作系统采用了一种 多级页表 的机制,巧妙的解决了这个空间爆炸的问题。其核心思想是,只为那些实际被使用的虚拟内存区域创建映射,而不是为整个4GB空间。
首先:虚拟内存的划分本质上就是一个 start 和 end 整数的上移和下移。
-
虚拟地址拆分:32位地址,10位页目录索引 + 10位页表索引 + 12位页内偏移。
-
索引过程:用第一部分找页目录项(PDE),用第二部分找页表项(PTE)。
-
数量关系:1024个页目录项,每个指向一个页表;每个页表有1024个项,所以总共可以管理 1024 * 1024 = 2^20 个页。这正好对应4GB地址空间。
-
页内偏移:找到物理页框的基地址后,加上12位的偏移量,就能定位到该4KB页内的任何一个字节。
思考一种极端情况:假设一个进程使用了全部4GB虚拟地址空间,并且每一页都有效,因此页目录和页表都占满。
页目录总占的大小:1024 * 4 bytes = 4096 bytes = 4KB。
每个页表总大小:1024 * 4 bytes = 4096 bytes = 4KB。
所有页表的总大小:1024个页表 * 每个页表的总大小4KB = 4MB。
所以最极端的情况下,一个进程的页表也只有4MB多,而我们刚开始以为的页表如果是单纯的虚拟和物理的映射,一个进程的页表就是32GB,所以采用多级页表的方式节约了大量的内存,其次是绝大多数进程只会用到很少一部分页表,1024个页目录中,只有几个被使用,其它大部分的页目录根本不需要分配页表。
-
页表项/页(PTE):里面存放的是 物理页框的基地址(物理内存的地址),再加上一些标志位(存在位、可写位、用户/内核位等)。
-
它直接指向物理内存,是给 CPU的MMU硬件 使用的。MMU通过PTE直接拿到物理地址,不需要任何软件介入。
-
每个进程的页目录的物理地址被 CR3 寄存器指向(其实也是CPU的上下文的一部分)。
-
所以虚拟地址转物理地址,是通过硬件 MMU 完成的,全程硬件自动、高速完成的。
-
问题:标志位存放到哪里?4字节不是存放地址了吗?
-
这个32位的值并不仅仅是一个纯粹的地址,而是被划分成了两部分:
-
高20位:用来存储物理页框号(就是page_map[1048576]的下标)。
- 2的20次方 = 1024 * 1024 = 1048576
-
低12位:用来存储标志位和控制信息。
-
-
-
-
struct page:是内核软件用于 管理 这个物理页框的数据结构。它描述了这块物理内存的状态(是否空闲?被谁引用?脏了吗?等)。- 它是给 操作系统内核软件 使用的。当内核需要分配、释放内存,或者处理缺页异常时,它需要通过
struct page来了解物理页框的元信息。
- 它是给 操作系统内核软件 使用的。当内核需要分配、释放内存,或者处理缺页异常时,它需要通过

总结:单级⻚表对连续内存要求⾼,于是引⼊了多级⻚表,但是多级⻚表也是⼀把双刃剑,在减少存储空间的同时降低了查询效率。
有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加⼀个中间层来解决。
MMU引入了快表TLB(其实,就是缓存,Translation Lookaside Buffer)。当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存。但 TLB 容量⽐较⼩,难免发⽣ Cache Miss ,这时候 MMU 就在⻚表中找到对应物理地址后,MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录⼀下刷新缓存。
2.4 用户申请内存的大概流程
这个流程的核心在于 拖延战术:直到万不得已,才真正分配物理内存。
1️⃣ 用户态 - malloc 调用
-
程序调用
malloc(4096):申请4KB内存。 -
malloc底层它会调用
brk()或sbrk()系统调用,请求操作系统扩大堆的结束地址(即上移vm_end指针)。 -
结果:到此为止,
malloc成功返回了一个虚拟地址(比如0x80123000)给程序。关键:此时没有任何物理内存被分配! 操作系统只是修改了进程的vm_area_struct,承诺这块虚拟地址你可以用了。
2️⃣ 用户态 - 首次访问
-
程序拿到
malloc返回的指针,比如char *p = malloc(4096);。 -
程序第一次对这块内存进行读写,例如
p[0] = 'A';。 -
CPU 执行这条存储指令,将虚拟地址
0x80123000发送给 MMU。
3️⃣ 硬件 - 触发缺页异常
-
MMU 查询页表:MMU 用虚拟地址
0x80123000去查找当前进程的多级页表。 -
发现页表项无效:在相应的页表项(PTE)中,存在位为0。这表明该虚拟地址还没有映射到任何物理页框。
-
触发缺页异常:MMU 产生一个缺页异常,CPU 中断当前执行流,切换到内核态。
4️⃣ 内核态 - 异常处理(核心环节)
这是最复杂的一步,内核的缺页异常处理程序开始工作:
-
诊断原因:内核检查出错的虚拟地址
0x80123000。 -
查找区域:在内核为进程维护的
vm_area_struct红黑树中,查找该地址属于哪个区域。 -
合法性检查:
-
找到了吗? 如果没找到(例如访问了未分配的区域),则发送
SIGSEGV信号(段错误),杀死进程。 -
权限正确吗? 如果区域是只读的,但进程试图写入,同样发送
SIGSEGV。
-
-
分配物理页框:检查通过,内核需要分配一个物理页框。
-
内核调用伙伴算法,从空闲物理内存链表中申请一个4KB的物理页框。
-
伙伴系统找到空闲页框,返回其物理地址(比如
0x12345000)。 -
同时,伙伴系统会更新对应的
struct page元数据,将其状态从空闲改为已使用,并增加引用计数等。
相当于就是伙伴算法维护了一些空闲链表(目的是为了不用遍历整个链表,空闲链表就是每个节点都是空闲的,直接取下一个就可以O(1)),而链表的节点就是一个page,然后找到空闲的page,根据page计算出物理地址返回,并且将该page中的元信息也填充(计算就是通过 page - page_map数组地址 算出下标就可以了)。*
-
-
更新页表:
-
内核修改当前进程的页表:找到(或创建)虚拟地址
0x80123000对应的那个页表项(PTE)。 -
将物理地址
0x12345000和权限标志位(可写、用户态、存在)填入该PTE。
-
-
可选:清零页面:出于安全考虑,内核通常会将新分配的物理页框清零,防止泄漏之前进程的旧数据。
5️⃣ 恢复执行
-
缺页异常处理完毕,内核返回到用户态,并重新执行那条触发异常的指令
p[0] = 'A';。 -
这次,MMU再次查询页表,发现PTE的存在位为1,且物理地址为
0x12345000。 -
MMU成功地将虚拟地址
0x80123000翻译成物理地址0x12345000,并将字符'A'写入该物理内存地址。 -
至此,内存申请和使用的整个流程才真正完成。
结合信号部分理解。
2.5 线程的优点
-
创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多。
-
与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多。
-
最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的,虽然他们的上下文都需要切换,但是线程切换CR3寄存器不需要切换。
-
另外⼀个隐藏的损耗是上下⽂的切换会 扰乱处理器的缓存机制,⼀旦去切换上下⽂,处理器中所有已经缓存的内存地址⼀瞬间都作废了。还有⼀个显著的区别是当你改变虚拟内存空间的时候,处理的⻚表缓冲 TLB (快表)会被全部刷新,这将导致内存的访问在⼀段时间内相当的低效。但是在线程的切换中,不会出现这个问题。
-
2.6 线程的私有数据
-
线程ID。
-
一组寄存器,存放线程的上下文数据。
-
栈。
2.7 补充
-
进程的时间片会被线程等分。
-
任何一个线程奔溃,都会导致整个进程奔溃。
3. 线程的操作
3.1 什么是 pthread 库
Linux中没有线程的概念,Linux中是使用进程模拟线程的,被称为轻量级进程。
所以,pthread 库并不属于内核,而是 glibc 的一部分,是一个第三方库属于用户态的库。
使用 g++ 编译时,需要携带 -lpthread 选项。
3.2 pthread_create
创建一个新的线程。
函数原型:
int pthread_create(pthread_t *thread,const pthread_attr_t *attr,void *(*start_routine) (void *),void *arg);
参数:
-
thread: 输出型参数,用于返回新线程的 ID。 -
attr: 线程属性,通常传入NULL使用默认属性(可设置栈大小、分离状态等)。 -
start_routine: 线程函数的指针。该函数必须形如void* function_name(void* arg)。 -
arg: 传递给线程函数的参数。
返回值: 成功返回 0,失败返回错误码(且 *thread 是未定义的)。
3.3 pthread_join
等待线程终止。
函数原型:
int pthread_join(pthread_t thread, void **retval);
参数:
-
thread: 要等待的线程 ID。 -
retval: 输出型参数,用于接收线程函数start_routine的返回值。如果不需要,可以传NULL。
返回值: 成功返回 0,失败返回错误码。
不等待线程终止,会造成内存泄漏问题。
3.4 pthread_self
获取当前线程的id。
pthread_t pthread_self(void);
3.5 线程的终止
3.5.1 三种方式
-
从线程函数
return。这种⽅法对主线程不适⽤,从 main 函数 return 相当于调⽤exit。 -
线程可以调⽤
pthread_ exit终⽌⾃⼰。 -
⼀个线程可以调⽤
pthread_ cancel终⽌同⼀进程中的另⼀个线程。
3.5.2 pthread_exit
线程调用此函数来主动终止自己。
函数原型:
void pthread_exit(void *retval);
参数:
retval: 线程的返回值,可以被pthread_join获取。
3.5.3 pthread_cancel
向一个线程发送取消请求。
当线程被取消时,pthread_join 的 retval 参数会接收到一个特殊值:PTHREAD_CANCELED,它是一个宏定义,通常是(void*)-1。
函数原型:
int pthread_cancel(pthread_t thread);
参数:
thread: 要取消的线程 ID。
返回值: 成功返回 0,失败返回错误码。
3.6 pthread_detach
用于将一个线程标记为 分离状态。
-
默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进⾏ pthread_join 操作,否则⽆法释放资源,从⽽造成系统泄漏。
-
如果不关⼼线程的返回值,join是⼀种负担,这个时候,我们可以告诉系统,当线程退出时,⾃动释放线程资源。
当一个线程处于分离状态时:
-
它不能被其他线程通过
pthread_join等待,且无法获取该线程的返回值,pthread_join会返回错误码。 -
当它终止时,系统会自动回收其资源。
函数原型:
int pthread_detach(pthread_t thread);
参数:
thread:要设置为分离状态的线程 ID。
返回值: 成功返回 0,失败返回错误码。
在 Linux 的 glibc 实现中,
pthread_join对于已分离的线程有一个特殊行为:
如果线程在调用
pthread_join之前就已经被分离,那么pthread_join会立即返回错误(EINVAL)但如果线程在调用
pthread_join之后才被分离(就像你的代码),pthread_join会正常等待线程结束并返回 0(成功)
3.7 案例
#include <iostream>
#include <unistd.h>
#include <string>
#include <pthread.h>void* routine(void* args) {// 线程分离pthread_detach(pthread_self());std::string name = static_cast<const char*>(args);int cnt = 3;while(cnt--) {sleep(1);std::cout << "I am a new thread , thread name: " << name << " , thread id: " << pthread_self() << std::endl;}// 线程终止pthread_exit(nullptr);
}int main() {// 创建线程pthread_t tid;pthread_create(&tid , nullptr , routine , (void*)"thread - 1");int cnt = 3;while(cnt--) {std::cout << "I am a main thread , thread id:" << pthread_self() << std::endl;sleep(1);}// 由于线程分离,pthread_join 函数返回错误码,并且不能获取返回值int res = pthread_join(tid , nullptr);if(res != 0) {std::cout << "pthread_join error res: " << res << std::endl; }std::cout << "main thread end" << std::endl;return 0;
}
3.8 查看线程的命令
ps -aL
4. 线程原理的理解
4.1 理解 TCB
当我们的程序引入了 pthread 库时,其实本质就是将 pthread 动态库映射到我们程序的地址空间中,通过动态链接的查找规则找到该函数调用。而在 pthread 库中存在描述当前线程的结构体 struct pthread 。
描述线程的TCB
/* Thread descriptor data structure. */
struct pthread {/* Thread ID */pid_t tid;/* Process ID - thread group ID in kernel speak. */pid_t pid; /* The result of the thread function. */void *result;// ⽤⼾指定的⽅法和参数void *(*start_routine) (void *);void *arg;// 线程⾃⼰的栈和⼤⼩void *stackblock;size_t stackblock_size;// ...
}
理解线程ID,线程传参和返回值,线程分离:
每当使用 pthread_create 创建线程时,pthread 库会在用户空间为我们分配一个线程控制块。这个 TCB 是一个数据结构,它包含了管理线程所需的所有元数据。
-
而
pthread_t类型(我们通常称之为 tid)本质上就是这个 TCB 在用户空间内存地址的抽象表示。而我们在pthread_join时,返回值其实是被保存到 TCB 描述结构体中,等待成功后,将其通过void**赋值给用户,并释放 TCB 相关的资源。这也就是为什么不进行pthread_join会产生内存泄漏问题,因为内存中的 TCB 没有释放。而每个接口需要传递的 tid,本质就是描述该线程 TCB 的起始地址。 -
线程分离的核心是改变线程结束时的资源回收机制。在 pthread 的实现中,当一个线程结束时,它的 TCB 和栈空间并不会立即被释放。这些资源需要被正确地回收,而分离状态决定了由谁来负责回收,每个线程的 TCB 中都有一个标志位记录它是
JOINABLE还是DETACHED。-
joinable:默认状态,线程结束时,它的 TCB 和退出状态值被保留在系统中。 -
detached:线程结束时,系统自动立即回收其 TCB 和栈资源,无法对该线程调用pthread_join,无法获取线程返回值。
-

4.2 理解线程栈
线程栈的主要作用和函数调用栈完全一样,它是线程独享的运行时内存空间,用于存储:
-
局部变量: 线程函数内部定义的非静态变量。
-
函数调用链路信息:
- 返回地址(调用完函数后回到哪里)。
-
参数传递: 当函数参数过多时,超出寄存器容量的部分会通过栈来传递。
那么私有栈是如何实现的呢?
当创建一个新线程时,系统会在进程的虚拟地址空间中划出一块特定的区域(比如在堆附近)分配给这个线程作为栈使用。对于这个线程来说,它拥有这块区域的读写权限。而对于进程内的其他线程,它们理论上知道这块内存的存在(因为大家都在同一个地址空间里),但约定俗成的编程规范是绝不会去访问其他线程的栈。
那本来栈里面的局部变量和信息不是本来就是独立的?那为什么还要单独申请空间存呢?
每个线程的函数调用链和局部变量在逻辑上本来就是独立的。 但问题在于,计算机的硬件(CPU)是如何跟踪和管理这些独立状态的?一个进程的上下文中只有一个栈指针 (SP),如果每个线程切换的时候共用一个栈指针会造成覆盖问题,所以就需要每个线程有一块私有的栈空间。
假设我们有两个线程,Thread A 和 Thread B,它们共享进程的唯一一个栈。
-
Thread A 开始执行:
-
调用
function_A1(),将返回地址、参数等压栈。 -
function_A1()内部定义了一个局部变量int x = 10;,也压栈。 -
此时栈的内容大致是:
[ ... | return_addr_A | x=10 | ... ]。
-
-
操作系统进行线程调度:
-
时间片用完,操作系统暂停 Thread A。
-
关键一步:操作系统保存 Thread A 的上下文,其中最重要的是栈指针(SP) 的值,它指向了栈顶(
x=10附近)。
-
-
Thread B 开始执行:
-
操作系统恢复 Thread B 的上下文,其中也包括栈指针(SP)。
-
但问题是,Thread A 和 Thread B 共享同一个栈!所以 Thread B 恢复的栈指针,直接指到了 Thread A 的地盘。
-
Thread B 调用
function_B1(),将它的返回地址、参数压栈。这个操作直接覆盖了 Thread A 栈帧的一部分数据!
-
通过为每个线程分配私有栈空间,并在上下文切换时同步切换SP寄存器的值:
-
线程A运行时,SP指向A的私有栈。
-
切换到线程B时:保存A的SP → 加载B的SP → 现在SP指向B的私有栈。
-
每个线程都在自己的私有栈上操作,互不干扰。
与 2.6 线程私有数据中的CPU上下文相对应,所以线程是被单独调度的。
主线程的栈结构就是进程地址空间的栈,而其他线程的栈结构其实是malloc出来的空间或者使用mmap (在进程的地址空间中映射一段新的内存区域) 开辟的一段空间
4.3 线程的局部存储
线程的局部存储是一种机制,允许每个线程拥有一个全局变量的独立副本。也就是说,在每个线程中都有自己独有的值,互不干扰。这非常适用于需要保持线程上下文状态的场景。
__thread 关键字,这是 Linux/GCC 环境下最简单、最高效的方法。它通过编译器直接支持,只支持内置类型。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>// 使用 __thread 关键字声明一个整型变量
__thread int tls_var = 0;void* thread_func(void* arg) {int thread_id = *(int*)arg;tls_var = thread_id * 10; // 每个线程设置自己的 tls_varsleep(1); // 模拟一些工作,确保线程调度能看到效果printf("Thread %d: tls_var = %d (address: %p)\n", thread_id, tls_var, (void*)&tls_var);return NULL;
}int main() {pthread_t t1, t2;int id1 = 1, id2 = 2;printf("Main thread: tls_var = %d (address: %p)\n", tls_var, (void*)&tls_var);pthread_create(&t1, NULL, thread_func, &id1);pthread_create(&t2, NULL, thread_func, &id2);pthread_join(t1, NULL);pthread_join(t2, NULL);printf("Main thread: tls_var = %d (address: %p)\n", tls_var, (void*)&tls_var);return 0;
}
上述打印新线程和主线程打印得 &tls_val 地址是不同的。
5. 封装 pthread 库
#pragma once#include <iostream>
#include <vector>
#include <string>
#include <unistd.h>
#include <pthread.h>
#include <functional>
#include <cstdlib>// 封装pthread
class Thread{using func_t = std::function<void()>;// 因为成员函数具有隐含的this指针, 不满足 void*(*start_routine)(void*),需要声明为静态函数// 但是 _routine 函数需要使用类内成员, 所以需要通过 args 把 this 指针传进来static void* _routine(void* args) {Thread* self = static_cast<Thread*>(args);self->_is_running = true;self->_method();self->_is_running = false;return nullptr;}public:Thread(const func_t& method) :_tid(0),_method(method),_is_running(false),_is_detach(false){_tname = "thread - " + std::to_string(id++);}// 禁止拷贝Thread(const Thread&) = delete;Thread& operator=(const Thread&) = delete;bool start(bool dt = false) {if(_is_running)return false; // 线程已经运行int n = pthread_create(&_tid , nullptr , _routine , (void*)this);if(n != 0) {std::cerr << "pthread_create error" <<std::endl;return false;}// 如果设置分离, 在启动后立即分离if(dt) {detach();} std::cout << "pthread_create success , thread name: " << _tname << std::endl;return true;}void join() {// 若分离 / 未启动则不需要joinif(_is_detach || !_is_running) {std::cout << "线程已经分离或线程未启动, 不能进行join" << std::endl;return;}int n = pthread_join(_tid , nullptr);if(n != 0) {std::cerr << "pthread_join error: " << n << std::endl;} else {std::cout << "pthread_join success , thread name: " << _tname << std::endl;_is_running = false;}}pthread_t gettid() { return _tid; }bool detach() { // 若分离 / 未启动则不需要detachif(_is_detach || !_is_running) {std::cout << "线程已经分离或线程未启动, 不能进行detach" << std::endl;return false;}int n = pthread_detach(_tid);if(n != 0) {std::cerr << "pthread_detach error" << std::endl;return false;} else {std::cout << "pthread_detach success , thread name: " << _tname << std::endl;_is_detach = true;return true;}}// 谨慎使用 cancel,可能造成资源泄漏bool cancel() {if(_is_running) {int n = pthread_cancel(_tid);if(n != 0) {std::cerr << "pthread_cancel error" << std::endl;return false;} else {std::cout << "pthread_cancel success" << std::endl;_is_running = false;return true;}}std::cout << "线程未启动" << std::endl;return false;}std::string& gettname() { return _tname; }~Thread() {if (_is_running && !_is_detach) {// 析构时如果线程还在运行且未分离,尝试joinpthread_join(_tid, nullptr);}}private:pthread_t _tid; // 线程idstd::string _tname; // 线程名字func_t _method; // 线程执行的方法bool _is_running; // 线程是否在运行bool _is_detach; // 线程是否分离// void* _result; public:static int id;
};是否在运行bool _is_detach; // 线程是否分离// void* _result; public:static int id;
};int Thread::id = 1;
