Sans-IO: Bí mật của Rust để xây dựng dịch vụ mạng hiệu quả
(firezone.dev)- Thư viện kết nối Rust
connlibcủ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
InstantvàTransmit, 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_inputlặp lại ở nhiều tầng
- Hầu như không có lệnh gọi
- 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
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
connlibcủ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
UdpSocketasync củatokiohoặ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::UdpSocketthì trở thành async, còn nếu xây trênstd::net::UdpSocketthì 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 Transmittrong ví dụ chứa các thông tin sauSocketAddrđíchpayloadcầ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::sendhoặcsend_tothật do event loop đảm nhiệm - Cũng như
Futurecủ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
SentvàReceived - Trạng thái trong ví dụ được biểu diễn bằng enum sau
SentReceived { address: SocketAddr }
StunBindinggiữ trạng thái hiện tại và hàng đợiTransmitđ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::recvvào máy trạng tháipoll_transmit: event loop lấyTransmitmà máy trạng thái muốn phát rapublic_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àohandle_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
Durationso với thời điểm trước đó Instantcủa Rust không phơi bày thời gian hiện tại, nhưng cho phép đoDurationgiữa haiInstant- 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 theohandle_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_inputnhận gói cùngInstanthiện tại và lưu dưới dạngState::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
StunBindinglàhandle_timeout,handle_input,poll_transmit,poll_timeoutkhô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
StunBindingvà 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
TransactionIdhoặc địa chỉ máy chủ
- 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
snownetcủ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 nhausnownetđược xây dựng trên thư viện WebRTC sans-IOstr0mvà 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
IceAgenttriển khai RFC 8445 - Vì
str0mtheo phong cách sans-IO, có thể chỉ lấyIceAgentvà 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
snownetchứaIceAgentvà đườ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
- Giảm số syscall khi gửi gói bằng
- 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
str0mxem 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ử
snownetcó đó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
Transmittừ một bên và chuyển vàohandle_inputcủ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ị
connlibnê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ủaconnlib - 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 selfvà tận dụng borrow checker để bảo đảm soundness của mã - Ngược lại, trong async Rust,
&mutcó 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
Futuretrong 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
- Con trỏ đếm tham chiếu và mutex như
- 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_timeouttrong 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
.awaitlà 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ư
StunBindingkhô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
boringtuncó một số phần không hoàn toàn thuần túy vì gọiInstant::nowbê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
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àyTrong 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)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 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ữ
.awaitkhắ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ậyDù 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ànhparser::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...
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
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_handlerthì 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 đisans-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
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 theotokio::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
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à
StunBindingcó 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-IOThư 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ệnconnlib, nơi kết hợp thêm ACL lên trênSửa: không có busy waiting. Thay vào đó
StunBindingcó một hàm thông quapoll_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. Khihandle_timeoutđược gọi vớiInstanttươ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ứ!
Đú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 ở đâyCó đ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
.awaitbiể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ý
PinRust 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ịchNế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ử trongVec, bạn sẽ thấy đau đớnNế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ọisocket.writesẽ được thay bằngyield transmitKhi đọ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
gensyntax: gen(yield_type, resume_type)gen(Transmit, &[u8]) fn stun_binding(server: SocketAddr) -> SocketAddr {
let req = make_stun_request();
yield Transmit {
server,
payload: req
};
Ở 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àmasync, tham chiếu mutable sẽ bị capture vào kiểuFutuređượ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ênLờ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
.awaitdà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ĩ.awaitlà 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_stdvà không có allocator, nhưng tôi không tìm được cách tránh dynamic dispatch quaBox, tức phần cần allocator, nên đã bỏ cuộchttps://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ự
Đ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 requestscho 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ả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