目录

一、线程概念

1. 什么是线程

2. 为什么需要线程

3. 为什么线程更轻量

二、虚拟地址空间

1. 为什么需要虚拟地址

2. 分页式存储管理

3. 页表作用

三、页表结构

1. 多级页表

2. 二级页表结构

3. 地址转换过程

4. 为什么多级页表能省内存

四、MMU 与 TLB

1. MMU

2. TLB

3. 协作过程

五、缺页异常

1. 什么是缺页

2. 缺页分类

3. 协作全过程

六、物理内存管理

1. struct page

2. 物理内存如何被管理

3. 初始化

七、线程与进程

1. 为什么线程轻量(从 MMU 与 TLB 的视角)

2. 线程共享什么

3. 线程不共享什么

4. 优缺点

5. 重新理解单进程

总结


一、线程概念

在之前的章节中,我们详细探讨了操作系统的进程管理机制。然而,随着计算任务日益复杂,仅依靠进程这种重量级执行单元已显得捉襟见肘

本篇我们将深入探讨线程(Thread)的本质,并从底层地址空间的本质出发,揭示 Linux 系统如何以巧妙的方式实现这一重要概念


1. 什么是线程

在传统的操作系统教材中,线程通常被定义为执行流的最小单位,而进程是资源分配的最小单位。但这个定义过于抽象

历史背景与逻辑复用

在早期的 UNIX 时代,系统里只有进程。如果你想让一个程序同时干两件事(比如一边下载一边播放),你必须 fork 一个子进程。但 fork 的代价是巨大的:它需要复制一份完整的页表、文件描述符表、信号处理逻辑等

Linux 的开发者在设计线程时,并没有像 Windows 或 Solaris 那样去专门设计一套复杂的 "原生线程" 数据结构。相反,他们利用了进程已有的逻辑,提出了 LWP(Lightweight Process,轻量级进程) 的概念

  • 复用机制:在 Linux 内核眼中,其实并没有严格意义上的 "线程"。每一个线程在内核里同样对应一个 struct task_struct

  • 共享本质:不同于进程 fork 时会复制地址空间,Linux 通过 clone() 系统调用创建线程时,会让这些新的 task_struct 共享父进程的同一个虚拟地址空间

如果进程是一个 "家庭",那么传统进程就是 "单人单户"。而 Linux 的线程,就是几个成年人(执行流)住在一个大房子(地址空间)里。他们共享厨房和客厅(全局变量、代码区),但每个人都有自己的卧室(私有栈)


2. 为什么需要线程

既然进程也能并发,为什么要费力搞出线程?

  1. 资源共享与通信速度: 由于所有线程都在同一个地址空间内,它们可以看到同样的全局变量。相比进程间通信(IPC)需要通过管道、共享内存或信号量等复杂手段,线程间通信只需要直接读写内存即可

  2. 提高并发效率: 在多核 CPU 时代,如果你只有一个进程(单执行流),那么无论你有多少个核心,你只能利用其中一个。线程允许我们将一个复杂的任务拆分成多个部分,并行地跑在不同的核心上

  3. 维持程序响应: 在图形界面程序中,如果主线程因为读取一个大文件而阻塞,界面就会卡死。通过创建一个工作线程去读文件,主线程可以继续刷新 UI,保持用户体验


3. 为什么线程更轻量

一个非常经典的问题:进程和线程都拥有 PCB,为什么线程更轻?

关键在于不能仅看 PCB 这个 "外壳",而要看它实际管理的内容

(1) 资源所有权

  • 进程:创建一个进程时,操作系统会为其分配一套完整的独立资源。虽然 PCB 本身占不了多少内存,但 PCB 指向的虚拟地址空间、文件描述符表等资源,全都要重新拷贝一份

  • 线程:创建一个线程时,内核依然会为你创建一个 PCB,但这个新的 PCB 并不会独立运作。它会直接指向父进程已经分配好的资源

(2) 创建销毁开销

