主题
进程、线程与协程:调度单位与切换成本
一句话:进程提供隔离,线程提供并行,协程(coroutine)把调度从内核搬到用户态;协程轻不是因为「栈小」,而是因为它省掉了陷入内核、内核调度器和内核栈,代价是运行时必须自己处理阻塞和抢占。
适用:Linux 4.x–6.x;Go 1.14+(异步抢占)、Go 1.25+(GOMAXPROCS 感知 cgroup);Python 3.11+ asyncio。开销数字是量级,随硬件、内核和负载变化。
为什么需要
同一台机器要同时做很多事,三者的分工不同:
- 进程:把地址空间和资源隔开,一个崩了不拖累别人,适合强隔离和多租户。
- 线程:共享地址空间,通信便宜,能利用多核,适合需要并行计算的场景。
- 协程:在少量线程上承载大量「大部分时间在等 I/O」的任务,降低每个任务的调度和内存成本。
三者不是替代关系,而是「隔离 / 并行 / 并发」三个维度的不同组合。选错的代价很具体:该隔离的没隔离,一个进程拖垮全部;协程去跑 CPU 密集,谁也没法并行;每个连接一个线程,上下文切换和栈内存先撑不住。
核心机制
三者的边界
- 进程:资源分配单位。独立虚拟地址空间、页表、文件描述符表、信号处理、凭证。进程间通信要经过内核(pipe、socket、共享内存)。
- 线程:CPU 调度单位。共享进程的地址空间和 fd,独立栈、寄存器、TLS 和调度状态。Linux 用
clone()实现,线程和进程在内核里都是 task。 - 协程:用户态执行单位。由运行时调度,有自己的栈和寄存器上下文,不直接对应内核 task。Go 的 goroutine(G)由系统线程(M)承载,多个 G 复用少量 M。
进程切换为什么贵
- 换地址空间:写 CR3,TLB 失效;PCID 能减少损失,但不会消除。缓存里的工作集也变冷。
- 内核调度:保存和恢复完整上下文,操作运行队列,可能跨 CPU 迁移。
- 通信必须出内核:管道、socket、共享内存都要系统调用或映射。
所以进程适合「隔离优先、创建不频繁」的场景:服务进程、任务隔离、多租户。
线程切换为什么便宜一些
- 同一地址空间,CR3 不变,不需要换页表。
- 仍要陷入内核:保存寄存器、切内核栈、走调度器;用户栈和 TLS 也要换。
- 共享堆和全局变量,通信便宜,但需要锁和原子操作。锁竞争、伪共享、缓存行弹跳成了新的成本。
量级上,线程切换通常亚微秒到几微秒,进程切换更高。具体数值不要当常数用。
协程为什么更轻:一条因果链
- 不进内核:切换是运行时里的函数调用,保存 callee-saved 寄存器、SP、PC,换栈指针。没有
syscall或中断,没有内核栈,也没有内核调度器锁。 - 调度点可控:运行时在自己知道的点切换(channel 阻塞、
await、函数调用或抢占点),不需要定时器中断和 IPI,可以把一批任务连续跑完,缓存局部性更好。 - 栈小且按需增长:Go goroutine 初始栈约 2KB(以所用 runtime 为准),按需扩缩;线程栈默认 MB 级(
ulimit -s,常见 8MB 虚拟保留)。同样内存能放更多任务。 - 创建和销毁不经过内核:分配 G 结构加栈,放进本地运行队列,成本亚微秒到微秒级,远低于
clone()。 - 并发度不再等于线程数:阻塞在 channel 或网络时,运行时把 M 让出去跑别的 G(Go 的
entersyscall/ handoff),少量线程就能承载大量等待任务。
代价同样明确:
- 一个协程长时间不让出,会占住 P/M。Go 1.14+ 用信号做异步抢占(preemption),但仍是协作式语义,不是硬实时。
- 阻塞式系统调用或 cgo 会占住一个 M,运行时需要额外线程补位;大量阻塞调用会退化成线程模型。
- 运行时自己管栈、调度和 GC,调试和性能分析多一层抽象;栈增长和复制也有成本。
- 和语言/运行时绑定:goroutine 只在 Go 里;Python
asyncio是单线程事件循环,一个阻塞调用卡住全部,CPU 密集仍要进程或线程。
对照表
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 隔离 | 地址空间隔离 | 共享地址空间 | 共享线程地址空间 |
| 调度者 | 内核 | 内核 | 运行时 |
| 切换 | 换 CR3 + 内核调度 | 内核调度,不换 CR3 | 用户态保存 / 恢复 |
| 栈 | 独立地址空间 | 独立栈,MB 级 | 独立小栈,KB 级起 |
| 通信 | IPC | 共享内存 + 同步 | channel / await |
| 适合 | 隔离、多租户 | CPU 并行 | 大量 I/O 等待 |
最小上手
Go(1.14+):
go
// main.go
package main
import ("fmt"; "runtime"; "sync"; "time")
func main() {
var wg sync.WaitGroup
for i := 0; i < 100000; i++ {
wg.Add(1)
go func() { defer wg.Done(); time.Sleep(time.Millisecond) }()
}
fmt.Println("goroutines:", runtime.NumGoroutine())
wg.Wait()
}bash
go run main.go # 期望输出 goroutines: 100001 左右
GOMAXPROCS=4 GODEBUG=schedtrace=1000 go run main.go # 每秒打印调度器状态schedtrace 会显示 gomaxprocs、idleprocs、threads、goroutines、runqueue。可以看到 G 的数量远大于 M,说明协程不是「一个协程一个线程」。
对比线程创建:
bash
go build -o app main.go
strace -f -e trace=clone,clone3 ./app 2>&1 | grep -c cloneGo 运行时只创建少量 M(受 GOMAXPROCS 和阻塞调用影响),不会为每个 goroutine 调用 clone。
Python:
python
import asyncio
async def main():
await asyncio.gather(*(asyncio.sleep(1) for _ in range(10000)))
asyncio.run(main()) # 约 1 秒完成,单线程同样 10000 个等待任务,用线程实现会创建 10000 个内核线程和 MB 级栈,成本和调度压力完全不同。
生产动作
- 按负载选模型:I/O 等待多 → 协程 / async;CPU 密集 → 多线程 / 多进程(Python 受全局解释器锁(GIL)限制,用多进程);强隔离或多租户 → 进程或容器。
- 控制并发度:协程不是免费。无上限地起 goroutine 会把下游打挂、把内存吃满;用带缓冲 channel、worker pool 或
errgroup.SetLimit(golang.org/x/sync,版本相关)限流。 - 查 goroutine 泄漏:
runtime.NumGoroutine()持续增长,或 pprof 的goroutineprofile 里同一栈大量堆积,常见于阻塞在 channel、response body 没关、RPC 没超时。block和mutexprofile 能定位锁等待。 - Go 容器里的 CPU 配额:Go 1.25+ 的
GOMAXPROCS默认感知 cgroup CPU 限额(go.mod声明 go 1.25+ 才生效,只看 limit 不看 request);更早版本默认NumCPU看到的是宿主机核数,需要automaxprocs或手动设置,否则在 CPU 限额下线程过多、调度抖动。 - 调度观测:
GODEBUG=schedtrace=1000看全局,GODEBUG=scheddetail=1输出很大只能短时用;系统层用pidstat -w 1看 cs,/proc/<pid>/status的Threads看内核线程数。 - Python 的阻塞调用:用
asyncio.to_thread(3.9+)隔离阻塞库,不要在事件循环里做 CPU 密集计算;PYTHONASYNCIODEBUG=1或asyncio.run(..., debug=True)能报慢回调。 - 线程池大小:按「核数 × (1 + 等待时间 / 计算时间)」估算,但要压测验证;阻塞队列必须有界,否则只是把过载藏进内存。
边界
- 协程不提供 CPU 并行。Go 的并行来自多个 M 跑多个 P;Python
asyncio单线程没有并行。 - 「协程切换几十纳秒」是量级,不是保证。栈增长、GC、调度器竞争都会让单次切换变慢。
- 协作式调度怕「不让出」:长循环、密集计算、阻塞式 C 调用都会破坏低延迟。异步抢占能缓解,但不能消除。
- 线程不是越少越好。没有协程支持的阻塞 I/O,线程池仍是正确选择;协程里做阻塞调用会占住 M。
- 进程隔离有成本:内存复制、IPC、启动时间。微服务拆得越细,这些成本越明显。
- 不要用「能创建一百万个」作为选型依据。要看的是目标延迟下的有效并发,以及下游能不能承受。
讲清楚
进程、线程、协程的差别,本质是「谁调度、共享什么、切换付什么」。进程由内核调度,有独立地址空间,切换要换 CR3、影响 TLB 和缓存,换来的是隔离;线程也由内核调度,共享地址空间,切换便宜一些,但仍要陷入内核、走调度器;协程把调度搬进运行时,切换只是保存寄存器、换栈,不进内核、不碰内核栈,所以单次成本低一个量级,加上栈小,同样内存能放更多任务。但协程的轻是有条件的:它假设任务大部分时间在等 I/O,并且运行时能接管阻塞。一个不让出的 CPU 密集协程会占住线程,阻塞式系统调用会占住 M。选型时先看负载是 I/O 等待还是 CPU 计算,再看隔离要求,最后才是「哪个更轻」。