一句话总结
异步靠三件东西运转:非阻塞 IO(发起操作立即返回,不傻等),事件循环(反复询问"谁就绪了"),就绪通知(操作系统主动告知"这个 IO 好了").
本篇把这套机制讲透--它就是 Go runtime 替我们藏起来的那部分.
先看清"阻塞"到底卡在哪
上一篇说"线程等待 IO 时被白白占用".这个"等待"具体发生在哪?看一次普通的阻塞读:
|
read 是一个系统调用.如果此刻网卡还没收到数据,操作系统会把发起调用的线程置为"睡眠",直到数据到达才唤醒它.这段睡眠期间,线程完全无事可做,却仍占着资源.
|
第一块拼图:非阻塞 IO
操作系统其实提供了另一种模式:把文件描述符设为非阻塞(non-blocking).这时调用 read,如果数据还没来,它不睡眠,而是立刻返回一个特殊错误 WouldBlock("现在还没好,待会儿再来").
非阻塞 read 的两种结果:
- 数据已就绪 → 立刻返回读到的字节
- 数据没就绪 → 立刻返回
WouldBlock错误(不睡眠!)
这就解开了线程的枷锁:线程发起读,拿到 WouldBlock,转身就能去干别的事,不必卡在原地.
但这带来一个新问题
线程怎么知道"待会儿"是什么时候?难道要写个死循环,对所有连接反复重试 read 直到成功?那叫忙轮询(busy polling)--CPU 会 100% 空转,比阻塞还糟.
我们需要一种"数据好了再通知我"的机制.
第二块拼图:就绪通知(epoll)
解决忙轮询的,是操作系统提供的IO 多路复用机制:Linux 上是 epoll,macOS 上是 kqueue,Windows 上是 IOCP.思路统一:
把成千上万个连接一次性登记给操作系统,然后调用一个"等待"接口.这个接口会睡眠,但它替我们监视所有连接--只要其中任意一个就绪,它就返回,并告诉具体是哪些就绪了.
|
忙轮询像每隔几秒就去快递柜挨个格子试一遍,有没有都白跑.
epoll 像装了到件通知:登记好要等的包裹就去做别的事,哪个到了系统主动推送,只取那一个.一次 epoll_wait 就能等一万个"包裹".
第三块拼图:事件循环
把前两块拼起来,就得到异步的引擎--事件循环(event loop).它的骨架是这样一个循环:
|
整个异步系统就围着这个循环转:任务要么在跑(有 CPU 活儿可干),要么在等(登记给 epoll 后让出).没有任何线程因"干等某个 IO"而被冻结.一个事件循环线程,就能驱动海量任务.
把三块拼图连起来
- 非阻塞 IO:让"发起操作"不再冻结线程.
- epoll 就绪通知:让"等待"集中,高效,不靠空转.
- 事件循环:把"就绪 → 唤醒任务 → 运行"串成持续运转的引擎.
Go runtime 藏起来的就是这套
现在回看 Go.当写下这段再普通不过的代码:
|
它看起来阻塞,但 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 把这些零件暴露出来.
动手练习
- 解释忙轮询:用自己的话解释"忙轮询为什么比阻塞还糟",再说明 epoll 如何避免它.
- 查阅签名:查阅
epoll_wait的函数签名,确认它"返回就绪的 fd 列表"这一行为. - 画流程图:画一张事件循环的流程图,标出"任务运行""任务让出""IO 就绪唤醒"三个状态转换.
- 解读 Go:解释 Go 的
conn.Read为何"看起来阻塞,实际不冻结线程". - 预测:如果一个异步任务在事件循环线程里执行一段耗时 5 秒的纯计算,会发生什么?(为第 14 篇的"阻塞运行时"埋下思考)