因为不需要重新分配和初始化庞大的虚拟地址空间,内核在创建线程时的工作量极小:

  1. 申请一个 task_struct 结构

  2. 申请一小块内存作为线程的私有栈

  3. 把现有的资源指针(内存、文件等)直接填进新的 PCB

  4. 设置好寄存器初始状态

(3) 地址空间

线程之所以能被称为轻量级,其核心在于:它不需要一套独立的、完整的虚拟地址空间

在 Linux 中,进程的 PCB 里有一个指针指向它的内存描述符(mm_struct)

  • 进程:每个进程都有自己独立的 mm_struct

  • 线程:同一进程内的所有线程,它们的 PCB 里的 mm_struct 指针全都指向同一个地址

这就是为什么线程切换起来更方便,创建成本更低的原因——因为它们本质上是在共享同一份内存资源

二、虚拟地址空间

既然线程通过共享地址空间实现 "轻量化",我们不妨深入探讨这个支撑所有线程运行的虚拟地址空间是如何构建的

进程好比演员,而虚拟地址空间则是它们的舞台。巧妙的是,每个进程都以为自己独占了整个剧院


1. 为什么需要虚拟地址

如果让进程直接访问物理内存,会发生什么?

  • 内存安全:进程 A 可以随手修改进程 B 的数据,甚至改写内核代码,系统毫无安全性可言

  • 地址不固定:每次程序加载到物理内存的位置都不一样,程序员在写代码时根本无法确定变量的绝对地址

虚拟地址的本质是一个大饼:操作系统给每个进程都发了一张 4GB(以 32 位为例)的空头支票。进程访问的是虚构的地址,而底层的内存管理单元(MMU)负责把这些虚构地址转化成真实的物理地址


2. 分页式存储管理

在没有分页的时代,程序是直接加载进物理内存的。但由于每个程序的代码、数据长度都不一样,物理内存会被分割成各种大小不一的块

  • 内存碎片:当程序退出后,它留下的物理空间由于大小尴尬,往往无法接纳新程序。久而久之,内存里全是 "缝隙",即便剩余总空间很大,也无法运行一个稍大的程序

  • 用户希望内存空间是连续的,这样写程序、算偏移才方便。操作系统希望物理内存是离散的,哪里有空就塞哪里,彻底杜绝碎片

分页机制完美解决了这个矛盾:它将连续的虚拟地址,拆解映射到离散的物理内存中

(1) 页(Page)与页框(Page Frame)

概念 归属 定义 典型大小
虚拟内存 操作系统将逻辑地址空间划分为固定长度的单元 32位系统常为 4KB;64位系统常为 8KB 或更多
页框 物理内存 物理内存被划分为与 "页" 大小完全相等的固定块,也叫物理页 必须与 "页" 的大小严格对应

页是一个逻辑上的数据块,而页框是物理内存中承载这个块的容器。一个页框包含一个物理页

(2) 间接访问

有了分页,CPU 就不再直接触碰物理内存地址

  1. 操作系统为每个进程分配 0 ~ 4GB-1 的虚拟地址空间

  2. 建立映射:操作系统维护一张页表,记录每一对页与页框的对应关系

  3. 地址转换:CPU 给出虚拟地址,硬件(MMU)查页表,找到对应的物理页框,最终访问真实的内存

核心逻辑:操作系统管理的目标,就是把虚拟内存中的每一个页,投射到物理内存中真实的页框里


3. 页表作用

页表是实现这种投射的核心索引表

(1) 映射的本质

页表中的每一个表项,都记录了一个虚拟页对应的物理页起始地址

  • 线性与离散:虚拟内存看上去是连续的,但页表可以将这些连续的页映射到随机、散乱的物理页框上。哪里有空闲,映射就指向哪里

  • CPU 视角:处理器在执行指令、访问数据时,使用的永远是连续的线性虚拟地址。只要页表在,CPU 就能找到物理内存真实地址

(2) 32 位系统下页表管理

为什么我们说 32 位系统的页表管理压力很大?我们可以算一笔账:

  1. 表项数量:4GB 空间按 4KB 分页,总共需要:

    \frac{4GB}{4KB} = \frac{2^{32}}{2^{12}} = 2^{20} = 1,048,576   个表项

  2. 表项大小:每个地址需要 4 字节来存储

  3. 页表总空间

    1,048,576 \times 4 \text{ bytes} = 4MB

