进程与线程 —— 个人阶段性理解笔记
本文档整理自学习操作系统过程中的问答记录,保留了个人理解的切入点,去除了对话中的冗余,补充了必要的背景知识。适合作为学习进程/线程章节时的辅助参考资料。
一、核心认知:两个最小单位
操作系统有两个截然不同的"最小单位",理解它们的区别是后续所有并发知识的基础:
| 进程 (Process) | 线程 (Thread) | |
|---|---|---|
| 身份 | 资源分配的最小单位 | 调度执行的最小单位 |
| 类比 | 一个独立的实验室,有自己的电力(内存)、器材(文件描述符)和门禁(地址空间) | 实验室里的实验员 |
| 隔离性 | 进程间资源完全隔离 | 同一进程的线程共享所有资源(堆、全局变量、文件描述符) |
| 通信方式 | IPC(管道、套接字、共享内存等),开销大 | 直接读写共享内存,方便但危险 |
| 创建开销 | 大(需要复制/拷贝页表、文件描述符表等) | 小(共享已有资源) |
进化逻辑:从隔离到共享的代价
- 纯进程时代:一个实验室只有一个实验员。要做两件事就得开两个实验室,搬器材麻烦,实验室间沟通得写信(IPC)
- 多线程引入:为了追求性能,让一群实验员在同一个实验室里干活
- 代价:实验室里的器材(堆、全局变量)是共享的——如果实验员 A 正在调配试剂,实验员 B 突然插了一手,结果就炸了。这就是竞态条件 (Race Condition)
二、虚拟地址空间布局:堆和栈为什么对向生长
2.1 标准布局
高地址 (0xFFFFFFFF)
┌─────────────────────┐
│ 内核空间 │ (用户不可见)
├─────────────────────┤
│ 栈 (Stack) │ ← 从高地址向低地址生长
│ ↓ │ 局部变量、函数调用、返回地址
├─────────────────────┤
│ 空闲区域 │
├─────────────────────┤
│ ↑ │
│ 堆 (Heap) │ → 从低地址向高地址生长
│ (通过 malloc 分配) │ 动态分配的长久数据
├─────────────────────┤
│ 数据段 (Data) │ 全局变量、静态变量
├─────────────────────┤
│ 代码段 (Text) │ 程序指令
└─────────────────────┘
低地址 (0x00000000)
2.2 为什么对向生长?
核心问题:我们不知道程序会用多少堆和栈。
- 如果它们并排放置,各分 10MB,程序递归太深栈用了 11MB,即使堆还剩 9MB,程序也会崩溃
- 对向生长相当于让两者"瓜分"中间的空白区域,只要总用量不超过地址空间,谁多用点都可以,最大化虚拟地址空间的利用率
2.3 多线程后的变化
- 一个进程有多个栈(每个线程一个),没法让所有栈都从最高地址往下长
- 操作系统必须在中间的"空闲区域"里,为每个新线程的栈划出一块固定大小的区域
- 副作用:创建过多线程会导致地址空间碎片化。每个线程栈通常默认 8MB,创建 1000 个线程仅栈就占用 ~8GB 虚拟地址空间
2.4 堆和栈存的本质不同
| 堆 (Heap) | 栈 (Stack) | |
|---|---|---|
| 存放内容 | 长期存在的对象(malloc/new 分配),函数结束后依然存活直到手动释放 |
临时状态(局部变量、函数调用链、返回地址) |
| 生命周期控制 | 程序员手动(或 GC) | 自动(函数进出) |
| 线程归属 | 所有线程共享 | 每个线程独有(私人的"笔记本") |
| 存储内容 | 数据 | 数据 + 执行状态(PC 值、寄存器快照等) |
三、进程树与线程:两个独立维度
3.1 两个维度的区分
进程和线程解决的是不同层面的问题,它们是正交的:
进程树(资源归属维度)
init
│
┌───┴───┐ ← fork() 建立父子关系
bash Chrome(父进程)
│
fork() 子进程 ← 每个标签页一个独立进程(隔离)
│
多线程 ← 每个子进程内部拆线程(并行执行)
| 维度 | 解决什么问题 | 关系类型 |
|---|---|---|
| 进程树 (Process Tree) | 管理生命周期和资源归属(杀父进程时子进程怎么办) | 父子关系 (Parent-Child),有"收尸"义务(wait()) |
| 线程 (Threads) | 真正的干活/计算 | 同侪关系 (Peers),无父子概念,平级共享资源 |
3.2 一个进程可以同时有子进程和多线程吗?
完全可以,而且非常常见。
典型例子 —— Chrome 浏览器:
- 主进程
fork()出多个子进程(每个标签页一个独立进程,防止一个页面崩溃导致全崩——隔离策略) - 每个标签页进程内部又开启多个线程(一个渲染画面、一个解析 JS、一个下载图片)
- CPU 同时调度父进程里的线程 A 和子进程里的线程 B,它们都在抢夺 ALU 和缓存
3.3 内核调度的对象到底是什么?
调度对象从来都是线程(而不是进程)。
- 在 Linux 中,线程实现为 轻量级进程 (LWP, Light-Weight Process)
- 当你说"调度一个进程"时,本质是 OS 选中了该进程中的某一个线程去 CPU 上跑
- 一个进程有 10 个线程,它就有 10 次被选中的机会(理论上)
- 逻辑闭环:进程本身是不动的,它只是一个装资源的"容器";线程才是长了腿、会去 CPU 排队的实体
四、细思极恐的 fork 陷阱
问题:一个拥有 5 个线程的进程调用 fork(),子进程有几个线程?
答案:只有 1 个(调用 fork() 的那个线程)。
父进程(5 个线程)
├── Thread A (执行 fork())
├── Thread B
├── Thread C
├── Thread D
└── Thread E
fork() 之后 ───→ 子进程(只有 1 个线程)
└── Thread A'(在子进程的地址空间中复活)
逻辑解释:
- 设计上:如果所有 5 个线程都在子进程中恢复,子进程将是父进程的完全镜像,相当于规避了多线程,但引入了极其复杂的同步问题——那些线程在 fork 瞬间的状态是否一致?它们是否正持有锁?
- 所以 POSIX 标准规定:只有调用 fork 的线程在子进程中存活
- 灾难场景:如果其他 4 个线程在 fork 时正持有锁(如
malloc内部锁),子进程中的 Thread A' 将永远无法释放这些锁——因为持有锁的线程不存在了,锁变成"孤儿锁",子进程后续调用malloc可能死锁
这就是为什么在多线程程序中,
fork()是危险操作。现代实践倾向于在 fork 后立即exec()替换地址空间,或使用pthread_atfork()注册 fork 前后的处理函数。
五、竞态条件: 的惨案
5.1 问题描述
两个线程同时对一个全局变量 counter++ 执行 10000 次,你期望结果是 20000,实际可能只有 12000。
5.2 原因
counter++ 在机器指令层面不是原子的,它分解为三步:
线程 A 线程 B
① 从内存读 counter (= 5) → 寄存器
① 从内存读 counter (= 5) → 寄存器
② 寄存器 +1 → 寄存器 (= 6)
② 寄存器 +1 → 寄存器 (= 6)
③ 写回内存 (counter = 6)
③ 写回内存 (counter = 6)
两个线程各自加了一次,但结果只加了 1——一次更新被另一次覆盖了。
5.3 根源
两个线程共享了进程的堆和数据段,且操作没有互斥保护。这就是并发问题的本质:多个执行流对共享资源的非原子操作。
六、线程切换为什么比进程切换快?
回忆前一篇 CPU 组成笔记中的 TLB:
| 切换类型 | 是否需要换页表 | 是否需要刷新 TLB | 开销 |
|---|---|---|---|
| 进程切换 | 是(换页表基址寄存器 CR3) | 是(TLB 条目全部失效,冷启动) | 大 |
| 线程切换 | 否(同一进程内共享页表) | 否(TLB 条目可继续使用) | 小 |
同一进程的多个线程共用同一套虚拟地址空间 → 共用同一份页表 → TLB 中的地址转换条目仍然有效 → 线程切换不需要刷新 TLB。
七、概念对照速查
| 概念 | 要点 | 一句话记住 |
|---|---|---|
| 进程 | 资源分配的最小单位,有独立地址空间 | 装资源的容器 |
| 线程 | 调度执行的最小单位,共享进程资源 | 长了腿去 CPU 排队 |
| 堆 | 线程间共享,从低地址往高生长 | 公共仓库 |
| 栈 | 每个线程独有,从高地址往低生长 | 私人笔记本 |
| 对向生长 | 最大化虚拟地址空间利用率 | 让两者瓜分中间的空地 |
| 进程树 | 父子关系,管理生命周期和资源归属 | 户口本 |
| 线程关系 | 同侪关系,平级共享 | 兄弟会 |
| fork 多线程 | 只有调用 fork 的线程存活 | 一人还魂 |
| 竞态条件 | 多个线程同时操作共享数据导致结果不可预测 | |
| 线程切换 vs 进程切换 | 线程切换不换页表、不刷 TLB | 快在缓存还在 |
八、术语速查表
| 术语 | 英文 | 定义 |
|---|---|---|
| PCB | Process Control Block | 内核中用于描述进程的数据结构(含页表、文件描述符、信号处理等) |
| TCB | Thread Control Block | 内核中用于描述线程的数据结构(含 PC、寄存器状态、栈指针等) |
| LWP | Light-Weight Process | Linux 中线程的实现方式,每个线程对应一个内核调度实体 |
| Race Condition | 竞态条件 | 多个执行流对共享资源的并发访问导致的不可预测结果 |
| 上下文切换 | Context Switch | CPU 从一个执行流切换到另一个执行流时保存/恢复状态的过程 |
| fork | — | Linux 创建子进程的系统调用,复制调用者的地址空间 |
| pthread_create | — | POSIX 创建线程的 API,新线程共享调用者的地址空间 |
导师的下一步建议:
进程与线程的区别是理解操作系统并发的基础。记住这句话:进程是资源分配的最小单位,线程是调度执行的最小单位。进程提供隔离的容器,线程提供并发的执行体。多线程编程的心智负担远大于多进程,因为共享地址空间意味着竞态条件无处不在。
接下来进入第 27 章的线程 API 学习——如何用 pthread 库创建和控制线程,以及这些接口背后的设计考量。