一、虚拟内存与分页机制
1.1 内存的两种粒度:寻址与管理
很多人会有一个疑问:C 语言中char占 1 字节,内存明明可以按字节访问,为什么常说内存的最小单位是 4KB? 这里需要区分两个完全不同的概念:
- 最小寻址单元:字节。CPU 可以独立读写内存中任意一个字节的内容,这是硬件层面的访问粒度。
- 内存管理粒度:页(4KB)。操作系统和 CPU 的内存管理单元(MMU)不会为每个字节单独做地址映射,而是把虚拟内存和物理内存都划分为连续的 4KB 块,以块为单位进行映射、保护和回收。 这样设计的核心目的是降低地址映射的开销、简化内存管理;和硬盘块对齐、便于 swap 交换只是分页机制的附带收益,并非核心设计目标。
1.2 虚拟页与物理页框
分页机制下,内存被划分为两类对等的 4KB 块:
- 虚拟页(Page):虚拟地址空间中逻辑上的 4KB 内存块,是进程视角下的逻辑单元。
- 物理页框(Page Frame):物理内存中实际的 4KB 内存块,是硬件层面的物理实体。
页表的核心作用,就是完成虚拟页号 → 物理页框号的映射。虚拟地址和物理地址的低 12 位(页内偏移)完全一致,不需要翻译,直接拼接即可得到最终物理地址。
如果不按页映射,而是为每个字节单独建立映射,页表本身会占用远超可用内存的空间,完全不可行;按页映射是在映射精度和管理成本之间的最优平衡。
1.3 二级页表的设计与寻址逻辑
32 位系统的虚拟地址空间总大小为 4GB,按 4KB 分页共有 1,048,576 个虚拟页。如果使用一级页表,每个进程都需要维护一张包含百万个条目的完整页表,内存开销极大且大部分空间会被浪费。
因此操作系统采用二级页表的分层设计,和文件系统的多级索引思路同源,实现按需分配、节省内存。32 位虚拟地址被拆分为三段:
31 22 21 12 11 0
┌──────────┬───────────┬──────────────┐
│ 页目录索引 │ 页表索引 │ 页内偏移 │
│ 10 bit │ 10 bit │ 12 bit │
└──────────┴───────────┴──────────────┘
二级页表的层级关系严格如下:
- 一个进程有且仅有 1 个页目录(页全局目录,PGD) :它是第一级索引表,包含 1024 个页目录项(PDE),每个 PDE 指向一个下一级的页表。
- 每个页表包含 1024 个页表项(PTE):每个 PTE 指向一个具体的物理页框。
设计的自洽性在于:每个条目占 4 字节,1024 个条目总大小恰好为 1024 × 4 = 4096 字节(4KB),因此页目录、每个页表都恰好占用一个物理页框,内存分配、地址对齐都可以用统一的页管理逻辑处理。
说明:不存在 "1024 个页目录" 的说法,页目录是每个进程唯一的一级表;1024 是页目录项、页表项的数量。

