3 điểm bởi GN⁺ 2025-10-05 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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ành None), 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ề Result và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_buf có 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

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 reserve sẽ giữ nguyên thứ tự chờ đặt chỗ
  • Dùng task: nếu chạy future thành task bằng tokio::spawn v.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)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

 
GN⁺ 2025-10-05
Ý kiến trên Hacker News
  • Tôi thấy ví dụ đặt timeout cho send/recv rất thú vị; hóa ra trong những ngôn ngữ mà future được thực thi ngay mà không cần polling khi chưa chạy, tình huống lại có thể xảy ra theo hướng ngược lại. Nếu đặt timeout cho send thì sau timeout thông điệp vẫn có thể được gửi, nhưng không bị thất lạc nên vẫn an toàn; còn nếu đặt timeout cho recv thì có thể xảy ra trường hợp đã đọc thông điệp từ channel rồi nhưng nhánh timeout lại được chọn, khiến thông điệp bị vứt luôn nên có thể không an toàn. Cách giải quyết là chọn giữa timeout hoặc trạng thái 'có thứ gì đó khả dụng' từ channel, và trong trường hợp sau thì dùng peek để xem dữ liệu một cách an toàn
    • Tôi đang nghĩ có lẽ đây chính là cốt lõi của cancellation-safety
    • Tôi nghĩ đây là một chỉ ra rất hay
  • Tôi muốn giới thiệu vài tài liệu mình đã viết về chủ đề này
    • Tôi đã viết một proposal vào năm 2020 về việc async function phải luôn chạy đến hết, có kèm graceful cancellation, và tôi vẫn nghĩ đến nay chưa có ý tưởng nào tốt hơn liên kết proposal
    • Cũng có một đề xuất về unified cancellation cho cả sync và async Rust ("A case for CancellationTokens") liên kết gist
    • Và cũng đã có một ví dụ triển khai thực tế cho các ý tưởng trên min_cancel_token
  • Tôi không thật sự hiểu việc futures bị hủy thì có gì là vấn đề; futures không phải task, và bài viết đó cũng tự thừa nhận điểm này. Nếu vậy thì future không chạy đến hết vốn chẳng phải là chuyện bình thường sao, và tôi không hiểu vì sao tình huống đó lại là vấn đề. Bài viết gọi ví dụ là future "cancel unsafe", nhưng tôi nghĩ cốt lõi là sự lệch nhau giữa kỳ vọng và thực tế
    • Ví dụ 1 là một nhánh trong try_join bị cancel vì lỗi
    • Ví dụ 2 là dữ liệu không được ghi khi bị hủy
      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
    • Đúng vậy! Thực tế ở Oxide đã từng phát sinh rất nhiều bug vì chuyện này. Khi đã hiểu đầy đủ rằng futures là thụ động và có thể bị hủy ở bất kỳ điểm await nào, thì phần còn lại chỉ là các kỹ thuật chi tiết
  • Tôi đã nghe bài nói này ở RustConf và thấy cực kỳ thú vị; sự phân biệt giữa cancel safety và cancel correctness thực sự rất hữu ích. Thật tuyệt khi bài nói cũng đã được đưa thành blog post; bài nói thì hay, nhưng khi được viết lại thành blog thì dễ chia sẻ và tra cứu hơn nhiều
    • Tôi thích cách diễn đạt "cancel correctness" vì nó đặt ngữ cảnh của cancellation rất đúng chỗ, còn thuật ngữ "cancel safety" thì tôi không thích lắm. Nó không thật sự khớp với khái niệm safety trong Rust, và còn tạo cảm giác phán xét không cần thiết. Safe/unsafe nghe như thể có cái nào tốt hơn, xấu hơn, trong khi tính mong muốn của hành vi khi bị cancel còn tùy từng tình huống. Ví dụ, future dùng để chờ một spawned task thường được gọi là "cancellation safe", nhưng nếu task vẫn tiếp tục chạy khi drop thì có thể tích lũy công việc không cần thiết, đồng thời chiếm lock hay port gây ra vấn đề. Ngược lại, một spawn handle dừng task khi bị drop thì thường bị xem là "cancellation unsafe", nhưng lại là một pattern rất quan trọng cho việc cleanup của dependent task
    • Tôi cũng thấy bài blog dễ đọc và hay hơn, hoàn toàn đồng ý
  • Tôi đặc biệt thấy thú vị với nội dung ở https://sunshowers.io/posts/cancelling-async-rust/#the-pain-of-tokio-mutexes, tôi nghĩ mình cũng rất dễ mắc phải kiểu sai lầm đó
    • Dù tôi là lập trình viên Go thì phần này vẫn rất hữu ích. Rust có công cụ hỗ trợ chặt chẽ hơn, nhưng với goroutines, channel, select và các concurrency primitive khác thì trong Go cũng rất dễ rơi vào cùng một cái bẫy
  • Ở ví dụ đầu tiên, tôi không rõ hành vi mong muốn là gì. Nếu queue đầy thì phải chọn giữa drop, chờ, hoặc panic. Đặt timeout cho thao tác blocking chủ yếu là để phát hiện deadlock. Đoạn code nói rằng "không phải mọi thông điệp đều đi vào channel", nhưng điều đó là hiển nhiên khi thiếu tài nguyên. Vậy mục tiêu là gì? Tắt chương trình cho gọn gàng? Điều đó trong môi trường thread vốn đã khá khó, và trong async cũng không dễ. Use case thực tế là khi trao đổi thông điệp với phía từ xa và bên kia ngắt kết nối thì phải dọn dẹp trạng thái phía mình
    • Lý tưởng nhất là giữ thông điệp trong buffer cho đến khi channel có chỗ trống; phần này được bàn trong nửa sau bài nói, ở mục "What can be done"
    • Trong ví dụ đã có câu trả lời rồi: đoạn code ghi log khi 5 giây không có chỗ trống là để chẩn đoán, nhưng lại tiềm ẩn nguy cơ dẫn đến mất dữ liệu. Hơi mang tính dàn dựng một chút, nhưng thực tế rất dễ bị gắn khắp hệ thống như một đoạn xử lý kiểu "sao không chạy nhỉ?"
    • Nhân tiện, tác giả bài viết này dùng đại từ they/she about
  • Luôn phải nhớ rằng await lúc nào cũng là một điểm có thể return tiềm tàng; tốt nhất nên tránh đặt await ở giữa hai hành động bắt buộc phải được thực thi cùng nhau một cách atomic
    • Tôi tò mò không biết điều này gây ra vấn đề trong thực tế như thế nào, ví dụ,
      async fn a() {
        b().await
      }
      async fn b() {
        c().await
        d().await
      }
      async fn c() {}
      async fn d() {}
      
      Trong đoạn code này, vấn đề d không được gọi sẽ xảy ra kiểu gì? Là vì bị cancel ở c? Hay là do ở a có chuyện gì đó xảy ra từ phía trên?
    • Vậy cái này có vẻ hơi nguy hiểm đúng không? Tất nhiên chắc là khó tránh khỏi, nhưng có thể có tình huống giữa hai await trong một "critical section" thì bị tạm dừng, trong khi cuối cùng vẫn bắt buộc phải chạy tiếp. Ví dụ, nếu vừa thay đổi DB vừa phải ghi audit log và cả hai đều bắt buộc phải được thực thi, thì liệu ngoài cách ghi chú do not cancel ra còn giải pháp nào khác không?
  • Future trong Rust cũng giống như move semantics trong C++: sau khi Future kết thúc thì nó có thể rơi vào trạng thái không còn hợp lệ. Vì Rust dùng thiết kế stackless coroutine, nên khi tự triển khai cấu trúc async dựa trên poll thì phải tự quản lý trạng thái trong struct. Tất cả những điều này đều là các cạm bẫy thường gặp. Và gần đây trong async Rust, cancellation là một biến số mới trong quản lý trạng thái. Khi tôi phát triển thư viện mea (Make Easy Async), nếu cancel safety không phải chuyện hiển nhiên thì tôi luôn ghi rõ trong tài liệu; và tôi còn nhớ có một trường hợp async cancellation bất cẩn đã gây ra vấn đề cho IO stack mea trường hợp trên reddit
  • Bài nói này thực sự rất hay! Là người hoàn toàn mới, tôi ước gì trong SOP người ta nhấn mạnh ngay từ đầu rằng Future không thể bị cancel. .await sở hữu future nên không thể drop(), lại thêm chuyện future là lazy nên sau .await thì không rõ cancellation diễn ra như thế nào. Sau đó tôi có tìm hiểu select!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
    • Câu hỏi: ở đây SOP có nghĩa là gì vậy?
  • Đúng là quá đúng lúc; hôm nay tôi vừa định thêm vào doc comment của một hàm mới câu "hàm này là cancel safe", và rồi lại suy nghĩ về tất cả những điều này. Mong là async drop sớm trở thành hiện thực
    • Tôi tò mò về hàm đó, bạn có thể giải thích thêm một chút không?