Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

async 閉包

本集目標

學會 async |...| 語法,理解 async 閉包為什麼能借用捕捉的環境,以及 AsyncFnOnceAsyncFnMutAsyncFn 的用途。

本集是第 6 章閉包非同步一章的補充。

概念說明

非同步一章已經用過 async fnasync block。如果想把一段非同步工作傳給另一個函數使用,過去常見的寫法是讓普通閉包回傳 async block:

|name| async move {
    println!("哈囉,{name}");
}

現代 Rust 可以直接寫成 async 閉包:

async |name| {
    println!("哈囉,{name}");
}

它和普通閉包一樣可以捕捉環境,但呼叫後不會立刻執行閉包內容,而是產生一個 Future;等這個 Futurepoll,內容才會前進。

下面的可執行範例會呼叫 block_on。它不是標準庫 API,而是非同步一章第 6 集那個最陽春的 executor;本集只借它把立即可完成的 Future 跑完,避免為了示範語言語法而綁定特定 runtime。

為什麼普通閉包常搭配 async move

普通閉包被呼叫時,會先執行閉包本身,再把 async block 產生的 Future 回傳。Future 之後才會被 .await,所以它可能在普通閉包的這次呼叫結束後才真正執行。

假設普通閉包收進一個 String,再讓 async block 使用它:

fn main() {
    let greet = |name: String| async {
        println!("哈囉,{name}");
    };

    let _future = greet(String::from("小明"));
}

這裡沒有 move,所以 async block 會嘗試借用參數 name。但普通閉包一回傳 Future,這次呼叫的參數 name 就該離開作用域;若 Future 仍借用它,之後執行時參考就可能失效,因此 Rust 不允許這段程式。

加上 move 後,name 會被搬進 Future

fn main() {
    let greet = |name: String| async move {
        println!("哈囉,{name}");
    };

    let _future = greet(String::from("小明"));
}

現在普通閉包回傳時,name 不會被留在已經結束的呼叫裡,而是由回傳的 Future 擁有,會跟著 Future 活到執行完畢或被丟棄。這就是 |參數| async move { ... }|參數| async { ... } 常見的原因。

不過,async move 並非所有情況都必須使用。如果 async block 沒有借用這次閉包呼叫裡的參數或區域變數,就不一定需要 move。重點是:Future 不能借用普通閉包呼叫結束時就會失效的資料。

第一個 async 閉包

use std::future::Future;
use std::task::{Context, Poll, Waker};

fn block_on<F: Future>(future: F) -> F::Output {
    let mut future = Box::pin(future);
    let mut cx = Context::from_waker(Waker::noop());
    loop {
        match future.as_mut().poll(&mut cx) {
            Poll::Ready(value) => return value,
            Poll::Pending => {}
        }
    }
}

fn main() {
    let greet = async |name: &str| {
        std::future::ready(()).await;
        println!("哈囉,{name}");
    };

    block_on(async {
        greet("小明").await;
        greet("小美").await;
    });
}

greet("小明") 產生一個 Future.await 才執行裡面的 readyprintln!。同一個閉包能被呼叫兩次,和普通 Fn 閉包很像。

那為什麼不一直使用 |x| async move { ... }

普通閉包和它回傳的 async move block 是兩個不同東西。下面的 async move block 必須擁有 prefix,因此外層的普通閉包被呼叫時,必須把自己捕捉的 prefix 搬進新產生的 Future

fn main() {
    let prefix = String::from("訊息");

    let make_future = || async move {
        println!("{prefix}");
    };

    let _first = make_future();
    let _second = make_future();
}

String 沒有實作 Copy。外層閉包第一次呼叫時會把 prefix 搬進回傳的 Future,等於把捕捉值移出自己的環境,因此這個閉包只能實作 FnOnce。第一次呼叫會消耗 make_future,第二次便不能再呼叫。

async 閉包能讓回傳的 Future 借用閉包內保存的資料。為了確實看出這項能力,而不只是複製一份共享參考,下面讓閉包內保存一個指向計數器的可變參考:

