- Async Rust cho phép chạy cùng một đoạn mã độc lập với executor trên cả máy chủ và vi điều khiển, nhưng do state machine mà compiler tạo ra, kích thước binary tăng lên rất rõ, đặc biệt là trong môi trường embedded
- Ngay cả ví dụ đơn giản như
bar() với 2 điểm await cũng tạo ra 360 dòng MIR cùng các trạng thái Unresumed, Returned, Panicked, Suspend0, Suspend1, trong khi phiên bản đồng bộ chỉ cần 23 dòng
- Nếu thay đổi để khi poll lại một future đã hoàn tất thì trả về
Poll::Pending thay vì panic, vẫn có thể đáp ứng contract mà không gây hành vi unsafe, và trong thử nghiệm đã giúp giảm kích thước binary 2%~5% cho firmware embedded
- Ngay cả
async { 5 } không có await hiện tại cũng tạo state machine với mặc định 3 trạng thái, nhưng nếu tối ưu để luôn trả về Poll::Ready(5) thì kích thước binary embedded giảm 0,2%
- Project Goal được đề xuất nhằm thúc đẩy trong compiler việc loại bỏ panic sau khi hoàn tất ở chế độ release, loại bỏ state machine cho async block không có await, inline future chỉ có một await, và gộp các trạng thái giống nhau
Vấn đề phình to ở cấp compiler của Async Rust
- Async Rust cho phép chạy cùng một đoạn mã độc lập với executor trên cả máy chủ và vi điều khiển, nhưng trên các vi điều khiển nhỏ, mức tăng kích thước binary đặc biệt dễ thấy
- Blog Rust từng giới thiệu async/await là abstraction không chi phí, nhưng trên thực tế async tạo ra rất nhiều bloat; vấn đề tương tự cũng tồn tại trên desktop và server, chỉ là ít lộ rõ hơn vì có nhiều bộ nhớ và tài nguyên tính toán hơn
- Tiếp nối cách lách để tránh bloat khi viết mã async, một Project Goal đã được đệ trình để giải quyết vấn đề này ngay trong compiler
- Vấn đề future lớn hơn mức cần thiết và bị sao chép quá nhiều không nằm trong phạm vi của bài này
Cấu trúc của future được tạo ra
- Mã ví dụ có
foo() trả về async { 5 }, còn bar() thực hiện foo().await + foo().await
bar có 2 điểm await nên state machine tối thiểu cần 2 trạng thái, nhưng trên thực tế còn tạo ra nhiều trạng thái hơn
- Rust compiler có thể dump MIR ở nhiều pass khác nhau, và pass
coroutine_resume là pass MIR cuối cùng dành riêng cho async
- async vẫn còn trong MIR nhưng không còn trong LLVM IR, nên quá trình biến async thành state machine diễn ra ở các pass MIR
- Hàm
bar tạo ra 360 dòng MIR, trong khi phiên bản đồng bộ chỉ dùng 23 dòng
CoroutineLayout mà compiler in ra về bản chất là một tập trạng thái dưới dạng enum
Unresumed: trạng thái ban đầu
Returned: trạng thái đã hoàn tất
Panicked: trạng thái sau panic
Suspend0: điểm await đầu tiên, nơi lưu future của foo
Suspend1: điểm await thứ hai, nơi lưu kết quả đầu tiên và future foo thứ hai
Future::poll là hàm an toàn, nên dù bị gọi lại sau khi future đã hoàn tất cũng không được gây ra UB
- Hiện tại sau
Suspend1, nó trả về Ready rồi chuyển future sang trạng thái Returned
- Nếu poll lại ở trạng thái này thì sẽ xảy ra panic
- Trạng thái
Panicked dường như dùng để ngăn việc poll lại future sau khi hàm async panic rồi panic đó bị bắt bởi catch_unwind
- Sau panic, future có thể ở trạng thái không hoàn chỉnh, nên poll lại có thể dẫn đến UB
- Cơ chế này rất giống với mutex poisoning
- Cách diễn giải này về trạng thái
Panicked chỉ ở mức khoảng 90% chắc chắn vì khó tìm được tài liệu xác nhận rõ ràng
Có nhất thiết phải panic khi poll sau khi đã hoàn tất không?
- Future ở trạng thái
Returned hiện tại sẽ panic, nhưng không nhất thiết phải như vậy
- Điều kiện cần chỉ là không gây ra UB
- panic tương đối tốn kém, đồng thời thêm vào một nhánh có tác dụng phụ mà rất khó loại bỏ bằng tối ưu hóa
- Nếu khi poll lại một future đã hoàn tất mà trả về
Poll::Pending, vẫn có thể đáp ứng contract của kiểu Future mà không tạo ra hành vi unsafe
- Khi thử nghiệm bằng cách chỉnh compiler theo hướng này, firmware embedded dùng async đã cho thấy giảm 2%~5% kích thước binary
- Đề xuất là cung cấp hành vi này dưới dạng một tùy chọn, tương tự
overflow-checks = false cho tràn số nguyên
- Ở bản debug vẫn tiếp tục panic để lộ lỗi hành vi sai ngay lập tức
- Ở bản release có thể nhận được future nhỏ hơn
- Khi dùng
panic=abort, có khả năng loại bỏ hoàn toàn trạng thái Panicked, nhưng tác động của việc này cần được xem xét thêm
Không có await nhưng vẫn luôn tạo state machine
foo() chỉ trả về async { 5 }, nên dạng tối ưu nhất nếu tự cài đặt bằng tay là một future không có trạng thái, luôn trả về Poll::Ready(5)
- Nhưng MIR do compiler tạo ra vẫn có 3 trạng thái mặc định là
Unresumed, Returned, Panicked
- Khi poll, nó kiểm tra discriminant của trạng thái hiện tại rồi phân nhánh
- Nếu poll lại sau khi đã hoàn tất thì sẽ panic với assert
`async fn` resumed after completion
- Trong trường hợp này có thể tối ưu để không tạo state machine mà chỉ luôn trả về
Poll::Ready(5)
- Khi áp dụng thử nghiệm vào compiler, kích thước binary embedded giảm 0,2%
- Mức tiết kiệm không lớn, nhưng đây là tối ưu đơn giản nên vẫn có thể đáng để áp dụng
- Tối ưu này có thay đổi hành vi một chút, nhưng chỉ ảnh hưởng đến các executor không tuân thủ quy ước
- Compiler hiện tại sẽ panic ở lần poll sau
- Sau tối ưu, future sẽ luôn trả về
Ready
Chỉ LLVM là không đủ
- Dù đầu ra MIR có kém hiệu quả, đôi khi LLVM vẫn có thể dọn dẹp hết, nhưng điều kiện khá hạn chế
- future phải đủ đơn giản
- phải dùng
opt-level=3
- Khi future phức tạp hơn, LLVM không thể loại bỏ hết, và vì mã async Rust theo kiểu thông dụng thường lồng future rất sâu nên độ phức tạp tăng rất nhanh
- Trong các môi trường thường tối ưu theo kích thước như embedded hay wasm, LLVM không thể tối ưu hết mọi thứ
- Ví dụ Godbolt: https://godbolt.org/z/58ahb3nne
- Trong assembly được tạo ra, LLVM biết rằng
foo trả về 5, nhưng không thể tối ưu đáp án của bar thành 10
- Lời gọi hàm poll của
foo vẫn còn đó
- Lý do là tồn tại các nhánh panic tiềm năng mà compiler không thể xác quyết hoàn toàn
- LLVM không biết rằng
foo trên thực tế chỉ được gọi một lần và không panic
- Nếu comment out nhánh panic trong IR thì tối ưu tốt hơn: https://godbolt.org/z/38KqjsY8E
- Thay vì kỳ vọng LLVM tối ưu hậu kỳ, compiler nên cung cấp đầu vào tốt hơn cho LLVM
Inline future chưa hoạt động tốt
- Inline rất quan trọng vì nó mở đường cho các pass tối ưu hóa tiếp theo, nhưng các future Rust được tạo ra hiện chưa được inline ở giai đoạn sớm
- Chỉ sau khi mỗi future có phần hiện thực riêng thì LLVM và linker mới có cơ hội inline, nhưng do những vấn đề ở trên nên lúc đó đã quá muộn
- Cơ hội inline trực tiếp nhất là khi
bar() chỉ đơn thuần thực hiện foo(blah).await
- Đây là mẫu rất hay gặp khi dùng trait để tạo lớp trừu tượng
- Compiler hiện tại tạo state machine cho
bar, rồi từ trong đó gọi state machine của foo
- Hiệu quả hơn là để
bar chính là future của foo
- Trường hợp có preamble và postamble thì phức tạp hơn
- Ví dụ:
bar(input) tạo blah từ input > 10, sau đó foo(blah).await rồi áp dụng * 2 lên kết quả
- Mẫu này rất phổ biến khi chuyển đổi async function sang chữ ký khác, đặc biệt trong triển khai trait
- Dạng
bar này cũng không nhất thiết cần trạng thái async riêng
- Không có dữ liệu nào cần được giữ qua điểm await duy nhất ngoài giá trị đã được
foo capture
- Tuy vậy
bar không thể đơn giản trở thành chính foo, mà phần lớn trạng thái có thể dựa vào foo
- Nếu tự cài đặt bằng tay,
BarFut có thể có các trạng thái Unresumed { input } và Inlined { foo: FooFut }
- Ở lần poll đầu tiên, nó chạy preamble để tạo
foo(blah) rồi chuyển sang trạng thái Inlined
- Sau đó áp dụng postamble lên kết quả của
foo.poll(cx)
- Nếu có thể thực thi sẵn mã trước điểm await đầu tiên thì còn có thể bỏ luôn trạng thái
Unresumed, nhưng điều này không thể thay đổi vì bảo đảm rằng future không làm gì trước khi được poll
- Nếu có thể truy vấn thuộc tính của future đang được poll thì sẽ có thêm cơ hội inline tối ưu
- Ví dụ, nếu biết future luôn trả về ready ở lần poll đầu tiên, future gọi nó sẽ không cần tạo trạng thái cho điểm await đó
- Nếu áp dụng đệ quy kiểu tối ưu này, rất nhiều future có thể được gộp thành state machine đơn giản hơn nhiều
- Với cấu trúc hiện tại của
rustc, có vẻ mỗi async block được biến đổi riêng lẻ và dữ liệu liên quan không được giữ lại sau đó, nên không thể thực hiện các truy vấn như vậy
- Inline future vẫn chưa được thử nghiệm, nhưng được kỳ vọng sẽ mang lại lợi ích lớn cho cả kích thước binary lẫn hiệu năng
Gộp các trạng thái giống nhau
- Với mỗi điểm await trong async block, state machine sẽ có thêm một trạng thái
- Mã như sau là tự nhiên, nhưng vì cả hai nhánh đều await cùng một async function nên tạo ra 2 trạng thái giống nhau
CommandId::A => send_response(123).await
CommandId::B => send_response(456).await
- Trong trường hợp này,
CoroutineLayout sẽ có _s0, _s1 lưu cùng một kiểu coroutine của send_response, đồng thời tạo hai trạng thái Suspend0, Suspend1
- MIR của hàm này dài 456 dòng, và nhiều basic block thực chất bị lặp lại
- Nếu refactor thủ công để trước tiên chỉ tính giá trị phản hồi, rồi chỉ gọi một lần
send_response(response).await, các trạng thái trùng lặp sẽ biến mất
CommandId::A là 123
CommandId::B là 456
- sau đó
send_response(response).await
- Sau khi refactor,
CoroutineLayout chỉ còn một future được lưu và chỉ còn một trạng thái Suspend0
- Tổng độ dài MIR giảm xuống còn 302 dòng, và sự trùng lặp biến mất
- Vì vậy, một pass tối ưu hóa tìm các đường đi mã và trạng thái giống nhau để gộp lại có vẻ rất hữu ích
- Tối ưu này có khả năng kết hợp rất tốt với pass inline future
Liên kết thử nghiệm và benchmark bổ sung
Kêu gọi hỗ trợ cho Project Goal
1 bình luận
Ý kiến trên Lobste.rs
Đây là một bài viết mang tính xây dựng hơn tôi tưởng rất nhiều khi chỉ nhìn tiêu đề
Mong rằng những ai muốn làm việc này sẽ nhận được sự hỗ trợ cần thiết
Thật tốt khi thấy vấn đề này đang được xử lý. Tôi đã vài lần đọc những bài viết nói rằng hiện tại rustc đang chuyển quá nhiều mã sang LLVM và chỉ mong trình tối ưu hóa xử lý hết, đặc biệt bài này còn đang kêu gọi tài trợ cho công việc đó
Trời ạ, hóa ra tôi đã ngốc thật
Tôi luôn nghĩ async về bản chất sẽ luôn “cồng kềnh” ở một mức nào đó vì nó cần runtime, theo dõi tác vụ và polling để xác nhận hoàn thành. Phần overhead đó đâu thể bằng 0
Tôi vẫn cho rằng “trừu tượng không chi phí” ở đây là nói về tính năng ngôn ngữ, tách biệt với runtime được thêm vào
Tôi thậm chí chưa từng nghĩ đến việc xem rustc thực sự tạo ra gì trước khi chuyển cho LLVM
Dành cho những ai chưa quen với async Rust:
Điều này thực sự đúng. Ngay cả một cây lồng nhiều lời gọi async, sau tối ưu hóa tối đa, cũng sẽ được cô đặc thành một struct duy nhất với máy trạng thái bên trong. Cách làm này thực sự rất thông minh
Nếu đi tới trường hợp này trong bản build phát hành thì có tạo ra một kiểu deadlock không? Hay cũng có thể gây rò rỉ do các tác vụ cứ chờ những công việc luôn ở trạng thái
Pending?Không thể poll sai bằng
.awaitTôi có vài suy nghĩ:
panic=unwind. Ngoài một số test harness, tôi gần như chưa thấy lợi ích nào của nó đủ để bù lại chi phí so vớipanic=abort. Ngay cả test harness, ít nhất trên Linux, có lẽ cũng có thể áp dụng lựa chọn tương tự bằng cách dùngclonetheo kiểu khá khó hiểu đểwaittrên thread thực thi thay vìpthread_join. Có thể tôi sai ở điểm nàyCó ai khác cũng vừa thấy link bị hỏng không?
Sửa: bài blog hiện lên khoảng nửa giây rồi chuyển sang trang 404
Sửa 2: Tôi vào danh sách bài viết của blog rồi bấm thử vài thứ, và ngay cả khi mở đúng bài đó trong danh sách cũng vẫn ra trang 404. Làm sao người ta có thể phá hỏng một blog là trang tĩnh, hoặc ít nhất đáng ra phải là vậy, theo cách này được chứ?
Tham khảo thêm, tôi đã thử làm theo đúng các bước tái hiện có vẻ giống vậy nhưng hoàn toàn không gặp 404. Tôi thử trên cả điện thoại lẫn máy tính, với JavaScript bật và tắt. Vì thế có thể hiện tượng họ gặp phức tạp hơn vẻ bề ngoài