看起来 4MB 不大,但这里隐藏着两个致命问题:

  • 连续性

    我们引入分页的初衷,是为了将进程划分为不连续的页,从而解决内存碎片问题。然而,为了管理这 4GB 空间,单级页表本身竟然需要 4MB 连续的物理页框(即 1024 个页框)。这与我们消除连续大内存需求的目标显得有些背道而驰

  • 利用率浪费

    根据局部性原理,一个进程在一段时间内往往只访问一小部分代码和数据。如果使用单级页表,即便进程只用了 10MB 的内存,我们也得为它常驻一个完整的 4MB 页表。这就像为了喝一口水,不得不买下整个自来水厂

三、页表结构

这一节我们将拆解 32 位 Linux 系统中最为经典、这一设计完美体现了计算机科学中的折中思想


1. 多级页表

在上一节我们算过:单级页表映射 4GB 空间需要 1024 个连续页框(4MB 内存)。但这里有两个逻辑死结:

  1. 物理内存的连续性要求太高:本来分页是为了碎片化利用内存,结果页表自己先要了一块 4MB 的大连续内存

  2. 空间浪费惊人:绝大多数进程的虚拟地址空间是稀疏的

    • 一个简单的 "Hello World" 程序,代码段在低地址,栈在高地址,中间 3.9GB 的空间全是空的

    • 单级页表的问题:即便中间 3.9GB 根本没用,单级页表也必须老老实实为这些空地址准备好 100 万个映射项,因为它是线性索引,下标必须连续

解决办法:多级页表。 它的核心逻辑是:如果这一片虚拟地址没被用到,那么连管理这片地址的次级页表都不需要创建


2. 二级页表结构

在 32 位 Linux 环境下,二级页表将 32 位的虚拟地址拆分成了三个部分,像导航一样指引 MMU 找到物理地址

地址拆分逻辑:

一个 32 位的虚拟地址被划分为:10 位 + 10 位 + 12 位

  1. 页目录索引(10 bits)

    • 高 10 位用来在一张名为 页目录 的表里找索引

    • 2 ^ {10} = 1024。这意味着页目录表里有 1024 个表项

    • 每个 PDE 指向一个二级页表

  2. 页表索引(10 bits)

    • 中间 10 位用来在刚才找到的那个二级页表里找具体的映射

    • 同样是 1024 个表项

    • 每个 PTE 最终指向一个真实的 物理页框 的起始地址

  3. 页内偏移(12 bits)

    • 最后 12 位代表你在这一页内部的哪个字节

    • 2 ^ {12} = 4096 字节(即 4KB)

  • 页目录(Level 1):管理页表的页表,实现了按需分配

  • 页表(Level 2):真正负责虚拟页到页框的映射

  • 地址拆分:10-10-12 模式


3. 地址转换过程

假设我们要访问虚拟地址 0x00401005

  1. 第一步

    MMU 取出地址的高 10 位,找到当前进程页目录的第 N 项。如果这一项为空
    (Present位=0),说明这 4MB 范围根本没分配,触发缺页异常;如果不为空,它会得到一个二级页表的物理基地址

  2. 第二步

    MMU 取出地址中间 10 位,去刚才那个二级页表里查。这一项里存着我们要找的、真正的物理页框的地址

  3. 第三步

    MMU 取出最后 12 位作为偏移,与页框基址相加

Physical\_Address = Page\_Frame\_Base + Offset

        自此,我们拿到了数据


4. 为什么多级页表能省内存

我们再算一笔账:假设一个进程只使用了 8KB 内存(代码段 4KB + 栈 4KB),且它们分布在完全不同的地址段

  • 单级页表:必须分配 4MB 内存来存页表

  • 二级页表

    1. 一个页目录表:4KB

    2. 代码段所在的二级页表:4KB

    3. 栈所在的二级页表:4KB

    4. 其他没用到的地址段?对应的二级页表根本不用创建!

    • 总开销:4KB + 4KB + 4KB = 12KB

