【Linux】二十四.线程篇一《一文吃透Linux线程全面解析:概念、内存管理、优缺点与用途》
线程的概念
线程是进程内的一个执行分支,是在进程内部运行的一个执行流,是CPU调度的基本单位。
通过一下角度我们来彻底对线程的概念进行清晰的解读:
1-1什么是线程
感性理解线程
先搞清楚两个基本概念:
进程= 内核数据结构(PCB、页表等)+ 代码和数据(执行流)。进程是分配系统资源的基本实体,它负责从操作系统申请内存、文件、网络等资源。
线程= 进程内部的一个执行分支,是CPU调度的基本单位。同一进程下的多个线程共享进程的资源(代码、数据、文件等),但各自拥有独立的栈和寄存器上下文。
用一句话简单总结就是:进程是资源的大管家,线程是干活的执行者。
接下来看这张图,这张图描述的是Linux线程的过程:
Linux线程的原理(多个 task_struct 共享 mm_struct 的实现方式和地址空间和页表的作用)
这是一张用户级页表,用户级页表是每个进程独立拥有的,通过页表把虚拟地址转换成物理地址,才能访问物理内存中的数据。
可执行程序在磁盘里就是一堆代码和数据,程序运行时会被加载到物理内存。每个进程都有独立的地址空间和页表,所以可以通过页表映射物理内存,找到对应的代码和数据去执行。
图片里画了多个
task_struct(PCB)指向同一个mm_struct,它们各自有独立的栈区,但共享代码区、数据区、堆区、共享区。CPU 调度的仍然是 PCB,但因为多个执行流共享同一个地址空间,调度时不需要切换页表,只切换栈和寄存器就行,所以比传统进程要轻量化。
图片里还画了一个进程从磁盘加载到物理内存的过程,通过用户级页表建立虚拟地址到物理地址的映射。如果访问的目标资源不在内存,就会触发缺页中断,进行内存分配、页面换入、建立映射。页表里还记录了 RWX 权限和 U/K 权限,用来控制访问权限。进程通过页表映射找到物理地址后,还要检查是否命中、RWX 权限、U/K 权限是否满足条件,满足才能访问。
如果想创建一个传统进程,需要独立的
task_struct、mm_struct、用户页表,还要通过 I/O 把程序的代码和数据加载到内存。但如果只创建task_struct,不创建新的mm_struct和页表,让新的 PCB 指向老的 PCB 的mm_struct,就实现了多个执行流共享同一份地址空间。在系统层面上,只要划分用户级页表,不同的 PCB 就能看到页表的一部分,从而只能访问进程资源的一部分。比如让不同的 PCB 看到不同的代码段,就能执行不同的函数;让它们看到不同的栈区,就能有各自独立的调用栈。
站在资源的角度来看,地址空间是进程 PCB 的"资源窗口"。传统进程是一个窗口对应一个人,线程则是一个窗口对应多个人——多个 PCB 共享同一个地址空间,每个 PCB 被 CPU 调度时,执行的粒度比原始进程要更小。这种比传统进程执行粒度更小的调度单位,就是 Linux 线程。
初步理解我们可以得到四个结论:
结论1:Linux "线程" 采用进程来模拟
Linux 内核没有为线程单独设计数据结构,而是直接复用
task_struct,用进程的机制来实现线程。也就是说,在 Linux 内核眼里,线程就是一个"轻量级进程"(LWP),它和普通进程的区别在于:它和父进程共享地址空间、文件描述符等资源。这样做的好处是内核代码可以全部复用,不需要为线程单独写一套调度、管理逻辑。
结论2:对资源的划分,本质是对地址空间虚拟地址范围的划分
虚拟地址就是资源的代表。进程拥有的资源(代码、数据、堆、栈等)在虚拟地址空间里都有自己的位置。要让多个执行流共享一份资源,本质上就是让多个
task_struct指向同一个mm_struct,然后在地址空间里划出不同的范围给不同的执行流用。比如划分出不同的栈区给不同的线程,划分出不同的代码段给不同的线程去执行。
结论3:函数就是虚拟地址空间中的一段代码区域
ELF 程序加载到内存后,不同的函数对应着虚拟地址空间中不同的偏移地址。让线程去执行 ELF 程序里不同的函数,本质上就是让不同的执行流跳转到同一份代码段里的不同地址去执行指令。所以线程能执行不同的任务,靠的是地址空间里代码段的不同划分。
结论4:我该如何面对历史的进程呢?
这里说的"历史的进程"指的是 Linux 用进程模拟线程这种设计思路。我们需要理解的是:线程和进程在 Linux 内核层面没有本质区别,都是用
task_struct来描述,区别只在于是否共享资源。多个task_struct共享一个mm_struct就是线程,各自独立mm_struct就是进程。概念是互相补充的,以前的进程可以看作内部只有一个线程的进程,现在的线程可以看作共享资源的多个执行流。
那这里就有几个问题给大家了:
补充理解:
进程是资源分配的基本单位,负责申请内存、文件等资源;线程是 CPU 调度的基本单位,负责执行代码。在 Linux 下,进程和线程都用同一个结构体
task_struct来描述,区别只在于是否共享资源——各自独享mm_struct就是进程,多个共享一个mm_struct就是线程。
问题1:为什么要这么设计?
为了复用内核代码。如果单独设计线程,需要重新实现一套调度、管理、销毁的逻辑,工作量大且有 bug 风险。直接复用
task_struct,把共享资源的逻辑加上,内核的调度代码一行都不用改,就能支持线程了。
问题2:其他平台,比如 Windows,也是这样吗?
不是。Windows 有独立的线程设计,内核里既管理进程也管理线程,线程和进程是分离的概念。Windows 的线程有自己的
ETHREAD结构,调度单位是线程,进程更像是一个资源容器。这和 Linux "一切皆文件、一切皆进程"的设计哲学不同。
问题3:Linux 程序员为什么没必要搞 TCB?
因为 Linux 内核里压根没有 TCB(Thread Control Block)这个概念。线程在 Linux 内核里就是
task_struct,和进程用同一个结构体。复用内核代码是 Linux 的一贯风格,不需要额外定义 TCB,直接沿用 PCB 的思路就行。再问一个问题:线程在内核里面要不要管理?当然要。内核里先描述(task_struct),再组织(链表),调度的还是task_struct。原来的进程变成了一种特殊情况——内部只有一个线程的进程。
问题4:概念是互相补充的
在 Linux 下,进程和线程没有本质区别,都是用
task_struct来描述。区别在于:一个task_struct独享mm_struct就是进程,多个task_struct共享一个mm_struct就是线程。进程承担资源分配的角色,线程承担调度的角色,两个概念互补。
综上我们就得出了:
所以整条线就是:感性理解 → 地址空间和页表 → 多个 task_struct 共享 mm_struct → 四个结论和四个问题 →所Linux 线程的本质是:多个
task_struct共享同一个mm_struct,共用一份页表和物理内存映射,通过划分地址空间的不同区域给不同的执行流,实现轻量级的调度单位。
Linux线程 VS 其他平台的线程(重要)
前面说的就是Linux线程的基本原理。
站在CPU的角度看,它看到的始终是
task_struct(PCB),只不过这些PCB可能共享了同一个地址空间,导致CPU执行起来比以前更轻量了。传统进程就1个执行流,现在新创建的进程有4个执行流,资源分配得更合理,跑起来自然就轻快了。5个PCB在CPU的等待队列里排队,CPU按照1个PCB为单位调度,它不管你后面挂了多少个线程。多个线程切换的时候,mm_struct、页表、代码和数据都不用换,就换一下栈和寄存器,比传统进程切换快多了。Linux下其实没有真正意义上的线程概念,是用进程PCB来模拟的。Linux不能直接给咱们提供线程相关的系统调用接口,只能提供轻量级进程的接口(比如
clone)。所以Linux下的进程往往比其他平台的进程更轻量,因为它可能只有一个线程,也可能有多个线程,所以Linux把进程统称为轻量级进程(LWP)。站在Linux系统的角度,它不严格区分线程和进程,统一叫轻量级进程。Windows那边就不一样了,它有真正意义上的线程。系统里一大堆进程,每个进程里又有好几个线程,线程数量不比进程少。操作系统要管线程,就得先描述再组织,所以Windows内核里有TCB(线程控制块),和PCB同时存在。一个进程对应多个TCB,TCB还得跟PCB扯上关系,证明这个线程属于哪个进程。这么搞必然导致OS变复杂,而且TCB和PCB描述的东西有很多是重复的。Linux一看,线程和进程本质都是执行流啊,干脆统一成一个结构体得了,内核里压根就没有TCB这个概念。所以Windows在OS层面提供了线程控制接口,Linux只在应用层提供了
pthread线程库,OS层面只提供轻量级进程的系统调用(clone、vfork)。实际开发中,咱们在Linux下用的是原生线程库
pthread,它是在应用层实现的。C++11的std::thread、Python的threading这些,底层其实也是靠pthread。
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:线程属性,一般填NULL用默认的就够了
start_routine:线程入口函数,线程从这儿开始跑
arg:传给入口函数的参数
返回值:成功返回0,失败返回错误码。
编译的时候得加上-lpthread:
g++ -o mythread mythread.c -lpthread -std=c++11写个简单的例子
#include <iostream> #include <pthread.h> #include <unistd.h> using namespace std; void* threadRoutine(void* arg) { int num = *(int*)arg; cout << "线程" << num << " 开始运行" << endl; return nullptr; } int main() { pthread_t tid[5]; int args[5] = {0, 1, 2, 3, 4}; for (int i = 0; i < 5; i++) { pthread_create(&tid[i], nullptr, threadRoutine, &args[i]); } for (int i = 0; i < 5; i++) { pthread_join(tid[i], nullptr); } return 0; }Linux vs Windows 线程设计对比
| 对比项 | Linux | Windows |
|---|---|---|
| 内核里有什么 | 只有 PCB(task_struct) | PCB + TCB 都有 |
| 线程怎么实现的 | 用进程模拟,共享地址空间 | 独立的线程结构 |
| 管理起来复不复杂 | 简单,复用了进程那套代码 | 复杂,PCB和TCB都要管 |
| OS提供什么接口 | 轻量级进程接口(clone等) | 完整的线程API |
| 用户层用什么 | pthread线程库 | Windows API线程函数 |
Linux的设计思路是:线程和进程本质都是执行流,统一用
task_struct描述,省得重复造轮子。Windows选择在OS层面完整支持线程,功能更全但代价也更大。两种设计没有谁好谁坏,就是设计理念不一样。
1-2 分⻚式存储管理
1.虚拟地址和⻚表的由来
思考⼀下,如果在没有虚拟内存和分⻚机制的情况下,每⼀个⽤⼾程序在物理内存上所对应的空间必须是连续的,如下图:
因为每⼀个程序的代码、数据⻓度都是不⼀样的,按照这样的映射⽅式,物理内存将会被分割成各种离散的、⼤⼩不同的块。经过⼀段运⾏时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎⽚的形式存在。
怎么办呢?我们希望操作系统提供给⽤⼾的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分⻚便出现了,如下图所⽰:
把物理内存按照⼀个固定的⻓度的⻚框进⾏分割,有时叫做物理⻚。每个⻚框包含⼀个物理⻚
(page)。⼀个⻚的⼤⼩等于⻚框的⼤⼩。⼤多数 32位 体系结构⽀持 4KB 的⻚,⽽ 64位 体系结
构⼀般会⽀持 8KB 的⻚。区分⼀⻚和⼀个⻚框是很重要的:
- ⻚框是⼀个存储区域;
- ⽽⻚是⼀个数据块,可以存放在任何⻚框或磁盘中。
有了这种机制,CPU 便并⾮是直接访问物理内存地址,⽽是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间,是操作系统为每⼀个正在执⾏的进程分配的⼀个逻辑地址,在32位机上,其范围从0 ~ 4G-1。
操作系统通过将虚拟地址空间和物理内存地址之间建⽴映射关系,也就是⻚表,这张表上记录了每⼀对⻚和⻚框的映射关系,能让CPU间接的访问物理内存地址。
总结⼀下,其思想是将虚拟内存下的逻辑地址空间分为若⼲⻚,将物理内存空间分为若⼲⻚框,通过⻚表便能把连续的虚拟内存,映射到若⼲个不连续的物理内存⻚。这样就解决了使⽤连续的物理内存造成的碎⽚问题。
2. 物理内存管理
假设⼀个可⽤的物理内存有 4GB 的空间。按照⼀个⻚框的⼤⼩ 4KB 进⾏划分, 4GB 的空间就是4GB/4KB = 1048576 个⻚框。有这么多的物理⻚,操作系统肯定是要将其管理起来的,操作系统需要知道哪些⻚正在被使⽤,哪些⻚空闲等等。
内核⽤ struct page 结构表⽰系统中的每个物理⻚,出于节省内存的考虑, struct page 中使⽤了⼤量的联合体union。
/* include/linux/mm_types.h */ struct page { /* 原⼦标志,有些情况下会异步更新 */ unsigned long flags; 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; }; struct { /* slab, slob and slub */ union { struct list_head slab_list; /* uses lru */ struct { /* Partial pages */ struct page* next; #ifdef CONFIG_64BIT int pages; /* Nr of pages left */ int pobjects; /* Approximate count */ #else short int pages; short int pobjects; #endif }; }; struct kmem_cache* slab_cache; /* not slob */ /* Double-word boundary */ void* freelist; /* first free object */ union { void* s_mem; /* slab: first object */ unsigned long counters; /* SLUB */ struct { /* SLUB */ unsigned inuse : 16; /* ⽤于SLUB分配器:对象的数⽬ */ unsigned objects : 15; unsigned frozen : 1; }; }; }; ... }; union { /* 内存管理⼦系统中映射的⻚表项计数,⽤于表⽰⻚是否已经映射,还⽤于限制逆向映射 搜索*/ atomic_t _mapcount; unsigned int page_type; unsigned int active; /* SLAB */ int units; /* SLOB */ }; ... #if defined(WANT_PAGE_VIRTUAL) /* 内核虚拟地址(如果没有映射则为NULL,即⾼端内存) */ void* virtual; #endif /* WANT_PAGE_VIRTUAL */ ... }其中⽐较重要的⼏个参数:
1. flags :
⽤来存放⻚的状态。这些状态包括⻚是不是脏的,是不是被锁定在内存中等。flag的
每⼀位单独表⽰⼀种状态,所以它⾄少可以同时表⽰出32种不同的状态。这些标志定义在
<linux/page-flags.h>中。其中⼀些⽐特位⾮常重要,如PG_locked⽤于指定⻚是否锁定,
PG_uptodate⽤于表⽰⻚的数据已经从块设备读取并且没有出现错误。
2. _mapcount :
表⽰在⻚表中有多少项指向该⻚,也就是这⼀⻚被引⽤了多少次。当计数值变
为-1时,就说明当前内核并没有引⽤这⼀⻚,于是在新的分配中就可以使⽤它。
3. virtual :
是⻚的虚拟地址。通常情况下,它就是⻚在虚拟内存中的地址。有些内存(即所谓
的⾼端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为NULL,需要的时候,必须动态地映射这些⻚。
要注意的是 struct page 与物理⻚相关,⽽并⾮与虚拟⻚相关。⽽系统中的每个物理⻚都要分配⼀
个这样的结构体,让我们来算算对所有这些⻚都这么做,到底要消耗掉多少内存。
算 struct page 占40个字节的内存吧,假定系统的物理⻚为 4KB ⼤⼩,系统有 4GB 物理内存。
那么系统中共有⻚⾯ 1048576 个(1兆个),所以描述这么多⻚⾯的page结构体消耗的内存只不过40MB ,相对系统 4GB 内存⽽⾔,仅是很⼩的⼀部分罢了。因此,要管理系统中这么多物理⻚⾯,这个代价并不算太⼤。要知道的是,⻚的⼤⼩对于内存利⽤和系统开销来说⾮常重要,⻚太⼤,⻚内必然会剩余较⼤不能利⽤的空间(⻚内碎⽚)。⻚太⼩,虽然可以减⼩⻚内碎⽚的⼤⼩,但是⻚太多,会使得⻚表太⻓⽽占⽤内存,同时系统频繁地进⾏⻚转化,加重系统开销。因此,⻚的⼤⼩应该适中,通常为 512B -8KB ,windows/Linux系统的⻚框⼤⼩为4KB。
注意:操作系统也要管理每⼀个⻚
3.⻚表
⻚表中的每⼀个表项,指向⼀个物理⻚的开始地址。在 32 位系统中,虚拟内存的最⼤空间是 4GB ,这是每⼀个⽤⼾程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可⽤,那么⻚表中就需要能够表⽰这所有的 4GB 空间,那么就⼀共需要 4GB/4KB = 1048576 个表项。如下图所⽰:
虚拟内存看上去被虚线“分割”成⼀个个单元,其实并不是真的分割,虚拟内存仍然是连续的。这个虚线的单元仅仅表⽰它与⻚表中每⼀个表项的映射关系,并最终映射到相同⼤⼩的⼀个物理内存⻚上。
⻚表中的物理地址,与物理内存之间,是随机的映射关系,哪⾥可⽤就指向哪⾥(物理⻚)。虽然最终使⽤的物理内存是离散的,但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时,使⽤的都是线性地址,只要它是连续的就可以了,最终都能够通过⻚表找到实际的物理地址。假设,在 32 位系统中,地址的⻓度是 4 个字节,那么⻚表中的每⼀个表项就是占⽤ 4 个字节。所以⻚表占据的总空间⼤⼩就是: 1048576*4 = 4MB 的⼤⼩。也就是说映射表⾃⼰本⾝,就要占⽤4MB / 4KB = 1024 个物理⻚。但这会存在哪些问题呢?
回想⼀下,当初为什么使⽤⻚表,就是要将进程划分为⼀个个⻚可以不⽤连续的存放在物理内存
中,但是此时⻚表就需要1024个连续的⻚框,似乎和当时的⽬标有点背道⽽驰了。
此外,根据局部性原理可知,很多时候进程在⼀段时间内只需要访问某⼏个⻚就可以正常运⾏了。因此也没有必要⼀次让所有的物理⻚都常驻内存。
解决需要⼤容量⻚表的最好⽅法是:把⻚表看成普通的⽂件,对它进⾏离散分配,即对⻚表再分⻚,由此形成多级⻚表的思想。
为了解决这个问题,可以把这个单⼀⻚表拆分成 1024 个体积更⼩的映射表。如下图所⽰。这样⼀来,1024(每个表中的表项个数) * 1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。
这⾥的每⼀个表,就是真正的⻚表,所以⼀共有 1024 个⻚表。⼀个⻚表⾃⾝占⽤ 4KB ,那么1024 个⻚表⼀共就占⽤了 4MB 的物理内存空间,和之前没差别啊?
从总数上看是这样,但是⼀个应⽤程序是不可能完全使⽤全部的 4GB 空间的,也许只要⼏⼗个⻚表就可以了。例如:⼀个⽤⼾程序的代码段、数据段、栈段,⼀共就需要 10 MB 的空间,那么使⽤ 3 个⻚表就⾜够了。
计算过程:
每⼀个⻚表项指向⼀个 4KB 的物理⻚,那么⼀个⻚表中 1024 个⻚表项,⼀共能覆盖 4MB 的物理内存;那么 10MB 的程序,向上对⻬取整之后(4MB 的倍数,就是 12 MB),就需要 3 个⻚表就可以了。
4.⻚⽬录结构
到⽬前为⽌,每⼀个⻚框都被⼀个⻚表中的⼀个表项来指向了,那么这 1024 个⻚表也需要被管理起来。管理⻚表的表称之为⻚⽬录表,形成⼆级⻚表。如下图所⽰:
所有⻚表的物理地址被⻚⽬录表项指向⻚⽬录的物理地址被 CR3 寄存器 指向,这个寄存器中,保存了当前正在执⾏任务的⻚⽬录地址。所以操作系统在加载⽤⼾程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为⽤来保存程序的⻚⽬录和⻚表分配物理内存
5. 两级⻚表的地址转换
下⾯以⼀个逻辑地址为例。将逻辑地址( 0000000000,0000000001,11111111111 )转换为物
理地址的过程:
- 在32位处理器中,采⽤4KB的⻚⼤⼩,则虚拟地址中低12位为⻚偏移,剩下⾼20位给⻚表,分成两级,每个级别占10个bit(10+10)。
- CR3 寄存器 读取⻚⽬录起始地址,再根据⼀级⻚号查⻚⽬录表,找到下⼀级⻚表在物理内存中存放位置。
- 根据⼆级⻚号查表,找到最终想要访问的内存块号。
- 结合⻚内偏移量得到物理地址。
- 注:⼀个物理⻚的地址⼀定是 4KB 对⻬的(最后的 12 位全部为 0 ),所以其实只需要记录物理⻚地址的⾼ 20 位即可。
- 以上其实就是 MMU 的⼯作流程。MMU(Memory Manage Unit)是⼀种硬件电路,其速度很快,主要⼯作是进⾏内存管理,地址转换只是它承接的业务之⼀。
到这⾥其实还有个问题,MMU要先进⾏两次⻚表查询确定物理地址,在确认了权限等问题后,MMU再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。那么当⻚表变为N级时,就变成了N次检索+1次读写。可⻅,⻚表级数越多查询的步骤越多,对于CPU来说等待时间越⻓,效率越低。
让我们现在总结⼀下:单级⻚表对连续内存要求⾼,于是引⼊了多级⻚表,但是多级⻚表也是⼀把双刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。
有没有提升效率的办法呢?
计算机科学中的所有问题,都可以通过添加⼀个中间层来解决。 MMU 引⼊了新武器,江湖⼈称快表的 TLB (其实,就是缓存,Translation Lookaside Buffer,学名转译后备缓冲器)
当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,⻬活。但 TLB 容量⽐较⼩,难免发⽣ Cache Miss ,这时候 MMU 还有保底的⽼武器⻚表,在⻚表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录⼀下刷新缓存。
6. 缺⻚异常
设想,CPU 给 MMU 的虚拟地址,在 TLB 和⻚表都没有找到对应的物理⻚,该怎么办呢?其实这就是缺⻚异常 Page Fault ,它是⼀个由硬件中断触发的可以由软件逻辑纠正的错误。
假如⽬标内存⻚在物理内存中没有对应的物理⻚或者存在但⽆对应权限,CPU 就⽆法获取数据,这种情况下CPU就会报告⼀个缺⻚错误。
由于 CPU 没有数据就⽆法进⾏计算,CPU罢⼯了⽤⼾进程也就出现了缺⻚中断,进程会从⽤⼾态切换到内核态,并将缺⻚中断交给内核的 Page Fault Handler 处理。
缺⻚中断会交给 PageFaultHandler 处理,其根据缺⻚中断的不同类型会进⾏不同的处理:
- Hard Page Fault 也被称为 Major Page Fault ,翻译为硬缺⻚错误/主要缺⻚错误,这时物理内存中没有对应的物理⻚,需要CPU打开磁盘设备读取到物理内存中,再让MMU建⽴虚拟地址和物理地址的映射。
- Soft Page Fault 也被称为 Minor Page Fault ,翻译为软缺⻚错误/次要缺⻚错误,这时物理内存中是存在对应物理⻚的,只不过可能是其他进程调⼊的,发出缺⻚异常的进程不知道⽽已,此时MMU只需要建⽴映射即可,⽆需从磁盘读取写⼊内存,⼀般出现在多进程共享内存区域。
- Invalid Page Fault 翻译为⽆效缺⻚错误,⽐如进程访问的内存地址越界访问,⼜⽐如对空指针解引⽤内核就会报 segment fault 错误中断进程直接挂掉
注意:关于内存操作的几个问题
1.new和malloc到底在干什么?
我以前一直以为
malloc就是直接去物理内存里划一块出来,后来才搞明白不是这样的。malloc做的事情其实是在虚拟地址空间里标记一块地址说"这块被占了",但这时候物理内存里其实还没真正分配。你拿到的只是一个虚拟地址,等你去读写这块内存的时候,CPU 才会触发缺页中断,操作系统才真正去物理内存里找个空闲页框给你用。简单说就是:先给你画张饼(虚拟地址),等你真要吃的时候再给你烙(分配物理内存)。这叫延迟分配,也叫按需分页。
2.申请内存,究竟是在干什么?
申请内存本质上就是:在地址空间里划一块区域,然后在
mm_struct和vm_area_struct里记上一笔——这段地址从哪到哪、权限是什么(读、写、执行)、有没有对应文件什么的。物理内存并不马上给,等真正访问的时候才通过缺页中断去分配。缺页中断里会检查这个地址是不是在vm_area_struct记录的合法范围内,如果是就分配物理页建立映射,如果数据在磁盘上还要读进来,如果不是就报段错误。所以申请内存只是"预约",真正的物理内存是访问的那一刻才拿到的。
3.写时拷贝到底是怎么回事?
fork创建子进程的时候,子进程直接复制父进程的页表,但不复制物理内存,父子指向同一块物理页,页表项被标记为只读。谁先尝试写,谁就触发缺页中断,操作系统检测到是写时拷贝场景,就复制一份物理页给写入方,然后把页表权限改成可写。之后父子就各自独立了。这个机制的好处是:如果 fork 之后马上exec换程序,根本不会触发写时拷贝,省掉了大量复制物理内存的开销,效率高很多。
4.如何区分是缺页中断还是越界访问?
缺页和越界都会触发 page fault 异常,操作系统在处理的时候会做判断:
先看触发异常的虚拟地址页号是否合法。页号合法但页面不在内存 → 缺页中断;页号本身非法 → 越界访问。
再看地址是否在
vm_area_struct的合法映射范围内。在范围内但页面不在内存 → 缺页中断;不在范围内 → 越界访问。还要检查权限。地址合法、页面也在内存,但你写了只读的页面,或者读了没权限的页面,也是越界。
5.越界了一定会报错吗?
不一定,这个问题挺坑人的。
情况一:越界但没触发 page fault。比如你申请了 10 个字节,但访问了第 20 个字节的位置,这个地址还在进程的地址空间范围内(还在
vm_area_struct的记录里),而且对应的物理页已经在内存了。这时候 CPU 不会触发 page fault,程序不会报错,但你可能把别的数据给改了,后面程序出现莫名其妙的 bug,这种 bug 最难查。情况二:越界触发了 page fault。你访问的地址超出了整个地址空间的范围,缺页中断处理程序发现页号非法,就给进程发 SIGSEGV 信号,然后程序崩溃报 "Segmentation fault"。这就是为啥有时候越界立刻崩,有时候啥事没有但结果不对。
线程资源划分的真相
只要把虚拟地址空间一划分,进程资源就天然分好了。每个线程分不同的栈区域,代码区、数据区、堆区大家一起用。创建线程的时候不需要分配新的物理内存,只需要在已有的地址空间里划一块出来给新线程当栈用就行。这就是为啥线程创建比进程快那么多——进程要复制页表、分配物理内存,线程就在原地划个区完事。你们有没有想过线程切换为什么比进程快?因为线程的
mm_struct压根不需要换。进程切换的时候得把整个页表换掉,TLB 全失效,缓存全凉。线程切换只用换栈指针和寄存器,页表还是那份页表,TLB 还是热乎的。你品品,一个要换整套家具,一个就换个人干活,哪个快?
7.理性理解线程
1.物理内存的划分
操作系统把物理内存划分成 4KB 大小的小块,每一块叫做页框(页帧)。4GB 的物理内存可以分成
4GB / 4KB = 1,048,576个页框,正好一百多万个。磁盘上的可执行程序存储的时候,天然也是按 4KB 为单位划分的,无论属性还是内容。4KB 的划分是操作系统做的,不管是磁盘(文件系统)还是内存,都统一用这个单位。操作系统要管理这些页框,必须“先描述再组织”。内核里用struct page结构体来描述每个页框的状态(是否被占用、属于哪个进程、访问权限等),然后把一百多万个page组织成一个数组。因为组织成了数组,每个page都有下标,内存管理就变成了对数组的操作。每个page的起始物理地址也就天然知道了——根据数组下标就能算出对应的物理地址。申请物理内存的时候,操作系统做的就是:查数组找空闲页框 --> 标记为已使用 -->建立页表映射 -->返回虚拟地址给进程。
当物理内存不够时,操作系统会把一些不常用的 4KB 页面换出到磁盘上的交换分区(swap),腾出空间给其他进程使用。需要时再通过缺页中断从磁盘换入。内存和磁盘之间的数据交换,就是以 4KB 为单位进行的。
2.虚拟地址到物理地址的转换
虚拟地址是一个 32 位的数字,代表一个具体的字节。一个 32 位的虚拟地址被分成三部分:
前 10 位:在页目录里找对应的页表。页目录有 1024 个条目。
中间 10 位:在页表里找对应的物理页框。每张页表也有 1024 个条目。
后 12 位:在物理页框里找具体字节。2^12 = 4096,正好是一页的大小,范围是 [0, 4095],也就是页内偏移。
CR3 寄存器里存的是当前进程的页目录地址,CPU 通过它找到页目录。整个查找过程分两个阶段:第一阶段,找到虚拟地址对应的物理页框。第二阶段,根据虚拟地址的后 12 位作为页内偏移,访问具体的字节。这个转换由 MMU(内存管理单元)硬件自动完成,不需要程序员手动干预。页框大小是 4KB,所以低 12 位用来做页内偏移。为什么是低 12 位?因为 2^12 = 4096,正好覆盖一页内的所有字节。
3.地址空间、页表与资源的关系
执行流看到的资源,本质上是虚拟地址。在合法的情况下,你拥有多少虚拟地址,就代表你能使用多少资源。地址空间(mm_struct)是进程所有资源的统计数据和整体描述,里面记录了代码段、数据段、堆、栈等各个区域的范围。vm_area_struct则进一步细化每个区域的具体信息(起始地址、结束地址、权限、文件映射等)。
页表是一张从虚拟地址到物理地址的地图。通过页表,操作系统把虚拟地址空间映射到物理内存上。
资源划分的本质:就是划分地址空间的不同范围给不同的执行流。
资源共享的本质:就是多个执行流共享同一份虚拟地址空间,指向同一块物理内存。
4.进程与线程的深刻理解
进程是分配系统资源的基本实体,线程是 CPU 调度的基本单位。
进程强调的是独占资源——每个进程有自己的地址空间,互不干扰。进程与进程之间资源隔离,通信需要额外的机制(管道、共享内存、消息队列等)。
线程强调的是共享资源——同一个进程里的多个线程共享同一份地址空间,共享代码和数据,共享文件描述符。线程之间的数据天然是共享的,不需要额外的通信机制。
从理性角度理解,资源划分涉及虚拟地址到物理地址的转换、页表、页表相关的概念,这些都是内存管理的一部分。
每个线程独有的东西很少:主要是栈(每个线程有自己的调用栈)、寄存器上下文(保存当前执行状态)、线程 ID 等。其他的都共享。
线程切换时,页表不用切换(因为共用一份),只需要切换栈和寄存器,所以开销小。进程切换时,页表要切换(因为每个进程有独立的页表),TLB 会失效,开销大。
5.各种场景下的页表操作
申请内存:查数组找空闲页框 --> 标记为已使用 --> 建立页表映射 --> 返回虚拟地址
fork 创建子进程:子进程复制父进程的页表,父子共享物理页,页表项标记为只读(写时拷贝)
写时拷贝:父或子尝试写入时触发缺页中断 -->OS 复制物理页 --> 重新建立映射 → 各自独立
缺页中断:访问的虚拟地址没有对应的物理页框时触发 --> OS 判断地址合法性 → 合法则分配物理页建立映射,非法则发 SIGSEGV 干掉进程
进程退出:释放物理页框 --> 从数组中标记为空闲 --> 释放页表 --> 回收资源
6.类比总结
把地址空间比作家里的房子,进程就是一个独立的家庭,拥有自己的房子和家具。线程就是家里的人,一家人共享房子和家具,各干各的事。进程切换相当于搬家——从一个家搬到另一个家,所有东西都要换。线程切换相当于家里换个人做事——老爸干活换成老妈干活,房子和家具都不用换,只换人就行。
我们小结一下,方便大家理解:进程是资源分配的基本单位,线程是 CPU 调度的基本单位。资源划分的本质是地址空间的划分,资源共享的本质是虚拟地址的共享。通过页表,虚拟地址被映射到物理内存,由 MMU 硬件自动完成转换。
1-3 线程的优点
1.创建销毁开销小
创建一个线程比创建一个进程快得多。进程创建要复制页表、分配物理内存、拷贝文件描述符表等一堆东西;线程创建就简单了,在已有地址空间里划一块栈区域,分配个
task_struct就完事了。
2.切换代价低
进程切换的时候,页表要换,TLB 全部失效,CPU 缓存里缓存的内存地址全作废,导致切换后一段时间内存访问效率很低。线程切换的时候,地址空间不变,页表不用换,TLB 大部分还能用,只换栈和寄存器就行。一个是换整套家具,一个是换个人干活,差距就在这。
3.占用资源少
线程共享进程的代码段、数据段、堆、文件描述符等,不需要像进程那样每个都独立复制一份。创建一万个线程比创建一万个进程省内存得多。
4.充分利用多核
多核 CPU 上,多个线程可以同时跑在不同的核心上,真正并行执行。计算密集型的程序把计算分解到多个线程里,能充分利用多核的算力。
5.I/O 等待时干别的事
一个线程在等 I/O 的时候(比如等磁盘读数据、等网络包),其他线程可以继续执行计算任务,不浪费 CPU 时间。比如一边写代码一边下载开发工具,就是多线程在起作用。
1-4 线程的缺点
1.性能有损耗
计算密集型的线程如果比 CPU 核心数还多,操作系统就得来回切换线程,调度和同步的开销就上来了。本来一个线程跑满一个核效率最高,多个线程抢一个核反而因为切换开销导致总体效率下降。
2.健壮性差
进程之间是隔离的,一个进程崩了不影响其他进程。但线程之间是共享地址空间的,一个线程挂了整个进程都得陪葬。野指针、数组越界、空指针解引用,任何一个线程出问题,进程直接崩溃,其他所有线程跟着一起死。
3.缺乏访问控制
进程是访问控制的基本单位。在一个线程里调某些系统函数(比如
chroot、setuid这种),会直接影响整个进程的所有线程,而不是只影响调用者自己。
4.编程难度大
多线程程序的 bug 比单线程难调试得多。数据竞争、死锁、条件竞争,这些 bug 往往跟时间顺序有关,不是每次都能复现,跑一百次可能只出一次问题,调试起来非常痛苦。
1-5 线程异常
单个线程出异常(比如野指针、除零、段错误),触发的是进程级的信号机制。信号是发给整个进程的,不是只发给出问题的那个线程。所以一个线程崩溃,发 SIGSEGV 干掉整个进程,进程里的所有线程都得跟着退出。这是线程安全性的一个短板——进程间是隔离的,但线程间是绑在一起的,一个出事全家遭殃。
1-6 线程用途
计算密集型程序:把大任务拆成多个子任务并行计算,充分利用多核 CPU,加快整体执行速度
I/O 密集型程序:一个线程等 I/O 的时候,其他线程继续干活,不浪费 CPU 时间,提升用户体验。浏览器里一个线程加载网页、一个线程播放视频、一个线程下载文件,就是多线程在背后的应用
服务器程序:每个客户端请求分配一个线程处理,主线程只负责接收请求,响应能力和并发能力大幅提升