- Futurelock là hiện tượng deadlock xảy ra khi một task quản lý đồng thời nhiều Future, trong đó một Future cần tài nguyên của Future khác nhưng không còn được poll nữa
- Dễ xảy ra khi trong
tokio::select! dùng đồng thời Future được tham chiếu (&mut future) và nhánh có chứa await
- Vấn đề này bắt nguồn từ việc không tách bạch trách nhiệm giữa task và Future; cùng một task chờ cả hai Future nhưng chỉ poll một phía, khiến hệ thống rơi vào trạng thái đình trệ
- Các dạng tương tự cũng có thể xuất hiện với FuturesUnordered, bounded channel, Stream
- Điểm mấu chốt để thiết kế bất đồng bộ an toàn là tách Future thành task riêng bằng
tokio::spawn hoặc tránh dùng await bên trong select
Khái niệm và ví dụ về Futurelock
- Futurelock xảy ra khi Future A đang giữ tài nguyên mà Future B cần dùng, nhưng task phụ trách cả hai Future không còn poll A nữa
- Trong ví dụ mã,
tokio::select! chờ đồng thời &mut future1 và sleep; nếu sleep hoàn tất trước thì future1 vẫn bị kẹt trong trạng thái chờ khóa
- Sau đó
future3 yêu cầu cùng một lock, nhưng lock đã được cấp cho future1 và future1 không còn được poll, khiến chương trình bị treo vĩnh viễn
Tương tác giữa tokio::select! và Mutex
tokio::sync::Mutex là lock công bằng (fair), cấp lock theo đúng thứ tự chờ
- Lock được chuyển cho
future1, nhưng task lúc này chỉ còn poll future3, nên future1 không thể chạy
- Mutex chỉ có nhiệm vụ đánh thức task đang chờ tiếp theo, chứ không biết Future nào thực sự đang được poll
Nguyên nhân phổ biến của Futurelock
- Cấu trúc phụ thuộc vòng tròn trong đó task T chờ Future F1, F1 phụ thuộc vào F2, còn F2 lại cần T tiếp tục poll
- Thường xảy ra trong các tình huống sau
- Dùng
&mut future trong tokio::select! rồi thực hiện await ở nhánh khác
- Hoàn tất một số Future trong
FuturesOrdered hoặc FuturesUnordered rồi tiếp tục làm công việc bất đồng bộ khác
- Hành vi tương tự trong Future được tự cài đặt thủ công
Các trường hợp trong Stream và cấu trúc khác
- Với
FuturesOrdered hoặc FuturesUnordered, Futurelock có thể xảy ra khi lấy một Future ra rồi chờ một Future khác sử dụng tài nguyên liên quan đến nó
join_all không gây ra Futurelock vì nó tiếp tục poll tất cả Future
Trường hợp thực tế và gỡ lỗi
- Trong trường hợp Omicron#9259, mọi Future truy cập cơ sở dữ liệu đều rơi vào Futurelock khiến các yêu cầu HTTP chờ vô hạn
- Việc gửi qua kênh
mpsc bị chặn, nhưng phía nhận lại được xác nhận là đang trống, nên rất khó xác định nguyên nhân
- Khi gỡ lỗi, các công cụ như
tokio-console có thể hữu ích, nhưng trong đa số trường hợp việc lần ra nguyên nhân là cực kỳ khó
Hướng dẫn phòng tránh Futurelock
- Khi một task poll nhiều Future, cần tránh dừng poll những Future đã bắt đầu chạy
- Nếu có thể, hãy spawn Future thành task mới để chúng chạy độc lập
- Truyền
JoinHandle vào tokio::select! sẽ loại bỏ nguy cơ Futurelock
- Những điểm cần chú ý khi dùng
tokio::select!
- Không dùng đồng thời
&mut future và await
- Nếu cả hai điều kiện cùng xuất hiện thì nguy cơ Futurelock rất cao
- Khi dùng
Stream, hãy tận dụng JoinSet để chạy từng Future trong task riêng
- Tăng dung lượng của
bounded channel không phải là giải pháp gốc rễ
- Thay vào đó có thể dùng
try_send() để tránh blocking
Các kiểu né tránh sai lầm
- Tăng dung lượng channel lên vô hạn là cách làm thiếu thực tế và gây tác dụng phụ như tăng độ trễ, tăng bộ nhớ
- Cố gắng loại bỏ phụ thuộc giữa các Future là cách mong manh, vì trong quá trình bảo trì có thể phát sinh phụ thuộc mới
- Cách an toàn duy nhất là tách task bằng
tokio::spawn
Cải tiến trong tương lai và lưu ý bảo mật
- Có đề xuất để Clippy lint cảnh báo khi dùng
&mut future trong tokio::select! hoặc khi có await bên trong
- Futurelock có thể bị khai thác dưới dạng tấn công từ chối dịch vụ (DoS), nhưng về bản chất đây là hành vi bất thường nên cần được phòng tránh
1 bình luận
Ý kiến trên Hacker News
Lướt qua tài liệu thì thấy đây là một báo cáo rất minh bạch và kỹ lưỡng
Đặc biệt phần chú thích rất thú vị
Điều gây ấn tượng là có nhiều người không biết đến vấn đề cancellation safety của Rust, và khả năng cao kiểu vấn đề này đang hiện diện rộng khắp trong Omicron
Thật mỉa mai khi người ta chọn Rust để tránh các vấn đề an toàn bộ nhớ của C, nhưng lần này lại phát sinh lỗi cancellation rất khó bắt ở runtime
Điều đặc biệt gây bức bối là lập trình viên phải tự đảm bảo các thuộc tính động mà compiler không thể hỗ trợ
Có vẻ ngay cả trong mô hình đồng thời của Rust vẫn tồn tại khả năng deadlock
Tưởng như kiểu quản lý tài nguyên theo RAII sẽ ngăn được chuyện này, nhưng thực tế lại không phải vậy, khá khó hiểu
Tôi muốn biết đây chỉ là một ngẫu nhiên của cách hiện thực, hay là giới hạn mang tính cấu trúc của mô hình Rust/Tokio
Điều này trông giống như một biến thể tinh vi của deadlock được mô tả trong bài viết FuturesUnordered của withoutboats
Khi dùng tính đồng thời “intra-task”, phải cẩn thận để không future nào rơi vào trạng thái đói tài nguyên
Về cơ bản, spawn task vẫn an toàn hơn, và nếu xử lý timeout bằng
tokio::select!thì nên quản lý tất cả pending future ngay bên trong đóTôi thật sự không khuyến nghị
FuturesUnorderedtrừ khi đã kiểm thử mọi edge caseNghe khá giống vấn đề priority inversion
Trong OS, nếu một thread ưu tiên thấp đang giữ lock và thread ưu tiên cao phải chờ, thì thread ưu tiên thấp sẽ được thừa kế ưu tiên để tiếp tục chạy
Tôi tò mò liệu Tokio có thể áp dụng khái niệm tương tự không — ví dụ nếu một future không thể chạy đang giữ Mutex, thì có thể poll future đó thay nó
Tuy nhiên, để phát hiện trạng thái “không thể chạy” như vậy có lẽ sẽ tốn overhead đáng kể
Cách tiếp cận này có thể khả thi ở cấp độ task trong Tokio
Nhưng không thể áp dụng cho future bên trong task
Lý do là thiết kế cơ bản của async Rust là “futures are inert” — future chỉ là một struct đơn thuần, runtime không biết bên trong nó có gì
Runtime chỉ biết ở cấp task, hoàn toàn không theo dõi trạng thái của future bên trong
Async của Rust dùng mô hình stackless coroutine, nên không an toàn nếu tùy ý tiếp tục thực thi một hàm async đang chạy dở
Mô hình stackless lưu trạng thái cục bộ trên một stack dùng chung, nên chỉ an toàn khi thực thi theo thứ tự LIFO
Vì thế mới cần coloring, và không thể yield tự do như stackful coroutine
Đoạn code này tạo cảm giác quá phức tạp
Nó có vẻ dài dòng hơn rất nhiều so với khi viết bằng Erlang, Elixir, Go, thậm chí cả C
Tôi nghĩ chuyện này tương tự một deadlock hai lock cơ bản
Hàng đợi chờ của Mutex trong Tokio và bộ lập lịch task đan vào nhau để tạo ra trạng thái bế tắc
Nếu là OS Mutex thì có thể đánh thức thread đang chờ khác để giải quyết, nhưng trong async Rust điều đó khó hơn vì cấu trúc state machine của future
Có thể xử lý bằng cách poll tuần tự các future trong hàng đợi chờ, nhưng như vậy lại có thể gây ra tác dụng phụ ngoài dự kiến
Tôi từng có kinh nghiệm xử lý những vấn đề kiểu này trong hệ sinh thái async Rust
Nếu không cho phép dùng reference trong
select!thì có thể tránh được, nhưng khi đó sẽ không thể dùng mẫu lặpselect!nhiều lần mà vẫn giữ nguyên vị trí trong queueCùng với vấn đề cancellation, đây có thể trở thành cái bẫy khó lường ngay cả với chuyên gia Rust
Dù vậy, nó vẫn ít gây bất ngờ hơn rất nhiều so với code dựa trên callback
Đúng vậy, sau khi đội chúng tôi phân tích deadlock này, chúng tôi cũng bàn xem “đáng ra có thể ngăn chuyện này thế nào?”, nhưng cuối cùng đi đến kết luận là không phải lỗi của riêng ai cả
Mọi primitive của Tokio đều hoạt động đúng như thiết kế, code cũng được viết đúng, nhưng sự tương tác giữa chúng lại tạo ra một deadlock ngoài dự kiến
Cấm
&mut futuretrongselect!có thể ngăn được, nhưng đổi lại sẽ chặn rất nhiều đoạn code hoàn toàn hợp lệCuối cùng chỉ đành đi tới kết luận chua chát là đây là thứ “phải tự cẩn thận mà tránh”
Thảo luận liên quan còn tiếp tục ở bình luận này
Nếu
select!trả lại các future không được chọn thay vì drop chúng, thì có thể tránh mất trạng tháiTuy vậy, cách này khá bất tiện và không phải lời giải tận gốc
Nguyên nhân thực sự, như giải thích trong chuỗi thảo luận này, nằm ở sự chưa hoàn thiện của cơ chế xử lý cancellation
Câu hỏi trong FAQ “future1 không bị cancel sao?” khá thú vị
Cancellation có hai giai đoạn — ngừng poll và drop
Trong ví dụ này, việc drop bị trì hoãn nên guard vẫn bị giữ lại, từ đó sinh ra tác dụng phụ
Tôi tự hỏi liệu có thể đảm bảo hai hành vi này luôn diễn ra đồng thời hay không
Tôi muốn hỏi các nhà thiết kế Rust — vì sao họ chọn mẫu async thay vì mô hình actor
Dùng Erlang cho cảm giác mô hình actor gọn gàng và an toàn hơn hẳn
JS thì do cấu trúc ngôn ngữ nên gần như buộc phải dùng async, nhưng Rust là ngôn ngữ mới, nên tôi tò mò vì sao lại chọn hướng đó
Thiết kế async của Rust chủ yếu xuất phát từ nhu cầu hỗ trợ môi trường embedded
Nó phải hoạt động được cả khi không có malloc hay thread, nên mô hình actor là không khả thi
Dù vẫn có thể viết code kiểu actor với Tokio, nhưng đó không phải cách tự nhiên nhất
Một lý do khác là hiệu năng
Mô hình actor có chi phí sao chép message khá lớn, còn Rust là ngôn ngữ hệ thống coi trọng hiệu năng nên đã theo đuổi zero-cost abstraction bằng async state machine
Erlang hay Go đơn giản là những ngôn ngữ chấp nhận các đánh đổi khác
Rust không muốn chấp nhận overhead khi gọi C FFI, nên mô hình dựa trên green thread đã bị loại bỏ
async/awaitđược biên dịch thành state machine nên có overhead thấpGo thời kỳ đầu cũng từng gặp vấn đề starvation tương tự vì chưa có preemption, rồi sau đó scheduler mới xử lý được
Rốt cuộc mỗi ngôn ngữ đều có mục tiêu và ràng buộc khác nhau
Tôi cũng bất ngờ khi Oxide lại chọn async
Nó quen thuộc hơn trong môi trường embedded hay HTTP server, nhưng tôi không nghĩ một công ty hệ thống như Oxide lại dùng sâu đến vậy
Điều tôi chưa hiểu khi đọc tài liệu là tại sao main thread lại được đánh thức thay vì future đang giữ lock
Nếu lock là công bằng thì đáng lẽ future1 phải được đánh thức, nên tôi thắc mắc vì sao runtime lại chọn thread khác
Bài viết thực sự rất thú vị
Ví dụ code cũng rõ ràng, và tuy việc tìm ra loại bug này giống như ác mộng, nhưng khi tìm ra rồi thì có cảm giác các mảnh ghép của câu đố cuối cùng cũng khớp với nhau
Cảnh Eliza, Sean, John và Dave cùng brainstorm để tìm ra nguyên nhân thật sự rất ấn tượng
Chúng tôi dự định phát hành một tập podcast về nội dung này vào thứ Hai
Có thể xem video liên quan tại RFD 537 và liên kết sự kiện này
Việc Rust không đảm bảo mọi active task đều cùng tiến triển có vẻ là một thiết kế khó hiểu và dễ sinh bug
Nếu đưa vào structured concurrency như Trio của Python thì có lẽ sẽ trực quan hơn
Tôi tò mò liệu Rust có thể áp dụng mô hình như vậy không
Rust cũng có thể làm structured concurrency, nhưng chỉ ở cấp độ task
Future đơn thuần chỉ là struct phải được poll thì mới tiến triển, nên không hề có khái niệm “active future”
Có vẻ cứ spawn mọi thứ thành task là giải quyết được, nhưng làm vậy cũng sẽ chặn một số pattern hữu ích
Phân biệt giữa task và future là rất quan trọng
Future nếu không được poll thì sẽ không làm gì cả
Nếu định nghĩa cancellation là “trạng thái không còn được poll trước khi bị drop”, thì sẽ xuất hiện những future bị dừng khi vẫn đang giữ lock như trong vấn đề lần này
Theo triết lý RAII của Rust, người ta kỳ vọng việc dọn dẹp diễn ra khi drop, nhưng khi poll đã dừng thì ngay cả điều đó cũng chưa xảy ra
Gần đây tôi có cảm giác async của Rust đã được phát hành hơi vội
Pin hay một số phần cú pháp có thể được tinh chỉnh, nhưng cấu trúc căn bản thì không cần thay đổi
Nó vẫn chỉ đang ở giai đoạn “mới xong phần móng, chưa xây xong ngôi nhà”, chứ không phải kết quả của việc làm vội
Dù vậy, tôi nghĩ vẫn cần thêm những tầng thấp hơn như coroutine tổng quát