写在前面 本文讲清三个执行单元:进程、线程、协程 。它们的本质区别是什么、开销差多少、各自适合什么场景。理解了这些,才能理解为什么 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 模型如何变成实际的网络框架。
Licensed under CC BY-NC-SA 4.0