2 điểm bởi GN⁺ 2025-11-01 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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)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ênFuture 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 future1sleep; 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 future1future1 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 futureawait
    • 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

 
GN⁺ 2025-11-01
Ý 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ợ

    • Tôi tự hỏi liệu có cần một lớp trừu tượng cấp cao hơn để tránh những vấn đề như thế này không
      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ị FuturesUnordered trừ khi đã kiểm thử mọi edge case

  • Nghe 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ặp select! nhiều lần mà vẫn giữ nguyên vị trí trong queue
    Cù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 future trong select! 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ái
      Tuy 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 polldrop
    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ấp
      Go 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ông ty chúng tôi ghi hình tất cả các cuộc họp và phiên debug, nên đúng khoảnh khắc “mảnh ghép khớp vào nhau” đó đã được lưu lại trên video
      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 537liê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

    • Tôi cũng nghĩ vẫn còn nhiều điểm cần cải thiện, nhưng thiết kế nền tảng tự thân nó là một cơ sở rất tốt
      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