一、什么是线程

1.1 教材定义

线程是进程内部的一个执行分支。

进程最终会被 CPU 调度,本质也是一个"执行流"的概念——它能让 CPU 执行自己的代码、完成进程要完成的任务。如果进程内部可以拆出多个这样的执行分支,那就是线程。

1.2 为什么教材上的定义总让人不可理解

因为操作系统教材谈论的向来不是具体的操作系统,它只提供指导思想,不提供实现方案。

  • 学科里:“线程是进程内部的执行分支” —— 这是概念
  • Linux 为实现这个概念,采用的是"用进程模拟线程"的方案
  • Windows 为实现同一个概念,采用的是"PCB + TCB"的方案

💡 操作系统是计算机学科的指导手册(哲学)。你让一个从一线打上来的将军理解"大战该怎么打",他能懂;但你对着一个没上过战场的书呆子只讲指导思想,就是空中楼阁——人往往先理解具体实现方案,再理解抽象思想。

所以要先把学习思路转变一下:聚焦具体的 Linux,看到它真实的实现,再推而广之。也就是先学具体,再学抽象。


二、重新定义进程与线程

先把结论摆出来(后面逐条解释):

定义关键词
进程承担分配系统资源的基本实体要资源
线程CPU 调度的基本单位被调度

💡 重新定义不是否定旧概念(进程 = 内核数据结构 + 代码和数据),而是换一个角度看同一枚硬币——侧看是一条圆弧,俯看是一个圆。不同角度是为了后面理解线程做准备。

2.1 为什么进程是"承担分配系统资源的基本实体"

想一个问题:创建一个进程时,就是"创建一个执行流"吗?

✖ 不是。进程一旦创建,就要:

  • 创建 PCB、地址空间(mm_struct)、页表
  • 加载代码和数据、构建映射关系
  • 创建文件描述符表、信号相关的表结构
  • 可能还要加载动静态库

这一大堆内核数据结构 + 自己的代码数据,归根结底都要占据内存资源、占用 CPU 资源。而内存是硬件资源,总有上限——申请一个进程,系统资源就少一份。

✔ 所以从"资源"的角度看:进程首先要干的是"给我整一批资源出来",它就是承担分配系统资源的基本实体。

那申请的资源是给谁用的?给执行流(线程)用的。以前进程内部只有一个执行流,所以看起来"进程 = 执行流"。

2.2 家庭比喻(感性理解)

概念现实对应
社会操作系统
社会分配资源的基本实体 = 家庭承担分配系统资源的基本实体 = 进程
家庭内部成员(爸妈赚钱、你上学、爷奶养老)线程——各做各的事
公园/学校/公司/土地CPU——每个人在不同的"CPU"上被调度
全家共同目标:过好日子进程要完成的任务
只有一个人的家庭(留守老人/独居)以前讲的单进程 = 内部只有一个线程的特殊情况

家庭里:房子、学校、医院、冰箱、电视是共享的;但你自己的作业、病历本、存折是独享的。对应到进程/线程:大部分资源是共享的,小部分资源是各线程私有的。


三、资源划分的真相:地址空间是"资源窗口"

3.1 一个进程拥有的资源,取决于它的"窗口"有多大

✔ 进程访问的绝大部分资源,都是通过地址空间访问的:

  • 自己的代码、堆、栈、加载的动静态库
  • 通信时申请的共享内存
  • 访问系统调用(3-4GB 内核区)

因为地址空间就是进程看到资源的窗口(mm_struct + 一堆 vm_area_struct 描述出范围)。所以:

一个进程拥有多少资源,本质是这个进程通过地址空间窗口能看到多少资源。

(打开的文件、信号不属于地址空间范畴,是 PCB 通过 fd 表、信号表直接找到的,属于"另一把事"。)

3.2 虚拟地址就是资源的代表

  • 地址空间上的每个合法虚拟地址,都一定有页表映射关系 → 能找到对应物理地址
  • 而物理地址对应的是存储空间,以及空间里的代码/数据

💡 虚拟地址 = 资源的代表。 你有多少合法虚拟地址,就拥有多少资源。(malloc 时只是把堆区的 start/end 往上挪了挪,本质是给虚拟地址范围,物理内存后面才申请。)

3.3 怎样把资源划分给不同线程:靠"函数"

