Trang chủ

Vì sao tôi thôi đắn đo về runtime async của Rust

Rust không đi kèm một runtime async. async/await nằm trong ngôn ngữ, nhưng thứ thật sự poll các future của bạn là một thư viện bạn tự chọn, và khi đến từ Swift — nơi runtime đơn giản là có sẵn — điều này cho cảm giác như một quyết định kiến trúc quan trọng.

Tôi mất một tuần so sánh chúng. Đây là bản mười phút mà tôi ước mình đã đọc.

Hãy dùng tokio

Trừ khi bạn có lý do cụ thể để không dùng, hãy dùng tokio. Không phải vì nó vượt trội về mặt kỹ thuật ở mọi phương diện, mà vì thứ thật sự quyết định: cả hệ sinh thái được viết dựa trên nó.

axum, tonic, sqlx, reqwest, hyper, tower, aws-sdk-rust, redis-rs — những crate bạn sẽ với tới hoặc là đòi tokio hoặc là liệt kê nó như tính năng mặc định. Chọn thứ khác nghĩa là hoặc phải tìm cái thay thế cho từng cái, hoặc phải chạy một lớp đệm tương thích, và cả hai đều là việc bạn làm thay vì xây thứ bạn định xây.

Đây không phải một lập luận về sự thanh lịch. Nó vẫn là lập luận “hãy dùng các tập hợp của thư viện chuẩn”: giá trị nằm ở chỗ mọi thứ khác đã đồng ý với bạn sẵn rồi.

Những cái còn lại, và khi nào chúng hợp lý

async-std nhắm tới việc phản chiếu bề mặt API của thư viện chuẩn, thứ khiến nó dễ chịu khi học. Đà của nó đã nhạt và hệ sinh thái đã không đi theo. Hôm nay tôi sẽ không khởi động một thứ mới trên nó.

smol thì nhỏ, dễ đọc và thật sự thanh lịch — cả bộ thực thi chỉ vài nghìn dòng, và đọc nó là cách tốt nhất tôi biết để hiểu async trong Rust thật ra hoạt động ra sao. Nó là lựa chọn hợp lý cho một bối cảnh gần với nhúng hoặc một công cụ nhỏ mà bạn muốn ít phụ thuộc, và nó chạy được các thư viện dựa trên tokio thông qua async-compat nếu bạn cần một cái.

Không dùng runtime nào cả là câu trả lời bị đánh giá thấp cho một CLI. Nếu chương trình của bạn làm ba request HTTP tuần tự rồi thoát, ureq cùng I/O chặn sẽ đơn giản hơn, biên dịch nhanh hơn và dễ gỡ lỗi hơn bất cứ thứ gì async. Async giải một bài toán về đồng thời; một chương trình không có bài toán đồng thời thì không cần nó.

Mẹo

Câu hỏi cần đặt không phải “runtime nào” mà là “tôi có cần một cái không”. Async trong Rust có lãi khi có nhiều tác vụ đồng thời bị chặn ở I/O. Với một công cụ làm dăm ba việc theo thứ tự, code chặn thật sự là lựa chọn kỹ thuật tốt hơn, và nó tiết kiệm cho bạn toàn bộ bài toán hàm nhuộm màu.

Những phần của tokio đáng biết sớm

Các kiểu runtime. Mặc định #[tokio::main] là đa luồng, với một bộ lập lịch ăn cắp việc trên mọi nhân. #[tokio::main(flavor = "current_thread")] là đơn luồng và là lựa chọn đúng cho các bài test cũng như cho bất cứ thứ gì giữ trạng thái không Send.

#[tokio::main(flavor = "current_thread")]
async fn main() { … }

Đừng bao giờ chặn runtime. Đây là sai lầm sẽ cắn bạn, và nó im lặng — không lỗi nào cả, chỉ có một dịch vụ ngừng phản hồi khi tải lên cao.

// sai: chặn một luồng công nhân của runtime
async fn handler() -> Result<String> {
    let data = std::fs::read_to_string("large.json")?;   // lời gọi hệ thống gây chặn
    Ok(parse(&data))
}

// đúng: I/O qua tokio
async fn handler() -> Result<String> {
    let data = tokio::fs::read_to_string("large.json").await?;
    Ok(parse(&data))
}

// đúng: công việc nặng CPU được chuyển ra khỏi các luồng async
async fn handler() -> Result<String> {
    let data = tokio::fs::read_to_string("large.json").await?;
    let parsed = tokio::task::spawn_blocking(move || expensive_parse(&data)).await??;
    Ok(parsed)
}

Quy tắc: spawn_blocking cho công việc nặng CPU và cho mọi thư viện gây chặn mà bạn không tránh được. Dùng bản hiểu async cho bất cứ thứ gì có sẵn một bản như thế.

Các cờ tính năng có ý nghĩa. tokio = { version = "1", features = ["full"] } thì ổn để bắt đầu và đáng cắt bớt về sau — full kéo vào tất cả, và thời gian biên dịch sẽ nhận ra điều đó. rt-multi-thread, macros và net lo được phần lớn các dịch vụ.

Một cái giá thật sự

Tokio là một phụ thuộc lớn. Một chương trình hello-world với tokio và axum kéo vào khoảng một trăm crate và mất một lúc để biên dịch lần đầu. Đó là một điểm trừ có thật và không có cách nào lách ngoài việc không dùng async.

Các lần build tăng dần sau đó thì nhanh, và cargo build --timings sẽ cho bạn thấy thời gian thật sự đi đâu nếu nó trở thành vấn đề. Nó làm tôi khó chịu khoảng một tuần rồi thôi không còn quan trọng.

Điều tôi sẽ nói với người mới bắt đầu

Hãy chọn tokio, dùng spawn_blocking cho bất cứ thứ gì nặng CPU, rồi bắt tay vào chương trình thật. Việc chọn runtime không phải phần thú vị trong kiến trúc của bạn và rất khó có khả năng nó là thứ bạn làm sai.

Nếu về sau bạn tìm ra một lý do cụ thể để chuyển — một đích thật sự nhúng, một ràng buộc cứng về kích thước binary — thì bản thân code async phần lớn là chuyển được, vì async/await là một tính năng ngôn ngữ và chỉ các lời gọi spawn cùng I/O mới gắn với runtime. Đó là một cuộc chuyển đổi nhỏ hơn nhiều so với những gì một tuần đi so sánh gợi ra, và đó là điều chính tôi đã làm sai.