12KB vs 4MB,内存节省了 340 多倍! 这就是多级页表能够支撑成百上千个进程同时运行的根本原因

四、MMU 与 TLB

虽然多级页表完美解决了内存空间的浪费问题,但这种设计在架构上引入了一个致命的副作用:性能开销

这一节我们来看看硬件工程师是如何通过中间层思想,化解多级页表的效率危机的


1. MMU

MMU 是 CPU 内部的一个核心硬件。它的唯一使命就是:翻译地址

当页表变为 N 级时,MMU 每访问一个虚拟地址,就必须先进行 N 次内存检索(查页目录、查二级页表……)来确定物理地址,最后再进行 1 次真正的内存读写

  • 1级页表:1次检索 + 1次访问 = 2次内存操作

  • 2级页表:2次检索 + 1次访问 = 3次内存操作

  • 4级页表(64位):4次检索 + 1次访问 = 5次内存操作

这意味着,为了读一个数,CPU 可能要跑 5 趟内存。对于追求极致速度的 CPU 来说,内存访问太慢了,这种成倍的延迟是无法接受的


2. TLB

计算机科学中有一句名言:所有的计算机问题都可以通过添加一个中间层来解决。

为了提升效率,MMU 引入了一个中间层——TLB,在中文里通常被称为快表

TLB 的本质就是缓存(Cache)。它是一块极小、极快、集成在 CPU 内部的静态存储器。它缓存的是什么呢?它缓存的是最近使用过的 "虚拟页号" 到 "物理页框号" 的映射结果

  • 逻辑:既然根据 "局部性原理",进程在一段时间内会反复访问同一片内存区域,那我们何必每次都费劲去查那张巨大的页表呢?只要把查过的结果记在 TLB 上,下次直接看结果就行了


3. 协作过程

当 CPU 想要访问一个虚拟地址时,硬件的协作逻辑如下:

  1. 先看 TLB: CPU 给出虚拟页号,第一时间去 TLB 里找。由于 TLB 是硬件电路级的高速并行查询,速度极快。如果找到了,直接拿到物理页框号

  2. TLB 缺失: 如果 TLB 里没有这条记录,MMU 就只能老老实实去内存里查找页表

    • 第一步:去内存查页目录

    • 第二步:去内存查二级页表

    • 第三步:拿到物理地址

  3. 更新 TLB: 在拿到物理地址的同时,MMU 会顺手把这个新发现的映射关系存入 TLB。如果 TLB 满了,会根据一定的算法(如 LRU)踢出旧的条目

效率对比

有了 TLB 之后,只要缓存命中率足够高(在实际运行中,由于程序访问的局部性,命中率通常能达到 95% 以上),原本繁琐的 "N 次检索" 就被简化成了 "1 次高速查询"

  • 无 TLB:慢速内存检索 * N + 慢速内存访问

  • 有 TLB:极速硬件查询 + 慢速内存访问

可以说,多级页表解决了 "存不下" 的问题,而 TLB 解决了 "查得慢" 的问题。两者配合,才让虚拟存储管理方案真正具有了工业实用价值

五、缺页异常

MMU和TLB的存在并不能完全避免地址转换过程中的问题。当MMU根据虚拟地址查询页表时,可能会发现对应的物理页面不存在,或者当前进程没有访问权限

这种情况会触发操作系统中最关键的机制之一——缺页异常(Page Fault)


1. 什么是缺页

在理想状态下,MMU 查 TLB 命中,直接拿到物理地址,皆大欢喜。但现实中,CPU 给出的虚拟地址在 TLB 和多级页表里可能都找不到对应的物理页

缺页异常是一个由硬件触发、但可以由软件逻辑纠正的错误

我们可以把它看作 MMU 在翻译失败时的 "一通紧急电话":

  1. MMU 发现目标虚拟页在物理内存中没有对应的页框(Present 位为 0),或者虽然有,但权限不够(想写一个只读的页)

  2. 由于 CPU 需要数据才能执行计算任务,当无法获取数据时,当前指令将被迫中断,CPU 只能进入等待状态

  3. 特权切换:硬件自动触发中断,进程立刻从用户态切换到内核态,并将控制权移交给内核预设的 Page Fault Handler


