Sans-IO: Rahasia Rust yang Efektif untuk Layanan Jaringan
(firezone.dev)connlib, library koneksi Rust milik Firezone, mengelola koneksi jaringan dan tunnel WireGuard, serta memperoleh pengujian cepat dan keandalan operasional tinggi berkat desain sans-IO- Protokol diimplementasikan sebagai state machine murni tanpa menangani socket secara langsung, dan event loop memanggil API seperti
handle_input,poll_transmit,handle_timeout, danpoll_timeout - Dengan mendorong pilihan IO ke luar state machine, beban function colouring pada async Rust berkurang, dan aplikasi dapat menentukan sendiri blocking IO, non-blocking IO, maupun runtime async tertentu
- Dengan mengabstraksikan socket dan waktu, berlalunya waktu, packet loss, dan respons anomali dapat diverifikasi hanya dengan
InstantdanTransmittanpa port nyata atau waktu tunggu - Namun, karena event loop harus dikelola langsung, bug halus bisa muncul, workflow berurutan membuat kode state machine bertambah, dan library sans-IO di ekosistem Rust masih terbatas
Struktur sans-IO yang dipilih Firezone connlib
- Firezone menggunakan Rust untuk membangun akses jarak jauh aman yang dapat diskalakan di Android, macOS, dan Linux
- Di pusat tiap aplikasi ada
connlib, dan library ini mengelola koneksi jaringan serta tunnel WireGuard untuk melindungi trafik - Stack Rust milik Firezone memakai
tokio,tungstenite,boringtun,rustls, dan lainnya, tetapi struktur internalnya berbeda dari kode async Rust pada umumnya- Hampir tidak ada pemanggilan
tokio::spawn - Semua komunikasi dimultipleks melalui satu socket UDP
- API seperti
handle_timeout,poll_transmit, danhandle_inputberulang di beberapa lapisan
- Hampir tidak ada pemanggilan
- Ciri-ciri ini adalah sinyal dari desain sans-IO, di mana logika protokol tidak melakukan IO secara langsung, melainkan mengekspresikan state dan niat input/output
- Di ekosistem Python ada situs dokumentasi khusus sans-IO, dan di Rust library berikut memakai pola ini
Beban async Rust dan function colouring
- Fungsi async di Rust hanya bisa dipanggil dari dalam fungsi async lain, sehingga muncul batasan function colouring di mana seluruh rantai pemanggilan berubah menjadi async
- Batasan ini menegakkan pada waktu kompilasi bahwa kemampuan menghentikan eksekusi lalu melanjutkannya nanti menjadi bagian dari kontrak API fungsi
- Karena satu fungsi async di kedalaman stack, fungsi lain di jalur pemanggilan juga bisa menjadi async demi
.await - Operasi async yang nyata biasanya terjadi di bagian paling bawah dari call stack
- menulis ke socket
- membaca file
- menunggu waktu berlalu
- Banyak fungsi async menjadi async bukan karena melakukan kerja asinkron secara langsung, tetapi karena bergantung pada fungsi async lain
connlibmilik Firezone memakai ICE untuk NAT traversal, dan menggunakan STUN guna menemukan server-reflexive candidate, yaitu alamat publik- STUN binding adalah protokol sederhana yang mengirim paket UDP ke server lalu menerima respons UDP yang memuat IP dan port yang dilihat server
- Contoh STUN yang sama dapat ditulis hampir identik baik dengan
UdpSocketasync miliktokiomaupun blocking IO dari library standar - Jika STUN disediakan sebagai library, akan muncul duplikasi karena harus memilih versi async atau versi blocking, atau menyertakan keduanya
- Kode contoh ada di firezone/sans-io-blog-example
Inti sans-IO adalah memisahkan kebijakan dan implementasi IO
- Inti sans-IO mirip dengan prinsip inversi dependensi dalam pemrograman berorientasi objek
- Kode kebijakan yang memutuskan “apa yang harus dilakukan” tidak seharusnya bergantung pada detail implementasi yang menjalankan “bagaimana melakukannya”
- Jika kode yang memutuskan untuk mengirim pesan jaringan bergantung langsung pada kode pengiriman socket nyata, maka kode di lapisan atas juga akan terikat pada pilihan async atau blocking IO
- Dalam contoh STUN, kode kebijakannya sama, tetapi jika disusun di atas
tokio::UdpSockethasilnya async, dan jika di atasstd::net::UdpSockethasilnya blocking IO - Alih-alih memanggil
UdpSocket::sendsecara langsung, sans-IO membuat abstraksi yang merepresentasikan niat transmisi Transmitpada contoh memuat informasi berikut- tujuan
SocketAddr payloadyang akan dikirim
- tujuan
- Kode protokol tidak menulis langsung ke socket, melainkan mengeluarkan
Transmit - Pemanggilan nyata ke
UdpSocket::sendatausend_toditangani oleh event loop - Kode sans-IO harus dijalankan oleh event loop, sebagaimana
Futuredi Rust baru berjalan ketika dipoll oleh runtime
Mengubah STUN binding menjadi state machine
- Permintaan STUN binding dapat dimodelkan sebagai state machine dengan state
SentdanReceived - State pada contoh direpresentasikan dengan enum berikut
SentReceived { address: SocketAddr }
StunBindingmemiliki state saat ini dan antreanTransmityang menunggu dikirim- API utamanya memiliki peran yang jelas
handle_input: meneruskan paket yang masuk dari hasilUdpSocket::recvke state machinepoll_transmit: event loop mengambilTransmityang ingin dikeluarkan state machinepublic_address: mengambil alamat publik yang telah diterima
- Dalam struktur ini, logika protokol hanya memodelkan perilaku program tanpa IO
- Event loop mengirim paket ke socket saat ada yang harus dikirim lewat
poll_transmit, dan jika tidak ada, meneruskan data yang dibaca dari socket kehandle_input - Event loop dapat bekerja tanpa mengetahui detail bahwa STUN adalah protokol request-response
- UDP adalah protokol yang tidak andal sehingga paket bisa hilang, dan STUN mewajibkan timer retransmisi untuk mengurangi masalah ini
Mengabstraksikan waktu juga
- Dalam protokol jaringan, kebutuhan akan waktu saat ini biasanya untuk memeriksa berapa lama telah berlalu sejak titik acuan tertentu
- apakah 5 detik sudah berlalu sejak permintaan dikirim
- apakah 30 detik sudah berlalu sejak keep-alive terakhir
- Dalam kasus seperti ini, waktu wall clock yang sebenarnya tidak diperlukan; yang dibutuhkan hanya
Durationterhadap waktu sebelumnya Instantdi Rust tidak mengekspos waktu saat ini, tetapi memungkinkan pengukuranDurationantara duaInstant- Untuk perilaku berbasis waktu, state machine sans-IO dapat memiliki dua API
poll_timeout: mengembalikan kapan event loop harus menjadwalkan timer wake-up berikutnyahandle_timeout: memberi tahu state machine bahwa timer telah kedaluwarsa
- Contoh memperluas state machine agar mengirim binding request baru setelah 5 detik sejak respons terakhir diterima
handle_inputmenerimaInstantsaat ini bersama paket, lalu menyimpannya dalam bentukState::Received { address, at }- Event loop menangani penerimaan socket dan kedaluwarsa timer sekaligus, lalu mengatur ulang timer berdasarkan hasil
poll_timeout
Komposisi dan fleksibilitas API
- API utama
StunBindingyaituhandle_timeout,handle_input,poll_transmit, danpoll_timeouttidak khusus untuk STUN saja - Sebagian besar protokol jaringan dapat diimplementasikan dalam bentuk ini atau variasinya, sehingga komposisi state machine menjadi mudah
- Jika ingin menanyakan 5 server STUN untuk menemukan IP publik, kita bisa membuat 5
StunBindingdan memanggilnya secara berurutan- Dalam kasus ini, multipleksing pesan STUN harus diimplementasikan dengan tepat, misalnya memakai
TransactionIdatau alamat server
- Dalam kasus ini, multipleksing pesan STUN harus diimplementasikan dengan tepat, misalnya memakai
snownetmilik Firezone menggabungkan ICE dan WireGuard untuk menyediakan tunnel IP yang bekerja di berbagai lingkungan jaringan bagi aplikasisnownetdibangun di atas library WebRTC sans-IOstr0mdan implementasi WireGuardboringtunyang hampir sans-IO- Firezone tidak membutuhkan seluruh stack WebRTC, melainkan hanya
IceAgentyang mengimplementasikan RFC 8445 - Karena
str0mmemakai pendekatan sans-IO,IceAgentdapat diambil sendiri dan mudah dikomposisikan ke dalam state machine kode yang sudah ada - Koneksi dalam
snownetmemuatIceAgentdan tunnel WireGuard, lalu meneruskan pesan masuk ke salah satunya
Keuntungan menulis event loop sendiri
- Kode sans-IO hanya merepresentasikan state sistem dan tidak menimbulkan efek samping, sehingga event loop harus menanyakan state, mengeksekusinya, dan menyampaikan input baru
- Struktur ini mungkin terlihat seperti boilerplate, tetapi karena event loop dapat ditulis sendiri, ia memberi kontrol yang rinci
- Aplikasi dapat memilih sendiri cara eksekusi seperti berikut
- mengurangi jumlah system call saat mengirim paket dengan
sendmmsg - memultipleks beberapa protokol melalui satu socket
- mengurangi jumlah system call saat mengirim paket dengan
- Penulis library dapat fokus pada implementasi fitur protokol alih-alih perdebatan runtime async atau penyediaan API opsi socket
str0mmenganggap enumerasi interface jaringan sebagai perhatian IO dan menyerahkannya ke aplikasi- Sebagai gantinya, ia hanya menyediakan API untuk menambahkan alamat socket ke state saat ini sebagai ICE candidate
- Firezone memanfaatkan struktur ini untuk mengumpulkan TURN candidate lebih awal sebelum koneksi dibuat, dan mengimplementasikan optimasi yang mengurangi latensi penyiapan koneksi
- Dalam ICE, kedua sisi mengumpulkan candidate, yaitu socket, lalu menguji konektivitas di antaranya
Pengujian cepat dan verifikasi edge case
- Kode sans-IO pada dasarnya tidak memiliki efek samping, sehingga sangat cocok untuk unit test
- Karena socket dan waktu diabstraksikan, pengujian tidak perlu membuka port nyata atau menunggu waktu berlalu
- Untuk menguji perilaku 5 menit kemudian, cukup kirim
Instantyang sudah berubah ke fungsi dan verifikasi perubahan state - Firezone menyediakan contoh nyata pengujian apakah
snownetmenutup idle connection setelah 5 menit - Pengiriman data juga tidak perlu melewati socket nyata; cukup ambil
Transmitdari satu sisi lalu teruskan kehandle_inputstate machine di sisi lain - Firezone mengimplementasikan reference state machine yang merepresentasikan bagaimana
connlibseharusnya bekerja - Reference state machine ini dipakai sebagai acuan dalam pengujian
- Dengan state machine testing dari
proptest, setiap CI dapat secara deterministik mensampling dan menjalankan ribuan skenario, lalu membandingkan reference state machine dengan state nyataconnlib - Tanpa IO, kegagalan dan perilaku anomali berikut juga mudah diuji
- paket hilang sehingga tidak ada respons yang diterima
- menerima respons yang salah
- RTT ke server sangat tinggi
- tidak ada interface IPv6 yang berfungsi
- hanya ada interface IPv6
- Dengan memisahkan implementasi protokol dan efek samping IO nyata, deteksi serta penanganan error menjadi bagian dari pemrosesan input state machine
Mengapa Rust dan sans-IO sangat cocok
- Rust memaksa kita menyatakan dengan jelas komponen atau fungsi mana yang memiliki suatu nilai
- Saat membaca dari
UdpSocket, kita harus menyediakan&mut [u8]sebagai ruang untuk menerima byte sebenarnya - Hanya pemilik nilai yang dapat membuatnya mutable atau memberikan mutable reference sementara ke fungsi lain
- Model kepemilikan dan mutabilitas yang eksplisit ini menjadi dasar fitur Rust seperti borrow checker
- API state machine dalam desain sans-IO semuanya berupa fungsi sinkron, dan tidak melakukan block saat menunggu IO atau waktu
- Karena state machine hanyalah struktur data, perubahan state mudah diekspresikan dengan
&mut self, dan borrow checker dapat dimanfaatkan untuk memastikan soundness kode - Sebaliknya,
&mutbisa terasa lebih sulit ditangani dalam async Rust - Fungsi async Rust dikompilasi menjadi struktur data yang mengimplementasikan
Future - Agar
Futureini bisa di-spawn ke runtime sepertitokio, struktur data tersebut harus'static, sehingga tidak bisa memuat referensi seperti&mut - Untuk mengubah state di luar
Future, biasanya digunakan salah satu dari berikut- pointer referensi berpenghitungan seperti
Arc<Mutex<T>>beserta mutex - pendekatan actor dengan men-spawn banyak task dan menghubungkannya lewat channel
- pointer referensi berpenghitungan seperti
- Kedua pendekatan ini memiliki overhead runtime
- lock dapat menimbulkan contention
- pengiriman pesan lewat channel memerlukan penyalinan
- Jika banyak task berjalan dalam urutan non-deterministik di dalam runtime, itu dapat menyebabkan race condition dan deadlock
- Karena kode protokol sans-IO tidak men-spawn task, perubahan state hanya memerlukan
&mut self - Tanpa task atau thread, primitive sinkronisasi seperti
Mutextidak diperlukan, dan tanpa channel kebutuhan menyalin data juga berkurang - Firezone menilai bahwa setelah berpindah ke sans-IO, kebutuhan menelusuri sisi seberang channel, channel yang tertutup, atau kode mana yang mengunci
Mutexberkurang, sehingga kode lebih mudah dipahami
Kekurangan dan cakupan penerapan
- sans-IO bukan solusi ajaib untuk semua hal
- Menulis event loop sendiri memang memberi kontrol yang kuat, tetapi pada awalnya bisa memunculkan bug halus yang sulit ditemukan
- Misalnya, jika nilai yang dikembalikan
poll_timeoutpada state machine tidak bergerak maju, event loop bisa terjebak dalam busy loop - Workflow berurutan memerlukan lebih banyak kode
- Fungsi async Rust dikompilasi menjadi state machine di mana tiap titik
.awaitadalah transisi ke state lain, sehingga developer dapat dengan mudah menulis non-blocking IO bersama kode berurutan - Dalam sans-IO, tahap-tahap semacam ini harus dimodelkan sendiri sebagai state machine
- Protokol request-response seperti
StunBindingtidak terlalu sulit, tetapi mengekspresikan workflow berurutan yang lebih besar bisa terasa membosankan - Di komunitas Rust, desain sans-IO masih belum tersebar luas
- Sebagian besar library mengimplementasikan blocking IO atau non-blocking IO, bukan sans-IO
boringtunmemanggilInstant::nowdi dalam internalnya sehingga ada bagian yang tidak sepenuhnya murni, dan isu terkait ada di cloudflare/boringtun#391
Penutup
- Kode sans-IO mungkin terasa asing pada awalnya, tetapi setelah terbiasa, pendekatan ini sangat cocok dengan alat pemodelan state machine di Rust
- Struktur ini memaksa error diperlakukan seperti input lain, sehingga juga selaras dengan cara menulis kode jaringan
- Ada cara lain untuk menulis async Rust, dan structured concurrency berada di antara sans-IO dan pendekatan async Rust yang dibahas di sini
- Untuk structured concurrency, lihat Let futures be futures dari withoutboats
1 komentar
Komentar Hacker News
Ini dikemas seolah-olah inovasi dan langkah maju, tetapi sebenarnya sebelum ada dukungan bahasa async/await, beginilah cara menangani asinkron di
$lang, termasuk RustPeningkatan produktivitas terbesar dalam pengembangan firmware embedded Rust adalah ketika kami bisa berhenti mengimplementasikan state machine sendiri di antara setiap operasi I/O dan memindahkan variabel lokal ke state kustom, lalu membuat Rust melakukannya dengan sintaks async/await
Di Rust, async pada akhirnya diuraikan menjadi state machine otomatis yang menyimpan nilai di antara titik I/O (
await)Namun tidak selalu begitu. Dalam use case yang berpusat pada paket seperti QUIC, WebRTC, dan IP, I/O itu sendiri sebenarnya mudah. Cukup kirim dan terima paket/datagram individual
Karena titik
.awaittidak banyak, tidak banyak pula yang perlu dihasilkan compiler. Pada saat yang sama, beberapa aspek harus berjalan bersamaan sehingga masing-masing perlu masuk ke future/task sendiri, sehingga manajemen state di seluruh future seperti ini mudah berubah menjadi kode spaghettiAsumsi terhadap lingkungan runtime menjadi lebih sedikit, sehingga lebih mudah diuji dan dikomposisikan
Secara teori, async/await juga bisa melakukan hal yang sama dengan cara membuat state machine untuk kita, tetapi dalam praktiknya cukup menyakitkan dan kebanyakan kode async/await tidak murni
Bahasa eksperimental seperti Eff, Koka, dan Frank punya dukungan yang sangat baik untuk gaya pemrograman seperti ini. Di balik pembahasan I/O di Haskell pun ada investasi mendalam pada teknik seperti free monad dan berbagai variannya
Belakangan ini Unison adalah bahasa yang menarik; sambil mengeksplorasi berbagai konsep baru, ia menempatkan sistem efek yang dapat diperluas di inti bahasa sehingga mendukung pola coding seperti ini dengan baik di tingkat bahasa
.awaitdi mana-mana. Akan bagus kalau bisa dibalik:.awaitberjalan secara default, dan hanya tidak begitu dengan sintaks khususMeski begitu, dalam praktiknya saya terlalu sering melihat library protokol melakukan I/O secara langsung :-(
Saya terus memikirkan area masalah ini, dan pendekatan ini sangat cocok dengan arah yang saya bayangkan. Hanya saja, seperti catatan kaki 3 di tulisan itu, masih ada bagian yang perlu dirapikan
Yang memicu pemikiran ini adalah diskusi tentang warna fungsi dan sebuah temuan tak sengaja. Saat membuat library VT100, unit test terasa sangat sulit, karena pada dasarnya saya melakukan
parser::new(stdin()). Pada penulisan ulang ketiga atau keempat, tanpa banyak berpikir saya mengubah parser menjadiparser::push(data), dan saat itu saya sadar Rust sedang menghukum anti-pola ala enterprise OOP yang kemudian saya sebut “obsesi enkapsulasi”Sekarang saya melihat pola ini dan dampaknya bukan hanya di I/O, tetapi di mana-mana
Ironisnya, solusi ini adalah sesuatu yang dipelajari bahkan di pendidikan pra-universitas maupun awal perkuliahan. Penjelasan paling sederhana tentang komputer adalah mesin yang menerima input, memproses/mentransformasi data, lalu menghasilkan output. Alasannya terkait dengan diskusi warna fungsi adalah karena yang perlu diperhatikan warnanya hanyalah input dan output, sementara logika inti biasanya hanyalah transformasi data
Ini terdengar jelas, tetapi melihat skala “perdebatan” warna fungsi, banyak orang tampaknya sudah dibiasakan untuk menyelesaikan masalah terlebih dahulu dengan enkapsulasi sehingga melewatkan atau melupakan fakta ini. Orang-orang dari kubu fungsional mungkin akan cukup senang melihat poin ini
Bagi saya, Rust lebih terasa seperti perjalanan mengurangi dan belajar ulang daripada sekadar perjalanan belajar. Ini pola yang bagus dan akan saya adopsi ke depannya
Edit: kode terkait: https://codeberg.org/jcdickinson/termkit/src/branch/main/src...
parser::push(data)dan Rust menghukum anti-pola ala enterprise OOP?Sebagai pembelajar Rust yang masih sangat pemula, saya belum bisa melihat dengan jelas apa masalah nyata dari pola itu dan bagaimana Rust menghukumnya
Type parameter dan trait ada di mana-mana, dan saya menyalahgunakan struct sebagai struktur mirip class yang menyediakan fitur
Rust terasa lebih cocok ketika sebisa mungkin menghindari type parameter dan definisi trait sendiri
Enkapsulasi dalam arti menjaga invariant itu bagus. Saya jadi teringat tulisan “parse, don’t validate” ini: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
Bagaimana desain ini dibandingkan dengan pendekatan memakai channel untuk mengirim data ke handler khusus? Saat memakai channel, ada beberapa masalah
(1) Sering muncul kode berbentuk jaring laba-laba yang sulit diikuti
(2) Harus mengimplementasikan sendiri tipe pesan yang bisa dikonversi menjadi pesan yang dapat dikirim lewat jaringan
(3) Harus secara eksplisit meneruskan sender ke entitas yang berkepentingan atau diizinkan
(4) Kita bisa tahu apakah pengiriman pesan channel gagal, tetapi tidak bisa tahu apakah pesan itu gagal dikirim lewat jaringan
Meski begitu, ini memang cukup praktis. Misalnya jika ada channel
ws_handler, kita cukup mengirim data ke sana, dan jika memungkinkan, handler khusus di suatu tempat juga bisa mengirim pesan itusans-IO bisa dipakai juga untuk aplikasi, tetapi saya merasa ini terutama berguna untuk library. Dalam library, ini menjadi jauh lebih berguna karena tidak memaksa konsumen memakai cara I/O tertentu
Di Rust, sudah ada pemisahan ekosistem antara I/O sinkron dan I/O asinkron, ditambah lagi berbagai runtime asinkron yang berbeda, jadi poin ini penting
Namun seperti yang disebutkan, ada konsekuensinya. Misalnya aktor/channel bisa terputus. Selain itu, untuk memiliki backpressure, channel harus berbatas. Ditambah lagi, karena perlu penyalinan, mencapai throughput tinggi bisa menjadi sulit
Yang juga layak dilihat: monad, khususnya monad Free(r), dan effect system[0]
Gagasan memisahkan logika dan eksekusi sudah menjadi topik besar yang cukup banyak dibahas di ekosistem Haskell
Sunting: Tidak disebutkan bagaimana pemanggilan
tokio::select!yang muncul saat perlu menangani hal terkait waktu dienkapsulasi. Apakah mereka membawa-bawatokio::Runtimeagar kode loop bisa dibuat async tanpa kode luar harus async?Sunting2: Bisa jadi maksudnya bukan menunjukkan bahwa library yang dienkapsulasi melakukan itu, melainkan menunjukkan bahwa aplikasi luar bisa memakai binding dalam konteks async
Yang lebih saya penasaran adalah bagaimana, dalam gaya sans-IO, mengimplementasikan fungsi terenkapsulasi yang harus menunggu suatu tindakan atau timer. Atau mungkin jawaban yang diharapkan adalah busy waiting, atau membawa instance runtime async sendiri yang pada dasarnya menggantikan busy waiting dengan sesuatu seperti
block_in_place[0]: https://okmij.org/ftp/Computation/free-monad.html
StunBinding. Ia merepresentasikan fungsi dari STUN binding. Ini bukan satu fungsi tunggal yang bisa langsung dipanggil, melainkan membutuhkan event loopIntinya,
StunBindingbisa berada di dalam library, dan pihak aplikasi bisa menggabungkannya ke dalam state machine program. Tentu saja dengan asumsi aplikasinya juga distrukturkan dalam gaya sans-IOLibrary
snownetyang ditautkan melakukan persis hal ini. Domainnya adalah menggabungkan ICE + WireGuard tanpa I/O, dan dipakai di libraryconnlibyang menyusun ACL di atasnyaSunting: Tidak ada busy waiting. Sebagai gantinya,
StunBindingmemiliki fungsi yang mengekspos apa yang sedang ia tunggu melaluipoll_timeout. Terserah pemanggil, yaitu event loop, bagaimana merealisasikannya. Saathandle_timeoutdipanggil denganInstantyang sesuai, tindakan yang tepat akan terjadiOh, ini thomaseizinger!
Saya pernah melihat-lihat bagian dalam rust-libp2p, jadi saat membaca tulisan ini polanya terasa sangat familier; sepertinya itu bukan kebetulan
Firezone terlihat keren. Hubungkan semuanya!
Benar, ada kemiripan dengan rust-libp2p. Namun di sana, stream dan connection sebenarnya masih berada di dalam struktur mirip
Future, jadi lebih saling terkait, dan tidak dipisahkan seketat sans-IO di siniAda bagian yang mengatakan workflow sekuensial membutuhkan lebih banyak kode. Di Rust, fungsi async dikompilasi menjadi state machine, dan setiap titik
.awaitmerepresentasikan transisi ke state lain. Karena itu, developer mudah memakai kode sekuensial bersama I/O nonblocking. Tanpa async, kita harus menulis state machine sendiri untuk mengekspresikan beberapa tahapApakah ada yang pernah menggabungkan async dan sans-IO? Setidaknya secara konseptual, jika kita menulis fungsi async yang meng-await helper yang paham sans-IO, keseluruhannya semestinya dikompilasi menjadi state machine di dalam struct dengan antarmuka sans-IO yang bagus, sehingga bisa dipanggil dengan mudah juga dari kode non-async
Saya belum pernah mencobanya sendiri, tetapi masalah utama yang saya perkirakan adalah usability yang layak dan penanganan
PinRust memiliki generator/coroutine yang sampai batas tertentu bisa menangani use case yang disebutkan, tetapi saat ini fiturnya sangat tidak stabil
Sayangnya, coroutine dalam bentuk saat ini memiliki batasan merepotkan: hanya diekspos melalui trait
std::ops::Coroutine. Karena itu, kita tidak bisa mengalokasikan langsung state machine internal yang dihasilkan compiler. Padahal ukuran state machine itu, secara tampilan luar, adalah konstanta waktu kompilasiJika hanya satu coroutine yang tetap berada di dalam fungsi dengan lifetime yang terdefinisi, itu bukan masalah. Compiler bisa memahaminya dan mengalokasikan state machine di stack
Namun penerapan coroutine yang paling berguna bisa dibilang adalah sebagai elemen antrean pada perangkat event loop. Implementasi ini mustahil tanpa boxing coroutine.
Vec>bukan struktur data yang ramah cache, dan jika pada I/O dengan konkurensi sangat tinggi kita membutuhkan satu juta elemen diVec, itu akan terasa menyakitkanJika suatu saat Rust memiliki sintaks generator native, mungkin ini bisa dilakukan. Karena di dalam konteks tugas async, kita bisa menulis
yield transmituntuk “menulis” data. Dengan kata lain, setiapsocket.writeberubah menjadiyield transmitSaat membaca data, generator berhenti (
.await) dan menunggu dilanjutkan kembali bersama data yang masuk. Saya tidak tahu apakah nightly punya sintaks seperti ini, tetapi kira-kira bentuknya harus seperti ini:// 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
};
Di level atas stack, keduanya cukup cocok. I/O nonblocking, yaitu async, memudahkan menunggu I/O soket dan waktu secara bersamaan. Di I/O blocking pun ini bisa dilakukan dengan menetapkan timeout baca pada soket, tetapi memakai primitive async sedikit lebih mudah
Saya juga terus memikirkan bagaimana menggabungkan keduanya. Salah satu masalah yang saya temui adalah fungsi async dikompilasi menjadi tipe opaque. Karena itu, sulit atau mustahil memakai kemampuan compiler untuk menghasilkan kode state machine. Sebab setelah dibuat, kita tidak bisa berinteraksi dengan state machine tersebut. Dalam arti tertentu, ini juga merusak borrow checker
Misalnya ada pekerjaan async dengan beberapa tahap, yaitu beberapa titik
await, dan hanya salah satu bagiannya yang memerlukan referensi mutable ke struktur data bersama. Begitu ini dinyatakan sebagai fungsiasync, referensi mutable tersebut akan ditangkap ke dalam tipeFutureyang dibuat, dan tipe itu ada di seluruh tahap. Akibatnya, Rust tidak mengizinkan lebih dari satu pekerjaan seperti itu berjalan bersamaanBiasanya saran untuk situasi seperti ini adalah “tangkap referensi mutable sesingkat mungkin”, tetapi dalam async itu tidak bisa dilakukan. Memecah fungsi async menjadi beberapa bagian juga menjadi berantakan, dan sampai batas tertentu menggagalkan tujuan awal untuk mengekspresikan semuanya sebagai satu fungsi
Dulu saya pernah mencoba mengodekan protokol HTTP/1.1 sebagai state machine Sans-IO yang memiliki titik
.awaituntuk I/O, tetapi tidak sampai jauh. Namun I/O itu, alih-alih mendaftarkan waker ke runtime async, mengembalikan kendali kepada pengguna agar pengguna melakukan I/O sendiri. Bisa dianggap bahwa.awaitmelepaskan kendali ke “atas”, bukan ke “bawah”Dalam konteks HTTP/1.1, kode async menjadi semacam cetak biru tentang bagaimana pengguna ingin perilaku pemanggilannya. Saat itu saya ingin membuatnya pasti berjalan di lingkungan
no_stddan tanpa allocator, lalu menyerah karena tidak menemukan cara menghindari dynamic dispatch melaluiBox, yaitu bagian yang membutuhkan allocatorhttps://news.ycombinator.com/item?id=40879547
Ada contoh yang ditulis dalam bentuk
async fn stun. Kode lengkap yang berjalan ada di sini: https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...Bagus! Jika state diekspos, fungsi async apa pun bisa dibuat murni. Pengguna hanya perlu mendorong state machine ke state berikutnya
Dulu saya pernah mencoba membuat binding OpenSSL ke async Rust, dan API async-nya juga mengikuti desain yang mirip
Apakah bagian yang dianggap mirip adalah bahwa pekerjaan yang dijadwalkan sebagai job itu sendiri independen dari bagaimana ia dijalankan?
Jika melihat contoh ini [0], API async itu tampak jauh lebih mirip dengan future di Rust
Di dalam job, kita bisa mengakses “wait context”, bisa berhenti pada kondisi tertentu, dan bisa memicu wake agar eksekusi dilanjutkan
[0]: https://www.openssl.org/docs/man1.1.1/man3/ASYNC_is_capable....
Ini hanyalah I/O asinkron umum yang memakai callback alih-alih coroutine
Setelah membaca artikelnya dan sebagian komentar, ini terdengar seperti menciptakan ulang gaya arsitektur heksagonal atau arsitektur port/adapter
Saya tidak begitu tahu apa yang harus diambil dari sini. Semua yang dibicarakan sudah merupakan pemrograman jaringan dasar
Tampaknya ini lebih berfokus pada plumbing tingkat lebih tinggi dan terlalu tenggelam dalam manajemen state, tetapi itu hanya soal selera dan tidak ada hubungannya dengan networking
Hal paling menarik yang saya pelajari dari artikel itu adalah bahwa Cloudflare menjalankan server STUN publik. Namun itu pun tidak terlalu membantu. Versi protokol STUN yang “baik” dan “berguna” adalah versi pertama yang mendukung fitur
change requestsyang memungkinkan enumerasi NAT. Pada versi STUN berikutnya, fitur itu dihapus berkat “saran membantu” dari para engineer Cisco yang ikut menyumbang ke spesifikasiSaat ini di Rust, misalnya jika Anda mengimplementasikan library WebRTC yang memakai runtime async Tokio, orang yang memakai I/O sinkron, runtime lain (smol, async-std, dan sebagainya), atau langsung memakai iouring akan sangat kerepotan menggunakannya
Dengan pendekatan ini, pilihan I/O tidak dipaksakan kepada konsumen, sehingga library bisa berguna bagi lebih banyak orang