1.4 页表项的结构:高 20 位页框号 + 低 12 位属性位
无论是页目录项还是页表项,大小都是 4 字节(32 位),其结构遵循同一套设计:
- 高 20 位:存储物理基址(页表的物理页框号 / 数据页的物理页框号)。 因为物理页框是 4KB 对齐的,起始地址的低 12 位恒为 0,因此只需要 20 位就能完整表示所有物理页框的基址。
- 低 12 位:被复用为属性位和状态位,包括存在位(该页是否已映射物理内存)、读写权限位、用户 / 内核权限位、脏位、访问位等。 我们常说的 "某块内存只读、某块内存可读写",本质就是通过页表项中的权限位进行管控的。
1.5 缺页中断与按需分配
虚拟内存的核心优势之一是延迟分配、按需加载:
- 调用
malloc/mmap申请内存时,操作系统仅仅是在进程的虚拟地址空间中划出一块合法范围,不会立刻分配对应的物理页框,也不会建立页表映射。 - 当程序真正访问这块虚拟地址时,MMU 查页表发现对应页表项的 "存在位" 为 0,会触发缺页中断,陷入内核。
- 内核判断地址合法后,会从空闲物理页中分配一个页框,建立页表映射,再返回用户态继续执行。
动态库的加载也遵循这一逻辑:
- 程序启动时不会把整个动态库全部加载进内存,只有执行到对应代码、访问到对应虚拟页时,才会触发缺页、将对应页加载到物理内存。
- 动态库的代码段是全局共享的:物理内存中只存在一份代码页,所有使用该库的进程都通过自己的页表,将同一块物理页映射到自身的虚拟地址空间,大幅节省内存;而数据段则是每个进程私有。
1.6 地址翻译的硬件加速:TLB 快表
虚拟地址到物理地址的翻译,全程由 CPU 内置的内存管理单元(MMU) 硬件自动完成,正常情况下不需要操作系统软件介入。 如果每次地址翻译都要遍历两级页表,会带来两次内存访问,性能损耗过大。因此 CPU 内置了转译后备缓冲器(Translation Lookaside Buffer,TLB,简称快表),用来缓存高频使用的「虚拟页号 → 物理页框号」映射。 TLB 的作用和文件系统的 dentry 缓存、页缓存思路一致,都是通过缓存高频结果减少查表开销;区别在于 TLB 是纯硬件实现,速度极快。
1.7 进程页表的管理与切换
每个进程拥有独立的虚拟地址空间,因此每个进程都有独立的页目录和一套页表。
- 进程的
mm_struct结构体中,保存了当前进程页目录的物理基址(pgd字段)。 - 发生进程切换时,内核会将当前进程的页目录基址写入 CPU 的 CR3 控制寄存器;MMU 始终通过 CR3 寄存器找到当前进程的页目录,启动地址翻译。
所有的页目录、页表本身,都存储在物理内存中。

1.8 内存越界与段错误
如果程序访问的虚拟地址超出了mm_struct中记录的合法地址范围,或者访问了没有对应权限的内存,MMU 会触发页错误,内核校验后判定为非法访问,向进程发送SIGSEGV信号(段错误),直接终止整个进程。 我们常说的 "缓冲区溢出" 是内存越界的一种具体场景,指向缓冲区写入超出容量的数据、覆盖后续内存,二者不能等同。