2. 缺页分类

当 MMU 查表失败,Page Fault Handler 介入后,它会根据该虚拟地址在内核管理的数据结构中的状态,将缺页分为以下三类:

(1) Hard Page Fault(硬缺页)

这是最严重的一种异常。这意味着目标数据完全不在物理内存中

  • 发生场景

    • Swap(交换):物理内存不足,这部分数据被系统置换到了磁盘的交换分区中

    • 文件映射:你正在运行一个巨大的游戏,由于内存放不下,内核只映射了地址,代码还没从硬盘读进来

  • 处理逻辑:内核必须发起 磁盘 I/O 操作,将数据从硬盘搬运到物理内存页框中

  • 后果:由于磁盘速度比内存慢数万倍,进程会被迫进入休眠状态等待数据传输完成,这正是导致系统运行迟缓的主要原因

(2) Soft Page Fault(软缺页)

这种情况属于轻微缺页错误。目标数据其实已经在物理内存里了,只是当前进程的页表还没建立好关联

  • 发生场景

    • 共享库重用:比如进程 A 加载了 libc.so,由于这是系统动态库,内核已经在物理内存里存了一份。现在进程 B 也要用,虽然内存里有,但进程 B 的页表项还是空的

    • 页缓存重用:数据刚被另一个进程读过,还留在内核的 Page Cache 中,只是没映射到你的空间

  • 处理逻辑:内核发现内存里有现成的,只需在页表里填上那个物理页框的地址,改一下 Present 位即可

  • 后果:由于不需要读磁盘,这种缺页的处理速度极快,对性能影响微乎其微

(3) Invalid Page Fault(无效缺页)

这是真正的非法操作,表明该进程越权访问了受保护区域

  • 发生场景

    • 越界访问:访问了完全没有定义的虚拟地址(如解引用一个野指针 NULL)

    • 权限违规:尝试往只读的代码段里写数据,或者用户态进程尝试访问内核态地址

  • 处理逻辑:MMU 报错后,内核检查发现该地址根本不合法

  • 后果:内核不会尝试修复,而是直接给进程发送 SIGSEGV 信号。如果没有特殊处理,进程会直接崩溃并提示 "Segmentation Fault"


3. 协作全过程

我们将前面学到的知识串联起来,看看一个地址访问是如何演变成缺页异常的:

  1. 发起请求:CPU 发出虚拟地址。

  2. TLB 检索:MMU 查 TLB,Miss(未命中)

  3. 页表走查:MMU 深入内存查多级页表

  4. 触发异常:MMU 发现页表项无效(Present = 0),向 CPU 发出信号

  5. 陷入内核:CPU 保存当前现场,跳转到 do_page_fault

  6. 分配映射:内核 Page Fault Handler 分配物理页框,填入页表

  7. 刷新 TLB:更新页表后,为了保证下次快速访问,相关的 TLB 项会被刷新或更新

  8. 指令重执行:CPU 回到刚才那条指令,重新查一次 TLB。这次,由于内核已经补好了漏洞,访问将瞬间成功

六、物理内存管理

在聊完了地址空间和多级页表后,我们必须落地到最真实的物理硬件上。无论虚拟地址如何精巧设计,数据最终必须存储在插在主板上的那根内存条里

在 Linux 内核中,每一块被切分出来的 4KB 物理页框,都有一个专属的内核数据结构 struct page


1. struct page

在内核中,物理内存管理的基本单位不是字节,而是。为了描述每一个物理页框的状态,内核为每一个页框都创建一个 struct page 实例

源码路径:

如果你看的是现代内核(比如 3.x, 4.x 到现在的 6.x),代码在 include/linux/mm_types.h

2.6.18 里,代码在 include/linux/mm.h

为什么这个结构体如此精简?

想象一下,如果你有 128GB 物理内存,按 4KB 分页,系统中会有超过 3300 万个 struct page实例。哪怕这个结构体多增加 8 个字节,也会平白消耗掉数百 MB 的宝贵内存。因此,struct page 是内核中设计最精打细算的结构体


