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

写在前面

本文讲清三个执行单元:进程、线程、协程。它们的本质区别是什么、开销差多少、各自适合什么场景。理解了这些,才能理解为什么 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) {
    // 子进程
    exec("/bin/program");   // 执行新程序
}
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
8
创建线程:约 几十微秒(比进程快,但仍不算便宜)
上下文切换:约 1~10 微秒
  - 保存/恢复寄存器
  - 切换内核栈
  - TLB/缓存失效(隐性开销最大)

线程栈:默认 1~8MB(1万个线程 = 10GB+ 内存)
  所以"一个连接一个线程"撑不住 C10K

三、进程 vs 线程

1
2
3
4
5
6
7
8
9
              进程                线程
─────────────────────────────────────────────────
地址空间        独立               共享
创建开销        大(几百μs~ms)    小(几十μs)
通信            贵(IPC)          便宜(共享内存)
切换开销        大                小
并发问题        无(隔离)         有(需锁)
崩溃影响        只影响自己         整个进程崩
多核利用        能                能
1
2
3
4
5
6
7
8
9
为什么 Nginx 选多进程:
  Worker 进程独立,一个崩了不影响其他
  无锁,简单可靠
  配合事件驱动,单进程抗万级并发

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

四、协程(Coroutine)

4.1 什么是协程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
协程 = 用户态的轻量级线程,由程序(运行时)自己调度

特点:
  ✓ 完全在用户态,内核不感知
  ✓ 创建开销极小(几百字节,几纳秒)
  ✓ 切换不进内核,只保存寄存器(纳秒级)
  ✓ 一个线程内可以跑成千上万个协程
  ✗ 协作式调度(需主动让出,或运行时在安全点抢占)

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

4.2 协程的优势

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
创建成本:
  线程:栈 1MB + 内核对象 → 创建几十微秒
  协程:初始栈 2~8KB(可动态增长)→ 创建几纳秒

  Go 程序轻松开 10 万 goroutine,毫无压力
  开 10 万线程则内存和调度都崩

切换成本:
  线程切换:进内核、保存寄存器、TLB 失效 → 1~10μs
  协程切换:用户态保存寄存器 → 几十纳秒

  差 100~1000 倍

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 在函数调用点抢占(部分抢占);纯 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:协程少,靠异步不让线程等

六、上下文切换

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

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

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

协程切换:最便宜
  纯用户态,只保存少量寄存器
  不进内核,缓存不失效

  所以协程能轻松百万并发,线程万级就吃力

七、如何选择

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
场景                          推荐
──────────────────────────────────────────────────────
需要强隔离(如浏览器多标签)   多进程(Chrome 每标签一进程)
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 模型如何变成实际的网络框架。