Danh sách bài viết

Bài 244: .await Operator

Bài 244 của series Rust Cơ Bản — .await là operator dùng để "chờ" một Future hoàn tất bên trong một async context. Khác hầu hết ngôn ngữ khác, Rust chọn postfix syntax: viết foo().await chứ không phải await foo(). Điểm này nhỏ về cú pháp nhưng lớn về ergonomics — nó cho phép xâu chuỗi với ? và method call: client.get(url).await?.text().await?. Về cơ chế, .await không chặn thread; nó gọi Future::poll lặp lại — nếu Future trả Poll::Ready(v) thì .await tiếp tục với giá trị v, còn nếu trả Poll::Pending thì hàm async hiện tại bị suspend, trả quyền điều khiển về runtime để chạy task khác, và sẽ được resume khi Waker báo có tiến triển. Quên .await là lỗi thường gặp nhất: Future của Rust lazy — không gọi .await thì không chạy, compiler chỉ cảnh báo bằng lint #[must_use] chứ không lỗi. Nhiều .await liên tiếp sẽ tuần tự cộng dồn thời gian; muốn chạy đồng thời nhiều Future phải gói qua tokio::join!, tokio::select! hoặc FuturesUnordered. Cuối cùng, một đặc tính quan trọng: cancellation safety — drop một Future tại điểm .await sẽ cancel toàn bộ tác vụ, code sau .await có thể không bao giờ chạy, nên cleanup phải dựa vào RAII Drop chứ không phải logic ghi sau .await.

10/06/2026
9 phút đọc
1 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu .awaitpostfix operator chỉ hợp lệ trong async fn / async {} block, dùng để "chờ" một Future hoàn tất.
  • Nắm cơ chế bên trong: .await gọi Future::pollReady(v) thì resume với v, Pending thì 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 .await tuần tự) với concurrent (gói qua tokio::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 .await chính là cancel — code sau .await có thể không bao giờ chạy.
2

.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ặc async move {} block. Dùng trong fn đồ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à Future luôn). Apply lên i32 hay String là 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.

3

.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. cx mang theo Waker để Future biết "ai cần được đánh thức khi tao sẵn sàng".
  • Ready(v): Future xong, .await resume 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 async hiện tại suspend ngay tại điểm .await, lưu state hiện tại vào state machine, trả về Poll::Pending ra 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ọi Waker::wake() → runtime poll lại Future này → lần này poll trả 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.

4

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ả.

5

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.

6

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")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ấy Err đầ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.

7

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()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 Drop impl của một guard struct (RAII), giống MutexGuard tự 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. Drop trê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ủa tokio::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::read thì 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 trong select!.

Đâ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.

8

Tổng Kết

  • .awaitpostfix operator chỉ dùng trong async fn / async {} block, áp lên biểu thức Future (hoặc IntoFuture) để chờ kết quả.
  • Behind the scene: .await lower thành vòng lặp gọi Future::pollReady(v) thì resume với v; Pending thì suspend hàm hiện tại, yield về runtime, được resume khi Waker bá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 .await liên tiếp chạy tuần tự, cộng dồn thời gian. Muốn concurrent dùng tokio::join!, tokio::try_join!, tokio::select! hoặc FuturesUnordered.
  • Cancellation safety: drop Future tại .await = cancel. Code sau .await có thể không bao giờ chạy. Cleanup phải qua RAII Drop guard, không phải code sau .await.
  • Khác JS Promise: Rust Future lazycancellable; JS Promise eagerkhông cancel được.
9

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Vì sao đặt .await trong một fn (không phải async fn) lại compile error? Lỗi cụ thể là gì?
  2. Đoạn code let f = foo(); (với fooasync fn) tạo ra cái gì? Body của foo đã chạy chưa?
  3. Vì sao Rust chọn postfix .await thay vì prefix await foo()? Cho 1 ví dụ cụ thể postfix viết gọn hơn.
  4. Hai snippet sau khác nhau bao nhiêu về thời gian chạy, giả sử mỗi http_get mấ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));
  5. Một worker đọc message từ queue rồi ack. Nếu giữa recv().awaitack().await mà task bị cancel, message có bị mất không? Cách thiết kế đúng là gì?
  6. Vì sao tokio::spawn(some_async_fn()) chạy được mà không cần .await, còn some_async_fn() đơn lẻ thì không?
Đáp án
  1. .await chỉ hợp lệ trong async fn, async {} block hoặc async move {} block. Trong fn đồng bộ, compiler báo: error[E0728]: `await` is only allowed inside `async` functions and blocks. Nguyên nhân: .await cần state machine của async để suspend/resume — fn bình thường không có cơ chế đó.
  2. let f = foo(); tạo ra một giá trị impl Future<Output = ...>. Body của foo chưa chạy dòng nào — Future Rust lazy, chỉ chạy khi có ai poll (thường qua .await hoặc tokio::spawn). Đây là khác biệt cực lớn với JS Promise (eager).
  3. 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 keyword await và identifier.
  4. Snippet A (sequential) mất ~2 giây — .await đầu chặn không cho start request thứ hai. Snippet B (concurrent với join!) mất ~1 giây — hai request poll xen kẽ, chạy đồng thời. Nếu các request độc lập, dùng join! là đúng; nếu B cần kết quả của A, buộc phải sequential.
  5. 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, impl Drop để gửi nack (negative ack) hoặc trả message về queue khi guard bị drop mà chưa .commit(). Cleanup phải nằm trong Drop, không phải code sau .await.
  6. 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ân spawn không lazy. Còn some_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 .await trực tiếp, hoặc spawn để runtime consume — không có cái nào thì không chạy.
10

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.