线程概念与控制:从虚拟地址空间到pthread库的深度剖析

📝 前言
在前面的文章中,我们已经系统学习了进程控制:fork 创建子进程、进程等待、进程程序替换……但你有没有想过一个问题:fork 一次创建一个子进程,代价实在太大了——要复制页表、写时拷贝、维护独立性,成本很高。
| 对比项 | 创建新进程 | 创建新线程 |
|---|---|---|
| 地址空间 | 独立,需重建映射体系 | ✅ 共享同一套 |
| 页表 | 整套复制 | ✅ 不用动 |
| 数据 | 写时拷贝,按需分离 | 天然共享,通信零成本 |
🔍 问题来了: 有没有办法让多个执行流共享同一份地址空间,既省下创建成本,又能并行干活?
答案就是——线程(Thread)!本文将从「线程是什么」一路讲到「虚拟地址空间如何被划分」,再落地到 pthread 库的创建、等待、终止、分离,最后亲手把线程封装成 C++ 类,彻底搞懂线程的概念与控制。
文章目录
一、🌲 线程概念:什么是线程
1.1 站在两个视角看线程
先复习一句话:进程 = 内核数据结构 + 代码和数据。其中真正在 CPU 上"跑"的,是代码和数据这个执行流部分。
- OS 视角:进程是承担分配系统资源的基本实体;
- CPU 视角:它只认执行流——线程就是 CPU 调度的基本单位。
所以更准确的定义是:线程是进程内部的一个执行分支,是"一个进程内部的控制序列"。线程在进程内部运行,本质是在进程地址空间内运行。一切进程至少都包含一个执行线程。
💡 关键理解: 进程访问的大部分资源,都是通过地址空间访问的。既然如此,只要把进程的虚拟地址空间划分给不同的执行流,就形成了不同的线程执行流——这就是"将进程资源合理分配给每个执行流"的含义。
由此我们可以得到几个重要结论:
💡 四个核心结论:
- 结论1:Linux 中的"线程"可以用进程来模拟——把资源分配给不同的 task_struct,用进程模拟线程;
- 结论2:对资源的划分,实质就是对地址空间的划分;
- 结论3:如何理解"代码段的划分"?函数本质上是虚拟地址的集合,让不同的线程去执行 ELF 中不同函数区间,代码就被"划分"了;
- 结论4:之前所学的"单进程",其实是内部只有一个线程的进程。
1.2 其他操作系统怎么做的?Linux 为什么特立独行?
Windows 中存在真正的线程 TCB 结构,内核需要单独为线程设计一套数据结构和管理逻辑。而 Linux 的代码更加轻量:进程和线程都使用同一个 task_struct,线程只是"轻量级进程",或者说用轻量级进程模拟的。
为什么这样设计?
- 线程控制块(TCB)和进程控制块(PCB)要管理的东西高度类似:状态、上下文、调度信息……
- 与其再造一套结构,不如✅ 直接复用 PCB——代码更少、更易维护、不易出 bug。
在 CPU 眼中,Linux 里的 PCB 比传统进程更轻量化,这个"更轻量化的 PCB"就叫 LWP(Light Weight Process,轻量级进程)。
1.3 线程的优点
| 优点 | 说明 |
|---|---|
| 创建代价小 | 创建一个新线程比创建一个新进程的代价小得多 |
| 占用资源少 | 线程占用的资源比进程少 |
| 切换成本低 | 见下方重点分析 |
| 充分利用多核 | 能充分利用多处理器的可并行数量 |
| I/O 与计算重叠 | 等待慢速 I/O 操作的同时,程序可执行其他计算任务 |
| 两类应用友好 | 计算密集型:将计算分解到多个线程跑满多核;I/O 密集型:让 I/O 操作重叠,同时等待不同的 I/O |
其中线程切换比进程切换"少做很多工作",这是面试高频点,值得展开:
💡 线程切换为什么快?
- 线程切换时虚拟地址空间不变,所以不用切换页表、不用更换 CR3 寄存器;
- 地址空间不变意味着硬件 cache、TLB(快表)里缓存的内容依然有效——而进程切换会把 TLB 全部刷新,导致切换后一段时间内内存访问相当低效,还会瞬间作废处理器中已缓存的内存地址;
- 两者上下文切换都由内核完成,寄存器内容切换是共同开销,但上面两条是线程独有的优势。
1.4 线程的缺点
| 缺点 | 说明 |
|---|---|
| 性能损失 | 计算密集型线程数量超过可用处理器时,反而增加额外的同步和调度开销,而可用资源不变 |
| 健壮性降低 | 线程之间缺乏保护,共享了不该共享的变量、时间分配的细微偏差都可能造成不良影响 |
| 缺乏访问控制 | 进程是访问控制的基本粒度,一个线程中调用某些 OS 函数会影响整个进程 |
| 编程难度提高 | 编写与调试多线程程序比单线程困难得多 |
1.5 线程异常:一损俱损
⚠️ 记住一句话:线程出异常 ⇒ 进程出异常 ⇒ 直接挂!
单个线程如果出现除零、野指针等问题导致崩溃,整个进程也会随之崩溃。因为线程只是进程的执行分支,线程出异常就类似进程出异常,会触发信号机制终止进程——进程一旦终止,该进程内的所有线程也就随即退出了。
二、🔍 深入虚拟地址空间:分页式存储管理
要真正理解线程,就必须搞清楚内核是如何进行资源划分的——而资源划分的载体,就是虚拟地址空间和页表。这一节是全文的地基。
2.1 为什么需要虚拟地址和页表?
设想没有虚拟内存和分页机制的情况:每个程序在物理内存上必须连续存放。程序退出后回收的空间大小不一,运行一段时间后物理内存就被切割成各种离散的、大小不同的内存碎片。
我们的诉求是:给用户看的地址空间连续,物理内存最好离散。于是:
- 把物理内存按固定长度分割成一个个页框(page frame);
- 把虚拟地址空间分成同样大小的页(page);
- 用页表记录"页 → 页框"的映射关系。
💡 区分页与页框: 页框是一个存储区域;页是一个数据块,可以存放在任意页框或磁盘中。大多数 32 位体系结构支持 4KB 的页,Windows/Linux 的页框大小就是 4KB。
2.2 物理内存怎么管理?——一张大数组!
4GB 内存按 4KB 划分:4GB / 4KB = 1048576 个页框。操作系统要把它们管理起来,内核用 struct page 结构表示每个物理页,并以数组形式组织:
// 内核中:物理页以数组形式被组织,用下标直接访问!
struct page mem[1048576];
💡 一个颠覆认知的结论: 申请物理内存,本质上就是查这个数组——找到没有使用的 page,拿到它的下标(即物理页框地址),然后建立内核数据结构之间的对应关系。
struct page内部用了大量联合体 union 来节省内存,每个结构体约 40 字节,管理 1M 个页总共只消耗约 40MB,对 4GB 内存来说代价很小。
2.3 页表太长怎么办?——多级页表
如果用单级页表映射全部 4GB 虚拟空间:需要 1048576 个表项,每项 4 字节,页表本身就要 4MB——而且必须连续占用 1024 个页框,这和我们引入分页的初衷背道而驰。再加上局部性原理告诉我们:进程一段时间内往往只访问某几个页,没必要让所有映射常驻内存。
解决方案:把页表再"分页"——形成二级页表:
- 一个页目录(4KB,1024 个表项)指向 1024 张页表;
- 每张页表(4KB,1024 个表项)指向物理页;
1024 × 1024依然覆盖 4GB,但一个 10MB 的程序用 3 张页表就够了。
于是 32 位虚拟地址被切分为 10 + 10 + 12 三段:
虚拟地址(32位):
| 10位:页目录索引 | 10位:页表索引 | 12位:页内偏移 |
查页目录 查页表 物理页内偏移
地址转换由 MMU(内存管理单元,一种硬件电路)完成,分两个阶段:
- 第一阶段:先查虚拟地址对应的页框——CR3 寄存器指向当前进程的页目录,按高 10 位查页目录、再按中间 10 位查页表,得到物理页框地址;
- 第二阶段:根据虚拟地址低 12 位作为页内偏移,访问物理页。
为什么偏偏是低 12 位做页内偏移?
- 页大小 4KB = 4096 Byte,正好需要 12 位编址;
- 一个物理页的地址一定是 4KB 对齐的(末 12 位全 0),所以页表项只需记录物理页地址的高 20 位;
- 高 20 位相同、低 12 位连续的地址,局部必然属于同一个 4KB 页。
💡 效率补丁——TLB 快表: 多级页表是双刃剑:省空间但查询慢(N 级页表 = N 次检索 + 1 次读写)。MMU 引入了缓存机制 TLB(转译后备缓冲器):先问 TLB,命中直接拿物理地址;未命中(Cache Miss)再查页表,并顺手把这条映射记进 TLB。
2.4 缺页异常:越界了就一定会崩溃吗?
CPU 给 MMU 的虚拟地址,在 TLB 和页表中都找不到对应物理页,或者存在但无权限,就会触发缺页异常(Page Fault)——一个由硬件中断触发、可以由软件逻辑纠正的"错误"。进程从用户态切换到内核态,交给 Page Fault Handler 处理:
| 类型 | 含义 | 处理方式 |
|---|---|---|
| Hard Page Fault | 物理内存中没有对应物理页 | 从磁盘读取到内存,再建立映射 |
| Soft Page Fault | 物理页存在(其他进程调入的),只是本进程还没建立映射 | 只需建立映射,无需读磁盘 |
| Invalid Page Fault | 非法访问,如野指针解引用 | 报 segment fault,进程挂掉 |
🔍 越界了不一定崩溃! 越界了不一定会崩溃,因为越界发生时操作系统不一定知道(可能恰好指向了合法空间)。OS 靠两步检查区分"缺页"和"越界":
- 第一步 · 页号合法性检查:检查触发事件的虚拟地址页号是否合法——页号合法但页面不在内存中 → 缺页中断;页号非法 → 越界访问;
- 第二步 · 内存映射检查:检查虚拟地址是否在当前进程的内存映射范围内——在范围内但不在内存中 → 缺页中断;不在范围内 → 越界访问。
由此,之前积攒的几个疑问全都通透了:
- 如何理解 new/malloc? 申请内存 → 查 page 数组 → 拿到空闲 page 的下标 → 得到物理页框地址,背后可能还要建立新的页表和映射关系(分配的往往只是虚拟地址空间,真正访问时才缺页建立映射);
- 如何理解写时拷贝? 本质也是缺页中断:访问时发现页面无写权限/不在内存,内核在缺页处理中重新建立映射;
- 进程的映射体系是什么? 一个进程 = 一张页目录 + N 张页表构建的映射体系。虚拟地址是索引,物理地址是目标,公式:
虚拟地址低12位 + 页框地址 = 物理地址。
2.5 资源划分与资源共享的真相
现在可以回答开头的问题了。执行流看到的资源,本质是:在合法的情况下,有明确分配给自己的虚拟地址——虚拟地址就是资源的"代表"。地址空间由 mm_struct + vm_area_struct 描述,页表则是一张从虚拟到物理的地图。
💡 全文最重要的两句话:
- 资源划分:本质是地址空间的划分——只要把虚拟地址空间划一块出来,资源就天然被划分好了;
- 资源共享:本质是虚拟地址(页表条目)的共享——多个执行流指向同一批页表映射,自然看到同一份资源。
线程的一切,都建立在这两句话之上。
三、📊 进程 VS 线程:哪些共享,哪些私有
| 维度 | 进程 | 线程 |
|---|---|---|
| 定位 | 资源分配的基本单位 | 调度的基本单位 |
| 独立性 | 进程间具有独立性 | 共享地址空间,也就共享进程资源 |
线程共享进程数据,但也拥有一部分"私有"数据:
| 共享 | 私有 |
|---|---|
| 同一地址空间(代码段、数据段共享) | 线程 ID |
| 文件描述符表 | 一组寄存器(上下文数据,可被调度) |
| 每种信号的处理方式(SIG_IGN、SIG_DFL 或自定义) | 栈(独立栈结构 → 线程是动态的) |
| 当前工作目录 | errno |
| 用户 id 和组 id | 信号屏蔽字 |
| — | 调度优先级 |
💡 再问一次: 如何看待之前学习的单进程?——它就是具有一个线程执行流的进程。
四、🛠️ Linux 线程控制
4.1 验证线程的轻量级包装:LWP
先眼见为实。写一个多线程程序,用 ps -aL 查看线程信息:
$ ps -aL | head -1 && ps -aL | grep mythread
PID LWP TTY TIME CMD
2711838 2711838 pts/235 00:00:00 mythread
2711838 2711839 pts/235 00:00:00 mythread
-L选项:打印线程信息;- 可以看到同一个 PID 下有两个 LWP——CPU 实际调度使用的就是 LWP;
- 其中一个 LWP 与 PID 相同,它就是主线程。
由此引出三个常见疑问:
- 关于调度的名词问题: 进程组内的多线程会"平分"进程的调度权重,组间权重不同——调度是以 LWP 为单位进行的;
- 异常怎么办? 单线程异常会直接使整个进程异常(见 1.5);
- 线程编号显示有时会混乱? 线程信息属于共享内存层面的显示内容,处理方式因实现而异,了解即可。
4.2 为什么要引入 pthread 线程库?
这里存在一个认知错位:
- 用户层:只认识"线程",所有操作系统教材讲的也都是线程;
- Linux 内核:根本没有"线程"这个概念,只有轻量级进程,底层只提供
clone()/fork()这类系统调用。
怎么办?——pthread 库把轻量级进程包装起来,提供统一的线程接口,LWP 用户不关心。
💡 重要结论: Linux 的线程方案是在用户层实现的,我们使用的其实是"用户态线程",pthread 被称为原生线程库(绝大多数函数以
pthread_开头,使用时引入<pthread.h>,编译时加-lpthread选项)。更妙的是:如果我们自己在上层设计线程管理块排队,甚至可以基于内核在用户层实现自己的"线程调度"——很多更高级的语言就做了这个工作。
4.3 创建线程:pthread_create
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void*), void *arg);
| 参数 | 含义 |
|---|---|
| thread | 输出型参数,返回线程 ID |
| attr | 线程属性,一般传 nullptr 用默认属性 |
| start_routine | 函数指针,线程启动后要执行的函数入口 |
| arg | 传给线程启动函数的参数 |
返回值:成功返回 0;失败返回错误码。注意 pthreads 函数出错时不会设置全局 errno,而是把错误码通过返回值返回(读取返回值比读取线程内的 errno 开销更小)。
第一个 demo——两个执行流同时打印:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <pthread.h>
void *rout(void *arg)
{
for ( ; ; )
{
printf("I am thread 1\n");
sleep(1);
}
}
int main(void)
{
pthread_t tid;
int ret;
// ========== 创建线程,检查返回值 ==========
if ((ret = pthread_create(&tid, NULL, rout, NULL)) != 0)
{
fprintf(stderr, "pthread_create : %s\n", strerror(ret));
exit(EXIT_FAILURE);
}
for ( ; ; )
{
printf("I am main thread\n");
sleep(1);
}
}
⚠️ 创建线程后要等待! 主线程要等待新线程,否则会造成类似僵尸进程的问题(线程退出了但资源没人回收)。
4.4 线程等待:pthread_join
为什么需要线程等待?
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内;
- 创建新的线程时,不会复用刚才退出线程的地址空间。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
// thread: 线程ID
// retval: 输出型参数,指向线程的返回值(退出码)
// 成功返回0;失败返回错误码
这个函数就是 Linux 下类似 waitpid 的函数:调用后挂起等待,直到 id 为 thread 的线程结束。线程以不同方式终止,join 拿到的终止状态也不同:
| 线程终止方式 | pthread_join 拿到的结果 |
|---|---|
| return 返回 | 线程函数的返回值 |
| pthread_cancel 被取消 | 常量 PTHREAD_CANCELED(值就是 -1) |
| pthread_exit 自行退出 | 传给 pthread_exit 的参数 |
| 不关心终止状态 | 给 retval 传 NULL 即可 |
三种终止方式一次验证:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void *thread1(void *arg) // 方式一:return
{
printf("thread 1 returning ...\n");
int *p = (int*)malloc(sizeof(int));
*p = 1;
return (void*)p;
}
void *thread2(void *arg) // 方式二:pthread_exit
{
printf("thread 2 exiting ...\n");
int *p = (int*)malloc(sizeof(int));
*p = 2;
pthread_exit((void*)p);
}
void *thread3(void *arg) // 方式三:被别人 cancel
{
while (1)
{
printf("thread 3 is running ...\n");
sleep(1);
}
return NULL;
}
int main(void)
{
pthread_t tid;
void *ret;
// ========== thread 1: return ==========
pthread_create(&tid, NULL, thread1, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
free(ret);
// ========== thread 2: pthread_exit ==========
pthread_create(&tid, NULL, thread2, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
free(ret);
// ========== thread 3: pthread_cancel ==========
pthread_create(&tid, NULL, thread3, NULL);
sleep(3);
pthread_cancel(tid);
pthread_join(tid, &ret);
if (ret == PTHREAD_CANCELED)
printf("thread return, thread id %X, return code:PTHREAD_CANCELED\n", tid);
else
printf("thread return, thread id %X, return code:NULL\n", tid);
}
运行结果:
$ ./a.out
thread 1 returning ...
thread return, thread id 5AA79700, return code:1
thread 2 exiting ...
thread return, thread id 5AA79700, return code:2
thread 3 is running ...
thread 3 is running ...
thread 3 is running ...
thread return, thread id 5AA79700, return code:PTHREAD_CANCELED
4.5 线程终止的三种方式与一个禁忌
如果只需要终止某个线程而不终止整个进程,有三种方法:
| 方法 | 说明 |
|---|---|
| return | 从线程函数 return。⚠️ 对主线程不适用——从 main 函数 return 相当于调用 exit,代表主线程结束,一般也代表进程结束 |
| pthread_exit | void pthread_exit(void *value_ptr); 线程专用终止函数 |
| pthread_cancel | int pthread_cancel(pthread_t thread); 取消同一进程中的另一个线程(取消成功后对方 join 到的退出码是 PTHREAD_CANCELED,被取消之后也是要 join 的) |
❌ 禁忌:线程不能调用 exit()! exit 会直接结束整个进程,进程终止,所有线程全部退出。
⚠️ 返回值的坑: pthread_exit 或 return 返回的指针,指向的内存必须是全局的或 malloc 出来的,不能放在线程函数的栈上——因为其它线程拿到这个返回指针时,线程函数的栈早已销毁。
💡 能力总结: 给线程传的参数和返回值,可以是任意类型(包括对象),于是可以利用对象来完成一系列任务。
4.6 线程分离:pthread_detach
默认情况下,新创建的线程是 joinable 的:线程退出后,必须对它执行 pthread_join,否则无法释放资源,造成系统泄漏。但如果主线程不关心新线程的返回值,join 就是一种负担——这时可以告诉系统:线程退出时自动释放资源。
#include <pthread.h>
int pthread_detach(pthread_t thread);
// 可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离自己:
pthread_detach(pthread_self());
💡 理解分离: 线程分离的语义是"主不等待新,新把自己分离"。但要注意——分离的线程依旧在进程地址空间中,进程的所有资源它依然可以访问和操作,只是主线程不再等待它了。joinable 和分离是冲突的:一个线程不能既是 joinable 又是分离的,join 对分离的线程不再生效。
#include <stdio.h>
#include <stdlib.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(void)
{
pthread_t tid;
if (pthread_create(&tid, NULL, thread_run, "thread1 run...") != 0)
{
printf("create thread error\n");
return 1;
}
int ret = 0;
sleep(1); // 很重要:要让线程先分离,再等待
if (pthread_join(tid, NULL) == 0)
printf("pthread wait success\n");
else
printf("pthread wait failed\n");
return ret;
}
4.7 线程 ID:tid 不是 LWP!
这里必须掰清楚两个"ID":
| ID | 属于谁 | 本质 |
|---|---|---|
| LWP | 内核调度范畴(ps -aL 看到的) | 内核中轻量级进程的唯一标识 |
| pthread_t(tid) | NPTL 线程库范畴 | 一个进程地址空间上的虚拟地址 |
pthread_create 第一个参数带回的"线程 ID"和 LWP 不是一回事。对 Linux 的 NPTL 实现而言,pthread_t 类型的线程 ID 本质就是一个地址——通过 pthread_self() 获得的这个数,实际上是虚拟地址空间上的一个地址,通过这个地址可以找到关于这个线程的基本信息(线程 ID、线程栈、寄存器等属性)。
#include <pthread.h>
pthread_t pthread_self(void); // 查看自己 tid 的函数
💡 为什么主线程特殊?
ps -aL中有一个线程 ID 与进程 ID 相同,它就是主线程。主线程的栈在地址空间的栈区上,而其他线程的栈在共享区(堆栈之间)——因为 pthread 系列函数由 pthread 库提供,而 pthread 库正是加载在共享区的。
五、🗺️ 线程 ID、地址空间布局与线程栈
5.1 库也要管理线程:tcb
线程的概念是在库中维护的——库内部一定有"被创建好的线程"需要管理,所以库也要管理线程。每个线程在库中对应一个 tcb(线程控制块),描述线程的结构体,其中包含线程应有的属性:
struct tcb
{
// 线程应有的属性
线程状态;
线程 id;
线程独立的栈结构;
线程栈大小;
// ...
优先级、时间片、上下文; // 这三者交由 LWP → PCB,内核层面管理
};
- tcb 中有一个 joinable 字段:若 joinable 为 0,则线程处于分离状态;
- tcb 中还有
void* ret——线程函数的返回值就放在这里,所以需要 join 回收 ret 并回收内存,主线程才能拿到返回值; - 所谓 tid,就是对应该线程管理块(tcb)的起始地址(默认地址)。
另外,tcb 的部分数据是压在内核栈中的——库层 tcb 管属性,内核层 LWP 管调度,两边配合。
结合地址空间布局,一张图看懂线程的"家":
虚拟内存(进程地址空间) pthread 库(共享区中的动态库)
┌─────────────────────┐ ┌──────────────────────┐
│ 内核空间 │ │ 动态库 │
├─────────────────────┤ │ struct pthread │
│ 主线程栈 ↘ │ │ (线程控制块/线程id) │ ← pthread_t tid1
├─────────────────────┤ ├──────────────────────┤
│ mmap 区域(动态库) │ ←─────── │ 同上(线程2的tcb) │ ← pthread_t tid2
├─────────────────────┤ │ ... + void* ret │
│ 堆 ↑ │ └──────────────────────┘
├─────────────────────┤
│ 未初始化数据/已初始化数据 │
├─────────────────────┤
│ 代码段 │
└─────────────────────┘
💡 __thread 线程局部存储: 用
__thread修饰的全局变量,其数据会存放在线程局部存储中——每个线程各有一份,互不干扰。用途:需要全局变量,但不想让其他线程看到,从而不存在并发问题。限制:只能有内置类型和部分指针。
5.2 线程栈:主线程的栈是动态的,新线程的栈是固定的
线程是共享同一地址空间的,那"独立的上下文和独立的栈"如何实现?
- 独立上下文:有独立的 PCB(内核)+ TCB(用户层,pthread 内部),寄存器上下文随 LWP 切换保存恢复;
- 独立的栈:每个线程要有自己独立的栈,要么是进程自己的,要么是库中 mmap 出来的。
Linux 中进程和线程都使用 task_struct,只不过线程多了一层封装(多出了用户层数据)。但进程和线程的栈其实是同区域的,只是待遇不同:
| 主线程(进程)的栈 | 新线程的栈 | |
|---|---|---|
| 位置 | 地址空间栈区 | 库通过 mmap 在共享区申请 |
| 增长方式 | 动态增长(fork 复制父栈空间 + 写时拷贝) | 固定的,一旦用尽就没了(默认约 8MB),超限栈溢出报段错误 |
| 私有性 | 并非私有——其他线程可以访问,只是地址"协商"好了,其它线程不知道 | 原则上线程私有,但同一进程的所有线程浅拷贝了生成者的 task_struct 众多字段,如果愿意,其它线程依然可以访问到 |
⚠️ 一定注意: “线程栈私有"只是原则上的约定,不是硬件级隔离——栈的地址对其他线程而言只是"不知道”,而不是"访问不到"。
5.3 clone:库创建线程的两步
pthread 库创建一个线程,实际做了两件事(对应底层 int clone(int (*fn)(void*), void *stack, int flag, void *arg, ...) 系统调用):
- 用户层:在库中创建线程的管理块(tcb),并通过 mmap 为新线程申请栈空间;
- 内核层:调用 clone 系统调用创建 LWP——告诉内核"执行什么方法(fn)、栈在哪(stack)"。
💡 源码佐证(了解即可): 在 pthread 库源码中,
__pthread_create_2_1先通过ALLOCATE_STACK宏申请空间(先查线程栈缓存,失败则mmap匿名内存),在空间中放置struct pthread(也就是 tcb),把start_routine和arg填进去,然后把 tcb 的地址作为 ID 传给上层(*newthread = (pthread_t)pd;——上层拿到的就是一个虚拟地址!);随后create_thread设置CLONE_VM | CLONE_FS | CLONE_FILES | ...标志位调用 clone,让内核创建轻量级进程。线程执行完毕的返回值就存放在 tcb 的result变量中——pthread_join 本质就是读取该结构体,这也印证了"线程执行流可以退出,但 tcb 可以暂时保留"这句话。
5.4 给线程起名字:setname / getname
#include <pthread.h>
// 设置调用线程自己的名字(name 存放在线程局部存储内)
int pthread_setname_np(pthread_t thread, const char *name);
// 读取线程名字
int pthread_getname_np(pthread_t thread, char *name, size_t len);
💡
_np表示 “non-portable”(非可移植),通常在 Linux 等类 Unix 系统中可用,用于调试和多线程程序的管理。线程名称最大长度为 16 个字符(包括结尾\0),超长会被截断;通常只有线程自身可以设置自己的名字。
六、📦 线程封装:用 C++ 把 pthread 包起来
系统接口用起来终究繁琐,我们模仿 C++11 的思路,把线程封装成一个类——对外暴露 Start/Join,内部自动处理线程名、分离与等待策略:
// Thread.hpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
namespace ThreadModule
{
// 原子计数器,方便形成线程名称
std::uint32_t cnt = 0;
// 线程要执行的外部方法
using threadfunc_t = std::function<void()>;
// 线程状态
enum class TSTATUS
{
THREAD_NEW,
THREAD_RUNNING,
THREAD_STOP
};
class Thread
{
private:
// 静态成员函数:符合 pthread 线程入口的原型要求
static void *run(void *obj)
{
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()); // 不想被join,自己分离自己
}
self->_func(); // 执行真正的任务
return nullptr;
}
void SetName()
{
// 后期加锁保护
_name = "Thread-" + std::to_string(cnt++);
}
public:
Thread(threadfunc_t func) : _status(TSTATUS::THREAD_NEW),
_joined(true), _func(func)
{
SetName();
}
void EnableDetach()
{
if (_status == TSTATUS::THREAD_NEW)
_joined = false;
}
void EnableJoined()
{
if (_status == TSTATUS::THREAD_NEW)
_joined = true;
}
bool Start()
{
if (_status == TSTATUS::THREAD_RUNNING)
return true;
int n = ::pthread_create(&_id, nullptr, run, this); // 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;
}
~Thread() {}
private:
std::string _name;
pthread_t _id;
TSTATUS _status;
bool _joined;
threadfunc_t _func;
};
}
使用方式:
// main.cc
#include <iostream>
#include <unistd.h>
#include "Thread.hpp"
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);
}
}
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");
ThreadModule::Thread t1(hello1);
t1.Start();
ThreadModule::Thread t2(std::bind(&hello2));
t2.Start();
t1.Join();
t2.Join();
return 0;
}
验证一下,ps -aL 里线程名一目了然:
$ ps -aL
PID LWP TTY TIME CMD
195828 195828 pts/1 00:00:00 main
195828 195829 pts/1 00:00:00 Thread-0
195828 195830 pts/1 00:00:00 Thread-1
💡 设计要点:
pthread_create的入口函数必须是void*(*)(void*)原型,所以类内的run要设计成 static 成员函数,再通过传入的this指针拿回对象、调用真正的任务函数。如果要像 C++11 那样支持可变参数传递也可以设计,但太麻烦了——真到了那一步直接用 C++11 线程库就好,我们的目标是理解系统概念的对象化,后续还可以用std::bind来进行对象间调用。
七、🤔 核心思考题
-
为什么线程切换比进程切换快?
线程切换虚拟地址空间不变——不用切换页表、不用更换 CR3 寄存器,硬件 cache 和 TLB 中缓存的内容也依然有效;而进程切换会刷新 TLB,导致切换后一段时间内内存访问低效。 -
越界访问一定会导致程序崩溃吗?
不一定。越界发生时操作系统不一定知道(可能恰好指向合法空间)。OS 通过"页号合法性检查 + 内存映射检查"两步,才能区分这是缺页中断还是真正的越界访问。 -
pthread_create 返回的 tid 和 ps -aL 看到的 LWP 有什么区别?
LWP 是内核调度范畴的轻量级进程 ID;tid 是 NPTL 线程库范畴的概念,本质是进程地址空间上的一个地址——指向库中该线程管理块(tcb)的起始地址,库的后续操作都根据这个 ID 来操作线程。 -
线程不能调用哪些函数?主线程 return 为什么危险?
线程不能调用exit(),它会直接终止整个进程(所有线程全部退出);主线程从 main 函数 return 也相当于调用 exit。线程只应通过 return、pthread_exit 终止自己,或被其他线程 pthread_cancel 取消。
本节完
📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/say_fall/article/details/169454798




