6 tháng mình né async Rust như né production deploy vào thứ Sáu. Mỗi lần google là đụng ngay Future, Waker, Poll::Pending — đọc nửa bài là đóng tab. Thôi sync cho lành.
Vấn đề không phải async Rust khó. Vấn đề là mình đọc implementation details của runtime trước khi hiểu cách dùng. Khi mình lùi lại, quên hết Waker, quên hết Poll, và nghĩ từ đầu — mọi thứ tự nhiên click.
Sai lầm khi học async Rust
Hầu hết tutorial bắt đầu từ đây:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
Đây là cách runtime triển khai, không phải cách anh em sẽ dùng. Học Future trait trước khi dùng async/await giống như học bytecode JVM trước khi viết Java.
Mental model đúng chỉ có một câu:
async fn= function trả về một “computation” chưa chạy.
Gọi fetch_data() không chạy gì cả. Nó tạo ra một object mô tả những gì sẽ làm. Object đó cần được executor chạy — và await là cách anh em nói với executor “chạy cái này đi, xong thì cho mình kết quả”.
async fn = factory cho state machine
Khi viết thế này:
async fn fetch_data(url: &str) -> String {
let response = reqwest::get(url).await.unwrap();
response.text().await.unwrap()
}
Rust compiler không generate một function thông thường. Nó generate một struct — một state machine — có dạng conceptually như sau:
// Pseudo-code, không phải actual generated code
enum FetchDataStateMachine {
// State 0: chưa bắt đầu
Start { url: String },
// State 1: đang chờ reqwest::get hoàn thành
WaitingForGet { future: ReqwestGetFuture },
// State 2: đang chờ response.text() hoàn thành
WaitingForText { future: ResponseTextFuture },
// State 3: xong
Done,
}
Mỗi await point là một state. Mỗi lần runtime poll struct này, nó hỏi “đang ở state nào, cái đang chờ xong chưa” — nếu xong thì chuyển sang state tiếp theo.
Hệ quả thực tế: một async fn 10 dòng có thể compile thành struct với vài chục fields lưu state. Đây là lý do Rust async zero-cost — không allocate trên heap, không context switching như thread.
await không block thread
Hai dòng này trông giống nhau, nhưng hoàn toàn khác nhau:
use std::thread;
use std::time::Duration;
// Cái này BLOCK thread trong 1 giây
thread::sleep(Duration::from_secs(1));
// Cái này YIELD control về runtime, thread vẫn làm việc khác
tokio::time::sleep(Duration::from_secs(1)).await;
Khi tokio::time::sleep().await chạy, task yield — runtime lấy thread đó đi xử lý task khác. Khi timer hết, runtime schedule task trở lại. Thread không bị giam.
Xem timing thực tế:
use tokio::time::{sleep, Duration, Instant};
async fn task(id: u32, secs: u64) {
println!("Task {id} bắt đầu");
sleep(Duration::from_secs(secs)).await;
println!("Task {id} xong");
}
#[tokio::main]
async fn main() {
let start = Instant::now();
// Sequential — tổng 3 giây
task(1, 1).await;
task(2, 2).await;
println!("Sequential: {:.1}s", start.elapsed().as_secs_f32());
let start = Instant::now();
// Concurrent — tổng ~2 giây (task dài nhất)
tokio::join!(task(1, 1), task(2, 2));
println!("Concurrent: {:.1}s", start.elapsed().as_secs_f32());
}
Output:
Task 1 bắt đầu
Task 1 xong
Task 2 bắt đầu
Task 2 xong
Sequential: 3.0s
Task 1 bắt đầu
Task 2 bắt đầu
Task 1 xong
Task 2 xong
Concurrent: 2.0s
tokio::join! chạy cả hai futures đồng thời trên cùng thread. Không spawn thread mới, không OS context switch. Trong lúc task 1 đang chờ, task 2 chạy.
async không tự chạy — cần executor
Cái này mình bị nhầm nhất hồi mới học. Tạo một Future không làm gì cả:
async fn send_email(to: &str) {
println!("Gửi email đến {to}");
// ... thực tế dùng SMTP client hay gì đó
}
fn main() {
// Tạo Future — KHÔNG có gì xảy ra
let _fut = send_email("[email protected]");
// Email không được gửi. Không có side effect.
// Compiler thậm chí warn: "unused implementor of Future"
println!("Chương trình kết thúc");
}
Output:
Chương trình kết thúc
Email không đi đâu hết. Future là lazy computation — nó cần executor drive mới chạy được.
#[tokio::main] macro là cách nhanh nhất để có executor:
#[tokio::main]
async fn main() {
// main() giờ là async, tokio runtime làm executor
send_email("[email protected]").await; // Giờ mới thực sự chạy
}
Macro này expand ra roughly như sau:
fn main() {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async {
send_email("[email protected]").await;
});
}
block_on là điểm mà sync world gặp async world — thread hiện tại block cho đến khi Future hoàn thành.
Send + Sync trong async context
Phần này hay khiến anh em bị lỗi compile lúc dùng tokio::spawn:
use std::rc::Rc;
use tokio::task;
async fn process(data: Rc<Vec<u8>>) {
println!("Processing {} bytes", data.len());
}
#[tokio::main]
async fn main() {
let data = Rc::new(vec![1, 2, 3]);
// Compile error!
task::spawn(process(data));
}
error[E0277]: `Rc<Vec<u8>>` cannot be sent between threads safely
--> src/main.rs:11:18
|
11 | task::spawn(process(data));
| ^^^^^^^^^^^^^ future is not `Send`
tokio::spawn có thể schedule task trên bất kỳ thread nào trong thread pool. Rc không thread-safe — nó không implement Send. Nếu task migrate sang thread khác đang giữ Rc, data race xảy ra. Borrow checker bắt được điều này tại compile time.
Fix đơn giản: dùng Arc thay Rc:
use std::sync::Arc;
use tokio::task;
async fn process(data: Arc<Vec<u8>>) {
println!("Processing {} bytes", data.len());
}
#[tokio::main]
async fn main() {
let data = Arc::new(vec![1, 2, 3]);
// OK — Arc implement Send
task::spawn(process(Arc::clone(&data))).await.unwrap();
}
Arc (Atomic Reference Count) dùng atomic operations thay vì plain integer, an toàn khi access từ nhiều thread.
Rule ngắn gọn: Rc → dùng trong single-threaded context. Arc → dùng khi cần share giữa tasks hoặc threads.
Còn nếu anh em cần spawn background task và không cần đợi kết quả:
#[tokio::main]
async fn main() {
// Spawn background task — không await, task chạy song song với main
let handle = tokio::spawn(async {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
println!("Background task xong");
});
println!("Main tiếp tục chạy ngay lập tức");
// Nếu muốn đợi background task hoàn thành:
handle.await.unwrap();
}
Kết
- Bắt đầu với
async/awaitvàtokio::join!— không cần hiểuFuturetrait hayPollđể viết code async thực tế. - Gặp “future is not
Send” → đổiRcthànhArc,RefCellthànhMutex. Đó là 90% trường hợp. - Mental model quan trọng hơn implementation details: async fn tạo state machine, await yield control, executor drive tất cả. Nắm 3 điều này là đủ để debug hầu hết lỗi async Rust.