use std::future::Future;
use std::task::{Context, Poll, Waker};

fn block_on<F: Future>(future: F) -> F::Output {
    let mut future = Box::pin(future);
    let mut cx = Context::from_waker(Waker::noop());
    loop {
        match future.as_mut().poll(&mut cx) {
            Poll::Ready(value) => return value,
            Poll::Pending => {}
        }
    }
}

fn main() {
    let mut count = 0;

    let mut increment = async || {
        std::future::ready(()).await;
        count += 1;
        println!("第 {count} 次");
    };

    block_on(async {
        increment().await;
        increment().await;
    });
}

increment 內部會保存一個指向 count 的可變參考。每次呼叫 increment() 產生的 Future,都會透過這份參考取得一個生命週期較短的可變參考,並持有到該 Future 完成或被丟棄。第一個 .await 完成後,這份短期可變參考已經失效,因此可以再次呼叫 increment()。這個閉包實作的是 AsyncFnMut,所以變數本身也要宣告成 mut

普通閉包若直接回傳仍持有這類可變參考的 async block,則無法通過編譯:

fn main() {
    let mut count = 0;

    let mut increment = || async {
        std::future::ready(()).await;
        count += 1;
    };

    let _future = increment();
}

這個 Future 必須在閉包呼叫結束後,繼續持有一個從閉包內部取得的可變參考;這個參考的生命週期來自本次 &mut self。但普通 FnMut 的固定 Self::Output 無法帶上每次呼叫才產生的生命週期。

AsyncFn 三兄弟

第 6 章學過,普通閉包會依閉包內容如何使用捕捉值,實作 FnOnceFnMutFnasync 閉包有一組對應的 trait

普通閉包async 閉包呼叫方式
FnOnceAsyncFnOnce只保證能呼叫一次,這次呼叫可能消耗捕捉值
FnMutAsyncFnMut能透過可變參考重複呼叫
FnAsyncFn能透過共享參考重複呼叫

編譯器大致實作了什麼?

第 6 章第 3 集曾把普通閉包想成「儲存捕捉值的匿名 struct,再替它實作 FnOnceFnMutFn」。async 閉包的前半段相同;差別在於呼叫方法不會直接算出結果,而是回傳一個 Future 狀態機。

省略一些實作細節後,三個 trait 的關係大致如下。這是用來說明結構的簡化版本,不是標準庫中可自行實作的定義:

use std::future::Future;

trait AsyncFnOnce<Args> {
    type Output;
    type CallOnceFuture: Future<Output = Self::Output>;

    fn async_call_once(self, args: Args) -> Self::CallOnceFuture;
}

trait AsyncFnMut<Args>: AsyncFnOnce<Args> {
    type CallRefFuture<'a>: Future<Output = Self::Output>
    where
        Self: 'a;

    fn async_call_mut(&mut self, args: Args) -> Self::CallRefFuture<'_>;
}

trait AsyncFn<Args>: AsyncFnMut<Args> {
    fn async_call(&self, args: Args) -> Self::CallRefFuture<'_>;
}

三種呼叫方式的 self 與回傳型別如下:

traitself呼叫所回傳的型別
AsyncFnOnceselfCallOnceFuture
AsyncFnMut&mut selfCallRefFuture<'_>
AsyncFn&self同一個 CallRefFuture<'_>

Output 是等待 Future 後得到的最終結果,不是 Future 本身。CallOnceFuture 來自消耗閉包的呼叫,因此可以直接擁有被搬出的捕捉值,不需要生命週期參數。

普通 FnMutcall_mut 回傳固定的 Self::Output;這個 associated type 無法隨每次 &mut self 呼叫的生命週期改變。因此普通閉包不能回傳一個仍持有「從本次 &mut self 取得之可變參考」的值。關鍵是回傳型別能不能帶上 &mut self 的參考生命週期,而不是工作是否同步完成。

