操作系统学习笔记(二):进程、线程与协程

写在前面

本文讲清三个执行单元:进程、线程、协程。它们的本质区别是什么、开销差多少、各自适合什么场景。理解了这些,才能理解为什么 Go 用协程、Nginx 用多进程、Java 用线程池。


一、进程(Process)

1.1 什么是进程

1
2
3
4
5
6
7
8
9
进程 = 程序的一次运行实例,是资源分配的基本单位

每个进程有:
  - 独立的虚拟地址空间(代码、数据、堆、栈)
  - 独立的页表
  - 文件描述符表、信号处理、工作目录等
  - PID(进程ID)

  隔离性极强:A 进程崩了不影响 B 进程

1.2 创建与开销

1
2
3
4
5
6
// Linux 创建进程
pid_t pid = fork();   // 复制当前进程
if (pid == 0) {
    // 子进程
    execl("/bin/program", "program", NULL);   // 执行新程序
}
1
2
3
4
5
6
7
8
fork 的开销(很大):
  - 复制页表(写时复制 COW 优化,但仍有开销)
  - 分配新的内核栈、task_struct
  - 拷贝文件描述符、信号表等
  - 大约 几百微秒 ~ 几毫秒

  进程间通信(IPC)也贵:管道、消息队列、共享内存、Socket
  因为地址空间独立,数据要跨边界拷贝

二、线程(Thread)

2.1 什么是线程

1
2
3
4
5
6
7
8
9
线程 = 进程内的执行单元,是 CPU 调度的基本单位

同一进程的多个线程:
  ✓ 共享地址空间(代码、堆、全局变量)
  ✓ 共享文件描述符
  ✗ 各自有独立的栈、寄存器、PC(程序计数器)

  创建比进程轻量,通信比进程快(共享内存,不用跨边界)
  但共享带来并发问题:需要锁

2.2 用户线程 vs 内核线程

1
2
3
4
5
6
7
8
9
内核线程:
  由操作系统内核管理、调度
  能被内核看到、能多核并行
  .NET/Java 的线程默认是内核线程(1:1 模型)

用户线程(绿色线程/M:N):
  由用户态运行时管理(Go、Erlang)
  内核看不到,M 个用户线程映射到 N 个内核线程
  创建极轻量,调度在用户态完成

2.3 线程的开销

1
2
3
4
5
6
7
创建线程和上下文切换都不是固定常数,会受操作系统、CPU、调度、NUMA 和负载影响
  - 保存/恢复寄存器
  - 切换内核栈
  - TLB/缓存失效(隐性开销最大)

线程栈上限或保留空间常见为 MB 量级,但实际提交的物理内存、默认值和增长方式因平台与运行时而异
  所以"一个连接一个线程"撑不住 C10K

三、进程 vs 线程

1
2
3
4
5
6
7
8
9
              进程                线程
─────────────────────────────────────────────────
地址空间        独立               共享
创建开销        通常较大           通常较小
通信            贵(IPC)          便宜(共享内存)
切换开销        大                小
并发问题        无(隔离)         有(需锁)
崩溃影响        只影响自己         整个进程崩
多核利用        能                能
1
2
3
4
5
6
7
8
9
为什么 Nginx 选多进程:
  Worker 进程独立,一个崩了不影响其他
  Worker 之间大部分请求状态相互隔离,但共享内存区、accept 协调等路径仍可能使用原子操作或锁
  配合事件驱动,单进程抗万级并发

为什么 Java/.NET 传统用多线程:
  线程比进程轻量
  共享内存通信方便
  但要小心锁和线程安全

四、协程(Coroutine)

4.1 什么是协程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
协程是由语言或运行时调度的轻量级并发任务;具体实现并不都等同于“用户态线程”

特点:
  ✓ 通常可在较少的内核线程上承载大量并发任务
  ✓ 通常比操作系统线程占用更少资源
  ✓ 任务间切换通常不需要每次进入内核调度
  ✗ 栈与状态保存方式依实现而异:goroutine 使用可增长栈,async 任务通常编译为状态机
  ✗ 调度方式也依实现而异,既有 await/yield 协作让出,也有运行时抢占

  代表:Go goroutine、Kotlin coroutine、Python async/await、Rust async

