阻塞与非阻塞:一个关于「线程在干嘛」的问题
上一章说的是「我这条执行线还能不能往下走」。这一章问一个完全不同的问题:在等待的那段时间里,那个 OS 线程 —— 那个花了 8MB 内存、要被内核调度的、真实存在的东西 —— 到底在干什么?是被占着不动(阻塞),还是被交还回去干别的活(非阻塞)?这个问题跟上一章正交:四种组合全都存在,而其中最反直觉的那一种,正是现代并发的全部秘密。
阻塞:一个线程的死亡时刻
当你写下这一行:
n = read(fd, buf, 4096); // 数据还没到
内核做的事是:
- 发现这个 fd 上没有数据。
- 把当前线程的状态从
RUNNING改成TASK_INTERRUPTIBLE(睡眠)。 - 把它从运行队列里摘出去,挂到这个 fd 的等待队列上。
- 调度器选另一个线程来跑。
这个线程从操作系统的视角看,就是「死」了——它不消耗 CPU,但它占着:
- 它的栈(Linux pthread 默认 8MB 虚拟地址空间;JVM 线程默认 1MB)
- 它的内核对象(
task_struct,几 KB) - 它在调度器数据结构里的位置
阻塞(blocking):线程被挂起,但线程本身没有被释放。它躺在那里,占着它的全部资源,什么也干不了,直到条件满足被唤醒。
非阻塞(non-blocking):线程不在这里等。它要么立刻拿到一个「还没好」的返回值(EAGAIN)继续跑,要么把自己的执行状态存起来、把线程还回去(这叫挂起 suspend,是协程干的事)。
唯一的判据:那个 OS 线程,还能不能去干别的活?
「阻塞」这个词的两层含义
这里要小心,因为「阻塞」有两个不同层面的用法,经常打架:
| 层面 | 「阻塞」指的是 | 例子 |
|---|---|---|
| 系统调用层 (Unix 的原意) |
这个 syscall 会不会让调用它的线程睡进内核。由 fd 上的 O_NONBLOCK 标志决定。 |
read() 阻塞read() + O_NONBLOCK 非阻塞(立刻返 EAGAIN) |
| 应用层 (我们平时说的) |
这段代码执行期间,那个线程被浪费掉了没有。 | Thread.sleep() 阻塞delay()(Kotlin)非阻塞一个纯 CPU 的死循环 —— 不算「阻塞」,但同样霸占线程 |
本书说「阻塞」时,用的是应用层的含义:那个线程被占着,没法去干别的活。这是工程上真正关心的事。
四象限:全部四种组合都存在
现在把两组词交叉。请特别注意右上角那一格 —— 它是这本书的主角。
| 阻塞(线程被占死) | 非阻塞(线程被交还) | |
|---|---|---|
| 同步 (代码顺序执行) |
① 同步阻塞 —— 最经典的形态socket.recv()Thread.sleep(1000)JDBC 查询、 requests.get()代码最好写。代价:一个连接一个线程。 |
② 同步非阻塞 ★ —— 本书的主角 Kotlin 的 suspend funGo 的 conn.Read()Python 的 await(在协程内看)Java 21 的虚拟线程 代码读起来像同步,线程却被还回去了。 |
| 异步 (调用立刻返回) |
③ 异步阻塞 —— 存在,但很蠢 发起一个异步调用,然后立刻 future.get() 死等。或者: runBlocking { asyncCall() }你付了异步的复杂度,却没拿到任何好处。这是最常见的一种「假异步」。 |
④ 异步非阻塞 —— 回调 / Reactor Node.js 的回调 Netty 的 ChannelHandlerepoll + 事件循环性能天花板最高,可读性最差。 |
第 ① 格(同步阻塞)好写但不扩展:一万个连接就要一万个线程。
第 ④ 格(异步非阻塞)扩展但难写:回调地狱、栈追踪断裂、try/catch 失效。
第 ② 格是想要两头都占:写起来像 ①,跑起来像 ④。
Kotlin 用编译器变换做到(把函数改成状态机),Go 用 runtime 拦截做到(在 Read() 底下偷偷挂 epoll),Java 21 用虚拟线程做到(JVM 帮你卸载栈)。手段不同,目标完全一致。
这也是为什么「协程」在过去十年成了几乎所有语言的标配 —— 它不是一个时髦,它是那个空格被填上了。
第 ③ 格:那个最常见的「假异步」
这一格值得单独拎出来骂,因为它在真实代码库里比你想象的常见得多。
// ❌ 典型的假异步:你付了钱,没买到东西
fun getUser(id: String): User = runBlocking {
api.getUser(id) // 里面是 suspend 的
}
// runBlocking 会阻塞当前线程,直到协程跑完。
// 你启动了一个协程,然后站在那儿等它 —— 那你要协程干嘛?
# ❌ Python 版的同一个错误
def get_user(id):
return asyncio.run(async_get_user(id)) # 阻塞当前线程直到跑完
# 每次调用都新建一个事件循环,跑一个 task,然后销毁。
# 你付了 asyncio 的全部复杂度,拿到的性能不如直接写同步代码。
// ❌ Java 版 CompletableFuture<User> f = api.getUserAsync(id); User user = f.get(); // ← 立刻阻塞在这里等 // 你把调用改成了异步的,然后马上把线程阻塞掉。等于什么也没做。
- Kotlin:
runBlocking出现在生产代码里(main()和单元测试除外)。它几乎总意味着「有人在逃避 suspend 的染色」。 - Python:
asyncio.run()出现在函数内部(而不是程序入口)。同上。 - Java:
CompletableFuture.get()/.join()在一个非终点的位置。
正确的做法永远是:把调用方也变成异步的,让染色一路传上去,直到某个真正的边界(HTTP handler、main()、UI 事件)—— 在那里,框架会替你启动协程。
如果调用方真的没法变成异步的(比如你在实现一个必须同步的接口)—— 那就诚实地用一个线程池,而不是假装自己在做异步。
另一种被忽略的「占线程」:CPU 死循环
严格说,下面这段代码不是「阻塞」 —— 它没有睡进内核,它一直在跑。
async def handler(req):
result = 0
for i in range(100_000_000): # 纯计算,跑 3 秒
result += i * i
return result
但从「线程还能不能去干别的活」这个角度看,它和阻塞一样致命 —— 在这 3 秒里,事件循环一次也没有机会去处理其他请求。
| 后果 | 为什么 | |
|---|---|---|
| Python asyncio | 整个事件循环卡死 所有请求一起超时 |
协作式调度。没有 await,就没有让出点。循环没有任何办法抢回控制权。 |
| Kotlin 协程 | 占死一个调度器线程 (不是全部,但 Dispatchers.Default 只有「核数」个线程) |
同样是协作式的。挂起点才是让出点。 而且:这个协程连「取消」都取消不掉 —— 取消检查也发生在挂起点(第 22 章)。 |
| Go | 基本没事 | Go 1.14 起有异步抢占:sysmon 发现某个 G 跑了超过 10ms,就发 SIGURG 信号把它踢下来。这是 Go 体质上的一个真实优势。(1.14 之前,这确实会卡死一个 P。) |
结论:在 Python 和 Kotlin 里,「CPU 密集的活不要放进事件循环 / Default 调度器」不是一条建议,是一条硬规则。要跑就丢到线程池去:await asyncio.to_thread(...)、withContext(Dispatchers.Default)(Kotlin 里 Default 就是给 CPU 用的,别放 Main 上)。
把「阻塞」的代价算成钱
为什么我们要这么较真?因为阻塞的代价是可以算出来的。
假设你有一个服务,每个请求要等 100ms 的数据库,CPU 只算 1ms。
→ 需要 ~1010 个线程。× 1MB 栈 = 1GB 内存,全部用来存那些什么也没干的栈。
→ 而这 1010 个线程真正需要的 CPU,只有 10000 × 1ms = 10 秒/秒 ≈ 10 个核。
你为了 10 个核的计算量,养了 1010 个线程。
→ 线程数 = 核数 = 10 个左右。内存从 1GB 降到几 MB。
这就是 C10K 问题的解法,也是整个「异步革命」的经济学基础。
内存只是明面上的成本。真正咬人的是上下文切换:
- 每次切换要陷入内核(~100ns 起步)
- 要保存/恢复寄存器、切换页表、刷 TLB
- 最贵的是缓存:新线程的数据不在 L1/L2 里,接下来几千个周期全是 cache miss
- 合计:一次线程切换 ≈ 1~5 微秒(含缓存污染的实际影响可能更高)
1010 个线程,如果每个每秒被切换 20 次 → 每秒 20200 次切换 × 2µs ≈ 40ms/s 的纯开销。看起来不多?把线程数加到 10000,这条曲线会掉头向下 —— 你会看到 CPU 使用率很高,但吞吐量在下降。那是调度器在自己跟自己搏斗。
第 7 章会把这笔账彻底算清。
1. 在你的服务里数一数线程数(jstack / ps -eLf | wc -l / Go 的 runtime.NumGoroutine())。再数一数核数。比值超过 100 了吗?
2. 用 Little 定律估一次:你的 QPS × 平均响应时间 = 同时在飞的请求数。这个数,就是你至少需要的并发单元数。如果它远大于核数 —— 你需要的是非阻塞,不是更大的线程池。
3. 全局搜一遍 runBlocking / asyncio.run( / .get() on Future。每一处都要给出理由。
下一章:最后一组词,也是最出名的一组。并发是一种代码结构,并行是一种运行时属性 —— Rob Pike 那句话的完整含义。