Async Rust chưa bao giờ thoát khỏi trạng thái MVP: Vấn đề bloat và giải pháp tối ưu hóa
Bài viết phân tích sâu về vấn đề "bloat" (phình to) trong Async Rust, đặc biệt là trên các hệ thống nhúng. Tác giả đề xuất các tối ưu hóa ở mức trình biên dịch để giảm kích thước mã nhị phân và đang tìm kiếm nguồn tài trợ cho dự án này.

Async Rust là một tính năng tuyệt vời, cho phép chúng ta viết mã độc lập với trình thực thi (executor-agnostic) có thể chạy đồng thời trên cả các máy chủ khổng lồ lẫn các vi điều khiển bé xíu. Tuy nhiên, đặc biệt là trên những vi điều khiển nhỏ này, chúng ta nhận thấy rằng Async Rust vẫn còn xa rời khỏi lời hứa về các "trừu tượng hóa chi phí bằng không" (zero cost abstractions).
Lý do là mỗi byte dung lượng binary đều quý giá, và async lại引入 (mang lại) rất nhiều sự dư thừa (bloat). Mặc dù vấn đề này cũng tồn tại trên máy tính để bàn và máy chủ, nhưng nó ít được chú ý hơn nhiều khi bạn có sẵn dung lượng bộ nhớ và sức mạnh tính toán dồi dào.
Tôi đã từng giải thích một số cách giải quyết tạm thời cho vấn đề này, nhưng tôi thà muốn giải quyết triệt để vấn đề từ gốc rễ, đó là trong trình biên dịch. Vì vậy, tôi đã đệ trình một "Project Goal" và đang tìm kiếm sự hỗ trợ để tài trợ cho nỗ lực này.
Giải phẫu một Future được tạo ra
Hãy xem xét cách trình biên dịch Rust xử lý các khối async. Khi chúng ta viết một hàm async với các điểm await, trình biên dịch sẽ chuyển đổi nó thành một máy trạng thái (state machine).
Ví dụ, với một hàm bar có hai điểm await, máy trạng thái phải có ít nhất hai trạng thái. Tuy nhiên, thực tế phức tạp hơn nhiều. Trình biên dịch tạo ra MIR (Mid-level Intermediate Representation) cho hàm bar dài tới 360 dòng, trong khi phiên bản không sử dụng async chỉ mất 23 dòng.
Trình biên dịch cũng xuất ra CoroutineLayout, về cơ bản là một enum với các trạng thái như Unresumed (chưa chạy), Returned (đã trả về), Panicked (đã panic), và các trạng thái Suspend (tạm dừng) tương ứng với các điểm await.
Tại sao lại Panic khi đã hoàn thành?
Một điểm thú vị là trạng thái Returned. Khi một Future đã hoàn thành và được gọi poll lại, nó sẽ panic. Điều này đảm bảo an toàn, nhưng liệu có hợp lý không? Panic tương đối đắt đỏ vì nó giới thiệu một đường dẫn với tác dụng phụ mà trình tối ưu hóa khó loại bỏ.
Tôi đề xuất rằng thay vì panic, chúng ta chỉ cần trả về Poll::Pending lần nữa. Không có gì không an toàn xảy ra cả, và chúng ta vẫn đáp ứng hợp đồng của kiểu Future. Tôi đã thử nghiệm sửa đổi này trong trình biên dịch và thấy sự giảm 2%-5% kích thước binary cho phần vững nhúng (embedded firmware).
Điều này nên là một công tắc (switch), giống như overflow-checks = false cho việc tràn số nguyên. Trong bản debug, nó vẫn panic để dễ phát hiện lỗi, nhưng trong bản release, chúng ta có được các Future nhỏ hơn.
Luôn luôn là một máy trạng thái?
Hãy xem xét một Future đơn giản không có điểm await nào: async { 5 }. Lý tưởng nhất, nó không cần bất kỳ trạng thái nào và chỉ cần trả về số 5. Tuy nhiên, trình biên dịch vẫn tạo ra một máy trạng thái với 3 trạng thái mặc định và chuyển đổi dựa trên chúng.
Đây là một cơ hội tối ưu hóa lớn bị bỏ lỡ. Tôi cũng đã thử nghiệm việc loại bỏ máy trạng thái cho các Future không có await và nó tiết kiệm được 0,2% kích thước binary. Mặc dù không nhiều, nhưng đây là một tối ưu hóa đơn giản và đáng giá.
Vấn đề về Inlining và LLVM
Có thể LLVM sẽ giải quyết vấn đề này? Đôi khi là có, nhưng chỉ khi các Future đủ đơn giản và bạn đang chạy ở mức tối ưu hóa opt-level=3. Nếu Future quá phức tạp (điều xảy ra rất nhanh vì các Future lồng nhau sâu trong mã Async Rust chuẩn) hoặc bạn đang tối ưu hóa cho kích thước (thường gặp với nhúng hoặc wasm), LLVM không thể tối ưu hóa triệt để mọi thứ.
Một vấn đề lớn là các Future được tạo ra trong Rust không bao giờ được nội tuyến (inlined) ngay lập tức. Cơ hội tốt nhất để inlining là khi một hàm async gọi một hàm async khác. Với trình biên dịch hiện tại, hàm gọi nhận được máy trạng thái riêng của nó gọi máy trạng thái của hàm được gọi, điều này rất lãng phí. Thay vào đó, hàm gọi có thể trở thành hàm được gọi bằng cách trả về Future của nó.
Gộp các trạng thái trùng lặp
Máy trạng thái nhận được một trạng thái bổ sung cho mỗi điểm await trong khối async. Nhưng có những mã mà nhiều trạng thái có thể được gộp thành một.
Ví dụ, khi sử dụng match để gọi các hàm async khác nhau cho các nhánh khác nhau, trình biên dịch tạo ra các trạng thái giống hệt nhau cho từng nhánh. Bằng cách refactor mã thủ công để di chuyển lệnh gọi async ra ngoài match, chúng ta có thể loại bỏ các trạng thái trùng lặp này và giảm đáng kể độ dài MIR.
Dường như đây là một bước tối ưu hóa tốt để tìm kiếm các đường dẫn mã và trạng thái giống hệt nhau và gộp chúng lại thành một.
Kết luận và Kêu gọi tài trợ
Hy vọng bài viết này đã làm sáng tỏ một số vấn đề của Async Rust! Tôi rất muốn làm việc trên các mục này trong trình biên dịch:
- Trạng thái
Returnedkhông còn panic trong chế độ release. - Các khối async không có
awaitkhông nên nhận máy trạng thái. - Inlining Future cho các Future có một
awaitduy nhất. - Gộp các trạng thái giống hệt nhau.
Tôi muốn làm việc trên vấn đề này trong trình biên dịch và vì vậy tôi đã đệ trình nó như một Project Goal của Rust. Tuy nhiên, tôi cần sự giúp đỡ của bạn vì tôi không thể làm gì nhiều nếu không có kinh phí.
Nếu bạn đang làm việc tại một công ty hoặc tổ chức sẽ được lợi từ công việc này và sẵn sàng tài trợ (một phần), vui lòng liên hệ với tôi. Phạm vi linh hoạt và số tiền tài trợ cần thiết cũng vậy. Tuy nhiên, tôi ước tính rằng 30.000 Euro có thể hoàn thành tất cả hoặc ít nhất là phần lớn công việc này.
Bài viết liên quan

Phần mềm
NHS yêu cầu chuyển hàng trăm kho lưu trữ GitHub sang chế độ riêng tư vì lo ngại về AI và bảo mật
05 tháng 5, 2026

Phần mềm
10 Bài học về Lập trình với AI Agent: Chúng ta nên làm gì khi viết mã trở nên rẻ tiền?
05 tháng 5, 2026

Công nghệ
NetHack 5.0 ra mắt: Bản cập nhật lớn đầu tiên sau 11 năm cho tựa game kinh điển
05 tháng 5, 2026
