3 điểm bởi GN⁺ 2024-07-05 | 1 bình luận | Chia sẻ qua WhatsApp
  • Thư viện kết nối Rust connlib của Firezone quản lý kết nối mạng và đường hầm WireGuard, đạt được khả năng kiểm thử nhanh và độ tin cậy vận hành cao nhờ thiết kế sans-IO
  • Giao thức không thao tác trực tiếp với socket mà được triển khai như một máy trạng thái thuần túy; event loop gọi các API như handle_input, poll_transmit, handle_timeout, poll_timeout
  • Khi đẩy lựa chọn IO ra ngoài máy trạng thái, gánh nặng function colouring của Rust async giảm đi, còn việc chọn blocking IO, non-blocking IO hay một async runtime cụ thể được giao cho ứng dụng
  • Khi trừu tượng hóa socket và thời gian, có thể kiểm chứng thời gian trôi qua, mất gói, phản hồi bất thường chỉ bằng InstantTransmit, không cần cổng thật hay thời gian chờ thật
  • Đổi lại, bạn phải tự quản lý event loop nên có thể phát sinh các lỗi tinh vi; workflow tuần tự làm mã máy trạng thái dài hơn; và thư viện sans-IO trong hệ sinh thái Rust vẫn còn hạn chế

Cấu trúc sans-IO mà Firezone connlib lựa chọn

  • Firezone sử dụng Rust để xây dựng truy cập từ xa an toàn, có khả năng mở rộng trên Android, macOS và Linux
  • Ở trung tâm của mỗi ứng dụng là connlib, thư viện quản lý kết nối mạng và đường hầm WireGuard để bảo vệ lưu lượng
  • Rust stack của Firezone dùng tokio, tungstenite, boringtun, rustls..., nhưng cấu trúc bên trong khác với mã async Rust thông thường
    • Hầu như không có lệnh gọi tokio::spawn
    • Mọi giao tiếp được multiplex qua một UDP socket duy nhất
    • Các API như handle_timeout, poll_transmit, handle_input lặp lại ở nhiều tầng
  • Những đặc điểm này là dấu hiệu của thiết kế sans-IO, trong đó logic giao thức không trực tiếp thực hiện IO mà biểu diễn trạng thái và ý định nhập/xuất
  • Hệ sinh thái Python có trang tài liệu riêng cho sans-IO, còn trong Rust các thư viện sau sử dụng mẫu này
    • quinn: triển khai QUIC độc lập
    • quiche: triển khai QUIC của Cloudflare
    • str0m: triển khai WebRTC sans-IO

Async Rust và gánh nặng function colouring

  • Hàm async của Rust chỉ có thể được gọi bên trong một hàm async khác, tạo ra ràng buộc function colouring khiến toàn bộ chuỗi gọi cũng chuyển thành async
  • Ràng buộc này buộc tại thời điểm biên dịch rằng khả năng tạm dừng thực thi rồi tiếp tục sau đó là một phần trong hợp đồng API của hàm
  • Chỉ một hàm async ở sâu trong stack cũng có thể khiến các hàm khác trên đường gọi phải trở thành async để dùng .await
  • Công việc async thực sự thường xảy ra ở tầng thấp nhất của call stack
    • Ghi vào socket
    • Đọc tệp
    • Chờ thời gian trôi qua
  • Nhiều hàm async không tự thực hiện tác vụ bất đồng bộ, mà trở thành async vì phụ thuộc vào các hàm async khác
  • connlib của Firezone dùng ICE cho NAT traversal, và dùng STUN để tìm server-reflexive candidate, tức địa chỉ công khai
  • STUN binding là một giao thức đơn giản: gửi gói UDP đến máy chủ và nhận phản hồi UDP chứa IP và cổng mà máy chủ nhìn thấy
  • Cùng một ví dụ STUN có thể được viết gần như giống nhau với UdpSocket async của tokio hoặc với blocking IO của thư viện chuẩn
  • Nếu cung cấp chức năng STUN dưới dạng thư viện, sẽ phát sinh sự trùng lặp do phải chọn một trong hai phiên bản async và blocking, hoặc bao gồm cả hai
  • Mã ví dụ nằm tại firezone/sans-io-blog-example