async 閉包本身可以看成一個由捕捉值組成的匿名 structAsyncFnMutAsyncFn 回傳的 Future 可能保存指向這個匿名 struct 內部資料的參考,因此 Future 型別必須帶上它借用閉包的生命週期。CallRefFuture<'a> 便用 GAT 將這個生命週期寫進 associated type。

AsyncFn 沒有另外宣告 associated type,而是沿用 AsyncFnMutCallRefFuture<'a>async_callasync_call_mut 都不會消耗閉包,回傳的 Future 都可能繼續借用捕捉環境,兩者只需要用同一組帶生命週期的 Future 型別來表示。

它們可用來替接收非同步處理函數的 API 寫 bound:

use std::future::Future;
use std::task::{Context, Poll, Waker};

fn block_on<F: Future>(future: F) -> F::Output {
    let mut future = Box::pin(future);
    let mut cx = Context::from_waker(Waker::noop());
    loop {
        match future.as_mut().poll(&mut cx) {
            Poll::Ready(value) => return value,
            Poll::Pending => {}
        }
    }
}

async fn run_twice<F>(job: F)
where
    F: AsyncFn(&str),
{
    job("第一次").await;
    job("第二次").await;
}

fn main() {
    let prefix = String::from("執行");

    block_on(run_twice(async |name| {
        std::future::ready(()).await;
        println!("{prefix}:{name}");
    }));
}

AsyncFn(&str) 的讀法是:「可以透過共享參考呼叫,參數是 &str,呼叫結果是可以等待的非同步工作。」

Fn(&str) 一樣,這裡省略的參數生命週期具有 HRTB 的效果:run_twice 每次呼叫 job 時,都可以傳入當下那次借用的 &str

async move |...|

async 閉包也能加 move

use std::future::Future;
use std::task::{Context, Poll, Waker};

fn block_on<F: Future>(future: F) -> F::Output {
    let mut future = Box::pin(future);
    let mut cx = Context::from_waker(Waker::noop());
    loop {
        match future.as_mut().poll(&mut cx) {
            Poll::Ready(value) => return value,
            Poll::Pending => {}
        }
    }
}

fn main() {
    let label = String::from("背景工作");

    let job = async move || {
        println!("{label}");
    };

    block_on(async {
        job().await;
        job().await;
    });
}

這裡的 move 控制閉包如何從外層環境捕捉 label:閉包取得 label 的所有權。它不代表每次呼叫都要把 label 從閉包裡搬走;閉包內容只共享讀取它,所以 job 仍可符合 AsyncFn,上面才能連續呼叫兩次。

要分清楚兩層:

  • async move || { ... }:建立閉包時,把外部值 move 進閉包。
  • 呼叫 job():產生一個可能借用該閉包的 Future

範例程式碼

use std::future::Future;
use std::task::{Context, Poll, Waker};

fn block_on<F: Future>(future: F) -> F::Output {
    let mut future = Box::pin(future);
    let mut cx = Context::from_waker(Waker::noop());
    loop {
        match future.as_mut().poll(&mut cx) {
            Poll::Ready(value) => return value,
            Poll::Pending => {}
        }
    }
}

async fn for_each_async<F>(items: &[String], action: F)
where
    F: AsyncFn(&str),
{
    for item in items {
        action(item).await;
    }
}

fn main() {
    let items = vec![
        String::from("alpha"),
        String::from("beta"),
        String::from("gamma"),
    ];
    let heading = String::from("處理");

    block_on(for_each_async(&items, async |item| {
        std::future::ready(()).await;
        println!("{heading}:{item}");
    }));

    println!("共處理 {} 筆", items.len());
}

重點整理

  • async |參數| { ... } 建立 async 閉包;呼叫它會產生 Future
  • 相較於普通閉包回傳 async move block,async 閉包能自然表達 Future 借用閉包捕捉環境的情況。
  • AsyncFnOnceAsyncFnMutAsyncFn 對應普通閉包的 FnOnceFnMutFn
  • async move 控制建立閉包時如何捕捉外部值,不代表每次呼叫都會消耗那些值。