二、Linux 线程的底层实现与 POSIX 标准
2.1 内核视角:只有轻量级进程,没有 "线程"
Linux 内核中不存在专门的 "线程" 数据类型,所有执行单元都用task_struct结构体描述,统称为轻量级进程(LWP) 。 内核通过clone()系统调用创建新的执行单元,可以通过参数灵活指定是否共享地址空间、文件描述符表、信号处理表、用户 ID 等资源:
- 不共享核心资源,创建出的就是传统意义上的独立进程;
- 共享地址空间、文件描述符等资源,创建出的就是用户态视角下的 "线程"。
也就是说,线程和进程在内核层面只有资源共享程度的区别,没有本质的类型差异。我们常说的 "单线程进程",就是只有一个执行单元的进程。
2.2 POSIX 标准与 pthread 的实现
真正定义线程接口规范的,是POSIX(可移植操作系统接口)标准,其中的线程规范称为 POSIX Threads,简称 pthread。 glibc 作为 Linux 系统的标准 C 库,基于内核的轻量级进程能力,在用户态补充了线程属性管理、同步原语、线程局部存储等逻辑,实现了完全符合 POSIX 标准的线程接口。
Linux 的 pthread 采用1:1 线程模型:一个用户态线程,对应一个内核轻量级进程,线程调度完全由内核调度器完成,属于典型的内核级线程,并非用户级线程。 早期 Linux 的线程实现为 LinuxThreads,存在诸多兼容性缺陷;2.6 内核之后升级为 NPTL(Native POSIX Thread Library,原生 POSIX 线程库),成为当前 Linux 线程的标准实现。
补充:历史上 pthread 的核心实现位于
libpthread.so动态库中,编译时需要通过-lpthread显式链接;从 glibc 2.34 版本开始,pthread 核心逻辑已合并入主库libc.so.6,libpthread.so仅作为兼容占位,现代 Linux 系统中编译 pthread 程序可以无需显式链接该库。
2.3 线程与进程的优劣对比
同一进程内的多个线程共享同一份虚拟地址空间、页表、文件描述符等资源,基于这个核心特性,线程相比独立进程有明确的优劣。
优点
- 上下文切换开销极低 同一进程的线程共享地址空间,线程切换时不需要更换页表、不需要刷新 TLB,CPU 缓存中的数据依然有效;仅需保存和恢复线程的寄存器、栈指针等私有上下文,切换成本远低于进程切换。
- 资源共享方便,通信效率高 线程间可以直接通过全局变量、堆内存交换数据,不需要复杂的进程间通信(IPC)机制,通信成本极低。
- 并发能力强,充分利用多核 计算密集场景下,可将重型任务拆分,通过多线程在多核 CPU 上并行执行,提升计算效率;IO 密集场景下,单个线程阻塞时,其他线程可以继续占用 CPU,避免 CPU 闲置,提升程序吞吐量。
缺点
- 故障隔离性差,健壮性弱 所有线程共享进程资源,单个线程出现非法内存访问、崩溃等异常,会直接导致整个进程终止,所有线程都会退出,故障会在进程内全局扩散。
- 编程复杂度高 共享资源的并发访问存在竞态条件,需要通过互斥锁、信号量等同步机制保证线程安全,设计不当容易出现死锁、数据竞争、优先级反转等难以复现的并发问题。
- 过量线程会导致性能下降 当全局就绪态的执行单元总数超过 CPU 物理核心数时,调度器需要通过时间片轮转让任务分时共享 CPU,频繁的上下文切换会带来持续的调度开销,缓存命中率也会下降,导致整体性能不升反降。 这一特性对进程和线程同样成立,区别仅在于进程切换的开销远大于同进程内的线程切换,超量时性能衰减更剧烈。
三、pthread 线程库核心接口
头文件:<pthread.h>
3.1 线程创建
int pthread_create(pthread_t* thread, const pthread_attr_t * attr,
void* (*start_routine)(void*), void* arg);
- 参数 1
thread:输出型参数,调用成功后,写入新创建线程的用户态线程 ID。 - 参数 2
attr:线程属性结构体指针,传入NULL则使用默认属性。 - 参数 3
start_routine:线程入口函数指针,函数返回值为void*,形参为void*。 - 参数 4
arg:传递给入口函数的实参,无需传参则传入NULL。 - 返回值:成功返回 0;失败返回对应错误码,此时
thread参数的值未定义。
补充说明:
pthread_t类型的线程 ID,是用户态线程库层面的 ID,和内核层面的轻量级进程 ID(LWP)不是同一个概念。可以通过ps -Lf命令查看,其中PID列是线程组 ID(即通常说的进程 ID),LWP列是内核轻量级进程的 ID。
3.2 线程等待与资源回收
int pthread_join(pthread_t thread, void** retval);
- 功能:阻塞等待指定线程退出,并回收其资源;若不等待,线程退出后会残留资源,类似进程的 "僵尸状态",造成内存泄漏。
- 参数 1
thread:要等待的线程 ID。 - 参数 2
retval:输出型参数,用于接收线程入口函数的返回值;无需获取则传入NULL。
补充:线程等待没有专门的 "异常状态" 参数。因为线程异常会直接导致整个进程退出,异常处理是以进程为单位的,单个线程不存在 "异常退出但进程存活" 的情况。
3.3 获取当前线程 ID
pthread_t pthread_self(void);
返回当前调用线程的用户态线程 ID。
3.4 线程的退出方式
线程的生命周期对应其入口函数的执行周期,入口函数返回则线程退出。常见的主动退出方式有三种:
-
入口函数
return返回 :最常用的方式,返回值会被pthread_join获取。 -
调用
pthread_exit:void pthread_exit(void* retval);在函数任意位置调用即可终止当前线程,
retval为返回值,同样可被pthread_join获取。注意:禁止在线程内调用
exit()退出。exit是终止整个进程的函数,调用后进程内所有线程都会一同退出,无法达到只退出单个线程的目的。 -
被其他线程取消:
int pthread_cancel(pthread_t thread);向指定线程发送取消请求。默认情况下线程采用延迟取消策略:只有执行到取消点(如
sleep、read、write等系统调用)时,才会响应取消并退出,并非立即强制终止。
3.5 线程分离
默认创建的线程处于joinable状态,退出后必须由其他线程调用pthread_join回收资源。如果不需要等待线程退出,可以将线程设置为detach(分离)状态,线程退出后系统会自动回收其资源,无需手动等待。
int pthread_detach(pthread_t thread);
- 成功返回 0,失败返回错误码。
- 线程分离后,无法再对其调用
pthread_join,调用会失败。
四、可重入函数与线程安全
多个执行流并发调用同一个函数时,函数的正确性会因为共享资源的访问受到影响,由此衍生出两个相关但不等价的概念:
- 可重入:函数在执行过程中被中断后再次进入(无论是单线程信号重入,还是多线程并发重入),执行结果依然正确。核心要求是不依赖全局变量、静态变量,仅使用栈上的局部数据和入参,且不调用不可重入函数。
- 线程安全:多线程并发调用函数时,执行结果依然正确。可以通过加锁保护共享资源来实现线程安全。
二者的关系:可重入函数一定是线程安全的;但线程安全的函数不一定可重入。例如通过互斥锁保护全局变量的函数是线程安全的,但在信号处理中重入可能导致死锁,因此不可重入。
线程局部存储(TLS)
如果希望一个变量每个线程拥有独立副本,互不干扰,可以使用线程局部存储。GCC 下通过__thread关键字声明:
__thread int g_val = 0;
每个线程都会拥有该变量的独立副本,修改只对当前线程生效。
注意:线程局部存储不位于线程栈中,而是存储在专门的 TLS 内存段,通过线程控制块中的指针寻址,和线程栈是相互独立的内存区域。
五、线程控制块(TCB)与线程栈