Cốt lõi của sans-IO là tách chính sách khỏi triển khai IO

  • Cốt lõi của sans-IO tương tự nguyên tắc đảo ngược phụ thuộc trong lập trình hướng đối tượng
  • Mã chính sách quyết định “làm gì” không nên phụ thuộc vào chi tiết triển khai thực hiện “làm như thế nào”
  • Nếu mã quyết định gửi thông điệp mạng phụ thuộc trực tiếp vào mã gửi socket thật, cả mã phía trên cũng bị ràng buộc vào lựa chọn async hay blocking IO
  • Trong ví dụ STUN, mã chính sách là giống nhau, nhưng nếu xây trên tokio::UdpSocket thì trở thành async, còn nếu xây trên std::net::UdpSocket thì thành blocking IO
  • Thay vì gọi trực tiếp UdpSocket::send, sans-IO tạo một trừu tượng hóa biểu thị ý định truyền dữ liệu
  • Transmit trong ví dụ chứa các thông tin sau
    • SocketAddr đích
    • payload cần gửi
  • Mã giao thức không ghi trực tiếp vào socket mà phát ra Transmit
  • Lệnh gọi UdpSocket::send hoặc send_to thật do event loop đảm nhiệm
  • Cũng như Future của Rust phải được runtime poll thì mới tiến triển, mã sans-IO phải được event loop điều khiển

Chuyển STUN binding thành máy trạng thái

  • Yêu cầu STUN binding có thể được mô hình hóa thành một máy trạng thái với các trạng thái SentReceived
  • Trạng thái trong ví dụ được biểu diễn bằng enum sau
    • Sent
    • Received { address: SocketAddr }
  • StunBinding giữ trạng thái hiện tại và hàng đợi Transmit đang chờ gửi
  • Các API chính có vai trò rõ ràng
    • handle_input: chuyển gói nhận được từ kết quả UdpSocket::recv vào máy trạng thái
    • poll_transmit: event loop lấy Transmit mà máy trạng thái muốn phát ra
    • public_address: truy vấn địa chỉ công khai đã nhận
  • Trong cấu trúc này, logic giao thức chỉ mô hình hóa hành vi của chương trình mà không có IO
  • Event loop gửi gói qua socket nếu có gói cần gửi từ poll_transmit; nếu không thì chuyển dữ liệu đọc từ socket vào handle_input
  • Event loop vẫn hoạt động dù không biết chi tiết rằng STUN là giao thức request-response
  • UDP là giao thức không đáng tin cậy nên gói có thể bị mất, và STUN yêu cầu bộ hẹn giờ truyền lại để giảm thiểu điều này

Trừu tượng hóa cả thời gian

  • Trong giao thức mạng, trường hợp cần thời gian hiện tại thường là để kiểm tra đã trôi qua bao lâu kể từ một mốc nào đó
    • Đã 5 giây sau khi gửi yêu cầu hay chưa
    • Đã 30 giây sau lần keep-alive cuối hay chưa
  • Trong các trường hợp này không cần thời gian wall clock thật, mà chỉ cần Duration so với thời điểm trước đó
  • Instant của Rust không phơi bày thời gian hiện tại, nhưng cho phép đo Duration giữa hai Instant
  • Máy trạng thái sans-IO có thể có hai API cho hành vi dựa trên thời gian
    • poll_timeout: trả về thời điểm event loop nên đặt timer wake-up tiếp theo
    • handle_timeout: thông báo cho máy trạng thái rằng timer đã hết hạn
  • Ví dụ mở rộng máy trạng thái để gửi binding request mới khi đã 5 giây kể từ phản hồi cuối
  • handle_input nhận gói cùng Instant hiện tại và lưu dưới dạng State::Received { address, at }
  • Event loop xử lý cả nhận socket lẫn timer hết hạn, rồi thiết lập lại timer dựa trên kết quả poll_timeout

Kết hợp và độ linh hoạt của API

  • Các API chính của StunBindinghandle_timeout, handle_input, poll_transmit, poll_timeout không chỉ chuyên biệt cho STUN
  • Phần lớn giao thức mạng có thể được triển khai theo dạng này hoặc biến thể của nó, nên việc kết hợp máy trạng thái trở nên dễ dàng
  • Nếu muốn truy vấn 5 máy chủ STUN để tìm IP công khai, có thể tạo 5 StunBinding và gọi lần lượt
    • Trong trường hợp này cần triển khai multiplex thông điệp STUN phù hợp, có thể dùng TransactionId hoặc địa chỉ máy chủ
  • snownet của Firezone kết hợp ICE và WireGuard để cung cấp cho ứng dụng một đường hầm IP hoạt động trong nhiều môi trường mạng khác nhau
  • snownet được xây dựng trên thư viện WebRTC sans-IO str0m và triển khai WireGuard gần như sans-IO là boringtun
  • Firezone không cần toàn bộ stack WebRTC, mà chỉ cần IceAgent triển khai RFC 8445
  • str0m theo phong cách sans-IO, có thể chỉ lấy IceAgent và kết hợp vào máy trạng thái của mã hiện có một cách dễ dàng
  • connection của snownet chứa IceAgent và đường hầm WireGuard, rồi chuyển thông điệp đến một trong hai thành phần

