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

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


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

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

进程 (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 锁