2. 物理内存如何被管理

内核并不会杂乱无章地丢出这些页,它使用了两套层级分明的机制:

(1) 伙伴系统(Buddy System)—— 解决外碎片

这是内核最底层的物理页分配器。它将空闲的物理页按 2 ^ n(阶)进行分组

  • 逻辑:如果你要 4 个连续页,它就从 2 ^ 2 的池子里拿。如果没有,它就把 2 ^ 3(8页)的一块内存对半拆分

  • 目的:通过不断的拆分与合并,尽量保证大块连续物理内存的供应

(2) Slab / Slub 分配器 —— 解决内碎片

伙伴系统分配的最小单位是 1 页(4KB)。但如果我们只需要分配一个 32 字节的结构体呢?直接给 4KB 太浪费了

  • 逻辑:Slab 就像一个 ”零售商“,它从伙伴系统批发一整页内存,然后将其切成一粒一粒的小方块(Object)卖给内核的其他组件


3. 初始化

物理内存的管理并不是一开机就有的,它经历了一个从“原始荒野”到“井然有序”的过程:

(1) 探测阶段

当控制权交给内核后,内核会通过 e820 等接口探测机器上一共有多少物理内存。此时内存还是一片混沌,只有非常简单的动态分配

(2) 映射与创建

内核在执行 start_kernel()(Linux 内核的 main 函数)期间,会调用 setup_arch()

  • 建立 Memmap:内核根据探测到的物理内存大小,在内存的某个区域开辟一块巨大的空间,用来存放所有的 struct page 结构体数组。这个数组被称为 memmap

  • 初始化:每个物理页框此时正式关联到了它的 struct page

(3) 权力移交

最后,内核调用 mem_init(),将所有空闲的物理页丢进伙伴系统的仓库里

自此之后,内核、驱动、用户进程通过 malloc 或 mmap 申请内存时,最终都会走到伙伴系统这里领取一页页的物理内存

七、线程与进程

在讨论了分页、多级页表、MMU 和 TLB 这些硬核的底层机制后,我们终于可以回过头来,给那个困扰已久的问题一个答案:为什么线程比进程轻量得多?

这种轻量不仅是创建时的省事,更是运行切换时的高效


1. 为什么线程轻量(从 MMU 与 TLB 的视角)

在 Linux 中,进程切换和线程切换虽然都由内核完成,但其背后的工作量完全不同

(1) 显性损耗:寄存器与上下文

无论是进程还是线程,切换时都必须保存当前执行流的寄存器状态。这部分开销是两者共有的

(2) 隐性损耗:地址空间与 TLB 

这是进程切换最昂贵的地方。当从进程 A 切换到进程 B 时:

  • 页表基址切换:CPU 的控制寄存器(如 CR3)必须指向进程 B 的页目录地址

  • TLB 全部失效:由于不同进程的虚拟地址映射完全不同,一旦切换了 CR3,TLB 内缓存的所有映射项在一瞬间全部作废

  • 后果:切换后的前一段时间,由于 TLB 为空,CPU 访问任何内存地址都必须去走 "多级页表查询"。这会导致内存访问效率在短时间内断崖式下跌

(3) 线程优势

同一进程内的线程共享同一个 mm_struct 和同一套页表

  • TLB 无需刷新:切换线程时,CR3 寄存器保持不变。这意味着 TLB 里的映射依然有效,CPU 换个执行流继续跑,地址转换依然是瞬发的

  • 硬件 Cache 友好:CPU 的 L1/L2/L3 缓存中存储的数据,对于同一进程的不同线程来说往往是有用的(比如共享的全局变量、相同的代码段)。线程切换不会导致硬件 Cache 的大规模失效


2. 线程共享什么

在 task_struct 层面,线程共享以下关键资源:

  • 虚拟地址空间:包括代码区(Text)、全局数据区(Data)、堆(Heap)和共享库

  • 文件描述符表:一个线程打开的文件,另一个线程可以直接读写

  • 信号处理动作:如何响应信号是整个进程定义的

  • 当前工作目录用户 ID组 ID