既然资源划分 = 划分虚拟地址范围,那代码区怎么划分?

  • C/C++ 写代码,最终都是由一个一个函数构成的;每个函数都有入口地址(所以语言里才有回调、函数指针)
  • 函数本质是代码块,块里每一行代码都有地址,第一份地址叫入口地址
  • ELF 编译采用平坦虚拟逻辑地址(段基址为 0,只留偏移)——所以:函数就是虚拟地址空间的集合

于是划分方式自然就出来了:

让不同的执行流,执行不同的入口函数。 各执行流从自己的入口函数开始跑,天然就会访问虚拟地址空间的不同区域——资源划分天然就完成了,不需要人为切分。

✔ 结论串起来:资源划分本质 = 划分地址空间(虚拟地址范围)= 划分页表条目;资源共享本质 = 地址空间共享 = 页表条目共享。


四、Linux 的实现方案:用进程模拟线程(轻量级进程)

在这里插入图片描述

4.1 思路

创建一批"特殊的进程":

  • 不给它们分配独立的地址空间(mm_struct)
  • 不给它们创建独立的页表
  • 只创建一个个 task_struct,让它们全部指向同一个地址空间

这样所有执行流就看到了同一份资源——这就叫线程。

4.2 为什么不单独设计线程结构(TCB)

理论上,如果要在内核里"真正实现"线程:

  • 线程数量一定比进程多,管理需求更迫切(也要创建/终止/调度/阻塞/挂起)
  • 先描述再组织 → 就需要 TCB(Thread Control Block),还要维护 TCB 与 PCB 的关系(PCB 里挂一条 TCB 链表,或全部 TCB 用链表组织起来)

但 Linux 程序员认为没必要:线程和进程在调度属性上相似度高达 90%——都要被调度、都要切换、都要上下文保存、都会被时钟中断、都要做时间片检查。

所以 Linux 直接把 task_struct 复用过来模拟线程:

  • 执行流的优先级、状态、切换、调度算法 一行代码都不用改(连多写一套调度器都不用)
  • 复用的是历史上被无数次印证过的代码 → 更健壮、测试/维护/debug 成本更低
  • 这正是 Linux 能长期当服务器后端的原因之一(进程/线程创建释放是高频操作)

4.3 对比:Windows 怎么做

方案LinuxWindows
实现用进程模拟线程(复用 task_struct)专门为线程创建 TCB(PCB + TCB 双结构)
特点结构简单、复用历史代码、健壮数据结构更多、内核维护成本更高、调度算法要另行设计
称呼轻量级进程 LWP线程(有单独的线程创建接口)

💡 思维观念很重要:说 Linux 优秀不代表 Windows 糟糕——只是从"代码复用/生命周期"角度看,Linux 的设计更漂亮。

4.4 CPU 视角:不区分进程还是线程

  • 操作系统软件视角:统一都叫执行流
  • CPU 硬件视角:更不管——CPU 只干两件事:① 主动不停向系统触发时钟中断,要求操作系统调度;② 被动地"你给我什么我就执行什么"

所以 CPU 眼里:执行流 ≤ 进程(等于进程 = 一个 PCB 代表整个进程;小于进程 = 多线程里的某一个 PCB)。

Linux 底层不存在真正意义上的"线程",一个一个的 PCB 统一叫轻量级进程(LWP)。


五、理性角度①:物理内存是怎么被管的

在这里插入图片描述

5.1 4KB 页框与页帧

  • 磁盘以 4KB 为单位划分数据块;可执行程序(ELF)也是按 4KB 存储的(section 合并成 segment 就是为了 4KB 对齐加载)
  • 物理内存同样被操作系统划分为 4KB 的内存块(使用上仍可按字节访问,但申请/管理以 4KB 为单位)
  • 4KB 的内存块叫页框(页帧):页 = 数据块(内容),页框 = 存储区(空间)

💡 4KB 这个数字一旦定下来,磁盘数据块、内存管理、虚拟地址划分全都要遵守它。32 位系统页 4KB,64 位一般是 8KB。

5.2 怎么管理这么多页框:先描述,再组织

4GB / 4KB = 1048576 个页框 → 就需要 100 多万个描述结构:

