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

Ảnh chụp sơ đồ nền tối so sánh async task với luồng hệ điều hành trong Rust tokio, bên trái mô hình async một async fn biên dịch thành máy trạng thái Future kích thước đúng bằng dữ liệu giữ qua điểm await Future rỗng 1 byte Future giữ mảng u64 8 phần tử 80 byte 100 nghìn task cùng sống chạy trên runtime chỉ 10 luồng worker tốn thêm 38 MB tức 393 byte mỗi task lập lịch hợp tác khi task chạm await và chờ thì nhường luồng cho task khác, bên phải mô hình luồng hệ điều hành mỗi luồng dành riêng 2 MB stack ảo 10 nghìn luồng cùng sống tốn 95 MB RSS tức 9800 byte mỗi luồng gấp 25 lần async nhân lập lịch ưu tiên chuyển ngữ cảnh nặng, ở giữa kết luận async 393 byte mỗi task so với luồng 9800 byte mỗi luồng async nhẹ hơn 25 lần dành cho tác vụ chờ nhiều IO mạng luồng dành cho tác vụ nặng CPU

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.

Ảnh chụp sơ đồ nền tối tổng hợp output thật async tokio trong rust 1.99.1 tokio 1.54.1, khối một kích thước Future size Future của nho rỗng bằng 1 byte size Future của vua giữ mảng u64 8 phần tử qua await bằng 80 byte so với stack một luồng HĐH 2 MB tức 2097152 byte dành riêng mỗi luồng, khối hai 100 nghìn task async cùng sống số luồng worker runtime 10 RSS nền 2284 KB RSS lúc 100 nghìn task 40720 KB bộ nhớ thêm 38436 KB mỗi task async khoảng 393 byte, khối ba 10 nghìn luồng hệ điều hành cùng sống RSS nền 1676 KB RSS lúc 10 nghìn luồng 97460 KB bộ nhớ thêm 95784 KB mỗi luồng khoảng 9808 byte gấp 25 lần async, kết luận async nhẹ hơn 25 lần dành cho tác vụ chờ nhiều IO mạng luồng dành cho tác vụ nặng CPU

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.

Phần sau