上一篇Future模型中介绍了Future模式是如何工作的, 我们开发异步程序时只要实现Future, 然后交给runtime去推动就可以了. 虽然状态机实现的Future, 具有内存占用少, 执行效率高等优点, 但也存在一些问题, 比如:

  1. 异步代码要考虑让出和继续, 和同步代码的线性编程方式很不同
  2. 状态机的执行依赖于状态的变更, 程序越复杂, 状态维护越困难
  3. 需要经常和生命周期, Pin等晦涩的概念打交道, 编程门槛不低

为了缓解Future模式带来的复杂性, 也为了能和同步编程有相似的体验, Rust提供了async和await关键字, 下面我们就来看看这两个语法糖具体做了什么.

解开async语法糖

async用来标记一块代码是异步的, 一方面把异步和同步代码显式区分开来, 因为这两种代码的执行过程是不一样的; 另一方面告诉编译器, 这一块代码需要特别处理.

// 异步函数, a1 的类型是 impl Future<output=i32>
async fn f1() -> i32 { 1 };
let a1 = f1();
  
// 异步代码块, a2 也是 impl Future<output=i32>
let a2 = async { 1 };

// 异步闭包, a3 也是 impl Future<output=i32>
let f3 = async move || 1;
let a3 = f3();

使用async的地方有三个: 函数async fn, 代码块async {}和闭包async ||. 这里函数与闭包一样, 被调用后返回一个Future, 而代码块本身是一个Future. async关键字对于这三者的作用是类似的, 下文主要介绍异步函数.

async fn foo() -> String { ... }
// 去掉async语法糖
fn foo() -> impl Future<Output=String> { ... }

async修改了异步函数的返回类型, 比如上面的foo函数, 返回类型String只是内部类型, 外面还有一层隐藏的impl Future<...>. 所以对于async fn foo() -> String { ... }这个函数来说, 去掉async语法糖后就形如fn foo() -> impl Future<Output=String>.

// 匿名的Future实现类型
enum _FooFuture { ... }
impl Future for _FooFuture {
  type Output = String;
  // 函数体转换成poll方法
  fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { ... }
  }
}

// async fn foo() -> String { ... }
fn foo() -> _FooFuture { ... }

直接用Future来写异步代码的话, 我们首先得创建一个任务类型, 然后为这个类型实现Future, 还要和Pin, Waker这些打交道. 编写这样的代码是既繁琐又易出错, 要是能让开发者摆脱这些负担就好了. 正是出于这样的考虑, 编译器会为每个异步函数生成一个匿名的Future类型, 并且作为函数的实际返回类型.

async fn f1() -> String { return String::new(); }
async fn f2() -> String { return String::new(); }

assert_eq!(f1().type_id(), f2().type_id());
let futs = vec! [f1(), f2()]; // Error

这一方案减轻了开发者的心智负担, 但也带来了一些问题. 比如上面代码中, 看上去一样的两个返回类型, 实际却是不相同的, 把f1()f2()直接放进集合的话会报错. 再比如还有怎么为返回的Future添加约束条件, 形如impl Future<...> + Send这样的问题.

所以async关键字的作用在于屏蔽Future的复杂性, 可以编写async fn f() -> T这样的异步函数, 代码中没有出现Future的身影. 开发者不需要了解底层的Future是什么样的, 不需要考虑poll该怎么实现, 也不需要和Pin, Waker这样的概念打交道, 很大程度上可以降低开发"上层"应用的难度.

解开await语法糖

async fn web() {
  receive().await;
  // receive之后才会开始process
  process().await;
}
async fn receive() {}
async fn process() {}

await是一种控制流程的语法. 对于并发来说, 多个任务之间是没有顺序关系的, 但单个任务本身还是有顺序的. 比如以web服务 接收请求 -> 中间处理 -> 返回响应 的例子来说, 虽然多个任务可以在线程上交替执行的, 但对于每一个任务, 中间处理 总要在 接收请求 之后. 任务会阻塞在await点上, 执行完await前的逻辑才会再执行后面的逻辑.

#[tokio::main]
async fn main() {
  let fut = web();
  let res = fut.await;
}

await表示这个任务需要执行. 调用异步函数只是创建了一个Future实例, 而poll方法对于的业务逻辑并不会执行. Future的这种惰性, 正需要await来推动. 比如上面的代码, 如果没有.await, 那就只是在内存中创建了一个变量, 没有用就销毁掉了, Future里的逻辑没有执行.

enum _WebFuture { RECEIVE, PROCESS, END }

impl Future for _WebFuture {
  type Output = ();
  fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
    return loop {
      match & *self {
        // 子过程1: 接收请求
        _WebFuture::RECEIVE => {
          *(self.as_mut().get_mut()) = _WebFuture::PROCESS;
          break Poll::Pending;
        }
        // 子过程2: 中间处理
        _WebFuture::PROCESS => {
          *(self.as_mut().get_mut()) = _WebFuture::END;
          break Poll::Ready(());
        }
        // 子过程3: 返回响应
        _WebFuture::END => { panic!(...); }
      }
    }
  }
}

async是任务让出执行与继续执行的点. 对于异步任务来说, await点是要等待事件的地方, 也就是前面文章里提到的任务被分割成子过程的地方, 所以对于poll方法来说, await点就要考虑让出与继续执行的点. 上文说到编译器会为异步函数生成匿名的Future实现, 而生成的poll方法是原来函数体的转换, 函数体里的await点对应poll里return和match的地方.

所以await关键字, 对于编译器来说, 提供了异步任务中的"断点"信息, 使得异步函数到Future的转换成为可能; 对于开发者来说, 提供了流程控制, 使得异步代码像同步代码一样, 可以用顺序的方式编写.


-> 如果文章有不足之处或者有改进的建议,可以在这边告诉我,也可以发送给我的邮箱