Lợi ích của việc tự viết event loop

  • Mã sans-IO chỉ biểu diễn trạng thái hệ thống và không gây side effect, nên event loop phải truy vấn trạng thái, thực thi và chuyển input mới vào
  • Cấu trúc này có thể trông giống boilerplate, nhưng cho phép tự viết event loop để có kiểm soát chi tiết
  • Ứng dụng có thể tự chọn cách thực thi như sau
    • Giảm số syscall khi gửi gói bằng sendmmsg
    • Multiplex nhiều giao thức qua một socket duy nhất
  • Tác giả thư viện có thể tập trung vào triển khai chức năng giao thức thay vì tranh luận về async runtime hay cung cấp API tùy chọn socket
  • str0m xem việc liệt kê network interface là mối quan tâm IO và giao cho ứng dụng
  • Thay vào đó, nó chỉ cung cấp API để thêm địa chỉ socket vào trạng thái hiện tại dưới dạng ICE candidate
  • Firezone tận dụng cấu trúc này để triển khai tối ưu hóa: thu thập trước TURN candidate trước khi kết nối được tạo, giảm độ trễ thiết lập kết nối
  • Trong ICE, hai bên thu thập candidate, tức socket, rồi kiểm tra khả năng kết nối giữa chúng

Kiểm thử nhanh và xác minh edge case

  • Mã sans-IO về bản chất không có side effect nên rất phù hợp cho unit test
  • Vì socket và thời gian được trừu tượng hóa, kiểm thử không cần mở cổng thật hay chờ thời gian thật
  • Để kiểm thử hành vi sau 5 phút, chỉ cần truyền Instant đã thay đổi vào hàm và xác minh thay đổi trạng thái
  • Firezone cung cấp ví dụ thực tế kiểm thử snownet có đóng idle connection sau 5 phút hay không
  • Việc truyền dữ liệu cũng không cần đi qua socket thật; chỉ cần lấy Transmit từ một bên và chuyển vào handle_input của máy trạng thái bên kia
  • Firezone triển khai một máy trạng thái tham chiếu biểu thị connlib nên hoạt động như thế nào
  • Máy trạng thái tham chiếu này được dùng làm chuẩn cho kiểm thử
  • Dùng state machine testing của proptest, mỗi lần CI lấy mẫu và chạy hàng nghìn kịch bản một cách xác định, rồi so sánh máy trạng thái tham chiếu với trạng thái thực của connlib
  • Khi không có IO, cũng dễ kiểm thử các lỗi và hành vi bất thường như sau
    • Gói bị mất nên không nhận được phản hồi
    • Nhận phản hồi sai
    • RTT đến máy chủ rất dài
    • Không có interface IPv6 hoạt động
    • Chỉ có interface IPv6
  • Khi tách triển khai giao thức khỏi side effect IO thật, việc phát hiện và xử lý lỗi trở thành một phần của xử lý input trong máy trạng thái

Vì sao Rust và sans-IO hợp nhau

  • Rust buộc phải chỉ rõ component hoặc hàm nào sở hữu giá trị
  • Khi đọc từ UdpSocket, phải cung cấp &mut [u8] làm vùng nhớ nhận byte thật
  • Chỉ chủ sở hữu của giá trị mới có thể làm giá trị mutable hoặc truyền temporary mutable reference cho hàm khác
  • Mô hình sở hữu và khả biến tường minh này là nền tảng cho các tính năng Rust như borrow checker
  • API máy trạng thái của thiết kế sans-IO đều là hàm đồng bộ và không block để chờ IO hay thời gian
  • Máy trạng thái chỉ là cấu trúc dữ liệu, nên dễ biểu diễn thay đổi trạng thái bằng &mut self và tận dụng borrow checker để bảo đảm soundness của mã
  • Ngược lại, trong async Rust, &mut có thể cho cảm giác khó xử lý hơn
  • Hàm async của Rust được biên dịch thành cấu trúc dữ liệu triển khai Future
  • Để spawn Future trong runtime như tokio, cấu trúc dữ liệu này phải là 'static, nên không thể chứa tham chiếu như &mut
  • Để thay đổi trạng thái bên ngoài Future, thường dùng một trong các cách sau
    • Con trỏ đếm tham chiếu và mutex như Arc<Mutex<T>>
    • Mô hình actor, spawn nhiều task và nối chúng bằng channel
  • Cả hai cách đều có overhead runtime
    • Lock có thể tạo contention
    • Gửi thông điệp qua channel đòi hỏi sao chép
  • Khi nhiều task chạy trong runtime theo thứ tự không xác định, điều đó có thể dẫn đến race condition và deadlock
  • Mã giao thức sans-IO không spawn task, nên chỉ cần &mut self để thay đổi trạng thái
  • Không có task hay thread thì không cần synchronization primitive như Mutex, và không có channel thì cũng giảm nhu cầu sao chép dữ liệu
  • Firezone cho rằng sau khi chuyển sang sans-IO, việc hiểu mã trở nên dễ hơn vì ít phải lần theo đầu bên kia của channel, channel đã đóng, hay đoạn mã nào đang lock Mutex