struct page {
    /* 标志位(位图)、引用计数、LRU 链表、缓存相关字段、虚拟地址… */
};
struct page mem[1048576];   // 全局定义,位于内核头部,位置固定
  • 描述:一个 struct page 描述一个 4KB 页框(必须很小,十几个字节,否则自身就吃掉十几 MB)
  • 组织:定义成数组,于是"管理整个物理内存"变成"对数组的增删改查"(基于数组之上还有伙伴系统等算法,本课不展开)

✔ 关键结论:数组下标 × 4KB = 该页框的起始物理地址,而 物理地址 = 起始物理地址 + 页内偏移。所以 struct page 里不用记录自己的物理地址。

5.3 申请物理内存在干什么

申请物理内存 = ① 在全局数组中找到一个 flag 为"未占用"的 page
             ② 把它置为"已占用"(改 page)
             ③ 建立内核数据结构对应关系(让进程/线程能挂上这块内存)
  • 谁申请:载体永远是进程或线程(打开文件要内存、申请堆空间要内存)
  • 怎么挂上:以为文件缓存页为例——struct file → address_space → radix tree(基数树) → 叶子节点指向具体 struct page;再由 page 反算下标/物理地址
  • 💡 内核数据结构可以被同时挂到多种结构上(数组、哈希表、LRU 链表、基数树)——这不冲突,是一种常规做法

struct page 里几个值得注意的字段:

字段含义
flags 位图PG_locked(被锁定,不能被换出)、PG_dirty、PG_writeback、PG_private、是否空闲等
_mapcount / _refcount引用计数——有多少个页表项指向该页框
virtual记录该页对应的虚拟地址(因为内核有时需要物理→虚拟反向查询权限)

💡 引用计数是写时拷贝的判定依据:权限位是 R(只读)、引用计数 > 1、又位于数据区 → 判定为写时拷贝,而不是普通缺页。


六、理性角度②:页表体系

6.1 页表不能是单张

在这里插入图片描述

假设一张页表、每条目记录一对虚拟↔物理映射:4GB 空间按字节算就有 4G 条映射,条目按 8 字节算——

✖ 光页表就要 32GB,而整个内存才 4GB。这还没算操作系统自己的代码、中断向量表、各种 PCB/地址空间数据结构。

✔ 所以:页表绝对不能是单张页表。

6.2 二级页表:10 + 10 + 12

在这里插入图片描述

32 位虚拟地址在逻辑上拆成三段:

段位数取值范围作用
高 10 位100 ~ 1023查页目录(1024 个页目录项)
中 10 位100 ~ 1023查页表(1024 个页表项)
低 12 位120 ~ 4095页内偏移
  • 页目录:一张,最多 1024 个目录项,每项指向一张页表
  • 页表:最多 1024 张,每项记录一个页框(物理)地址(只需要 20 位表示页框,剩下的位做标志)
  • 每张页表 = 4 字节 × 1024 = 4KB = 正好一页
  • 拉满的情况下:1024 × 4KB + 页目录 4KB ≈ 4MB(而不是 32GB)
  • 但一个进程不会用满整个物理内存,所以实际页表只有十几张、二十几张,远远小于 4MB → 方案可行

✔ 严格来说:页表记录的是"虚拟地址 → 页框"的映射(页框级),具体字节由低 12 位偏移定位。
在这里插入图片描述

6.3 谁来查页表:CR3 + MMU

  • CR3 寄存器:保存当前进程页目录的起始物理地址,属于进程的硬件上下文。进程切换时上下文一换,页表、地址空间全跟着换
  • MMU(内存管理单元):CPU 内部集成的硬件组件,虚拟地址 → 物理地址的转换由硬件完成
    • 只做"索引 + 加法",硬件实现很简单
    • CPU 输入虚拟地址,经过 MMU(结合 CR3 指向的页表)后输出的直接就是物理地址,连到地址总线上

6.4 TLB 快表

多级页表是双刃剑:省了空间,但降低了查询效率——页表本身也在内存里,一次转换要多次访存(而且计算机里所有操作都要经过虚实转换,慢一点就整体都慢)。

