《Linux系统编程》Linux 系统多线程(一):线程概念(从虚拟地址空间与分页机制到优缺点解析)

📅 2026/7/18 4:16:56
《Linux系统编程》Linux 系统多线程(一):线程概念(从虚拟地址空间与分页机制到优缺点解析)
小叶-duck个人主页❄️个人专栏《Data-Structure-Learning》《C入门到进阶自我学习过程记录》《Linux操作系统从入门到实践》《Qt从入门到实践》《算法题讲解指南》--优选算法《算法题讲解指南》--递归、搜索与回溯算法《算法题讲解指南》--动态规划算法✨未择之路不须回头已择之路纵是荆棘遍野亦作花海遨游目录前言一、感性角度理解线程线程是什么1.1 什么是线程1.2 Linux线程的一种实现方式轻量级进程1.2.1 什么是轻量级进程1.2.2 windows实现线程方式与Linux的不同之处1.2.3 题外话操作系统和具体操作系统概念之间的关联1.2.4 重谈进程的定义1.2.5 线程轻量级进程和进程的关系1.2.5.1 “家庭”示例理解两者关系1.2.5.2 创建进程和创建线程本质二、理性角度理解线程资源划分(内存管理)2.1 虚拟地址和页表的由来2.1.1 没有虚拟内存会怎样2.1.2 分页基本概念2.2 物理内存管理重点看图2.3 页表单级页表2.4 多级页表二级页表 地址转换2.4.1 页目录结构2.4.2 两级页表地址转换流程2.5 TLB 快表2.6 缺页异常 Page Fault三、线程的优缺点和异常与用途3.1 线程的优点3.1.1 线程切换 VS 进程切换3.2 线程的缺点3.3 线程的异常与用途最后总结结束语前言从本篇文章开始我们就要讲解Linux系统编程中线程这一大板块的知识点了由于线程相关的内容非常多所以我会拆分成好几个板块分别进行讲解。本篇文章是线程入门的第一篇主要讲解线程是什么、为什么需要虚拟内存、页表究竟是什么以及分页机制、Linux 线程本质和优缺点与用途。一、感性角度理解线程线程是什么1.1 什么是线程讲线程是什么之前必须先谈一下进程是什么进程 内核数据结构 自己的代码和数据具体实现角度去谈的抽象 - 具体今天给进程再下一个定义从内核视角出发的进程是承担分配系统资源的基本单位。我们再给线程下个定义线程是进程内部的一个执行分支概念从内核视角出发线程是CPU调度的基本单位。一个进程至少有1 个线程主线程线程在进程虚拟地址空间内运行共享进程大部分资源Linux 内核没有专门的 “线程结构体”线程本质是轻量级进程 LWP用 task_struct 描述只是比普通进程更 “轻量化”1.2 Linux线程的一种实现方式轻量级进程1.2.1 什么是轻量级进程这里的task_struct叫做轻量级进程。在系统当中线程个数比进程更多。这很好理解对于线程定义说了线程知识是进程内部的一个执行分支。线程是在进程地址空间内运行的。1.2.2 windows实现线程方式与Linux的不同之处线程也有自己的唯一性标识——线程也需要被操作系统管理起来那必然要有一个结构体struct TCB {}。线程在OS内如果存在并且一个进程可能存在多个线程那么就一定会存很多OS也就必然要对线程进行管理——要管理那必然还是“先描述再组织”。线程内部是不是有自己的结构呢也有状态也有优先级。TCBThread Ctrl Block线程是作为进程内部中的一个执行分支那么 PCB 里面是不是还要用一个结构将这些线程组织起来呢(比如线程链表等等)这个系统就会导致设置的非常复杂而Windows操作系统就是这样干的所以有时候会有莫名其妙的错误。Linux 在这里就比较聪明了并没有像windows那样创建内核级的 TCB而是直接复用进程的 PCB实现线程的效果——轻量级进程。这样就可以不用再像windows那样需要为 TCB 重新设计一套独立的调度算法Linux的复用思想使得线程和进程共同使用同一个调度算法。1.2.3 题外话操作系统和具体操作系统概念之间的关联1.2.4 重谈进程的定义当我们现在接触了线程后我们需要重新定义一下曾经学习的进程了进程内部的执行流 地址空间 代码和数据合起来叫做进程。1.2.5 线程轻量级进程和进程的关系今天谈的和之前的概念完全适配今天的进程叫做多执行流进程内部可能有一个或多个PCB。我们以前学的是特殊情况今天是普遍情况。创建线程只需要创建PCB然后分配就行啦所以说线程比进程更小、更细、更轻。1.2.5.1 “家庭”示例理解两者关系故事全世界的社会资源分配的单位如果是家庭假设每个家庭有一个自己的房子今天内部通常会存在很多的人每一个人都在做各自的事情今天内部会有各种各样的成员各自做着各自的事情——其实都在共同实现同一个目标我们都在完成把家里的日子过好这样一个目标——社会交给家庭的任务。今天的线程其实是轻量级进程的多执行流进程以前的“进程”只是单执行流的特殊情况相当于一个家里只有一个家庭成员而线程就是有各种各样的家人为了一个同样的目标——“把家里面的日子过好”——把家庭看成进程把家庭内部的一个一个成员叫做线程。1.2.5.2 创建进程和创建线程本质二、理性角度理解线程资源划分(内存管理)2.1 虚拟地址和页表的由来线程之所以能“共享、轻量化”完全依赖虚拟地址空间。这部分是理解线程的根基。2.1.1 没有虚拟内存会怎样早期的操作系统没有虚拟内存程序直接占用连续物理内存程序大小不一退出后留下大量内存碎片多程序容易地址冲突、越界、崩溃因为每一个程序的代码、数据长度都是不一样的按照这样的映射方式物理内存将会被分割成各种离散的、大小不同的块。经过一段运行时间之后有些程序会退出那么它们占据的物理内存空间可以被回收导致这些物理内存都是以很多碎片的形式存在。而我们希望的是用户视角地址连续、用起来方便内核视角物理内存离散、无碎片、可管理我们希望操作系统提供给用户的空间必须是连续的但是物理内存最好不要连续。此时虚拟内存和分页便出现了。2.1.2 分页基本概念虚拟地址按同样大小分割页Page物理内存按固定大小分割页框Page Frame常见页大小32 位系统 4KB64 位系统 8KB作用把连续的虚拟地址映射到不连续的物理内存页解决内存碎片问题。机制CPU不直接访问物理内存而是通过虚拟地址空间间接访问。操作系统为每个执行中的进程分配逻辑地址空间。32 位机范围0 ~ 4GB。通过页表建立虚拟地址 ↔ 物理地址的映射。总结一下其思想是将虚拟内存下的逻辑地址空间分为若干页将物理内存空间分为若干页框通过页表便能把连续的虚拟内存映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片问题。2.2 物理内存管理重点看图以 4GB 物理内存、4KB 页框为例总页数 4GB / 4KB 1048576 个页框。内核用 struct page 表示系统中的每个物理页。为节省内存struct page 大量使用 union联合体。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; /* 复用 lru */ struct { /* Partial pages */ struct page *next; #ifdef CONFIG_64BIT int pages; /* 剩余页数 */ int pobjects; /* 近似对象计数 */ #else short int pages; short int pobjects; #endif }; }; struct kmem_cache *slab_cache; /* 不用于 slob */ /* 双字边界对齐 */ void *freelist; /* 第一个空闲对象 */ union { void *s_mem; /* slab: 第一个对象 */ unsigned long counters; /* SLUB: 计数器 */ struct { /* SLUB 专用 */ unsigned inuse : 16; /* 已使用的对象数 */ 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 */ /* 后续可能还有其他成员取决于内核配置 */ ... };关键成员flags存放页的状态是否锁定、是否脏页、是否在缓存、是否空闲等。每一位表示一种状态最多可表示 32 种状态。重要标志PG_locked页是否被锁定PG_uptodate页数据是否有效PG_dirty页是否被修改脏页_mapcount表示有多少页表项指向该页即被引用计数。当值为 -1 时表示该页空闲可分配。virtual页的内核虚拟地址。高端内存不永久映射此时 virtual 为 NULL需要动态映射。内存开销计算要注意的是struct page 与物理页相关而并非与虚拟页相关。而系统中的每个物理页都要分配一个这样的结构体那么如果对所有这些页都这么做到底要消耗掉多少内存。算 struct page 大约占40个字节的内存假定系统的物理页为4KB大小系统有4GB物理内存。那么系统中共有页面1048576个(1兆个所以描述这么多页面的 page 结构体消耗的内存只不过40MB相对系统4GB内存而言仅是很小的一部分罢了。因此要管理系统中这么多物理页面这个代价并不算太大。页大小的权衡页太大页内碎片大页太小页表过长、切换开销大Linux/Windows 默认4KB。2.3 页表单级页表页表中的每一个表项指向一个物理页的开始地址。在32位系统中虚拟内存的最大空间是4GB这是每一个用户程序都拥有的虚拟内存空间。既然需要让4GB的虚拟内存全部可用那么页表中就需要能够表示这所有的4GB空间那么就一共需要 4GB/4KB1048576 个表。页表中的物理地址与物理内存之间是随机的映射关系哪里可用就指向哪里物理页。虽然最终使用的物理内存是离散的但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时使用的都是线性地址只要它是连续的就可以了最终都能够通过页表找到实际的物理地址。假设在32位系统中地址的长度是4个字节那么页表中的每一个表项就是占用4个字节。所以页表占据的总空间大小就是1048576*44MB 的大小。也就是说映射表自己本身就要占用4MB /4KB1024个物理页。这会存在哪些问题呢?虽然会存在上面这些问题但是不是说不能接受而之所以现代通用OS坚决不用单张页表的其中一个原因是对于64位操作系统而言所以为了解决单张页表带来的这些问题分页机制也就出现了。2.4 多级页表二级页表 地址转换解决需要大容量页表的最好方法是把页表看成普通的文件对它进行离散分配即对页表再分页由此形成多级页表的思想。为了解决这个问题可以把这个单一页表拆分成1024个体积更小的映射表。如下图所示。这样一来1024(每个表中的表项个数)*1024(表的个数)仍然可以覆盖4GB的物理内存空间。这里的每一个表就是真正的页表所以一共有1024个页表。一个页表自身占用4KB那么1024个页表一共就占用了4MB的物理内存空间那好像和之前没什么差别啊从总数上看是这样但是一个应用程序是不可能完全使用全部的4GB空间的也许只要几十个页表就可以了。例如一个用户程序的代码段、数据段、栈段一共需要10MB的空间那么向上对齐取整之后4MB的倍数就是12MB)就只需要3个页表就可以了。2.4.1 页目录结构到目前为止每一个页框都被一个页表中的一个表项来指向了但是这1024个页表呢是不是也需要被管理起来。所以这张管理页表的表称之为页目录表形成二级页表。如下图所示所有页表的物理地址被页目录表项指向页目录的物理地址被CR3寄存器指向这个寄存器中保存了当前正在执行任务的页目录地址。虚拟地址划分32 位、4KB 页10 位页目录 | 10 位页表 | 12 位页内偏移页目录与页表可以离散存储进程只加载用到的页表大幅节省内存支持大地址空间2.4.2 两级页表地址转换流程CR3 寄存器存放页目录物理地址用虚拟地址高 10 位查页目录 → 找到页表用中间 10 位查页表 → 找到物理页框低 12 位偏移 → 最终物理地址整个过程由MMU内存管理单元硬件完成。到这里其实还有个问题MMU要先进行两次页表查询确定物理地址在确认了权限等问题后MMU再将这个物理地址发送到总线内存收到之后开始读取对应地址的数据并返回。那么当页表变为N级时就变成了N次检索1次读写。可见页表级数越多查询的步骤越多对于CPU来说等待时间越长效率越低。总结单级页表对连续内存要求高于是引入了多级页表但是多级页表也是一把双刃剑在减少连续存储要求且减少存储空间的同时降低了查询效率。那对于上面的问题有没有提升效率的办法呢有的。从计算机科学中的所有问题都可以通过添加一个中间层来解决。MMU引入了新武器江湖人称快表的TLB(其实就是缓存Translation Lookaside Buffer学名转译后备缓冲器2.5 TLB 快表MMU 集成TLB 缓存流程如下CPU 给出虚拟地址MMU 先查 TLB命中直接得到物理地址不命中查页表并把映射写入 TLB当CPU给MMU传新虚拟地址之后MMU先去问TLB那边有没有如果有(命中)就直接拿到物理地址发到总线给内存。但TLB容量比较小难免发生没命中(CacheMiss)这时候MMU还有保底的老武器页表在页表中找到之后MMU除了把地址发到总线传给内存还把这条映射关系给到TLB让它记录一下刷新缓存。2.6 缺页异常 Page Fault当虚拟地址在 TLB 与页表中都找不到物理页时触发缺页异常。这是硬件中断可由软件修复。缺页异常分为三类硬缺页Major Page Fault物理内存中没有该页必须从磁盘读取到内存再建立映射。软缺页Minor Page Fault物理内存已有该页只是当前进程未建立映射直接映射即可。常见于共享内存、父子进程、多线程。无效缺页Invalid Page Fault地址非法越界、空指针、非法权限。触发Segment Fault内核直接终止进程。三、线程的优缺点和异常与用途3.1 线程的优点创建一个新线程的代价要比创建一个新进程小的多之前创建进程申请资源的时候会浪费大量的时间创建PCB、申请虚拟地址空间、建立映射关系。创建线程更加轻量化了进程可以做的事线程也可以做进程的代码和线程的代码不都是CPU一行行执行的嘛但是线程占用的资源要比进程少不用重新建立大量的虚拟地址空间虚拟物理地址映射关系都不需要。创建PCB就可以了然后有一部分进程的代码给你因为线程在进程内部运行。3.1.1 线程切换 VS 进程切换与进程之前的切换相比线程之间的切换需要OS做的工作要少很多有些人会认为之所以线程切换比进程切换开销小的原因是不需要切换页表、虚拟地址空间等等。虽然这的确是线程切换比进程切换开销小的其中一个原因但这个开销其实微乎其微。而线程切换比起进程切换开销小的主要原因是进程切换会导致 TLB 和 Cache 失效下次运行需要重新缓存。最主要的区别是线程的切换虚拟内存空间依然是相同的但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说一旦去切换上下文处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候处理的页表缓冲TLB快表会被全部刷新这将导致内存的访问在一段时间相当的低效。但是在线程的切换中不会出现这个问题当然还有硬件cache。线程占用的资源要比进程少能充分利用多处理器的并行数量在等待慢速I/O操作结束的同时程序可执行其他的计算任务计算密集型应用为了能够在多处理器系统上运行将计算分解到多个线程中实现I/O密集型应用为了提高性能将I/O操作重叠。线程可以同时等待不同的I/O操作总结3.2 线程的缺点性能损失⼀个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同⼀个处理器。如果计算密集型线程的数量比可用的处理器多那么可能会有较大的性能损失这里的性能损失指的是增加了额外的同步和调度开销而可用的资源不变。健壮性降低编写多线程需要更全面更深入的考虑在⼀个多线程程序里因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的换句话说线程之间是缺乏保护的。缺乏访问控制进程是访问控制的基本粒度在⼀个线程中调用某些OS函数会对整个进程造成影响。编程难度提高编写与调试⼀个多线程程序比单线程程序困难得多对于线程的健壮性降低的缺点我们会在下篇文章线程控制中先了解学习创建线程的相关接口然后通过代码对线程的这个缺点进行证明讲解。3.3 线程的异常与用途异常单个线程如果出现除零野指针问题导致线程崩溃进程也会随着崩溃线程是进程的执行分支线程出异常就类似进程出异常进而触发信号机制终止进程进程终止该进程内的所有线程也就随即退出用途合理的使用多线程能提高CPU密集型程序的执行效率合理的使用多线程能提高IO密集型程序的用户体验如生活中我们⼀边写代码一边下载开发工具就是多线程运行的⼀种表现最后总结进程资源分配的基本单位线程CPU 调度的基本单位Linux 线程 轻量级进程 LWP线程共享地址空间、页表、文件描述符、堆、数据段线程私有栈、寄存器、线程 ID、errno、信号屏蔽字线程优点创建快、切换快、占用少、适合多核、适合 I/O线程缺点健壮性差、一崩全崩、编程复杂线程用途加速计算、提高 I/O 体验、高并发服务结束语到此线程的概念我们就讲解的差不多了。线程是 Linux 并发编程的核心基石理解其内核本质、与进程的核心区别、优缺点和适用场景是我们后续掌握线程控制、同步互斥、线程安全的关键。希望这篇博客能帮你吃透线程的基础概念。