Nhược điểm và phạm vi áp dụng

  • sans-IO không phải giải pháp vạn năng
  • Tự viết event loop đem lại quyền kiểm soát mạnh, nhưng ban đầu có thể phát sinh lỗi tinh vi khó tìm
  • Ví dụ, nếu giá trị trả về của poll_timeout trong máy trạng thái không tiến về phía trước, event loop có thể rơi vào busy loop
  • Workflow tuần tự đòi hỏi nhiều mã hơn
  • Hàm async của Rust được biên dịch thành máy trạng thái, trong đó mỗi điểm .await là một lần chuyển sang trạng thái khác, nên lập trình viên có thể dễ dàng viết non-blocking IO cùng với mã tuần tự
  • Trong sans-IO, phải tự mô hình hóa các bước này dưới dạng máy trạng thái
  • Giao thức request-response như StunBinding không khó, nhưng biểu diễn workflow tuần tự lớn hơn có thể trở nên nhàm chán
  • Trong cộng đồng Rust, thiết kế sans-IO vẫn chưa phổ biến rộng rãi
  • Phần lớn thư viện triển khai blocking IO hoặc non-blocking IO thay vì sans-IO
  • boringtun có một số phần không hoàn toàn thuần túy vì gọi Instant::now bên trong; issue liên quan nằm ở cloudflare/boringtun#391

Kết luận

  • Mã sans-IO ban đầu có thể xa lạ, nhưng khi đã quen thì rất hợp với công cụ mô hình hóa máy trạng thái của Rust
  • Cấu trúc này buộc phải xử lý lỗi như một loại input khác, nên cũng rất phù hợp với cách viết mã networking
  • Cũng có những cách khác để viết async Rust, và structured concurrency nằm giữa sans-IO và cách tiếp cận async Rust được đề cập ở đây
  • Về structured concurrency, có thể tham khảo Let futures be futures của withoutboats

