从零构建 Rust 异步运行时
自建异步运行时,深入理解Rust异步机制与设计权衡。
如果你曾使用 Rust 构建过任何 Web 应用,或者开发过需要通过网络与其他服务进行通信的系统,那么你很可能已经接触过 async/await 语法。在这个过程中,你也很可能安装并依赖了一个异步运行时,其中 tokio 是目前生态中最主流的选择。在大多数应用场景中,像 tokio 这样的异步运行时库能够完美地“隐身”于幕后:开发者只需在 main 函数上添加一个简单的 #[tokio::main] 宏注解,并将标准库中的 std::net::TcpListener 替换为 tokio::net::TcpListener,你的服务器便仿佛获得了魔力,能够仅用少数几个系统线程就优雅地处理成千上万的并发 TCP 连接。
然而,这种强大能力背后的原理究竟是什么?tokio 官方文档中的“深入异步”部分已经非常出色地解释了 Rust 标准库所提供的各种异步 trait 和辅助结构体,并概述了 tokio 是如何实现它们的。通过阅读,你会对执行器(executor)、任务(task)、唤醒器(waker)和未来对象(future)等核心概念建立起初步的认知。但文档中的示例往往停留在相对抽象的层面,例如启动一个线程休眠若干秒,然后借助某个 crate 提供的、略显神秘的 ArcWaker trait 来调度任务。这种学习方式虽然有益,但距离真正理解其内部运作机制,仿佛还隔着一层薄纱。
作为一名长期使用异步 Rust 的开发者,我阅读过大量相关文章,使用过诸多异步库,也编写过数量可观的异步代码。多年来,我多次翻阅这些文档,始终是异步 Rust 的满意用户。但为了从根本上理解像 tokio 这类成熟运行时背后的设计决策与核心权衡——例如,为何 future 需要满足 Send + ‘static 约束,这有时确实令人烦恼;又或者,一个纯粹的单线程运行时究竟会是什么模样——我决定亲手从零开始,为 Rust 构建一个简易的异步运行时。我的目标是完全不依赖任何外部异步库(当然,为了系统调用,我使用了 rustix 来绑定所需的 POSIX API,此外便是 Rust 标准库本身)。
最终构建出的这个运行时体积小巧、设计粗糙,几乎可以确定在某些关键细节上存在微妙的缺陷。除了用于教学和探索之外,它几乎不具备任何生产环境下的实用价值。但正是这个亲手搭建的过程,让我对 Rust 异步运行时的核心机理获得了前所未有的深刻理解。在接下来的内容中,我将系统地拆解这个简易运行时的各个功能模块,阐述执行器、任务、唤醒器与 Future 之间是如何精密协作的,并最终分享我从这次实践中所获得的核心洞见与思考。
异步运行时的核心四要素
一个完整的异步运行时,其核心可以概括为四个相互关联的组件:Future 特质、执行器、任务和唤醒器。Future 是 Rust 异步编程的基石,它代表一个尚未完成的计算。你可以将其类比为一个承诺,它最终会产生一个值,但在结果就绪之前,它可能会多次暂停并让出控制权。执行器是运行时的大脑,它负责调度和执行这些 Future。它持有一个或多个任务队列,并循环检查哪些任务已准备好继续执行。
任务是一个可调度的执行单元,它封装了一个顶层的 Future。执行器管理着这些任务的生命周期。而唤醒器则是连接 Future 与执行器的关键纽带。当一个 Future 在等待异步操作(如 I/O)而无法立即完成时,它会持有一个唤醒器。一旦该操作就绪,相关的中断或回调机制就会调用这个唤醒器,通知执行器:“嘿,这个任务可以继续推进了!” 执行器随后会重新调度并轮询该任务。
构建过程与关键决策
在构建过程中,第一个关键决策是执行器的设计模式。我选择了一个基于单线程、工作窃取队列的执行器模型。这意味着每个工作线程维护自己的任务队列,但当某个线程的队列为空时,它可以尝试从其他线程的队列中“窃取”任务来执行,这有助于平衡负载。我们首先需要实现一个并发的任务队列,用于存储待执行的 Waker 和 Future 组合体。
接下来是唤醒器的实现,这是最具挑战性的部分之一。Waker 在 Rust 中是一个特质,它要求我们提供一个 wake 方法。当异步操作就绪时,需要调用此方法来通知执行器。在我的实现中,每个任务都包含一个指向其所在执行器任务队列的引用。当 wake() 被调用时,它会将该任务推回队列中,等待执行器下次轮询。这要求我们精心设计内存布局与生命周期管理,确保引用有效且不会产生数据竞争。
然后,我们需要将 Future、任务和唤醒器粘合起来。执行器的主要循环不断从任务队列中弹出任务,并使用一个通过任务上下文创建的 Waker 来轮询其内部的 Future。如果 Future 返回 Poll::Pending,它通常会将提供的 Waker 注册到某个事件源(例如,一个 I/O 通知系统)。如果返回 Poll::Ready,则任务完成,其资源可以被清理。
为了处理实际的 I/O 操作,我借助 rustix 库实现了基于 epoll 的系统调用封装,这是 Linux 上高效的多路复用 I/O 机制。执行器在轮询任务队列的间隙,也会调用 epoll_wait 来检查是否有注册的文件描述符已就绪。如果有,则取出关联的 Waker 并调用 wake(),从而将对应的任务重新加入队列,实现从 I/O 事件到任务续期的闭环。
实践带来的核心洞见
通过这次从零开始的构建,我获得了几个至关重要的洞见。首先,我深刻理解了 Send + ‘static 约束的根源。在多线程执行器中,任务可能在线程间移动,因此其 Future 和 Waker 必须是 Send 的。同时,因为任务的生命周期是不确定的,需要 ‘static 来确保其内部引用不会悬垂。这并非运行时作者的任意规定,而是安全、并发调度内存的内在要求。
其次,我体会到了运行时设计中无处不在的权衡。一个全局的、多线程、工作窃取的执行器(如 tokio 默认模式)提供了卓越的吞吐量和 CPU 密集型任务的性能。然而,它引入了同步开销,并且要求所有任务都是 Send 的。相比之下,一个单线程执行器(如 tokio::runtime::Builder::new_current_thread)则消除了同步成本,允许使用非 Send 类型,并且在任务间切换时延迟更低,但代价是无法利用多核优势,吞吐量受限。没有一种设计是万能的,最佳选择完全取决于具体应用场景。
最后,也是最重要的,是对“协作式调度”本质的领悟。在异步 Rust 中,任务必须主动让出控制权(返回 Poll::Pending),其他任务才有机会执行。这意味着,一个长时间运行而不返回 Pending 的 Future(例如,在循环中进行大量计算而不等待)会阻塞整个线程,导致其他任务“饿死”。这与操作系统线程的“抢占式调度”有根本区别。因此,编写良好的异步代码要求开发者有意识地将长耗时操作分解或通过 yield_now 主动让出,这是构建高响应性系统的关键。
总结
亲手构建一个简易的异步运行时,是一次极具启发性的深度之旅。它剥开了像 tokio 这样成熟库的复杂外壳,让你直接面对并发、I/O 多路复用、任务调度和内存安全这些核心问题。虽然这个自制的运行时远非完美,但通过它,你不再将 async/await 视为魔法,而是能清晰地看到其背后的机械结构与精妙设计。这种理解不仅能让你更自信地使用异步 Rust,写出更高效、更可靠的代码,也能让你在面对复杂的并发问题时,具备更深刻的调试与优化能力。异步编程的世界充满挑战,但也正是这些挑战,使得掌握它变得如此有价值。





