Tokio 是一个基于 Rust 语言开发的异步运行时。初接触的开发者可能会存在两个疑问,为什么要异步,什么要基于 Rust 来做异步?
简单的说,异步更符合计算机的运行机制,能够更大的发挥计算能力。当然,这个是针对 IO 密集型的任务。如果是 CPU 密集型的,长耗时的纯计算,那还是同步机制好
从通常的场景来看,大部分的应用都是 IO 密集型的。长耗时的纯 CPU 计算只需要写一个脚本跑就可以了,比较简单
为什么采用 Rust 来做异步,这个可能要说的内容比较长。个人的理解,从 Python 的 yield 和 green thread ,到后来的 Node callback ,到 go 和 java 的异步,都实现的比较别扭,没有完全释放异步的性能。究其原因,还是因为之前的语言有自己的设计模式,只是在原有语言的能力上增加了一些语法糖来实现 Async ,没有专门针对异步来设计。通过 green thread 或者 callback 都不是异步的全部。Rust 是系统级语言,也就是底层的能力透明的使用 Linux 系统能力,也就有可能充分利用系统能力来打造完全为异步而设计的框架,Tokio 就是这么一个框架。
有一篇文章:Rust’s Journey to Async/Await 讲述了 Rust Async 的历史。我在 [Rust 高性能开发](深入了解 Rust 异步开发模式 - lipi的文章 - 知乎 https://zhuanlan.zhihu.com/p/104098627) 里面也描述了这个过程:
Async 为什么会采用 Native Thread ,什么是 Native Thread ?
核心的原因是,Rust 是 “system programming language” ,和 C 之间不能有 overhead 。也就是说,Rust 必须使用系统 Native 的 Thread,才能和 C 的转换没有额外的 IO 损耗。
Rust 的 Async 采用了一种 “Synchronous non-blocking network I/O” (同步非阻塞IO)。这个看上去有些矛盾,但是仔细了解一下,感觉挺有道理的。同步阻塞的问题,就是效率较低。异步非阻塞的问题,对于长耗时的操作效率较低。异步阻塞,能够让长耗时的任务安排到独立线程运行,达到更好的性能。同步非阻塞IO,就是用同步的方法来写代码,但是内部其实是异步调用。
async-std 在这篇博客 这样说:"The new runtime detects blocking automatically. We don’t need spawn_blocking 。anymore and can simply deprecate it " 。系统 runtime 竟然能够自动检测是不是阻塞操作,不需要显式调用 spawn_blocking 来针对阻塞操作。但是 Tokio 没有采用这个办法,Tokio 提供了 spawn_blocking 来创造阻塞任务
但是 Native Thread 在应对 IO 请求的时候,存在问题。它会针对每个请求,准备一个线程。这样会极大消耗系统资源,并且这些线程在等待的时候什么都不做。这样的机制面对大量请求的异步操作时会非常低效。
Go 和 Erlang 都是采用 Green Thread 来解决这个问题。但是 Rust 因为不想和 C 之间有更多的隔阂,不想采用 Green Thread 模式。
Rust 参考了 Nginx 的 Event Poll 模型,还有 Node.js 的 “Evented non-blocking IO” 模型。withoutboats 非常推崇 Node.js 模型,但是 Node.js 带来了回调地狱 (callback hell) 。Javascript 又创造了 Promise 来避免回调的一些问题。Promise 就是 Future 的思路来源。
Twitter 的工程师在处理这个问题的时候,放弃了 JVM 转而用 Scala ,获得了非常大的性能提升 。然后他们写了一个 Paper 叫做 “Your Server as a Function” 。介绍了一个概念,叫做 Future 。这样描述:
A future is a container used to hold the result of an asynchronous operation such as a network RPC, a timeout, or a disk I/O operation. A future is either empty—the result is not yet available; succeeded—the producer has completed and has populated the future with the result of the operation; or failed—the producer failed, and the future contains the resulting exception
Future 是一个容器用来收置一个异步操作,例如网络、RPC、超时、或者磁盘 I/O。Future 或者是空,这个时候结果还没有返回;或者是成功,生产者( 生产Future 的函数或者进程)已经提交并且完成了 Future 的操作,获得了结果;或者失败,生产者出现了错误,future 返回了异常结果
Rust 在这个基础上,完善并推出了 zero cost future 。Aaron Turon 写了另外一篇文章:Zero-cost futures in Rust 来详细说明这个。这应该是 Rust 语言级别对 Async Future 的优化,也是 Rust Async 的精华所在,Aaron 在里面是这样说的:
I’ve claimed a few times that our futures library provides a zero-cost abstraction, in that it compiles to something very close to the state machine code you’d write by hand.
- None of the future combinators impose any allocation. When we do things like chain uses of
and_then
, not only are we not allocating, we are in fact building up a bigenum
that represents the state machine. (There is one allocation needed per “task”, which usually works out to one per connection.)- When an event arrives, only one dynamic dispatch is required.
- There are essentially no imposed synchronization costs; if you want to associate data that lives on your event loop and access it in a single-threaded way from futures, we give you the tools to do so.
意思就是 Rust 设计了一个类似“状态机”的机制来优化 future 的调度。减少多层嵌套 future 的额外分配的开销;多次事件处理一次分配;提供工具在同步进程关联数据。这个挺有意思的,我们可以在下一个篇幅里面研究一下 Rust 是怎么实现的。这篇先说 Tokio
Tokio 经过几次更迭,到 1.0 的时候,已经是一个非常优雅的多层技术栈了。
![image-20210125085150585](../../pics/image-20210125085150585.png)
这是 Tokio 主页的图。Runtime 是核心,承载 I/O、文件、同步和调度,是异步框架的基础;Hyper 是 http1/2 的网路协议实现;Tonic 是 gRPC 的 Rust 实现;Tower 是网络组件,提供负载均衡、超时重试等客户端之间的网络管理能力;Mio 是系统级的事件驱动的最小实现;Tracing 调试跟踪能力;Bytes 流式数据处理能力
既然 Async Future 是 核心,那就让我们从 Future 开始:
Future 在 Rust 的定义是这样:
A future represents an asyncchronous computation
A future is a value tha may not have finished computing yet. This kind of “asynchronous value” make it possible for a thread to continue doing useful work whilt it waits for the value to become available
Future 表示的是一个异步计算,还没有完成的计算的值。这个“异步的值”让线程在等待这个值可用之前,可以做其他事情
异步就是提交了一个操作之后,本线程继续执行。这个操作由其他线程承载,在操作完成之后,通过事件机制记录异步的操作完成了。然后提交这个操作的线程下一次轮询的时候就可以直接获取这个值。这个在很多文章里面都有介绍。
![image-20210125213104309](../../pics/image-20210125213104309.png)
上面是 Rust 异步调用的一个示意图,原图来自于 ira 。
Future 提交到任务执行队列(1)。执行后台是一个 event loop 和线程池。Future 提交之后,会有一个线程 poll 这个队列(2),然后分配线程来执行 Future 。执行的时候会调用 Future 的 poll,如果发现 poll 的状态是 Ready(3),就把返回值返回给在 await 位置等待的线程(4)。如果 poll 的状态是 Pending,则把在事件循环里面的事件树上做记录,线程继续执行 Future,主 poll 线程离开。
线程在执行 Future 之后,执行完成。就通过 waker 提交到事件树(5),告诉本线程执行完成。事件循环到这个事件的时候,发现 Future 执行完成。就通过回掉 Waker(6),把 Future 重新推送到执行队列。这个时候,Future 已经执行完成,Future 也有返回值。再次 poll 的时候,就返回结果给在 await 等待的线程
上面的过程有些复杂,核心包括两个:
- 线程池和轮询分配机制
- 事件树和轮询机制
可以简化理解为,后面有一堆线程。提交一个 Future 到队列。后面有个分配者(线程)分配线程池的线程来执行。分配线程只做分配,同时看队列的线程执行完成没有(通过查看 poll 状态)。
事件和 waker 就是查看和调度在执行 Future 的线程。一旦发现执行完成,就把执行完成的 Future 再推到执行线程,通过 poll 返回给调用者
所以我们还是回到 Future 的定义,以及怎么执行 Future。就可能会更清晰一些
Future 的定义如下:
pub trait Future{
type Output;
fn poll(self:Pin<&mut Self>, cx:&mut Context<`_>) -> Poll<Self::Output>;
}
Output 是当 Future 完成之后的结果
poll 是一个函数,返回结果有两个。如果 Future 还没完成就返回 Poll::Pending
;如果 Future 执行完成就返回 Poll::Ready(val)
,val 就是具体的返回值
Poll 参数有两个,一个是 Pin,一个是 Context。Pin 的作用是让这个 trait 不要移动。因为移动会造成额外的开销。withoutboats 有一篇文章 Zero Cost Abstractions 里面有一段说明了 Pin 的作用:
Async/await and Futures. The Futures API is an important example, because the early versions of futures hit the “zero cost” part of zero cost abstraction really well, but was not actually providing a good enough UX to drive adoption. By adding pinning to support async/await, references across awaits, and so on, we’ve made a product that I really think will solve users’ problems and make Rust more viable for writing high performance network services.
Zero Cost Abstraction(零成本抽象) 是 Rust 设计 Zero Cost Future 的初衷。withoutboats 在上面这边文章里面引用了 C++ 设计者 Bjarne Stroustrup 的一段话作为 Zero Cost Abstract 的定义
What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any