公司动态

【Linux】二十四.线程篇一《一文吃透Linux线程全面解析:概念、内存管理、优缺点与用途》

📅 2026/7/24 21:48:11
【Linux】二十四.线程篇一《一文吃透Linux线程全面解析:概念、内存管理、优缺点与用途》
线程的概念线程是进程内的一个执行分支是在进程内部运行的一个执行流是CPU调度的基本单位。通过一下角度我们来彻底对线程的概念进行清晰的解读1-1什么是线程感性理解线程先搞清楚两个基本概念进程 内核数据结构PCB、页表等 代码和数据执行流。进程是分配系统资源的基本实体它负责从操作系统申请内存、文件、网络等资源。线程 进程内部的一个执行分支是CPU调度的基本单位。同一进程下的多个线程共享进程的资源代码、数据、文件等但各自拥有独立的栈和寄存器上下文。用一句话简单总结就是进程是资源的大管家线程是干活的执行者。接下来看这张图这张图描述的是Linux线程的过程Linux线程的原理多个 task_struct 共享 mm_struct 的实现方式和地址空间和页表的作用这是一张用户级页表用户级页表是每个进程独立拥有的通过页表把虚拟地址转换成物理地址才能访问物理内存中的数据。可执行程序在磁盘里就是一堆代码和数据程序运行时会被加载到物理内存。每个进程都有独立的地址空间和页表所以可以通过页表映射物理内存找到对应的代码和数据去执行。图片里画了多个task_structPCB指向同一个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 线程。初步理解我们可以得到四个结论结论1Linux 线程 采用进程来模拟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 一切皆文件、一切皆进程的设计哲学不同。问题3Linux 程序员为什么没必要搞 TCB因为 Linux 内核里压根没有 TCBThread 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_structPCB只不过这些PCB可能共享了同一个地址空间导致CPU执行起来比以前更轻量了。传统进程就1个执行流现在新创建的进程有4个执行流资源分配得更合理跑起来自然就轻快了。5个PCB在CPU的等待队列里排队CPU按照1个PCB为单位调度它不管你后面挂了多少个线程。多个线程切换的时候mm_struct、页表、代码和数据都不用换就换一下栈和寄存器比传统进程切换快多了。Linux下其实没有真正意义上的线程概念是用进程PCB来模拟的。Linux不能直接给咱们提供线程相关的系统调用接口只能提供轻量级进程的接口比如clone。所以Linux下的进程往往比其他平台的进程更轻量因为它可能只有一个线程也可能有多个线程所以Linux把进程统称为轻量级进程LWP。站在Linux系统的角度它不严格区分线程和进程统一叫轻量级进程。Windows那边就不一样了它有真正意义上的线程。系统里一大堆进程每个进程里又有好几个线程线程数量不比进程少。操作系统要管线程就得先描述再组织所以Windows内核里有TCB线程控制块和PCB同时存在。一个进程对应多个TCBTCB还得跟PCB扯上关系证明这个线程属于哪个进程。这么搞必然导致OS变复杂而且TCB和PCB描述的东西有很多是重复的。Linux一看线程和进程本质都是执行流啊干脆统一成一个结构体得了内核里压根就没有TCB这个概念。所以Windows在OS层面提供了线程控制接口Linux只在应用层提供了pthread线程库OS层面只提供轻量级进程的系统调用clone、vfork。实际开发中咱们在Linux下用的是原生线程库pthread它是在应用层实现的。C11的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创建成功后会在这里填上线程IDattr线程属性一般填NULL用默认的就够了start_routine线程入口函数线程从这儿开始跑arg传给入口函数的参数返回值成功返回0失败返回错误码。编译的时候得加上-lpthreadg -o mythread mythread.c -lpthread -stdc11写个简单的例子#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 线程设计对比对比项LinuxWindows内核里有什么只有 PCBtask_structPCB 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个bit1010。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 时间提升用户体验。浏览器里一个线程加载网页、一个线程播放视频、一个线程下载文件就是多线程在背后的应用服务器程序每个客户端请求分配一个线程处理主线程只负责接收请求响应能力和并发能力大幅提升