幕 I · 正名CH 03进度 03/25

阻塞与非阻塞:一个关于「线程在干嘛」的问题

上一章说的是「我这条执行线还能不能往下走」。这一章问一个完全不同的问题:在等待的那段时间里,那个 OS 线程 —— 那个花了 8MB 内存、要被内核调度的、真实存在的东西 —— 到底在干什么?是被占着不动(阻塞),还是被交还回去干别的活(非阻塞)?这个问题跟上一章正交:四种组合全都存在,而其中最反直觉的那一种,正是现代并发的全部秘密。

线程的下场四象限挂起 ≠ 阻塞O_NONBLOCK

阻塞:一个线程的死亡时刻

当你写下这一行:

n = read(fd, buf, 4096);   // 数据还没到

内核做的事是:

  1. 发现这个 fd 上没有数据。
  2. 把当前线程的状态从 RUNNING 改成 TASK_INTERRUPTIBLE(睡眠)。
  3. 把它从运行队列里摘出去,挂到这个 fd 的等待队列上。
  4. 调度器选另一个线程来跑。

这个线程从操作系统的视角看,就是「死」了——它不消耗 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 fun
Go 的 conn.Read()
Python 的 await(在协程内看)
Java 21 的虚拟线程
代码读起来像同步,线程却被还回去了。
异步
(调用立刻返回)
③ 异步阻塞 —— 存在,但很蠢
发起一个异步调用,然后立刻 future.get() 死等。
或者:runBlocking { asyncCall() }
你付了异步的复杂度,却没拿到任何好处。这是最常见的一种「假异步」。
④ 异步非阻塞 —— 回调 / Reactor
Node.js 的回调
Netty 的 ChannelHandler
epoll + 事件循环
性能天花板最高,可读性最差。
◆ 定论 · 第 ② 格是过去十年的全部工作

第 ① 格(同步阻塞)好写但不扩展:一万个连接就要一万个线程。

第 ④ 格(异步非阻塞)扩展但难写:回调地狱、栈追踪断裂、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 秒里,事件循环一次也没有机会去处理其他请求。

◈ 三语 · 一个不 yield 的 CPU 死循环,会怎样
后果为什么
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。

同步阻塞:一个线程 = 一个请求 = 大部分时间在睡目标 10000 QPS
线程 1 阻塞 99% 的时间
算账 每个请求占住线程 101ms。10000 QPS 需要同时在飞 ~1010 个请求(Little 定律:L = λ × W = 10000 × 0.101)。
需要 ~1010 个线程。× 1MB 栈 = 1GB 内存,全部用来存那些什么也没干的栈
→ 而这 1010 个线程真正需要的 CPU,只有 10000 × 1ms = 10 秒/秒 ≈ 10 个核
你为了 10 个核的计算量,养了 1010 个线程。
非阻塞:等待期间线程被还回去同样 10000 QPS
线程 1 req1 req2 req3 req1 req4 req2 req5
算账 同样 1010 个「在飞」的请求,但它们是 1010 个协程 / goroutine(每个几十字节到 2KB),不是 1010 个线程。
线程数 = 核数 = 10 个左右。内存从 1GB 降到几 MB。
这就是 C10K 问题的解法,也是整个「异步革命」的经济学基础。
⚙ 机器 · 那 1010 个线程,还有一笔看不见的账

内存只是明面上的成本。真正咬人的是上下文切换

  • 每次切换要陷入内核(~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 那句话的完整含义。