Bài 10 dùng luồng hệ điều hành — tốt khi bạn có vài chục tác vụ nặng CPU. Nhưng khi cần phục vụ hàng chục nghìn kết nối mạng cùng lúc, mỗi kết nối phần lớn thời gian chỉ chờ (chờ byte tới, chờ database trả lời), thì một luồng hệ điều hành cho mỗi kết nối là quá lãng phí: luồng tốn bộ nhớ, và chuyển ngữ cảnh giữa hàng nghìn luồng đè nặng lên nhân. Đây là chỗ async/await vào cuộc — mô hình cho phép hàng nghìn tác vụ (task) đồng thời chạy trên một dúm luồng. Bài này đo chính cái làm async đáng giá: chi phí bộ nhớ mỗi tác vụ, so với một luồng hệ điều hành — trong container rust:1 (rustc 1.99.1) với tokio 1.54.1.
Future là một máy trạng thái, không phải một luồng
Điểm cốt lõi để hiểu async Rust: một async fn không tạo ra luồng nào. Trình biên dịch biến nó thành một máy trạng thái (state machine) — một struct ẩn tên là Future, với các trạng thái ứng với từng đoạn giữa các điểm await. Kích thước của Future đúng bằng lượng dữ liệu phải giữ qua điểm await, không hơn:
size Future của `nho()` = 1 byte (hàm async rỗng → máy trạng thái tối thiểu)
size Future của `vua()` = 80 byte (giữ [u64;8]=64B qua điểm await + cờ trạng thái)
so sánh: stack một luồng HĐH = 2 MB (2.097.152 byte) dành RIÊNG cho mỗi luồng
Một Future rỗng chỉ 1 byte; một Future phải nhớ một mảng 64 byte qua lời await thì 80 byte. Đây là cùng tinh thần size_of của bài 3: cấu trúc chỉ chiếm đúng những gì nó chứa. Tương phản với một luồng hệ điều hành — luôn dành riêng 2 MB stack ảo ngay khi tạo, dù tác vụ chỉ cần vài chục byte. Một Future là một giá trị nhỏ trên heap; một luồng là một thực thể nặng của nhân. Khác biệt kích thước đó là toàn bộ câu chuyện.
Đo thật: 100.000 task trên 10 luồng
Cho 100.000 task async cùng sống một lúc (mỗi task dừng tại một điểm await, tất cả cùng parked), đo bộ nhớ thật (RSS) mà tiến trình dùng:
số luồng worker của runtime : 10
số task async cùng sống : 100.000
RSS nền : 2.284 KB
RSS lúc 100.000 task sống : 40.720 KB
bộ nhớ thêm cho 100.000 task: 38.436 KB
=> mỗi task async ~ 393 byte
Một trăm nghìn tác vụ đồng thời, tốn thêm khoảng 38 MB — tức ~393 byte mỗi task. Và để ý: toàn bộ chạy trên runtime chỉ 10 luồng worker (bằng số nhân). Tokio không tạo 100.000 luồng; nó giữ một nhúm luồng và lập lịch hợp tác (cooperative scheduling) các Future lên đó — khi một task chạm await và phải chờ, nó nhường luồng cho task khác. 100.000 "đường thực thi" logic, 10 luồng vật lý.

Hình 1: Một async fn là máy trạng thái nhỏ (1–80 byte) chạy trên vài luồng worker; một luồng hệ điều hành dành riêng 2 MB stack. Async ~393 byte/task vs luồng ~9800 byte.
So với luồng hệ điều hành: nhẹ hơn 25 lần
Chạy phép đo đối chứng bằng luồng thật — 10.000 luồng hệ điều hành cùng sống (đã ít hơn async 10 lần, vì nhiều hơn nữa dễ cạn bộ nhớ):
số luồng HĐH cùng sống : 10.000
RSS nền : 1.676 KB
RSS lúc 10.000 luồng sống : 97.460 KB
bộ nhớ thêm cho 10.000 luồng: 95.784 KB
=> mỗi luồng HĐH ~ 9.808 byte (RSS thật chạm tới)
Mỗi luồng hệ điều hành ngốn ~9.808 byte RSS — gấp 25 lần một task async (393 byte). Và đó mới chỉ là phần RSS thật sự chạm tới; mỗi luồng còn dành riêng 2 MB stack ảo, nên 10.000 luồng đã khoá 20 GB không gian địa chỉ ảo — chạm trần hệ thống từ rất sớm. Với async, 100.000 task vẫn thoải mái vì mỗi Future chỉ là một giá trị vài trăm byte trên heap. Đây là lý do một máy chủ web Rust async phục vụ được hàng trăm nghìn kết nối đồng thời trên một tiến trình, điều bất khả thi nếu mỗi kết nối là một luồng.

Hình 2: Số đo thật — kích thước Future 1–80 byte, 100.000 task async ~393 byte/task trên 10 luồng, so với luồng HĐH ~9808 byte mỗi luồng (gấp 25 lần).
async không thay thế luồng — nó cho việc khác
Đừng rút ra kết luận "async luôn tốt hơn". Hai mô hình cho hai loại việc, và dùng sai thì phản tác dụng. Luồng (bài 10) hợp với tác vụ nặng CPU: chia-để-trị tính toán, mỗi luồng cày một nhân — ở đó async chẳng giúp gì vì không có lúc nào để chờ. Async hợp với tác vụ chờ nhiều, nhẹ CPU: hàng nghìn kết nối mạng, mỗi cái phần lớn thời gian chờ I/O — ở đó luồng quá đắt. Chi phí 393 byte mỗi task chỉ đáng giá khi bạn có rất nhiều tác vụ phần lớn đang chờ. Và async có cái giá riêng chưa đo ở đây: mã async "nhiễm" (hàm gọi async phải là async), lập lịch hợp tác đòi task không được chiếm luồng quá lâu, và một task chặn đồng bộ (tính toán nặng không await) sẽ làm đói cả runtime. Chọn mô hình theo bản chất tác vụ chờ hay tính, không theo cái nào "hiện đại" hơn.
Gói lại
Async Rust đáng giá vì một con số: ~393 byte mỗi task so với ~9.800 byte mỗi luồng hệ điều hành (và 2 MB stack ảo dành riêng mỗi luồng). Bí mật nằm ở chỗ một async fn biên dịch thành một máy trạng thái cỡ đúng bằng thứ nó giữ qua await — 1 byte cho hàm rỗng, 80 byte cho hàm giữ 64 byte — chứ không phải một thực thể nặng của nhân; và tokio lập lịch hàng trăm nghìn Future nhỏ ấy lên chỉ vài luồng worker. Nhờ đó 100.000 tác vụ đồng thời chạy gọn trên 10 luồng. Nhưng async là công cụ cho tác vụ chờ nhiều (I/O mạng), không phải cho tính toán nặng — nơi luồng ở bài 10 mới đúng. Bài cuối sê-ri bước ra ngoài vùng an toàn của Rust: unsafe và FFI — khi nào và tại sao bạn tự tay tháo một phần đảm bảo của trình biên dịch, với chi phí đo được.