Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu
.awaitlà postfix operator chỉ hợp lệ trongasync fn/async {}block, dùng để "chờ" mộtFuturehoàn tất. - Nắm cơ chế bên trong:
.awaitgọiFuture::poll—Ready(v)thì resume vớiv,Pendingthì suspend hàm hiện tại và yield về runtime. - Giải thích vì sao Rust chọn postfix thay vì prefix như JS/C#/Python — và lợi ích chainable với
?. - Nhận diện lỗi quên
.await: Future là lazy, compiler chỉ cảnh báo#[must_use]chứ không panic. - Phân biệt sequential (nhiều
.awaittuần tự) với concurrent (gói quatokio::join!/select!) — và biết khi nào chọn cái nào. - Hiểu cancellation safety: drop một Future tại điểm
.awaitchính là cancel — code sau.awaitcó thể không bao giờ chạy.
.await Là Gì
.await là một operator hậu tố (postfix operator) trong Rust, dùng để "chờ" một giá trị thuộc Future hoàn tất rồi trả ra kết quả của Future đó. Cú pháp đơn giản:
async fn fetch_user_id() -> u64 {
42
}
async fn run() {
let id: u64 = fetch_user_id().await;
println!("user id = {id}");
}
fetch_user_id() không trả về u64 ngay — nó trả về một impl Future<Output = u64>. Phần .await mới biến Future đó thành giá trị u64 thực sự để gán vào id.
Hai ràng buộc bắt buộc của .await:
- Chỉ dùng trong async context: bên trong
async fn,async {}block, hoặcasync move {}block. Dùng trongfnđồng bộ là compile error"`await` is only allowed inside `async` functions and blocks". - Biểu thức bên trái phải implement
IntoFuture(thường làFutureluôn). Apply lêni32hayStringlà lỗi"the trait `IntoFuture` is not implemented".
Điểm quan trọng: .await không chặn thread. Khi Future chưa sẵn sàng, nó yield quyền điều khiển trở lại runtime để thread đó chạy task khác. Đây là toàn bộ lý do async tồn tại — một thread phục vụ nghìn kết nối nhờ liên tục yield ở mỗi I/O point.
.await Behind The Scene
Compiler không có "phép thuật" gì đặc biệt cho .await. Nó được dịch ra một vòng lặp gọi Future::poll (đã học ở bài 243). Mô hình tưởng tượng:
// Pseudo-code: `let v = fut.await;` được lower thành đại loại:
let mut fut = fut;
let v = loop {
// SAFETY: compiler tự lo Pin
match Pin::new(&mut fut).poll(cx) {
Poll::Ready(v) => break v, // resume với kết quả
Poll::Pending => yield, // suspend, trả quyền về runtime
}
};
Ba bước lặp lại cho đến khi xong:
- Poll: runtime gọi
poll(cx)trên Future.cxmang theoWakerđể Future biết "ai cần được đánh thức khi tao sẵn sàng". - Ready(v): Future xong,
.awaitresume tiếp tục chạy code phía dưới với giá trịv. - Pending: Future chưa xong (đang đợi I/O, đang đợi timer, đang đợi mutex...). Hàm
asynchiện tại suspend ngay tại điểm.await, lưu state hiện tại vào state machine, trả vềPoll::Pendingra ngoài. Runtime nhận tín hiệu, lên lịch task khác. Khi nguồn I/O sẵn sàng, nó gọiWaker::wake()→ runtime poll lại Future này → lần nàypolltrảReady.
Quan trọng để hiểu: mỗi .await là một suspension point tiềm năng. Compiler biến async fn của bạn thành một state machine, mỗi .await tương ứng một state. Trạng thái các biến cục bộ được lưu trong struct ẩn của Future, không trên stack. Vì thế .await không tốn thread, không tốn OS context-switch — chỉ tốn vài lệnh CPU để chuyển state.
Khác Biệt Với JS await — Postfix
JavaScript, Python, C# đều chọn cú pháp prefix: await foo(). Rust chọn postfix: foo().await. Quyết định này từng tranh cãi gay gắt ở RFC 2394 (2018-2019), cuối cùng postfix thắng vì hai lý do thực dụng:
Thứ nhất, chainable với ?. So sánh:
// Rust — postfix .await chain đẹp với ?
let body = client
.get(url)
.send()
.await?
.text()
.await?;
// JS — prefix await phải dán ngoài cùng, ngắt nhịp method chain
const resp = await client.get(url).send(); // muốn .text() phải tách dòng
const body = await resp.text();
// hoặc viết một dòng dài, lúc ngoặc rối:
const body = await (await client.get(url).send()).text();
Trong code thực, mỗi I/O call vừa có .await vừa có ? propagate error. Postfix gộp cả hai vào một mạch tự nhiên đọc trái-sang-phải.
Thứ hai, không xung đột method resolution. .await chỉ là một "field hậu tố đặc biệt" giống .0 của tuple, không phải keyword đứng trước expression — không cần parser nhìn xa hai bên để parse đúng.
Ngữ nghĩa giữa hai ngôn ngữ giống nhau: cả hai đều suspend cho đến khi Future/Promise xong rồi resume. Khác biệt nằm ở lazy vs eager: Future của Rust không chạy gì cho tới khi được .await (hoặc spawn); Promise của JS bắt đầu chạy ngay khi tạo ra. Đây là lý do quên .await trong Rust là một bug nghiêm trọng — không có gì xảy ra cả.
Quên .await: Lỗi #[must_use]
Lỗi số một của người mới: quên .await.
async fn send_email(to: &str) {
println!("sending email to {to}");
// ... I/O ...
}
async fn checkout() {
send_email("[email protected]"); // BUG: thiếu .await
println!("checkout done");
}
Khi chạy, dòng println!("sending email...") bên trong send_email không bao giờ in ra. Lý do: send_email("...") chỉ tạo ra một Future rồi vứt đi — Future của Rust lazy, không có .await hay spawn thì không có ai poll nó cả, body bên trong không bao giờ chạy.
May mắn là Future trait được đánh dấu #[must_use = "futures do nothing unless you `.await` or poll them"]. Compiler sẽ cảnh báo:
warning: unused `impl Future` that must be used
--> src/main.rs:7:5
|
7 | send_email("[email protected]");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: futures do nothing unless you `.await` or poll them
Đây chỉ là warning, không phải error — code vẫn compile và chạy với hành vi sai. Khuyến nghị mạnh: bật #![deny(unused_must_use)] ở crate root để biến warning thành error. Trong production code, một .await bị quên có thể là bug hàng giờ debug.
Trường hợp có chủ đích không muốn chờ: dùng tokio::spawn(send_email("...")) để runtime tự poll trong background, hoặc bind vào tên có tiền tố _ để silence lint — nhưng nhớ rằng Future sẽ bị drop và không bao giờ chạy.
Sequential vs Concurrent
Nhiều .await đặt liên tiếp không tự chạy đồng thời — chúng chạy tuần tự, cộng dồn thời gian. Đây là bẫy hiệu năng phổ biến:
use tokio::time::{sleep, Duration};
async fn slow(name: &str) -> &str {
sleep(Duration::from_secs(2)).await;
name
}
// SEQUENTIAL — mất ~4 giây
async fn sequential() {
let a = slow("A").await; // chờ 2s
let b = slow("B").await; // chờ thêm 2s
println!("{a} {b}");
}
Hai Future slow("A") và slow("B") hoàn toàn độc lập, nhưng .await trên a chặn không cho code đi tới slow("B"). Tổng thời gian: 2 + 2 = 4 giây.
Muốn chạy đồng thời, dùng tokio::join!:
// CONCURRENT — mất ~2 giây
async fn concurrent() {
let (a, b) = tokio::join!(slow("A"), slow("B"));
println!("{a} {b}");
}
join! nhận nhiều Future, poll xen kẽ trên cùng một task, chờ cho tới khi tất cả đều ready rồi trả ra tuple kết quả. Tổng thời gian: max(2s, 2s) = 2 giây.
Các công cụ thường dùng:
tokio::join!(f1, f2, ...)— chờ tất cả xong, trả tuple. Nếu một Future panic, các Future khác bị drop (cancel).tokio::try_join!(f1, f2, ...)— nhưjoin!nhưng cho Future trảResult; thấyErrđầu tiên là return ngay, cancel phần còn lại.tokio::select! { x = f1 => ..., y = f2 => ... }— chờ bất kỳ một Future ready trước, các nhánh khác bị cancel. Pattern phổ biến cho timeout:select!giữa task vàsleep(timeout).FuturesUnordered— collection nhiều Future, yield kết quả ngay khi cái nào xong trước, không cần biết trước bao nhiêu Future.
Quy tắc: nếu các tác vụ độc lập (không depend lẫn nhau), dùng join!/try_join! để chạy concurrent. Nếu phụ thuộc (B cần kết quả A), .await tuần tự là đúng. tokio::spawn dành cho khi bạn muốn fire-and-forget background hoặc dùng parallelism (chạy thật trên thread khác trong multi-thread runtime), không chỉ concurrency.
Cancellation Safety: Drop = Cancel
Một đặc tính rất Rust và hay gây bất ngờ: drop một Future tại điểm .await chính là cancel tại đó. Không có exception, không có signal — chỉ cần Future bị drop là task dừng tại suspension point gần nhất.
use tokio::time::{sleep, timeout, Duration};
async fn long_task() {
println!("start");
sleep(Duration::from_secs(10)).await; // ← drop tại đây nếu cancel
println!("end"); // ← có thể KHÔNG BAO GIỜ chạy
}
#[tokio::main]
async fn main() {
// Cho long_task chạy tối đa 1 giây
let _ = timeout(Duration::from_secs(1), long_task()).await;
// In ra "start" rồi sau 1s, Future bị drop tại .await của sleep.
// "end" không bao giờ in.
}
Cụ thể: timeout(...) dùng select! bên trong giữa long_task() và sleep(1s). Khi sleep(1s) ready trước, nhánh kia bị drop — kéo theo long_task bị drop ngay tại .await đang chờ. Mọi local variable trong frame của long_task chạy Drop theo thứ tự ngược, rồi Future biến mất.
Hệ quả thực tế cho code:
- Đừng đặt cleanup code "sau .await" nếu cleanup phải chạy. Code đó có thể không bao giờ chạy. Cleanup phải nằm trong
Dropimpl của một guard struct (RAII), giốngMutexGuardtự unlock khi drop. - Cẩn thận với "đang giữ resource giữa
.await": một half-finished database transaction, một message đã đọc khỏi queue nhưng chưa ack — nếu Future bị drop ở giữa, resource phải được khôi phục đúng.Droptrên guard là cách duy nhất an toàn. - "Cancel-safe" là một thuộc tính API: ví dụ
recv()củatokio::sync::mpsc::Receiverđược tài liệu hoá là cancel-safe (drop giữa chừng không mất message),tokio::io::AsyncReadExt::readthì không (có thể đã đọc một phần buffer mà bạn không bao giờ thấy). Đọc kỹ docs khi dùng trongselect!.
Đây là khác biệt lớn so với JS Promise: trong JS, một async function đã start là chạy tới end (hoặc throw), không ai cancel được giữa chừng. Rust trao quyền cancel cho caller, đổi lại bạn phải code cẩn thận hơn với resource giữa các .await.
Tổng Kết
.awaitlà postfix operator chỉ dùng trongasync fn/async {}block, áp lên biểu thứcFuture(hoặcIntoFuture) để chờ kết quả.- Behind the scene:
.awaitlower thành vòng lặp gọiFuture::poll—Ready(v)thì resume vớiv;Pendingthì suspend hàm hiện tại, yield về runtime, được resume khiWakerbáo. - Postfix syntax giúp chainable với
?:client.get(url).send().await?.text().await?— đọc trái-sang-phải. - Quên
.await: Future lazy, không chạy gì. Compiler warn qua#[must_use]nhưng không error — bật#![deny(unused_must_use)]để bảo vệ. - Sequential vs concurrent: nhiều
.awaitliên tiếp chạy tuần tự, cộng dồn thời gian. Muốn concurrent dùngtokio::join!,tokio::try_join!,tokio::select!hoặcFuturesUnordered. - Cancellation safety: drop Future tại
.await= cancel. Code sau.awaitcó thể không bao giờ chạy. Cleanup phải qua RAIIDropguard, không phải code sau.await. - Khác JS Promise: Rust Future lazy và cancellable; JS Promise eager và không cancel được.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Vì sao đặt
.awaittrong mộtfn(không phảiasync fn) lại compile error? Lỗi cụ thể là gì? - Đoạn code
let f = foo();(vớifoolàasync fn) tạo ra cái gì? Body củafoođã chạy chưa? - Vì sao Rust chọn postfix
.awaitthay vì prefixawait foo()? Cho 1 ví dụ cụ thể postfix viết gọn hơn. - Hai snippet sau khác nhau bao nhiêu về thời gian chạy, giả sử mỗi
http_getmất 1 giây?// A:let x = http_get(u1).await; let y = http_get(u2).await;// B:let (x, y) = tokio::join!(http_get(u1), http_get(u2)); - Một worker đọc message từ queue rồi ack. Nếu giữa
recv().awaitvàack().awaitmà task bị cancel, message có bị mất không? Cách thiết kế đúng là gì? - Vì sao
tokio::spawn(some_async_fn())chạy được mà không cần.await, cònsome_async_fn()đơn lẻ thì không?
Đáp án
.awaitchỉ hợp lệ trongasync fn,async {}block hoặcasync move {}block. Trongfnđồng bộ, compiler báo:error[E0728]: `await` is only allowed inside `async` functions and blocks. Nguyên nhân:.awaitcần state machine củaasyncđể suspend/resume —fnbình thường không có cơ chế đó.let f = foo();tạo ra một giá trịimpl Future<Output = ...>. Body củafoochưa chạy dòng nào — Future Rust lazy, chỉ chạy khi có ai poll (thường qua.awaithoặctokio::spawn). Đây là khác biệt cực lớn với JS Promise (eager).- Postfix chainable đẹp với
?và method chain:client.get(u).send().await?.text().await?. Prefix tương đương trong JS phải lồng ngoặc xấu:await (await client.get(u).send()).text()— hoặc phải tách biến trung gian. Postfix cũng tránh xung đột parser giữa keywordawaitvà identifier. - Snippet A (sequential) mất ~2 giây —
.awaitđầu chặn không cho start request thứ hai. Snippet B (concurrent vớijoin!) mất ~1 giây — hai request poll xen kẽ, chạy đồng thời. Nếu các request độc lập, dùngjoin!là đúng; nếu B cần kết quả của A, buộc phải sequential. - Có, message bị mất nếu thiết kế ngây thơ: message đã ra khỏi queue (qua
recv) nhưng chưa được ack — task drop, không ai biết để retry. Cách đúng: dùng acknowledge-on-drop pattern. Tạo một guard struct giữ message, implDropđể gửinack(negative ack) hoặc trả message về queue khi guard bị drop mà chưa.commit(). Cleanup phải nằm trongDrop, không phải code sau.await. tokio::spawn(future)trao Future cho runtime để runtime tự poll trong background trên các worker thread. Nó trả vềJoinHandle(cũng là một Future) ngay lập tức — bản thânspawnkhông lazy. Cònsome_async_fn()đơn lẻ chỉ tạo Future rồi vứt đi: không ai poll, body không chạy. Tóm tắt: Future cần một "consumer" — hoặc.awaittrực tiếp, hoặcspawnđể runtime consume — không có cái nào thì không chạy.
Bài Tiếp Theo
Bài 245: Tại Sao Rust Không Có Built-in Async Runtime — bài tiếp giải thích quyết định thiết kế gây tranh cãi nhất của async Rust: Future trait nằm trong std nhưng executor (cái thật sự poll Future) thì không. Bạn sẽ hiểu vì sao tokio, async-std, smol cùng tồn tại, ưu/nhược của từng runtime, và vì sao chọn runtime là quyết định kiến trúc level project chứ không phải level function.
