2846 字
约 9 分钟
21
S02-X11 · 进程、线程与协程:调度视角的差异

"用多进程还是多线程""协程是不是更轻""虚拟线程能不能顶替线程池"——这些问题的答案不在名词解释里,而在谁来做调度、切换时保存什么、失败时影响谁这三件事上。

按调度视角拆一遍,判断标准就清楚了。


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. 自测

  1. 进程切换比线程切换贵在哪几个具体动作上?
  2. "协程更轻"轻的是什么?它省掉的是内存还是陷入内核的开销?
  3. Java 21 虚拟线程在什么情况下没有收益?为什么?
  4. 一条链路上存在阻塞调用时,协程的并发度上限由什么决定?
  5. 为什么"关键任务隔离"通常选进程而不是线程?
  6. 并发与并行的区别,在单核机器上哪一个仍然成立?
S02-X11 · 进程、线程与协程:调度视角的差异
https://www.clxhxhhr.top/posts/5903/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。