Xử lý hủy trong Rust bất đồng bộ
(sunshowers.io)- Xử lý hủy trong môi trường Rust bất đồng bộ rất tiện lợi, nhưng nếu xử lý sai có thể dẫn đến lỗi khó lường và nhiều khó khăn
- Trong Rust đồng bộ, thường cần kiểm tra cờ tường minh hoặc kết thúc tiến trình, nhưng trong Rust bất đồng bộ việc hủy có thể thực hiện rất dễ chỉ bằng cách drop future
- Tính an toàn khi hủy (cancel safety) và tính đúng đắn khi hủy (cancel correctness) là hai khái niệm khác nhau; việc hủy một future có thể gây ra vấn đề cho toàn bộ hệ thống
- Các mẫu vấn đề chính liên quan đến hủy gồm Tokio mutex, macro select, try_join và các lỗi khi sử dụng future
- Không có lời giải hoàn hảo, nhưng có thể giảm bớt các vấn đề do hủy gây ra bằng cách dùng API an toàn với hủy, pin future và tách task
Giới thiệu
- Bài viết này dựa trên nội dung trình bày tại RustConf 2025 về xử lý hủy (cancellation) trong Rust bất đồng bộ
- Trong các ví dụ mã bất đồng bộ Rust phổ biến, khi thêm timeout vào vòng lặp nhận hoặc gửi thông điệp, thường sẽ phát hiện ra vấn đề thất lạc thông điệp
- Bài viết đề cập đến các vấn đề hủy và các lỗi thực tế phát sinh khi sử dụng async Rust trong những hệ thống quy mô lớn ngoài đời thật như tại Oxide Computer Company
- Bài viết gồm 3 phần: 1) khái niệm về hủy, 2) phân tích hủy, 3) các giải pháp thực tế
- Tác giả từng trải nghiệm cả ưu điểm lẫn khó khăn của Rust bất đồng bộ thông qua việc phát triển signal handling trong Rust, cargo-nextest, v.v.
1. Hủy là gì?
Ý nghĩa của hủy
- Hủy (cancellation) là tình huống một tác vụ bất đồng bộ đã được khởi động nhưng bị dừng giữa chừng
- Ví dụ: có thể hủy giữa chừng trong các tác vụ như tải xuống dung lượng lớn, yêu cầu mạng, đọc một phần tệp
Cách hủy trong Rust đồng bộ
- Thông thường có các cách như kiểm tra định kỳ trạng thái hủy bằng cờ nguyên tử, dùng ngoại lệ đặc biệt (panic), hoặc cưỡng ép kết thúc toàn bộ tiến trình
- Một số framework (như Salsa) dùng panic payload, nhưng cách này không hoạt động trên mọi nền tảng của Rust (đặc biệt là môi trường Wasm)
- Việc cưỡng ép chỉ kết thúc một thread là không được phép do các ràng buộc về an toàn của Rust và cấu trúc mutex
- Tóm lại, trong Rust đồng bộ không tồn tại một giao thức hủy phổ dụng và an toàn
Rust bất đồng bộ: Future là gì?
- Future là một máy trạng thái (state machine) do trình biên dịch Rust tạo ra, và về bản chất chỉ là dữ liệu đơn thuần trong bộ nhớ
- Nó không chạy ngay khi được tạo ra, mà chỉ tiến triển khi gọi await hoặc poll
- Future trong Rust là bị động (inert); nếu không có poll/await tường minh thì nó sẽ không xử lý bất kỳ công việc nào
- Điều này đối lập với Go/JavaScript/C#, nơi future thường bắt đầu chạy ngay khi được tạo
Giao thức hủy trong Rust bất đồng bộ
- Hủy Future đơn giản là drop nó hoặc không tiếp tục gọi poll/await nữa
- Vì là state machine, Future có thể bị bỏ đi bất cứ lúc nào
- Trong Rust bất đồng bộ, việc hủy vừa rất mạnh vừa rất dễ áp dụng
- Nhưng cũng vì quá dễ, future có thể bị drop một cách âm thầm, kéo theo việc hủy dây chuyền các future con (theo mô hình ownership)
- Chính đặc tính này khiến việc hủy trở thành một hiện tượng không cục bộ (non-local), ảnh hưởng đến toàn bộ chuỗi gọi
2. Phân tích việc hủy
Tính an toàn khi hủy và tính đúng đắn khi hủy
- Tính an toàn khi hủy (cancel safety): thuộc tính cho phép một future riêng lẻ bị hủy an toàn mà không gây tác dụng phụ
- Ví dụ: future sleep của Tokio là an toàn khi hủy
- Ngược lại, thao tác send của Tokio MPSC có nguy cơ làm thất lạc thông điệp khi bị drop (không an toàn khi hủy)
- Tính đúng đắn khi hủy (cancel correctness): thuộc tính mang tính toàn cục của cả hệ thống, bảo đảm các đặc tính cốt lõi vẫn được giữ khi xảy ra hủy
- Nếu trong hệ thống không có future không an toàn khi hủy, thì không có vấn đề về tính đúng đắn
- Chỉ khi một future không an toàn khi hủy thực sự bị hủy thì vấn đề mới phát sinh
- Nếu việc hủy gây mất dữ liệu, vi phạm bất biến hoặc bỏ sót bước dọn dẹp thì đó là vi phạm tính đúng đắn khi hủy
Khó khăn với Tokio mutex
- Tokio mutex hoạt động bằng cách lấy khóa, điều chỉnh dữ liệu rồi giải phóng khóa
- Vấn đề: nếu trong vùng khóa, trạng thái bị tạm thời làm sai lệch (ví dụ đổi
Option<T>thànhNone), rồi đi qua một điểm await và future bị hủy, dữ liệu có thể bị mắc kẹt ở trạng thái sai - Trong công việc thực tế (ví dụ quản lý trạng thái sled tại Oxide), đã có những trạng thái không ổn định phát sinh do hủy tại các điểm await
- Điều này cho thấy việc hủy trong quản lý trạng thái của mã bất đồng bộ có thể trở thành nguyên nhân của các lỗi rất nguy hiểm
Các mẫu phát sinh hủy và ví dụ
- Gọi future nhưng thiếu
.await: Rust sẽ cảnh báo với future không được dùng, nhưng nếu nhận giá trị trả vềResultvào_thì có thể không có cảnh báo (cần lint mới nhất của Clippy) - Các phép try như try_join: khi một future thất bại thì các future còn lại sẽ bị hủy theo (đã từng dẫn tới lỗi trong logic dừng dịch vụ thực tế)
- Macro select: sau khi xử lý song song nhiều future, tất cả future chưa hoàn thành sẽ bị hủy (đặc biệt dễ gây thất lạc dữ liệu trong vòng lặp select)
- Các mẫu này có được nhắc đến trong tài liệu, nhưng trên thực tế việc hủy bất đồng bộ có thể xảy ra ngầm ở rất nhiều nơi
3. Có thể làm gì?
- Hiện vẫn chưa có lời giải căn cơ và hoàn chỉnh cho các vấn đề liên quan đến tính đúng đắn khi hủy
- Tuy vậy, trong thực tế có thể giảm khả năng phát sinh lỗi do hủy bằng những cách sau
Tái cấu trúc theo future an toàn với hủy
- Ví dụ với MPSC send: tách bước đặt chỗ (
reserve) và bước gửi thực tế (send) để đạt được mức an toàn một phần trước việc hủy- Hủy ở bước đặt chỗ sẽ không làm thất lạc thông điệp liên quan
- Sau khi đã lấy được permit thì có thể gửi mà không phải lo việc hủy
- Với AsyncWrite::write_all: việc ghi toàn bộ buffer bằng write_all không ổn định trước hủy, còn
write_all_bufcó thể dùng con trỏ buffer để theo dõi tiến độ khi bị hủy- Trong vòng lặp, có thể dùng
write_all_bufđể tiếp tục an toàn từ trạng thái tiến độ dở dang
- Trong vòng lặp, có thể dùng
Vận hành future theo cách tránh hủy
- Pin future: trong các vòng lặp như select, có thể cố định future bằng pin để poll nó qua tham chiếu mà không bị hủy
- Ví dụ: tái sử dụng future
reservesẽ giữ nguyên thứ tự chờ đặt chỗ
- Ví dụ: tái sử dụng future
- Dùng task: nếu chạy future thành task bằng
tokio::spawnv.v., thì dù drop handle, bản thân task vẫn do runtime quản lý riêng và không bị cưỡng ép hủy- Ví dụ trong máy chủ HTTP Oxide Dropshot, mỗi request được chạy như một task riêng; ngay cả khi kết nối của client bị ngắt, việc xử lý request vẫn được bảo đảm hoàn tất
Giải pháp có hệ thống?
- Ở cấp độ safe Rust hiện tại vẫn còn nhiều hạn chế, nhưng có một số hướng tiếp cận đang được thảo luận
- Async drop: cho phép chạy mã dọn dẹp bất đồng bộ khi future bị hủy
- Linear types: buộc chạy một số đoạn mã nhất định khi drop, hoặc đánh dấu một số future là không thể hủy
- Tuy nhiên, tất cả các hướng này đều có khó khăn đáng kể trong triển khai
Kết luận và khuyến nghị
- Cần nhận thức rõ ở mức nền tảng rằng Future là bị động (passive)
- Cần nắm vững các khái niệm tính an toàn khi hủy (cancel safety) và tính đúng đắn khi hủy (cancel correctness)
- Cần nhận diện các trường hợp lỗi hủy điển hình và các mẫu mã liên quan để chuẩn bị sẵn chiến lược ứng phó
- Một số khuyến nghị thực tế
- Tránh dùng Tokio mutex và cân nhắc các lựa chọn thay thế
- Thiết kế hoặc sử dụng API hoàn tất từng phần hay API an toàn với hủy
- Với các future không an toàn khi hủy, cần chọn cấu trúc mã bảo đảm chúng chắc chắn hoàn tất
- Ngoài ra, cũng nên xem xét thêm các chủ đề nâng cao như cooperative cancellation, mô hình actor, structured concurrency, panic safety, mutex poisoning
- Tài liệu liên quan có thể tham khảo tại sunshowers/cancelling-async-rust
Cảm ơn đã đọc. Tác giả gửi lời cảm ơn tới các đồng nghiệp tại Oxide đã xem lại bài trình bày và tài liệu tham khảo, cũng như cung cấp phản hồi
1 bình luận
Ý kiến trên Hacker News
Tất cả những trường hợp này đều là hành vi hiển nhiên khi context bị cancel và công việc không hoàn tất. Nếu công việc bắt buộc phải xong thì chỉ cần tách nó ra thành một task độc lập. Tôi tự hỏi có phải mình đang bỏ lỡ một nuance quan trọng nào đó không; theo tôi hiểu thì việc work biến mất vì cancellation vốn là chủ đích thiết kế của futures, nên mong ai đó chỉ lại xem chính xác vấn đề nằm ở đâu
.awaitsở hữu future nên không thểdrop(), lại thêm chuyện future là lazy nên sau.awaitthì không rõ cancellation diễn ra như thế nào. Sau đó tôi có tìm hiểuselect!vàAbortable()nên đã hiểu ra, nhưng nếu sau này còn thuyết trình về chủ đề này thì chỉ cần callout phần đó ngay đầu bài là hoàn hảo