进程、线程和协程的区别是什么?
三者是同一条执行流在不同"资源与调度"边界上的切分:进程是资源分配的最小单位,线程是 CPU 调度的最小单位, 协程是用户态协作式调度的最小单位。进程有独立地址空间,线程共享进程地址空间,协程共享线程的栈之外的几乎所有东西, 只保留自己的执行上下文。
1. 一张表看清差别
| 维度 | 进程 Process | 线程 Thread | 协程 Coroutine |
|---|---|---|---|
| 资源归属 | 独立地址空间、文件表、信号处理 | 共享所属进程资源 | 共享所属线程/进程资源 |
| 私有部分 | 全部 | 栈、寄存器、TID | 栈、寄存器(很小的栈) |
| 调度者 | 内核调度器 | 内核调度器 | 用户态运行时(或语言 VM) |
| 切换成本 | 高(换页表、刷 TLB) | 中(进内核,约 2~5us) | 低(用户态,约百 ns) |
| 创建成本 | 重,fork 要复制页表 | 中,默认栈 MB 级 | 轻,初始栈 KB 级 |
| 通信方式 | IPC(管道/共享内存/socket) | 共享变量 + 锁 | 共享变量 + 锁/通道 |
| 崩溃影响 | 只死自己 | 整个进程挂掉 | 整个进程挂掉 |
| 典型数量级 | 几十~几百 | 几百~几千 | 十万~百万 |
2. 地址空间视角
进程 A 进程 B
┌───────────────────────────┐ ┌───────────────────────────┐
│ 代码段 / 数据段 / 堆 │ │ 代码段 / 数据段 / 堆 │
│ 线程1 栈 │ 线程2 栈 │ ... │ │ 线程1 栈 │ ... │
│ mmap 区 / 页表 / 文件表 │ │ mmap 区 / 页表 / 文件表 │
└───────────────────────────┘ └───────────────────────────┘
▲ 内核可见的调度实体:TID ▲
协程在用户态,内核根本不知道它的存在:
G1 G2 G3 ... ── 由 runtime 复用到少量线程 M 上
关键点:协程没有独立的 TID,内核调度的是线程;协程之间的切换不经过内核, 所以既省了系统调用,也省了 TLB 与缓存污染。
3. 代码与观测
// 10 万个协程,初始栈各 2KB 左右,总量可控
var wg sync.WaitGroup
for i := 0; i < 100000; i++ {
wg.Add(1)
go func(n int) { // go 语句创建一个 G,由 runtime 调度到 M 上执行
defer wg.Done()
_ = n * n
}(i)
}
wg.Wait()
fmt.Println(runtime.NumGoroutine())
# 一个 Go 进程实际有几个内核线程
ps -o pid,nlwp,cmd -p $(pgrep myserver)
# 线程的栈大小(默认 8MB 虚拟地址,按需提交)
ulimit -s
# 上下文切换成本
perf bench sched pipe
4. 常见追问
Q:线程切换为什么比协程贵? 线程切换要陷入内核:保存/恢复全部寄存器、切换内核栈、可能换页表导致 TLB 失效、走调度器选下一个任务。 协程切换只是保存少量寄存器到用户态结构体、换一下栈指针,一次几十到几百纳秒,且不污染 TLB。
Q:进程切换比线程切换贵在哪? 主要贵在地址空间:切换 CR3、刷新 TLB(有 PCID/ASID 可缓解)、换页表后缓存局部性变差。 线程共享页表,所以便宜得多。
Q:有哪几种线程模型? 一对一线程(1:1,Linux pthread/Java Thread,简单但受内核线程数限制)、 用户态多对一(N:1,早期绿色线程,一个阻塞全阻塞)、 混合 M:N(Go 的 GMP、Erlang 的进程),兼顾数量与并行。
Q:协程会不会"饿死"其他协程?
会。协作式调度依赖协程主动让出(IO、channel、runtime.Gosched),如果某个协程长时间纯计算不阻塞,
同线程上的其他协程就得等。Go 1.14 之后引入基于信号的异步抢占,缓解了这个问题。
Q:多进程和多线程怎么选? 需要强隔离、崩溃不影响他人、能利用多机扩展 → 多进程(Nginx worker、Chrome 标签页); 需要共享大量内存、创建频繁、通信简单 → 多线程;海量并发 IO → 协程。
小结
进程管资源,线程管调度,协程管并发度。切换成本从高到低是进程 > 线程 > 协程, 隔离性也是同样的顺序。回答时先给这张三层表,再补一句"协程由 runtime 调度到线程上, 内核只看见线程",最后用 GMP 或 1:1 模型收尾,逻辑就闭环了。