信号处理动作

在 Linux 中,虽然每个线程可以有自己的信号屏蔽字,但信号的处理方式却是共享的

  • 如果线程 A 通过 sigaction 修改了 SIGINT 的处理函数,那么线程 B、C 看到的处理动作也会同步改变

  • 当线程出现除零或野指针等错误导致崩溃时,整个进程也会随之终止。这是因为线程作为进程的执行单元,其异常会触发信号机制,最终导致进程退出。一旦进程终止,其包含的所有线程都将自动结束

为什么不能只让出事的线程死掉?

你也许会问:为什么内核不能只杀掉那个犯错的线程,让其他线程继续跑?

  • 逻辑不确定性:线程共享地址空间。如果线程 A 因为野指针写乱了内存导致崩溃,你无法保证它在死掉之前有没有把线程 B 的数据也给改脏了

  • 状态不一致:如果线程 A 拿着一把互斥锁时突然被内核 "定点清除",这把锁将永远无法释放,导致其他线程随后全部死锁

结论: 线程是进程的执行分支,它们在逻辑上被视为一个整体。线程出异常,本质就是进程出异常


3. 线程不共享什么

为了保证执行流的独立运行,每个线程必须拥有自己的一套:

  • 线程 ID (TID):身份标识

  • 一组寄存器:包括程序计数器(PC)和栈指针(SP),记录当前跑到哪了

  • :存储局部变量和函数调用链。虽然在同一个地址空间,但内核会为每个线程划分独立的栈区,防止打架

  • 错误码 (errno):每个线程应该有独立的错误状态

  • 信号屏蔽字:每个线程可以选择屏蔽哪些信号

  • 调度优先级


4. 优缺点

优点:

  1. 极低的开销:创建和切换线程比进程快得多(得益于 TLB 和页表共享)

  2. 极速通信:线程间通信不需要 IPC(管道、消息队列),直接读写共享内存即可

  3. 重叠 I/O:当一个线程在等待磁盘或网络返回时,其他线程可以继续计算,极大提高了 I/O 密集型应用的吞吐量

  4. 并行计算:在多核系统上,真正实现分头行动

缺点:

  1. 健壮性降低:线程之间缺乏保护。由于共享地址空间,一个线程的非法内存访问(段错误)会导致整个进程崩溃

  2. 缺乏访问控制:在线程中调用某些修改全局状态的 OS 函数(如 chdir),会影响到所有其他线程

  3. 性能损失(过度竞争):对于很少阻塞的计算密集型任务,如果线程数超过核心数,频繁的调度切换反而会因为同步和竞争降低效率

  4. 编程难度:死锁、竞争条件、优先级翻转……多线程程序的调试是无数程序员的噩梦


5. 重新理解单进程

到这里,我们对进程的理解应该升华了: 现在的进程不再只是一个执行流,而是一个 "资源容器"。它代表了操作系统分配的一整套资源(内存、文件、权限)

  • 单线程进程:这个容器里只有一个工人在干活

  • 多线程进程:这个容器里有一群工人在共享资源、分工协作

所谓的 "单进程程序",本质上就是只有一个 "主线程" 在跑的特殊情况

总结

综上所述,从虚拟地址空间、分页式存储管理,到页表、多级页表、TLB 与 MMU,我们逐步梳理了线程背后真正依赖的整套内存管理体系。而线程之所以被称为“轻量级进程”,本质原因也终于浮出水面:

线程并不是 "没有PCB",而是多个执行流共享了同一套地址空间与页表体系

也正因如此,线程在创建、切换以及通信成本上远低于传统进程,但与此同时,共享资源也意味着更容易出现竞争、同步与数据安全问题

至此,我们已经完成了线程理论层面的整体认知:从 CPU 如何通过页表访问内存,到线程如何共享进程资源,再到缺页异常与地址转换机制,线程运行所依赖的底层基础已经基本打通

而在下一篇中,我们将正式进入线程的实际控制与操作部分,开始学习 pthread 线程库、线程创建与回收、线程ID、线程控制等具体内容,真正让线程跑起来

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