"用多进程还是多线程""协程是不是更轻""虚拟线程能不能顶替线程池"——这些问题的答案不在名词解释里,而在谁来做调度、切换时保存什么、失败时影响谁这三件事上。
按调度视角拆一遍,判断标准就清楚了。
1. 隔离与调度:三者的本质差异
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 地址空间 | 独立,互相不可见 | 共享同一进程的堆与方法区 | 共享所属线程的栈与堆 |
| 调度者 | 内核 | 内核(1:1 模型下) | 运行时(用户态) |
| 切换触发 | 时间片、阻塞、优先级 | 时间片、阻塞、锁竞争 | 代码显式 await/yield 或 IO 事件 |
| 切换代价 | 最高(换页表、刷 TLB) | 中(进内核,保存寄存器与栈) | 最低(用户态保存少量寄存器/栈指针) |
| 崩溃影响 | 只死自己 | 整个进程一起死 | 通常随所属线程一起 |
| 通信方式 | IPC(管道/共享内存/socket) | 直接读写共享内存 + 锁 | 共享变量或 channel |
一句话概括:进程是隔离单位,线程是调度单位,协程是"在用户态把等待变成可调度的点"。
2. 上下文切换的成本来自哪
| 切换类型 | 保存/恢复的内容 | 是否进内核 | 典型量级 |
|---|---|---|---|
| 进程切换 | 页表基址、TLB 失效、寄存器、内核栈 | 是 | 最高,随工作集增大而上升 |
| 线程切换(同进程) | 寄存器、栈指针、TLS | 是 | 低于进程切换,仍含系统调用开销 |
| 协程切换 | 少量寄存器、栈指针 | 否 | 最低,纯函数调用级别 |
"协程更轻"的准确含义是:它把"等待"从"内核态阻塞 + 调度器介入"改成了"用户态保存现场 + 换成另一个任务"。省掉的是陷入内核与调度决策的开销,不是省掉了内存。
$ pidstat -w -p PID 1 # 每秒自愿/非自愿上下文切换
$ vmstat 1 | awk '{print $12,$13}' # cs 与 in 两列
$ cat /proc/PID/status | grep -E "ctxt|threads"
自愿切换(voluntary)多说明在等锁/等 IO;非自愿(involuntary)多说明 CPU 竞争激烈或时间片太短。这两个数字的比值比总数更有诊断价值。
3. 线程模型与虚拟线程
| 模型 | 用户线程 : 内核线程 | 代表 | 特点 |
|---|---|---|---|
| 1:1 | 1:1 | 传统 Java、pthread | 一个阻塞调用就占住一个内核线程 |
| N:1 | N:1 | 早期绿色线程 | 无法并行,一个阻塞全卡 |
| N:M | N:M | Go 的 GMP、Java 21 虚拟线程 | 阻塞时把载体线程让出去 |
Java 21 的虚拟线程改变了什么:Thread.sleep、阻塞 IO 等操作不再必然占住一个内核线程——JVM 在阻塞点把虚拟线程从载体线程上卸载,载体线程去跑别的虚拟线程。于是"一请求一线程"的写法又能用了,不必为每个请求准备一个 OS 线程。
// Java 21:每个任务一个虚拟线程,不必池化成百上千个平台线程
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var id : ids) {
executor.submit(() -> handle(id)); // handle 里可以放心阻塞
}
}
但有三条边界必须记住:CPU 密集任务没有收益;synchronized 块内阻塞可能钉住载体线程(版本相关,按目标 JDK 核对);对象头与栈的监控方式也变了,jstack 看到的是载体线程。
4. 协程的适用前提:代码里不能有阻塞调用
| 情况 | 协程表现 | 说明 |
|---|---|---|
全是 await/yield 的异步 IO |
优秀 | 协程的价值就在这里 |
| 调用了阻塞式库(同步 HTTP 客户端、JDBC 旧驱动) | 退化成占线程 | 阻塞点不在调度器视野里 |
| 长时间 CPU 计算 | 无收益,且会饿死其他协程 | 需要多线程/多进程并行 |
sleep 而非异步定时 |
分情况 | 运行时若能拦截则可让出,否则照占 |
// Go:GOMAXPROCS 决定并行度,但阻塞 syscall 会额外占用 M
runtime.GOMAXPROCS(8)
// 排查:看 goroutine 数与阻塞在哪
pprof.Lookup("goroutine").WriteTo(os.Stdout, 1)
判断一个"协程方案"是否成立,只需问一句:这条链路上有没有一个调用会阻塞当前线程? 有的话,并发度上限就是线程数,而不是协程数。
5. 共享状态的三种处理
| 策略 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 不可变 | 每次变更产生新对象 | 无需锁,天然线程安全 | 内存与 GC 压力上升 |
| 加锁 | synchronized、ReentrantLock、原子类 |
直观,改动小 | 竞争时排队,可能死锁 |
| 消息传递/隔离 | channel、Actor、每线程独立副本 | 消除共享,易推理 | 需要设计消息协议,可能引入拷贝 |
选择依据是冲突频率:几乎不冲突用原子类或不可变;高频写同一份状态,先想能不能分片(按 key 拆成多个独立状态),再考虑锁。
6. 进程间通信的方式与代价
| 方式 | 数据路径 | 延迟量级 | 适用 |
|---|---|---|---|
| 管道 / FIFO | 内核缓冲区 | 低 | 父子进程、流式数据 |
| Unix domain socket | 内核,不走协议栈 | 低 | 本机高频 RPC |
| TCP socket | 完整协议栈 | 高于前者 | 跨机、需要标准协议 |
| 共享内存 | 直接映射同一物理页 | 最低 | 大数据量、需要自己同步 |
| 文件 | 磁盘/页缓存 | 高 | 落盘、跨重启传递 |
$ ls /proc/PID/task | wc -l # 该进程的线程数
$ ss -lx | grep APP # 监听中的 Unix socket
$ ipcs -m # 共享内存段
"用进程代替线程"最容易翻车的地方就在这里:原本一次内存写入的操作,变成了序列化 + 系统调用 + 反序列化。当消息体较大或频率很高时,这部分开销经常把并行带来的收益吃光。
7. 崩溃的影响范围
| 崩溃点 | 影响 | 恢复手段 |
|---|---|---|
| 单线程抛未捕获异常 | 该线程结束,进程通常存活 | 线程池补位,需监控线程数 |
| 进程内 OOM / 段错误 | 整个进程(含所有线程)消失 | 重启 + 健康检查 |
| 协程内未捕获异常 | 取决于运行时,可能终止所在线程 | 统一异常处理钩子 |
| 多进程中的一个 | 只损失部分能力(如某 worker) | 主进程重启该子进程 |
这解释了为什么"关键任务隔离"要用进程:线程之间没有故障隔离,一个 OutOfMemoryError 会带走整个 JVM。
$ ps -eo pid,ppid,nlwp,pcpu,comm --sort=-pcpu | head
$ top -H -p PID # 按线程维度看 CPU
8. 调度的可见性:在哪看谁在跑
| 对象 | 观察工具 | 看到什么 |
|---|---|---|
| 进程 | ps、top |
进程级 CPU/内存、父子关系 |
| 线程 | top -H、ps -L、jstack |
线程级 CPU、栈、锁等待 |
| 协程 | 运行时 profile(py-spy、pprof、JFR) |
协程数量、阻塞点、调度延迟 |
| 内核调度 | perf sched、/proc/schedstat |
运行队列长度、迁移次数 |
$ py-spy dump --pid PID # Python:看每个线程/协程卡在哪
$ jcmd PID Thread.dump_to_file -format=json /tmp/t.json
$ go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine
协程的可见性天然更差:它不在内核的调度表里,top -H 看不到它。必须依赖运行时自己的观测接口,这也是引入协程后必须同步补上的工程能力。
9. 并发与并行的区别
| 概念 | 定义 | 单核上成立吗 | 解决的问题 |
|---|---|---|---|
| 并发 | 多个任务在时间上交错推进 | 成立 | 吞吐与响应性(等待时切走) |
| 并行 | 多个任务在同一时刻真正同时执行 | 不成立 | 缩短 CPU 计算的总时长 |
一条实用推论:IO 密集看并发,CPU 密集看并行。协程与虚拟线程优化的是前者;后者只能靠更多核或更少计算。
10. 常见坑
| 坑 | 现象 | 正解 |
|---|---|---|
| 把协程当并行用 | 加了协程但 CPU 打满、延迟不降 | CPU 密集改线程池或分片 |
| 多线程里共享可变对象 | 偶发数据错乱、难复现 | 不可变对象或明确加锁,避免"看起来只读" |
| 用进程代替线程 | 序列化开销吃掉收益 | 先测消息大小与频率,再决定拆分粒度 |
| 以为虚拟线程能解决 CPU 密集 | 吞吐无变化,甚至更差 | 虚拟线程只为阻塞让路,不为计算加速 |
| 协程里调阻塞库 | 并发度卡在载体线程数 | 换异步客户端,或隔离到独立线程池 |
| 只看进程 CPU 不看线程 | 定位不到热点 | top -H + 栈采样 |
| 线程数越多越好 | 切换开销与内存上涨 | 按 CPU 核数与阻塞比例定池大小 |
11. 边界
| 需求 | 更合适的做法 | 理由 |
|---|---|---|
| 故障隔离 | 多进程 | 线程无隔离,一个 OOM 全带走 |
| 高并发 IO | 协程 / 虚拟线程 | 等 IO 时能让出载体线程 |
| CPU 密集计算 | 多线程 + 核数匹配,或分布式 | 并行度受物理核数限制 |
| 大数据量本机传递 | 共享内存 | 避免拷贝与序列化 |
| 需要精细调度可见性 | 少用协程,多用线程 | 协程不在内核调度表里 |
一句话:进程买隔离,线程买并行,协程买"等待时的高并发"——三样东西不能互相替代。
12. 自测
- 进程切换比线程切换贵在哪几个具体动作上?
- "协程更轻"轻的是什么?它省掉的是内存还是陷入内核的开销?
- Java 21 虚拟线程在什么情况下没有收益?为什么?
- 一条链路上存在阻塞调用时,协程的并发度上限由什么决定?
- 为什么"关键任务隔离"通常选进程而不是线程?
- 并发与并行的区别,在单核机器上哪一个仍然成立?