进程与线程 —— 个人阶段性理解笔记

本文档整理自学习操作系统过程中的问答记录,保留了个人理解的切入点,去除了对话中的冗余,补充了必要的背景知识。适合作为学习进程/线程章节时的辅助参考资料。


一、核心认知:两个最小单位

操作系统有两个截然不同的"最小单位",理解它们的区别是后续所有并发知识的基础:

进程 (Process) 线程 (Thread)
身份 资源分配的最小单位 调度执行的最小单位
类比 一个独立的实验室,有自己的电力(内存)、器材(文件描述符)和门禁(地址空间) 实验室里的实验员
隔离性 进程间资源完全隔离 同一进程的线程共享所有资源(堆、全局变量、文件描述符)
通信方式 IPC(管道、套接字、共享内存等),开销大 直接读写共享内存,方便但危险
创建开销 大(需要复制/拷贝页表、文件描述符表等) 小(共享已有资源)

进化逻辑:从隔离到共享的代价

  1. 纯进程时代:一个实验室只有一个实验员。要做两件事就得开两个实验室,搬器材麻烦,实验室间沟通得写信(IPC)
  2. 多线程引入:为了追求性能,让一群实验员在同一个实验室里干活
  3. 代价:实验室里的器材(堆、全局变量)是共享的——如果实验员 A 正在调配试剂,实验员 B 突然插了一手,结果就炸了。这就是竞态条件 (Race Condition)

二、虚拟地址空间布局:堆和栈为什么对向生长

2.1 标准布局

高地址 (0xFFFFFFFF)
  ┌─────────────────────┐
  │       内核空间        │  (用户不可见)
  ├─────────────────────┤
  │       栈 (Stack)      │  ← 从高地址向低地址生长
  │         ↓             │      局部变量、函数调用、返回地址
  ├─────────────────────┤
  │       空闲区域         │
  ├─────────────────────┤
  │         ↑             │
  │       堆 (Heap)       │  → 从低地址向高地址生长
  │   (通过 malloc 分配)   │      动态分配的长久数据
  ├─────────────────────┤
  │     数据段 (Data)      │      全局变量、静态变量
  ├─────────────────────┤
  │     代码段 (Text)      │      程序指令
  └─────────────────────┘
低地址 (0x00000000)

2.2 为什么对向生长?

核心问题:我们不知道程序会用多少堆和栈。

2.3 多线程后的变化

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 浏览器:

  1. 主进程 fork() 出多个子进程(每个标签页一个独立进程,防止一个页面崩溃导致全崩——隔离策略)
  2. 每个标签页进程内部又开启多个线程(一个渲染画面、一个解析 JS、一个下载图片)
  3. CPU 同时调度父进程里的线程 A 和子进程里的线程 B,它们都在抢夺 ALU 和缓存

3.3 内核调度的对象到底是什么?

调度对象从来都是线程(而不是进程)。


四、细思极恐的 fork 陷阱

问题:一个拥有 5 个线程的进程调用 fork(),子进程有几个线程?

答案:只有 1 个(调用 fork() 的那个线程)。

父进程(5 个线程)
  ├── Thread A (执行 fork())
  ├── Thread B
  ├── Thread C
  ├── Thread D
  └── Thread E

fork() 之后 ───→ 子进程(只有 1 个线程)
                  └── Thread A'(在子进程的地址空间中复活)

逻辑解释:

这就是为什么在多线程程序中,fork() 是危险操作。现代实践倾向于在 fork 后立即 exec() 替换地址空间,或使用 pthread_atfork() 注册 fork 前后的处理函数。


五、竞态条件:1+1=1 的惨案

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 的线程存活 一人还魂
竞态条件 多个线程同时操作共享数据导致结果不可预测 1+1=1
线程切换 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 库创建和控制线程,以及这些接口背后的设计考量。

MOC · 下一章:Ch28 锁