select!
本集目標
學會用 select! 等待多個 branch,直到其中一個完成且輸出符合 pattern,並理解它和取消(cancellation)的密切關係。
正文
等「誰先到」
join! 等的是「全部都完成」。select! 可說是它的一種相反:它同時等多個 branch,只要其中一個完成,而且輸出符合左邊的 pattern,就執行那個 branch 對應的 handler,然後整個 select! 就結束——其他還沒完成的 branch 會被 drop 掉。
基本語法
select! 的每個 branch 大致長這樣:
tokio::select! {
pattern = future => {
// future 完成後,輸出會被 pattern 接住
}
_ = other_future => {
// 不關心 other_future 的輸出
}
}
等號前的 pattern 用來接住等號後那個 Future 的輸出;如果 pattern 裡綁定了變數,那個變數會被帶進右邊的大括號裡使用。等號後寫的是要等待的 Future 而已,這裡不要自己加 .await。select! 會負責同時 poll 這些 Future,等其中一個完成且輸出符合 pattern。
如果你不需要某個 Future 的輸出,就像一般 match pattern 一樣用 _ 忽略它:
tokio::select! {
value = compute() => {
println!("算出來了:{}", value);
}
_ = shutdown.recv() => {
println!("收到 shutdown 訊號");
}
}
如果輸出本身是 Option<T> 或 Result<T, E>,最直覺的寫法是先把整個值接住,再在 handler 裡自己 match:
tokio::select! {
message = receiver.recv() => {
match message {
Some(message) => println!("收到訊息:{}", message),
None => println!("channel 關閉"),
}
}
_ = shutdown.recv() => {
println!("準備關閉");
}
}
select! 本身也可以有回傳值,回傳的是勝出 branch 大括號裡最後一個表達式。這點跟 match 很像:每個 branch 都要回傳同一種型別。
let status = tokio::select! {
value = compute() => {
println!("算出來了:{}", value);
"done"
}
_ = shutdown.recv() => {
println!("收到 shutdown 訊號");
"shutdown"
}
};
println!("狀態:{}", status);
select! 最經典的用途是 timeout:把「真正的工作」和「一個計時器」一起 select!,看誰先到。
extern crate tokio;
use tokio::time::{sleep, Duration};
async fn do_work() {
sleep(Duration::from_secs(5)).await; // 假設工作要五秒
println!("工作完成");
}
#[tokio::main]
async fn main() {
tokio::select! {
_ = do_work() => {
println!("工作順利做完了");
}
_ = sleep(Duration::from_secs(1)) => {
println!("逾時!工作太久了,不等了");
}
}
}
這裡計時器一秒就到,比五秒的工作快,所以 select! 走計時器那個 branch、印出「逾時」,然後把 do_work() 那個 Future drop 掉——工作就此被取消。
select! 很適合這些情境:
- timeout(上面的例子)。
- 同時接收多個 channel:哪個 channel 先有訊息就處理哪個。
- 等待 shutdown signal:一邊做正常工作,一邊聽「該收工了」的訊號,誰先到就反應誰。
在迴圈裡用 select! 要小心 cancellation safety
剛剛提到了 drop:這正是上一集講的 cancellation:drop 一個 Future 就是取消它。而 select! 天生就會在某個 branch 勝出時,把其他 branch 全部 drop。理解這一點,後面用 select! 才不會踩雷。
select! 常常被放在 loop 裡反覆使用(例如一個伺服器迴圈:每輪 select! 等「新工作」或「shutdown 訊號」)。這種寫法要特別小心上一集的 cancellation safety。
回想上一集:read_exact 這類「跨多次推進、累積中間狀態」的操作不是 cancellation safe,中途被取消時,可能已經消費了一部分資料,但整個「讀滿 buffer」的動作沒有完成。而 select! 每一輪都可能因為別的 branch 先完成,而把這一個 branch 的 Future drop 掉(取消)。如果你把 read_exact 放進 select! 的某個 branch,又在 loop 裡反覆跑,那它就很可能在讀到一半時被取消,留下不好接續的半成品。
輸掉的 branch 不會執行自己的大括號,這本來就是 select! 的正常行為,問題不在這裡。真正要小心的是:那個輸掉的 Future 在被丟掉以前,可能已經造成一部分外部效果,例如從 socket 讀走一些 bytes,或把一部分資料寫出去。
所以風險不是「handler 沒跑」,而是「Future 被取消時,已經做了一半的事沒有被完整收尾」。如果這個操作需要跨多步累積進度,就應該把進度放在 select! 外面,branch 裡只等待一次可以安全取消的小步驟。本章後面我們會示範如何遵守這樣的設計原則。
幾個實用補充
select! 還有幾個常用功能:
branch precondition:在 branch 後面加 , if 條件。這個條件可以使用進入 select! 前就已經存在的變數,例如下面的 accepting_jobs;但不能使用左邊 pattern 才會綁定的變數。job 要等 jobs.recv() 完成且 Some(job) 匹配成功後,才能在 handler 裡使用。
tokio::select! {
Some(job) = jobs.recv(), if accepting_jobs => {
handle(job).await;
}
_ = shutdown.recv() => {
accepting_jobs = false;
}
}
處理一次 select! 時,相關步驟依序是:
- 先求值所有 branch 的
ifprecondition;條件為假的 branch 會在這次select!中被停用。 - 求值所有等號右邊的
async表達式,包括已停用 branch 的表達式。條件為假時,表達式仍會被求值以建立Future,但產生的Future不會被poll。這裡的「求值」是建立Future,不是執行其中的async工作;不過,建立Future前的參數運算等一般表達式仍會執行。 poll其餘 branch 的Future。- 某個
Future完成後,嘗試用它的輸出匹配左邊的 pattern。匹配成功就執行 handler 並結束select!;匹配失敗則停用這個 branch,繼續等待其他 branch。因此最先完成的 branch 不一定會勝出;勝出的是完成且 pattern 匹配成功的 branch。 - 所有 branch 都被停用時,執行
else;如果沒有else,select!會 panic。
else branch:例如 Some(job) = jobs.recv() 遇到 channel 關閉、.recv() 回 None 時,Some(job) 會匹配失敗,這個 branch 便會被停用。如果其他 branch 也全部被停用,就會執行 else。
tokio::select! {
Some(job) = jobs.recv(), if accepting_jobs => {
handle(job).await;
}
Some(msg) = messages.recv(), if accepting_messages => {
handle_message(msg).await;
}
else => {
break; // 這一輪沒有任何 branch 能跑
}
}
公平性與 biased;:select! 預設會隨機選擇從哪個 branch 開始 poll。這主要在 select! 被反覆執行(通常是在 loop 裡),而且多個 branch 持續 ready 時有影響。每次改變起點,能降低某個 branch 只因排在前面而每輪都勝出的風險。
加上 biased; 後,select! 會固定由上到下 poll。各個 branch 仍是一個一個被 poll,但只要其中一個回傳 Ready 且輸出符合 pattern,整個 select! 就會立刻結束。因此,排在前面且一直 ready 的 branch,可能讓後面的 branch 永遠得不到 poll:
loop {
tokio::select! {
biased;
// 如果一直有訊息在等,這個 branch 就會一直是 Ready
Some(message) = messages.recv() => {
handle_message(message).await;
}
// 即使 shutdown 已經到達,這個 branch 也可能永遠得不到 poll
_ = shutdown.recv() => {
break;
}
}
}
如果 messages.recv() 每一輪都立刻 ready,它就會在程式檢查到 shutdown.recv() 之前勝出,這就是 starvation。使用 biased; 時,不能延遲的 branch 應該排在前面:
loop {
tokio::select! {
biased;
_ = shutdown.recv() => {
break;
}
Some(message) = messages.recv() => {
handle_message(message).await;
}
}
}
重點整理
select!同時等多個 branch,第一個完成且輸出符合 pattern 的 branch 會執行對應 handler,其他 branch 被drop(取消)。- 基本語法是
pattern = future => { ... };=>左邊不用寫.await,不需要輸出時用_ = future;select!本身也能回傳勝出 branch 的值。 - 所以
select!是程式裡最常製造取消的地方;適合 timeout、多 channel 接收、等 shutdown 訊號。 - 在
loop裡用select!要注意 cancellation safety:別把read_exact這類不可安全取消的Future放進會被drop的 branch。 - 補充功能:branch
if(precondition)、pattern 不匹配時停用該 branch、else(所有 branch 都被停用時),以及用biased;固定由上到下poll。