浅析 Rust 异步运行时 tokio
现在!运行!任务!
起步
企业级的服务往往需要承载大量的并发请求。如果只是让每个线程负责一个请求,那么其高昂的线程切换上下文、内存开销对于系统而言都是难以接受的。实际上,我们会让一个线程处理多个请求(在 Linux 下,底层可能是 io_uring 或者 epoll)。其要点在于,当我们处理请求的时候,一旦发现可以让出 CPU 的时候(例如,请求数据库或者上游服务的时候),我们需要以极低的开销来切换去处理其他请求,同时尽可能降低内存开销。甚至,这些请求可以包装起来,从而在不同的进程上执行(即,当其可以唤醒以继续处理的时候,不需要在让出前的那个线程上继续执行)。
我们将这种用户态的轻量级的执行模型称为协程。Go 语言的 goroutine 是一个非常有名气的协程实现,它的 GMP 模型可以在 goroutine 让出 CPU 的时候,找到下一个待执行的 goroutine 并继续执行。它也拥有极低的内存开销(目前,其为 2KB,并在需要的时候动态扩容)。
对于 Rust 而言,和 Go 语言不同的是,它在语言层面提供了 async / await 语法,在标准库中提供了 Future trait,不过执行和调度则留给社区提供的 runtime 来实现。当然,它们选择的模型也有所不同,Rust 选择了无栈协程,而 Go 选择了有栈协程。
其中 tokio 就是 Rust 社区中最主流的异步运行时(runtime)实现。tokio 是 Rust 的一个异步运行时。我们可以这样来使用它:
#[tokio::main]
async fn main() {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
可以看到,简单使用 tokio 是很简单的,它看起来也像是一个同步代码。但是事实上,使用 #[tokio::main],我们已经建立了一个 tokio 运行时实例,它包含多个线程,并等待执行异步任务(task)。在 tokio 中,协程被包装为 task(不过,这里的 main 作为入口点,比较特殊,其不会被包装成一个 task)。
在 Rust 中,async 函数实际上返回的是一个 future,这里的 main 函数返回的 future 会作为 runtime 的入口 future 被执行,我们就从这里开始。Future 本身只是 “将来如何继续计算” 的描述,它是惰性的;只有当 runtime 不断 poll 它的时候,它才会真正向前推进。
当 task 遇到 .await 的时候,任务会让出 CPU,而 runtime 会选择下一个任务并执行(如果有的话)。注意,被挂起的是 task,而不是 OS 线程,线程在这段时间里仍然可以继续执行其他 task。下面这个例子中,我们派生了两个 task:
#[tokio::main]
async fn main() {
let task1 = tokio::spawn(async {
for i in 0..3 {
println!("task 1 -> {i}");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
});
let task2 = tokio::spawn(async {
for i in 0..3 {
println!("task 2 -> {i}");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
});
let _ = tokio::join!(task1, task2);
}
这个例子中我们可以看到有两个 task。这里两段逻辑交错向前推进。而 main 函数对应的 future 则会等待两个对应的 task 都执行完毕。
在学习 tokio 的时候,我们最重要的是需要理解下 tokio 的两个概念,即 runtime 和 task,以及 Rust 提供的 future 抽象。其中:
- Runtime 提供了运行异步代码的环境。这里,runtime 会管理自己的线程,并不断地执行 task。
- Task 提供了一个小巧的抽象,对应异步代码执行的最小单位。其封装了 future,从而支持更多功能,例如我们能够取消它,能够等待它执行完毕。对于一个简单的 API 服务而言,一个请求就对应一个 task。
- Future 是一段异步计算的逻辑。它提供了一个简明的接口,即
poll,使得运行时能够通过不断调用poll来推动异步计算的逻辑。在 tokio 中我们会将其包装在 task 中。
当然,我们还有很多相关的 APIs,包括用于在异步运行时中同步(例如 Mutex、Semaphore 等多种同步原语)、和时间交互(例如 sleep)、和 I/O 交互(例如处理网络请求)、等等。
在后文的 “主题” 一节中,我们更是讨论了几个常见的模式。
还有更多......
注意,本文旨在讨论 tokio 本身,不深入讨论 future 等异步基础概念,读者可以自行了解。
链接和引用
- docs.rs / tokio: https://docs.rs/tokio/latest/tokio/index.html