本篇与 smol
无关,是基于 Rust 协程的基础知识讨论,意在帮助读者更好的理解后面的内容,如果你已经基本了解了 Rust 协程相关的知识,可以跳过。
简介
smol
是 Rust 语言中的一个轻量级、高效的异步运行时(async runtime),专注于提供简洁的 API 和高性能的异步 I/O 操作。
Rust 并没有像 Go 一样提供内置的异步运行时,仅仅只提供了异步的标准接口 Future
,异步运行时需要用户自己去实现,这样做其实也是多方面权衡后的结果。在 Rust 生态中,目前有几个主流的运行时:tokio
async-std
smol
,其中 tokio
事实上已经是Rust异步运行时的标准。而 smol
则主打的是轻量,代码量少,适合需要精简项目依赖的项目。
本文适合有一定 Rust 基础,对 Rust 中的协程有基本的了解以及使用经验,对协程背后的调度工作原理有兴趣的读者阅读。当然,在源码的解析过程中,我也会以刚开始读源码的视角给读者分析每行代码的作用以及我在阅读源码的过程中遇到的一些问题。由于是源码解析的文章,所以篇幅一般都会比较长,下面就正式开始了。
协程原理内部探究
首先我们要知道协程被发明出来到底是用来干嘛的,它是为了解决传统并发模型中的一些痛点:
1. 轻量级并发(对比线程)
- 问题:线程创建和切换开销大,且线程数过多会导致性能下降。
- 解决 :Rust 的异步任务(如
tokio
或async-std
运行时)在单线程/多线程中调度多个协程,资源占用极低。
rust
use tokio::task;
#[tokio::main]
async fn main() {
// 启动 10 万个协程(实际是 Future)
for i in 0..100_000 {
task::spawn(async move {
println!("Coroutine {}", i);
});
}
// 对比:如果用线程,这里会崩溃或极慢
}
2. 用同步风格写异步代码(避免回调地狱)
- 问题 :传统回调或
Future
组合器(如.then()
)会导致嵌套和难以维护。 - 解决 :
async/await
语法让异步代码看起来像同步代码。
rust
use tokio::net::TcpStream;
use std::error::Error;
async fn fetch_data() -> Result<(), Box<dyn Error>> {
// 异步连接(不阻塞线程)
let mut stream = TcpStream::connect("127.0.0.1:8080").await?;
// 异步写入(挂起时其他任务可运行)
stream.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n").await?;
// 异步读取
let mut buf = [0; 1024];
let n = stream.read(&mut buf).await?;
println!("Received: {}", String::from_utf8_lossy(&buf[..n]));
Ok(())
}
#[tokio::main]
async fn main() {
fetch_data().await.unwrap();
}
3. 避免锁竞争(单线程内协作式调度)
- 问题 :多线程共享数据需要
Mutex
或Arc
,可能引发死锁。 - 解决 :单线程运行时(如
tokio::runtime::Runtime::new().single_thread()
)的协程无需锁。
rust
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(100);
// 生产者协程
tokio::spawn(async move {
tx.send("Hello from coroutine").await.unwrap();
});
// 消费者协程
if let Some(msg) = rx.recv().await {
println!("{}", msg); // 无锁通信
}
}
4. 高效 I/O 复用(基于 epoll
/kqueue
)
- 问题:阻塞 I/O 会浪费线程资源。
- 解决 :Rust 的异步运行时(如
tokio
)在 I/O 等待时自动切换任务。
rust
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let task1 = async {
sleep(Duration::from_secs(1)).await;
println!("Task 1 done");
};
let task2 = async {
sleep(Duration::from_secs(2)).await;
println!("Task 2 done");
};
// 并发执行(总耗时约 2 秒,而非 3 秒)
tokio::join!(task1, task2);
}
协程的核心是挂起 与恢复,当代码执行到 I/O 操作时,让出线程资源,在 I/O 操作处挂起,此时线程可以选择执行其它的协程代码,当挂起的函数因为 I/O 就绪后,又可以被恢复然后继续执行。这就意味着,如果你的业务是 CPU 密集型的任务,那么使用协程对性能几乎是没有提高的。
在 Rust 中,语法关键词 async
为我们提供了方便的定义协程函数的方式,就像这样:
rust
async fn do_something() {
do_something_else().await;
println!("do_something");
}
async fn do_something_else() {
println!("do_something_else");
}
async
本质上是 Future
trait 的语法糖形式,Future
的定义如下:
rust
pub trait Future {
#[stable(feature = "futures_api", since = "1.36.0")]
#[lang = "future_output"]
type Output;
#[lang = "poll"]
#[stable(feature = "futures_api", since = "1.36.0")]
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
- Output:协程执行完成后的结果
- poll:当协程状态发生变化时,外部会调用,推进协程的执行进程,直到下次挂起或者执行完成
每次外部调用 poll
函数会产生两种结果,一种是 Poll::Pending
,另外一种是 Poll::Ready
。Poll::Pending
就是挂起状态,协程无法一次性执行完成,例如执行到某些 I/O 操作时,暂时的挂起自身,等 I/O 就绪时,再次请求调用 poll
,继续推进协程执行,直到返回 Poll::Ready
。
这里我们还注意到,poll
的第一个参数不是常规的 self: &mut Self
来代表协程本身,而是使用了 self: Pin<&mut Self>
,这里这么做的原因是协程可能会包含自引用结构,需要有一种方式来阻止协程运行后的地址发生移动,导致自引用失效,所以用 Pin
包裹了一层(这是一个比较复杂的话题,需要单独的文章来讲述,但是不理解这个不影响我们对本文的理解)。
Context
,我们可以理解成是一种通知机制,在上面我们提到过当我们返回 Poll::Pending
后,肯定需要一种机制,来让外部再次调用 poll
,推进协程的进行,而这个机制,就放到了 Context
当中,Context
中有一个 Waker
结构,我们可以在 poll
的时候,把这个 waker
保存起来,当协程挂起结束后需要恢复时,可以直接调用 waker.wake()
,通知外部再次 poll
我们的协程,完成状态的推进。
通过上面的解释,我们可以看出协程的背后是一个状态机,每个 await
就对应着状态机的一个状态跳转。 async
定义的协程函数会被编译器转换为一个状态机,上面的例子和如下代码是完全等价的:
rust
// do_something_else() 生成的状态机
struct DoSomethingElseFuture {
state: State,
}
enum State {
Start,
Printed,
Terminated,
}
impl Future for DoSomethingElseFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, _cx: &mut Context) -> Poll<()> {
match self.state {
State::Start => {
println!("do_something_else");
self.state = State::Printed;
Poll::Ready(())
}
_ => Poll::Ready(())
}
}
}
// do_something() 生成的状态机(包含子 Future)
struct DoSomethingFuture {
state: DoSomethingState,
// 自动捕获的子 Future
sub_future: Option<DoSomethingElseFuture>, // 用于存储 do_something_else().await
}
enum DoSomethingState {
Start,
AwaitingSubFuture, // 等待子 Future 完成
AfterAwait, // await 完成后的状态
Terminated,
}
impl Future for DoSomethingFuture {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<()> {
loop {
match self.state {
DoSomethingState::Start => {
// 初始化子 Future
self.sub_future = Some(do_something_else());
self.state = DoSomethingState::AwaitingSubFuture;
}
DoSomethingState::AwaitingSubFuture => {
// 轮询子 Future
if let Poll::Ready(()) = self.sub_future.as_mut().unwrap().poll(cx) {
self.state = DoSomethingState::AfterAwait;
} else {
return Poll::Pending;
}
}
DoSomethingState::AfterAwait => {
println!("do_something");
self.state = DoSomethingState::Terminated;
return Poll::Ready(());
}
DoSomethingState::Terminated => panic!("Future already terminated"),
}
}
}
}
谁来推进协程的进行?
从上面的介绍,我们大致理解到了 Rust 为我们提供的协程接口 Future
是如何运作的,那么现在的另一个问题就是,我们如何让我们写好的协程函数跑起来,以及高效的跑起来,这就是协程运行时做的事情了,也就是本文讨论的 smol
。
试想一下,我们有100个协程函数要运行,我们应该怎样管理这些协程的状态(协程调用waker.wake从挂起状态恢复的时候需要我们即时响应)?怎样最大化的利用 CPU 的资源,让这些任务尽可能同时运行?
一个比较容易想到的方式是我们将这些协程分散到多个线程中去执行,每个线程中用一个 HashMap
来管理协程函数,每一个协程为其分配一个唯一的 id,在执行 poll
的时候,我们提供一种回调机制,协程从挂起到恢复的时候调用此回调,这样我们就知道哪个协程需要再次执行 poll
推进状态了。
下面是简单的伪代码表示:
rust
struct FutureTask {
future: Box<dyn Future<Output=()> + 'static>,
responder: Sender,
}
struct WakeupTask {
id: usize,
}
struct AsyncExecutor;
struct SpawnHandle {
tx: Sender,
}
impl SpawnHandle {
//产生一个异步任务,使用一次性的通道来接收异步任务的结果
fn spawn<F>(&self, future: F) -> Receiver
where
F: Future + Send + 'static,
{
let (sender, receiver) = oneshot_channel();
self.tx.send(FutureTask { future: Box::new(future, sender) });
receiver
}
}
impl AsyncExecutor {
fn run(self) -> SpawnHandle {
//构造Multi Producer Multi Consumer类型的通道
let (tx, rx) = channel();
for _ in 0..8 {
//启动多个线程用于执行异步任务
let tx = self.tx.clone();
let rx = self.rx.clone();
std::thread::spawn(|| {
let mut futures: HashMap<usize, FutureTask> = HashMap::new();
loop {
//通道接收枚举类型的值,既可以是FutureTask,也可以是WakeupTask
let task = rx.recv();
match task {
future_task => {
//外部生成了异步任务,为其分配id,立即poll一次,如果完成了就直接返回值
//如果挂起了,就放到Map中等待后续poll
let id = random_id();
if Self::poll_future(id, tx, &mut future_task) {
future_task.responder.send(());
} else {
futures.insert(id, future_task);
}
}
wakeup_task => {
//有异步任务从挂起状态唤醒,根据id找到对应的future,执行poll
let mut future_task = futures.remove(&wakeup_task.id).unwrap();
if Self::poll_future(wakeup_task.id, tx, &mut future_task) {
future_task.responder.send(());
} else {
futures.insert(wakeup_task.id, future_task);
}
}
}
}
});
}
SpawnHandle { tx }
}
//推进Future的状态转变
fn poll_future(id: usize, tx: &Sender, task: &mut FutureTask) -> bool {
let tx = tx.clone();
//这里我们简单的认为Waker就是类似与回调的东西,当我们调用cx.waker.wake()时,就执行回调
let waker = move || {
tx.send(WakeupTask { id });
};
let cx = Context::from_waker(&waker);
task.future.as_mut().poll(cx).is_ready()
}
}
上面是一段不那么严谨的伪代码,但可以帮助读者理解异步运行时大致所做的工作是什么。我们实现的异步运行时有诸多的问题,例如没有 work-stealing
机制来调节线程之间的繁忙状态,也无法执行捕获引用变量的异步任务和 !Send
类型的任务,我们强制要求异步任务的类型是 Send + 'static
。同时我们也限定死了返回值是 ()
类型,但实际情况我们可能需要各种各样的返回值,此时就要求我们的 FutureTask
需要带上泛型的返回值,但是这又与我们的实现相冲突,或者将返回值改为 Box<dyn Any>
类型,然后进行向下转型。
smol
的异步运行时主要分为了两个 crate
,第一个是 async-task
,另外一个是 async-executor
。async-task
主要的职责就是对异步任务进行封装,为其附上状态,让我们可以更加方便的进行 poll
以及获取任务结果。摘取了一段 async-task
的说明:
Task abstraction for building executors. To spawn a future onto an executor, we first need to allocate it on the heap and keep some state attached to it. The state indicates whether the future is ready for polling, waiting to be woken up, or completed. Such a stateful future is called a task.
async-executor
则是真正的异步运行时部分,负责在单线程或者多线程模式下进行任务调度。后面一篇文章会对 async-task
进行分析。