4.2 协程的优势

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
创建成本:
  线程:需要内核调度实体和较大的栈地址空间
  协程:通常由语言运行时管理,可使用较小且可增长的栈

  Go 程序可以承载大量 goroutine,但上限仍受栈增长、调度、文件描述符和业务资源约束
  同数量的内核线程通常成本更高,但不能脱离工作负载给出固定容量结论

切换成本:
  线程切换通常需要内核调度;同进程线程不会更换地址空间
  协程切换通常由运行时在用户态调度,但遇到系统调用、抢占和工作窃取时成本会变化

4.3 协程的两种实现

1
2
3
4
5
6
7
8
9
1. 有栈协程(Stackful)
   每个协程有自己的栈
   可在任意位置挂起(抢占友好)
   代表:Go goroutine、Lua coroutine

2. 无栈协程(Stackless)
   基于状态机(编译器转换)
   只能在特定点挂起(await 点)
   代表:C# async/await、Rust async、C++20 coroutine、Python async/await

五、调度模型

5.1 抢占式 vs 协作式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
抢占式调度(内核线程):
  内核按时间片强制切换
  一个线程卡死不影响其他
  .NET/Java 线程、操作系统进程

协作式调度(协程):
  协程主动让出 CPU(await / yield)
  一个协程不让出,其他协程饿死
  Go 调度器既使用安全点,也从 Go 1.14 起支持异步抢占;纯 async/await 任务通常在 await 点让出

  若在事件循环中执行运行时无法感知的阻塞调用,可能卡住承载该任务的线程及其上的其他任务

5.2 Go 的 GMP 模型

1
2
3
4
5
6
7
8
9
Go goroutine 的调度(M:N):
  G = Goroutine(用户协程)
  M = Machine(内核线程)
  P = Processor(逻辑处理器,持有可运行 G 的本地队列)

  N 个 G 在 M 个 M 上跑,M 个 M 绑定到 P(通常 = CPU 核数)
  P 的本地队列 + 全局队列 + 工作窃取(work stealing)

  效果:goroutine 创建极轻,调度高效,自动负载均衡多核

5.3 .NET 线程池

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
.NET 用内核线程 + 线程池:
  池化复用线程,避免频繁创建销毁
  按需增长(IO 密集型增长快)
  async/await 在线程池线程上跑,等待时释放线程

  对比 Go:
    Go 用协程(M:N),轻量
    .NET 用线程 + async(线程少,靠异步等 I/O 时不占线程)

  思路不同,目标都是高并发:
    Go:协程够轻,开很多协程
    .NET:不依赖海量线程,等待 I/O 时释放线程

六、上下文切换

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
上下文切换 = 保存当前执行状态、恢复另一个的状态

进程切换:最贵
  切换地址空间(页表)→ TLB 失效 → 内存访问变慢

线程切换:中等
  同进程内,地址空间不变,TLB 不全失效
  但仍进内核、保存寄存器

协程切换:最便宜
  通常由语言运行时完成,不必每次进入内核调度
  具体保存状态和缓存影响取决于协程实现

  因而协程常适合大规模 I/O 并发,但可承载数量仍受栈、任务状态、连接和业务内存约束

七、如何选择

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
场景                          推荐
──────────────────────────────────────────────────────
需要强隔离(如浏览器站点隔离) 多进程
CPU 密集并行计算              线程池 / 协程池
高并发 I/O(万级连接)        协程 / 事件驱动(epoll)
语言原生支持协程(Go)        goroutine
语言原生 async(C#/JS)       async/await
传统多线程代码                线程 + 锁(但要小心)

通用建议:
  I/O 密集 → 协程或异步(不阻塞)
  CPU 密集 → 多线程/多进程(多核并行)

八、小结

  • 进程:资源分配单位,独立地址空间,隔离强但开销大
  • 线程:CPU 调度单位,共享地址空间,轻量但要锁
  • 协程:用户态轻量线程,创建/切换极便宜,适合高并发
  • 调度:抢占式(线程)vs 协作式(协程,不能阻塞)
  • Go GMP:M:N 调度 + 工作窃取;.NET:线程池 + async/await
  • 选择:I/O 密集用协程/异步,CPU 密集用多线程多核并行

下一篇讲 Reactor / Proactor 模式——I/O 模型如何变成实际的网络框架。