于是引入中间层:

  • TLB(Translation Lookaside Buffer,快表 / 转译后备缓冲器):CPU 内集成的一段存储空间,与 MMU 直接协同工作
  • 流程:CPU 给 MMU 虚拟地址 → MMU 先查 TLB → 命中就直接拿到物理地址送内存;**未命中(TLB Miss)**则走多级页表这个"保底武器",查完顺便把映射关系写进 TLB
    在这里插入图片描述

💡 一句话:计算机里所有性能问题都可以"加一个中间层"来解决。


七、为什么页内偏移是低 12 位

两个问题:为什么数字是 12?为什么是低 12 位?

① 为什么是 12:页框大小 4KB,取值范围 0 ~ 4095(共 4096 个字节),而 2¹² = 4096 ——用 12 位刚好完整覆盖一个页框的范围。

② 为什么是低位:编译时 ELF 是从低地址向高地址平坦有序编排的,所以高 20 位相同 → 一定落在同一个 4KB 页框里;用低 12 位做页内偏移,意味着用高 20 位来区分页框。

💡 这样设计的好处:聚集效果 + 局部性原理。程序访问第 10 行代码时,大概率接着访问它周边的代码(那些地址的高 20 位相同,在同一个页框内)——一次页表映射就能覆盖一大片连续访问,这正是预加载/局部性原理的根本。


八、页表标志位决定了内存管理的所有决策

8.1 缺页异常(Page Fault)

当 MMU 在 TLB 和页表中都没找到物理页,但该虚拟地址是合法的(在进程地址空间范围内),CPU 硬件就自动触发缺页异常:

  • 用户程序被打断 → CPU 转去执行中断向量表里对应的处理方法(缺页中断处理程序)
  • 处理方法会申请物理内存、修改 struct page 标志位、把页框号和页表项填充/重建映射关系

缺页异常分两类:

类型含义处理
Hard / Major Page Fault(硬件缺页 / 主缺页)物理内存中确实没有对应物理页打开磁盘设备把数据读入内存,再重建映射
Soft / Minor Page Fault(软件缺页 / 次缺页)物理页已经存在,只是当前进程还没建立映射(典型:动态库已被其他进程载入)只需在内存中补建映射,不用读数据
Invalid Page Fault地址非法(越界、空指针等)内核返回 SIGSEGV(段错误),终止进程

8.2 越界了不一定会崩溃

经典例子:

int i = 0;
int array[10];
for (i = 0; i <= 10; i++)   // 故意写成 <=
    array[i] = 0;

array[10] 越界访问到了紧挨着的 i(栈上变量),把 i 又改回 0 → 循环变死循环,但进程没崩。

✔ 本质:你的越界操作系统都不知道。指针指向的仍是你的合法地址空间,OS 没有理由拦你。所以:

  • 越界问题需要你自己 debug
  • 只有当你访问的地址本身非法(不在进程的映射方案内)时,才会被判为越界 → SIGSEGV

8.3 操作系统如何区分"缺页"和"越界"

  1. 前提:查页表失败(MMU 转不过去),触发异常
  2. 执行缺页中断处理程序,检查虚拟地址的页号是否合法——依据就是进程自己的地址空间布局(mm_struct + vm_area_struct 描述的各区域范围)
  3. 判定:
    • 页号合法、但页不在内存 → 缺页中断(补申请 + 建映射)
    • 页号非法 → 越界访问(SIGSEGV 终止)
    • 映射已建立但权限不对(比如用户态写只读页、访问内核页)→ 非法访问,同样可判非法

8.4 串起前面学过的两个申请资源的知识点

malloc / new 的本质是"延迟申请":

  • 底层是系统调用 brk(改堆区大小,即改 data 段/堆的 start-end)或 mmap(基于文件)
  • 它们只改虚拟地址范围,不申请物理内存——因为你申请完不一定马上用
  • 真正访问时触发缺页中断,才做物理内存的二次申请
  • 好处:你不用的时候,这块物理内存可以给别人用,提高内存利用率

写时拷贝的本质是"以页框为单位":

  • fork 之后父子进程指向同一批页框,权限位被置为只读
  • 因为页表映射以页框为单位,所以权限管理也只能以页框为单位 → 触发写时拷贝时,拷贝的是整个页框,而不是那一个字节/变量
  • 💡 这和 STL 扩容按 1.5/2 倍、共享内存必须是 4KB 整数倍一样,都是在资源浪费与效率之间做平衡
Logo

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

更多推荐