1 bình luận

 
GN⁺ 2024-07-05
Các ý kiến trên Hacker News
  • Điều này được đóng gói như một sự đổi mới và một bước tiến, nhưng thực ra trước khi có hỗ trợ async/await ở cấp ngôn ngữ, bất đồng bộ trong $lang, bao gồm cả Rust, từng được xử lý theo cách này
    Trong phát triển firmware nhúng bằng Rust, bước tăng năng suất lớn nhất là khi có thể ngừng tự tay triển khai máy trạng thái giữa mỗi thao tác I/O, ngừng chuyển các biến cục bộ vào trạng thái tùy chỉnh, và để Rust làm thay bằng cú pháp async/await
    Trong Rust, async rốt cuộc được hạ xuống thành một máy trạng thái tự động lưu các giá trị giữa các điểm I/O (await)

    • Tôi đã định đề cập điểm này ở phần cuối bài. Nếu thứ bạn triển khai chủ yếu là các thao tác I/O tuần tự, mô hình này khá khổ sở
      Nhưng không phải lúc nào cũng vậy. Với các trường hợp dùng xoay quanh gói tin như QUIC, WebRTC, IP, bản thân I/O thực tế lại dễ. Chỉ cần gửi và nhận từng gói tin/datagram
      Không có nhiều điểm .await, nên cũng không có mấy thứ để trình biên dịch sinh ra. Đồng thời, nhiều khía cạnh phải chạy song song nên mỗi phần phải nằm trong future/task riêng; vì vậy việc quản lý trạng thái xuyên suốt các future như thế rất dễ biến thành mã spaghetti
    • Tôi nghĩ cách giải thích đó không hoàn toàn chính xác. Điểm quan trọng ở đây là máy trạng thái không trực tiếp làm I/O. Nó luôn ủy quyền việc đó cho event loop đang host nó, nên có thể được diễn giải trong nhiều ngữ cảnh
      Ít giả định hơn về môi trường runtime, nên dễ kiểm thử và dễ kết hợp hơn
      Về lý thuyết, async/await cũng có thể làm điều tương tự bằng cách tạo máy trạng thái, nhưng trên thực tế khá đau đớn và phần lớn mã async/await không thuần
      Các ngôn ngữ thử nghiệm như Eff, Koka, Frank hỗ trợ rất tốt kiểu lập trình này. Đằng sau các thảo luận về I/O của Haskell cũng là khoản đầu tư sâu vào các kỹ thuật như free monad và các biến thể của nó
      Gần đây Unison là một ngôn ngữ thú vị: vừa khám phá nhiều khái niệm mới, vừa đặt hệ thống hiệu ứng có thể mở rộng ở lõi, hỗ trợ tốt kiểu viết mã này ở cấp ngôn ngữ
    • Vào thập niên 1980, người ta làm I/O như thế này trong assembly. Chứ còn viết một triển khai YMODEM dựa trên ngắt kiểu gì nữa?
    • Đúng. Điều duy nhất gây khó chịu trong async là phải viết .await khắp nơi. Giá mà có thể đảo ngược lại: mặc định là .await được thực thi, và chỉ bằng cú pháp đặc biệt mới không như vậy
    • Bài viết không đóng gói nó như một cuộc cách mạng. Trong bài cũng có câu: “Mẫu này không phải do chúng tôi phát minh! Thế giới Python thậm chí còn có một website riêng cho nó.”
      Dù vậy, trên thực tế vẫn quá thường thấy thư viện giao thức trực tiếp làm I/O :-(
  • Tôi vẫn luôn nghiền ngẫm miền vấn đề này, và đây là một cách tiếp cận rất khớp với hướng tôi đang nghĩ. Tuy nhiên, như chú thích 3 trong bài, vẫn còn vài chỗ cần chỉnh
    Điều khiến tôi nghĩ đến chuyện này là cuộc thảo luận về màu của hàm và một phát hiện tình cờ. Khi làm một thư viện VT100, unit test quá khó, vì về cơ bản tôi đang làm parser::new(stdin()). Đến lần viết lại thứ ba hoặc thứ tư, tôi vô thức đổi parser thành parser::push(data), và lúc đó nhận ra Rust đang trừng phạt thứ mà tôi gọi là anti-pattern kiểu OOP doanh nghiệp của “nỗi ám ảnh đóng gói
    Giờ đây tôi thấy mẫu này và tác hại của nó ở khắp nơi, không chỉ trong I/O
    Trớ trêu là lời giải này là thứ ta học cả ở giáo dục trước đại học lẫn những năm đầu đại học. Cách mô tả máy tính đơn giản nhất là một cỗ máy nhận đầu vào, xử lý/biến đổi dữ liệu và tạo đầu ra. Lý do nó liên quan đến cuộc thảo luận về màu của hàm là: chỉ đầu vào và đầu ra mới cần quan tâm đến màu, còn logic cốt lõi thường chỉ là biến đổi dữ liệu
    Nghe hiển nhiên, nhưng nhìn vào quy mô của “tranh luận” về màu của hàm thì có vẻ nhiều người đã được rèn để giải quyết vấn đề trước hết bằng đóng gói, nên bỏ lỡ hoặc quên mất điều này. Phe lập trình hàm chắc sẽ khá hả hê ở điểm này
    Với tôi, Rust giống một hành trình gỡ bỏ và học lại hơn là một hành trình học thêm. Đây là một mẫu tốt và tôi định sẽ áp dụng về sau
    Sửa: mã liên quan: https://codeberg.org/jcdickinson/termkit/src/branch/main/src...

    • Bạn có thể giải thích thêm về đoạn đổi sang parser::push(data) và việc Rust trừng phạt anti-pattern kiểu OOP doanh nghiệp không?
      Với tư cách một người học Rust rất mới, tôi chưa thấy rõ vấn đề hiển nhiên của mẫu đó là gì, hay Rust trừng phạt nó như thế nào
    • Tôi cũng chuyển từ thế giới OOP sang Rust, và đã được 6 năm rồi. Trong 2–3 năm đầu, tôi đã viết ra mã kinh khủng
      Type parameter và trait có mặt khắp nơi, và tôi lạm dụng struct như những cấu trúc giống class cung cấp chức năng
      Rust hợp hơn khi bạn tránh type parameter và tự định nghĩa trait nếu có thể
      Đóng gói theo nghĩa duy trì bất biến là tốt. Bài “parse, don’t validate” này làm tôi nhớ đến: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
  • Thiết kế này so với cách dùng channel để gửi dữ liệu tới handler chuyên dụng thì thế nào? Khi dùng channel đã có nhiều vấn đề
    (1) thường sinh ra mã dạng mạng nhện khó theo dõi
    (2) phải tự triển khai kiểu message có thể chuyển đổi thành message gửi qua mạng
    (3) phải truyền tường minh sender cho các entity quan tâm hoặc được phép
    (4) có thể biết việc gửi message qua channel thất bại hay không, nhưng không biết message đó có thất bại khi truyền qua mạng hay không
    Dù vậy nó vẫn khá tiện. Ví dụ nếu có channel ws_handler thì chỉ cần gửi dữ liệu vào đó, và nếu ở đâu đó có handler chuyên dụng làm được việc đó thì message ấy cũng có thể được gửi đi

    • Mục 4 có thể triển khai bằng cách truyền kèm message một channel để gửi kết quả trả về. Nếu muốn, cũng có thể chặn phía gửi suốt tới tận điểm gọi
      sans-IO có thể dùng cho ứng dụng, nhưng tôi thấy nó đặc biệt hữu ích cho thư viện. Trong thư viện, vì không ép người dùng theo một cách I/O nào nên nó trở nên hữu ích hơn nhiều
      Trong Rust vốn đã có sự chia tách hệ sinh thái giữa I/O đồng bộ và I/O bất đồng bộ, lại còn có các async runtime khác nhau, nên điểm này rất quan trọng
    • Nếu có thể đưa phần mềm theo thiết kế gần giống actor thì channel hoạt động tốt
      Tuy nhiên như đã nói, nó đi kèm vấn đề. Ví dụ actor/channel có thể bị ngắt. Ngoài ra để có backpressure thì nhất thiết phải là channel có giới hạn. Hơn nữa vì cần sao chép nên có thể khó đạt throughput cao
  • Cũng đáng xem thêm: monad, đặc biệt là Free(r) monad và effect system[0]
    Ý tưởng tách logic và thực thi đã là một chủ đề lớn được bàn khá kỹ trong hệ sinh thái Haskell
    Sửa: bài không nói đã đóng gói lời gọi tokio::select! xuất hiện khi cần xử lý liên quan đến thời gian như thế nào. Có phải họ mang theo tokio::Runtime để biến mã vòng lặp thành async mà mã bên ngoài không cần async không?
    Sửa 2: cũng có thể ý họ không phải là thư viện được đóng gói sẽ làm như vậy, mà là ứng dụng bên ngoài có thể dùng binding trong ngữ cảnh async
    Theo phong cách sans-IO, tôi tò mò hơn về cách triển khai một hàm được đóng gói cần chờ một hành động hay timer nào đó. Hoặc câu trả lời kỳ vọng có thể là busy waiting, hoặc về thực chất là mang theo một instance async runtime riêng để thay cho busy waiting theo kiểu như block_in_place
    [0]: https://okmij.org/ftp/Computation/free-monad.html

    • “Hàm được đóng gói” ở đây là struct StunBinding. Nó biểu thị chức năng của STUN binding. Không phải một hàm đơn lẻ có thể gọi thẳng, mà cần một event loop
      Điểm cốt lõi là StunBinding có thể nằm trong thư viện, còn phía ứng dụng có thể kết hợp nó vào state machine của chương trình để dùng. Tất nhiên điều này giả định ứng dụng cũng được cấu trúc theo phong cách sans-IO
      Thư viện snownet được liên kết làm đúng việc này. Miền bài toán là kết hợp ICE + WireGuard mà không có I/O, và nó được dùng trong thư viện connlib, nơi kết hợp thêm ACL lên trên
      Sửa: không có busy waiting. Thay vào đó StunBinding có một hàm thông qua poll_timeout để phơi bày nó đang chờ gì. Bên gọi, tức event loop, sẽ hiện thực điều đó thế nào là tùy bên gọi. Khi handle_timeout được gọi với Instant tương ứng, hành động phù hợp sẽ diễn ra
  • Ồ, là thomaseizinger!
    Tôi từng xem bên trong rust-libp2p, nên khi đọc bài, giữa chừng tôi thấy pattern này cực kỳ quen thuộc; có vẻ không phải ngẫu nhiên
    Firezone trông hay đấy. Kết nối mọi thứ!

    • Cảm ơn!
      Đúng, có điểm giống rust-libp2p. Tuy nhiên ở đó các stream và connection thực tế vẫn nằm trong một cấu trúc kiểu Future, nên rối hơn, và không được tách nghiêm ngặt như sans-IO ở đây
  • Có đoạn nói workflow tuần tự đòi hỏi nhiều mã hơn. Trong Rust, async function được biên dịch thành state machine, và mỗi điểm .await biểu thị một chuyển tiếp sang trạng thái khác. Vì vậy nhà phát triển dễ kết hợp mã tuần tự với non-blocking I/O. Nếu không có async, phải tự viết state machine để biểu diễn nhiều bước
    Đã có ai thử kết hợp async và sans-IO chưa? Ít nhất về mặt khái niệm, nếu viết async function await các helper hiểu sans-IO, thì toàn bộ lẽ ra sẽ được biên dịch thành một state machine bên trong struct có interface sans-IO tốt, và có thể dễ gọi từ mã non-async
    Tôi chưa tự thử, nhưng vấn đề chính dự kiến có lẽ là tính dễ dùng và xử lý Pin

    • Rust có generator/coroutine có thể xử lý phần nào use case bạn nói, nhưng hiện tại đó là tính năng rất chưa ổn định
      Đáng tiếc là coroutine ở dạng hiện nay có hạn chế phiền phức: nó chỉ được phơi bày qua trait std::ops::Coroutine. Vì vậy không thể tự cấp phát state machine nội bộ do compiler tạo ra. Dù nhìn bên ngoài thì kích thước của state machine có vẻ là hằng số tại thời điểm biên dịch
      Nếu là một coroutine đơn lẻ chỉ nằm trong một hàm có lifetime được xác định thì không vấn đề. Compiler có thể hiểu được và cấp phát state machine trên stack
      Nhưng có thể xem nơi áp dụng hữu ích nhất của coroutine là các phần tử hàng đợi của một thiết bị event loop. Triển khai này không thể làm được nếu không boxing coroutine. Vec> không phải cấu trúc dữ liệu thân thiện với cache, và nếu trong I/O có concurrency cực cao mà cần một triệu phần tử trong Vec, bạn sẽ thấy đau đớn

    • Nếu một ngày nào đó Rust có cú pháp generator native thì có thể làm được. Vì để “ghi” dữ liệu trong khi vẫn ở trong ngữ cảnh của tác vụ async, ta có thể viết yield transmit. Tức là mọi socket.write sẽ được thay bằng yield transmit
      Khi đọc dữ liệu, generator sẽ bị tạm dừng (.await) và chờ được resume cùng với dữ liệu đi vào. Tôi không biết trên nightly đã có cú pháp như vậy chưa, nhưng đại khái nó nên trông như thế này:

      // Made up gen syntax: gen(yield_type, resume_type)
      gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
      let req = make_stun_request();

yield Transmit {
server,
payload: req
};

let res = .await; // Cú pháp "tạm dừng và tiếp tục với đối số" được bịa ra.

let addr = parse_stun_response(res);

addr  
}
  • Ở tầng cao hơn của stack, hai thứ này khá ăn khớp với nhau. I/O không chặn, tức async, giúp dễ chờ đồng thời I/O socket và thời gian. Với I/O chặn cũng có thể làm được nếu đặt timeout đọc cho socket, nhưng dùng các primitive async thì dễ hơn một chút
    Tôi cũng đã liên tục suy nghĩ về cách kết hợp hai thứ này. Một trong những vấn đề tôi gặp là các hàm async được biên dịch thành kiểu mờ. Vì vậy rất khó, hoặc không thể, dùng khả năng để trình biên dịch sinh mã máy trạng thái cho mình, vì sau khi nó được tạo ra, ta không thể tương tác với máy trạng thái đó. Theo một cách nào đó, điều này cũng làm hỏng borrow checker
    Ví dụ, giả sử có một tác vụ async gồm nhiều bước, tức nhiều điểm await, và chỉ một đoạn trong đó cần tham chiếu mutable tới một cấu trúc dữ liệu dùng chung. Ngay khi biểu diễn nó bằng một hàm async, tham chiếu mutable sẽ bị capture vào kiểu Future được tạo ra, và kiểu đó tồn tại xuyên suốt mọi bước. Kết quả là Rust không cho phép chạy đồng thời từ hai tác vụ như vậy trở lên
    Lời khuyên thông thường trong tình huống này là “capture tham chiếu mutable trong phạm vi ngắn nhất có thể”, nhưng với async thì không làm được. Việc tách hàm async thành nhiều hàm cũng trở nên lộn xộn, và ở một mức độ nào đó làm hỏng mục đích ban đầu là muốn biểu diễn mọi thứ trong một hàm duy nhất

  • Trước đây tôi từng thử mã hóa giao thức HTTP/1.1 thành một máy trạng thái Sans-IO có các điểm .await dành cho I/O, nhưng không đi được xa. Tuy nhiên, I/O đó không đăng ký waker với runtime async, mà trả quyền điều khiển lại để người dùng tự thực hiện I/O. Có thể nghĩ .await là nhả quyền điều khiển “lên trên” chứ không phải “xuống dưới”
    Trong ngữ cảnh HTTP/1.1, mã async trở thành một dạng bản thiết kế về hành vi gọi mà người dùng mong muốn. Khi đó tôi muốn nó nhất thiết phải chạy được trong môi trường no_std và không có allocator, nhưng tôi không tìm được cách tránh dynamic dispatch qua Box, tức phần cần allocator, nên đã bỏ cuộc

  • https://news.ycombinator.com/item?id=40879547
    Có ví dụ viết dưới dạng async fn stun. Mã chạy đầy đủ nằm ở đây: https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...

  • Làm tốt lắm! Nếu phơi bày trạng thái, ta có thể biến bất kỳ hàm async nào thành thuần túy. Người dùng chỉ cần đẩy máy trạng thái sang trạng thái tiếp theo
    Trước đây tôi từng thử binding OpenSSL vào async Rust, và API async của nó cũng theo thiết kế tương tự

    • Tìm nhanh thì thấy OpenSSL có API “async job”. Tuy nhiên cái đó có vẻ thực hiện I/O. Thậm chí việc tạo job còn rất tốn kém nên tài liệu nói cần tái sử dụng job
      Điểm được xem là tương tự có phải là bản thân tác vụ được lên lịch thành job độc lập với cách nó được thực thi không?
      Nhìn ví dụ này [0], API async đó trông giống future của Rust hơn nhiều
      Bên trong job có thể truy cập “wait context”, có thể dừng trong một số điều kiện, và có thể kích hoạt đánh thức để tiếp tục thực thi
      [0]: https://www.openssl.org/docs/man1.1.1/man3/ASYNC_is_capable....
  • Đây chỉ là I/O bất đồng bộ thông thường dùng callback thay vì coroutine

  • Đọc bài và một số bình luận thì nghe như đang phát minh lại kiểu kiến trúc hexagonal hoặc kiến trúc port/adapter

  • Tôi không rõ nên rút ra điều gì từ đây. Những gì được nói tới đều đã là lập trình mạng cơ bản
    Có vẻ bài tập trung vào phần “đường ống” ở tầng cao hơn và quá sa đà vào quản lý trạng thái; đây chỉ là vấn đề sở thích chứ không liên quan đến networking
    Điều thú vị nhất tôi học được từ bài là Cloudflare vận hành một máy chủ STUN công khai. Nhưng điều đó cũng không hữu ích lắm. Phiên bản “tốt” và “hữu ích” của giao thức STUN là phiên bản đầu tiên, có hỗ trợ tính năng change requests cho phép liệt kê NAT. Trong các phiên bản STUN về sau, tính năng đó đã bị loại bỏ nhờ những “đề xuất hữu ích” từ các kỹ sư Cisco tham gia đóng góp vào đặc tả

    • Trong ngữ cảnh Rust, tôi nghĩ lợi ích lớn là nó giải quyết vấn đề màu hàm. Như bài viết giải thích, việc test cũng trở nên thật sự đơn giản
      Hiện nay trong Rust, chẳng hạn nếu bạn triển khai một thư viện WebRTC dùng runtime async Tokio, thì những người dùng I/O đồng bộ, hoặc runtime khác (smol, async-std, v.v.), hoặc dùng trực tiếp iouring, sẽ rất khó sử dụng
      Với cách tiếp cận này, ta không ép người tiêu dùng phải chọn I/O theo mình, nên có thể làm thư viện hữu ích với nhiều người hơn