pthread 线程库在用户态为每个线程维护一个线程控制块(TCB,对应 glibc 中的struct pthread结构体),记录了线程的入口函数、参数、返回值、线程属性、栈地址、线程局部存储指针等信息。
pthread_create的执行流程大致为:
- 在用户态分配 TCB 与线程栈空间(通常通过
mmap映射在内存映射段); - 初始化 TCB 中的各项信息;
- 调用
clone()系统调用,创建内核轻量级进程,并将用户态栈地址传入内核; - 轻量级进程启动后,执行用户态的线程入口逻辑,最终调用业务入口函数。
我们常说的pthread_t类型的线程 ID,本质上就是当前线程 TCB 结构体的首地址。这也是pthread_join能够获取线程返回值的底层原因 ------ 通过线程 ID 找到对应 TCB,读取其中保存的返回值。
六、C++ 类成员函数作为线程回调的实现
pthread_create要求的入口函数签名是void* (*)(void*),而类的非静态成员函数有一个隐式的this指针参数,函数签名不匹配,无法直接作为线程入口函数。 原因在于:非静态成员函数的调用必须依赖类的实例,编译器会隐式将对象地址作为第一个参数(this 指针)注入函数,因此其参数列表和 pthread 要求的回调签名不兼容。
静态成员函数属于类本身,不绑定具体对象,没有隐式的 this 指针,签名可以匹配回调要求;但静态成员函数无法直接访问类的非静态成员。
行业内的标准解决方案是静态函数跳板法 :用静态成员函数作为回调入口,将对象的this指针作为参数传入,在静态函数内部通过 this 指针调用非静态成员函数。既满足了回调签名要求,又能访问类的所有成员。示例框架如下:
class MyThread {
public:
void create() {
// 将当前对象的this指针作为参数传给静态入口函数
pthread_create(&tid_, nullptr, thread_entry, this);
}
private:
// 静态成员函数,作为回调跳板,无this指针
static void* thread_entry(void* arg) {
// 将参数转回对象指针
MyThread* self = static_cast<MyThread*>(arg);
// 调用真正的业务成员函数
self->run();
return nullptr;
}
// 真正的线程业务逻辑,非静态成员函数
void run() {
// 可以自由访问类的所有成员变量和成员函数
}
pthread_t tid_;
};
C++ 标准库的std::thread,底层也是基于这一思路,结合模板、参数包、完美转发等特性,实现了对任意可调用对象、任意参数的通用支持。在 Linux 平台下,std::thread的底层本质就是对 pthread 库的面向对象封装;在 Windows 平台下则封装 Win32 原生线程 API,通过条件编译实现跨平台的统一接口。