阶段一 · 理解异步

异步模型解密

一句话总结

异步靠三件东西运转:非阻塞 IO(发起操作立即返回,不傻等),事件循环(反复询问"谁就绪了"),就绪通知(操作系统主动告知"这个 IO 好了").
本篇把这套机制讲透--它就是 Go runtime 替我们藏起来的那部分.

先看清"阻塞"到底卡在哪

上一篇说"线程等待 IO 时被白白占用".这个"等待"具体发生在哪?看一次普通的阻塞读:

let mut buf = [0u8; 1024];
let n = stream.read(&mut buf)?; // 数据没来之前,这一行不返回

read 是一个系统调用.如果此刻网卡还没收到数据,操作系统会把发起调用的线程置为"睡眠",直到数据到达才唤醒它.这段睡眠期间,线程完全无事可做,却仍占着资源.

阻塞读的时间线:
线程 ──调用 read()──▶ [睡眠,等数据............] ──数据到──▶ 返回 n 字节
└─ 这段时间线程被冻结,什么也干不了 ─┘

第一块拼图:非阻塞 IO

操作系统其实提供了另一种模式:把文件描述符设为非阻塞(non-blocking).这时调用 read,如果数据还没来,它不睡眠,而是立刻返回一个特殊错误 WouldBlock("现在还没好,待会儿再来").

非阻塞 read 的两种结果:

  1. 数据已就绪 → 立刻返回读到的字节
  2. 数据没就绪 → 立刻返回 WouldBlock 错误(不睡眠!)

这就解开了线程的枷锁:线程发起读,拿到 WouldBlock,转身就能去干别的事,不必卡在原地.

但这带来一个新问题

线程怎么知道"待会儿"是什么时候?难道要写个死循环,对所有连接反复重试 read 直到成功?那叫忙轮询(busy polling)--CPU 会 100% 空转,比阻塞还糟.

我们需要一种"数据好了再通知我"的机制.

第二块拼图:就绪通知(epoll)

解决忙轮询的,是操作系统提供的IO 多路复用机制:Linux 上是 epoll,macOS 上是 kqueue,Windows 上是 IOCP.思路统一:

把成千上万个连接一次性登记给操作系统,然后调用一个"等待"接口.这个接口会睡眠,但它替我们监视所有连接--只要其中任意一个就绪,它就返回,并告诉具体是哪些就绪了.

忙轮询(坏)                      epoll(好)
────────── ──────────
while True: 把 1 万个 fd 登记给 epoll
for fd in 10000 个连接: epoll_wait() ← 睡眠,不耗 CPU
试着 read(fd) ← CPU 空转 └─ 任意 fd 就绪即返回就绪列表
只处理真正就绪的那几个

忙轮询像每隔几秒就去快递柜挨个格子试一遍,有没有都白跑.
epoll 像装了到件通知:登记好要等的包裹就去做别的事,哪个到了系统主动推送,只取那一个.一次 epoll_wait 就能等一万个"包裹".

第三块拼图:事件循环

把前两块拼起来,就得到异步的引擎--事件循环(event loop).它的骨架是这样一个循环:

事件循环(伪代码):
loop {
就绪列表 = epoll_wait() // 睡眠,直到有 IO 就绪
for 每个就绪的 IO:
唤醒等待它的任务 // 让对应任务继续往下跑
运行该任务,直到它再次因 IO 等待而让出
}

整个异步系统就围着这个循环转:任务要么在(有 CPU 活儿可干),要么在(登记给 epoll 后让出).没有任何线程因"干等某个 IO"而被冻结.一个事件循环线程,就能驱动海量任务.

把三块拼图连起来

  • 非阻塞 IO:让"发起操作"不再冻结线程.
  • epoll 就绪通知:让"等待"集中,高效,不靠空转.
  • 事件循环:把"就绪 → 唤醒任务 → 运行"串成持续运转的引擎.

Go runtime 藏起来的就是这套

现在回看 Go.当写下这段再普通不过的代码:

n, err := conn.Read(buf) // 看起来是"阻塞"的

看起来阻塞,但 Go runtime 在底层做的,正是本篇这套机制:它把 conn 设为非阻塞,数据没来时把这个 goroutine 挂起,登记到 netpoller(Go 对 epoll/kqueue 的封装),让出线程去跑别的 goroutine;数据到了,netpoller 通知 runtime 唤醒该 goroutine.

Go 给我们一副"阻塞式"的方向盘,底层却是异步引擎在驱动--以为在直线倒车,其实自动泊车系统在打方向.
Rust 的异步则把引擎盖打开给我们看:直接接触到非阻塞,唤醒,事件循环这些零件.

这对学习 Rust 异步意味着什么

Go 把"挂起/唤醒任务"做成了透明的运行时行为.Rust 没有内建运行时,所以"一个任务如何被挂起,又如何被唤醒"必须用语言层的抽象表达出来.

这就是下一篇 Future 要解决的:它是"一个可被暂停,可被恢复的异步任务"在类型系统里的样子.

一张全景图

异步在不同层次的对应关系,先建立全局印象,后续逐篇填充细节:

层次 角色 本系列对应篇目
操作系统 非阻塞 IO + epoll/kqueue 就绪通知 本篇
语言抽象 Future:可暂停/恢复的任务 03,04
运行时 事件循环 + 任务调度器(executor + reactor) 05
库与应用 Tokio API,网络服务 06 起

快速回顾

  • 阻塞的本质:数据未就绪时,内核让发起系统调用的线程睡眠,资源被冻结.
  • 非阻塞 IO:操作未就绪时立即返回 WouldBlock,线程不被冻结.
  • 就绪通知:epoll/kqueue 一次监视海量连接,任意就绪即返回就绪列表,杜绝忙轮询.
  • 事件循环:"等待就绪 → 唤醒任务 → 运行 → 再让出"的持续循环,是异步引擎.
  • Go 的真相:看似阻塞的 conn.Read,底层就是 netpoller + 挂起/唤醒 goroutine;Rust 把这些零件暴露出来.

动手练习

  1. 解释忙轮询:用自己的话解释"忙轮询为什么比阻塞还糟",再说明 epoll 如何避免它.
  2. 查阅签名:查阅 epoll_wait 的函数签名,确认它"返回就绪的 fd 列表"这一行为.
  3. 画流程图:画一张事件循环的流程图,标出"任务运行""任务让出""IO 就绪唤醒"三个状态转换.
  4. 解读 Go:解释 Go 的 conn.Read 为何"看起来阻塞,实际不冻结线程".
  5. 预测:如果一个异步任务在事件循环线程里执行一段耗时 5 秒的纯计算,会发生什么?(为第 14 篇的"阻塞运行时"埋下思考)