3 poin oleh GN⁺ 2024-07-05 | 1 komentar | Bagikan ke WhatsApp
  • 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, dan poll_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 Instant dan Transmit tanpa 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, dan handle_input berulang di beberapa lapisan
  • 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
    • quinn: implementasi QUIC mandiri
    • quiche: implementasi QUIC dari Cloudflare
    • str0m: implementasi WebRTC sans-IO

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
  • connlib milik 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 UdpSocket async milik tokio maupun 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::UdpSocket hasilnya async, dan jika di atas std::net::UdpSocket hasilnya blocking IO
  • Alih-alih memanggil UdpSocket::send secara langsung, sans-IO membuat abstraksi yang merepresentasikan niat transmisi
  • Transmit pada contoh memuat informasi berikut
    • tujuan SocketAddr
    • payload yang akan dikirim
  • Kode protokol tidak menulis langsung ke socket, melainkan mengeluarkan Transmit
  • Pemanggilan nyata ke UdpSocket::send atau send_to ditangani oleh event loop
  • Kode sans-IO harus dijalankan oleh event loop, sebagaimana Future di Rust baru berjalan ketika dipoll oleh runtime

Mengubah STUN binding menjadi state machine

  • Permintaan STUN binding dapat dimodelkan sebagai state machine dengan state Sent dan Received
  • State pada contoh direpresentasikan dengan enum berikut
    • Sent
    • Received { address: SocketAddr }
  • StunBinding memiliki state saat ini dan antrean Transmit yang menunggu dikirim
  • API utamanya memiliki peran yang jelas
    • handle_input: meneruskan paket yang masuk dari hasil UdpSocket::recv ke state machine
    • poll_transmit: event loop mengambil Transmit yang ingin dikeluarkan state machine
    • public_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 ke handle_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 Duration terhadap waktu sebelumnya
  • Instant di Rust tidak mengekspos waktu saat ini, tetapi memungkinkan pengukuran Duration antara dua Instant
  • Untuk perilaku berbasis waktu, state machine sans-IO dapat memiliki dua API
    • poll_timeout: mengembalikan kapan event loop harus menjadwalkan timer wake-up berikutnya
    • handle_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_input menerima Instant saat ini bersama paket, lalu menyimpannya dalam bentuk State::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 StunBinding yaitu handle_timeout, handle_input, poll_transmit, dan poll_timeout tidak 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 StunBinding dan memanggilnya secara berurutan
    • Dalam kasus ini, multipleksing pesan STUN harus diimplementasikan dengan tepat, misalnya memakai TransactionId atau alamat server
  • snownet milik Firezone menggabungkan ICE dan WireGuard untuk menyediakan tunnel IP yang bekerja di berbagai lingkungan jaringan bagi aplikasi
  • snownet dibangun di atas library WebRTC sans-IO str0m dan implementasi WireGuard boringtun yang hampir sans-IO
  • Firezone tidak membutuhkan seluruh stack WebRTC, melainkan hanya IceAgent yang mengimplementasikan RFC 8445
  • Karena str0m memakai pendekatan sans-IO, IceAgent dapat diambil sendiri dan mudah dikomposisikan ke dalam state machine kode yang sudah ada
  • Koneksi dalam snownet memuat IceAgent dan 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
  • Penulis library dapat fokus pada implementasi fitur protokol alih-alih perdebatan runtime async atau penyediaan API opsi socket
  • str0m menganggap 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 Instant yang sudah berubah ke fungsi dan verifikasi perubahan state
  • Firezone menyediakan contoh nyata pengujian apakah snownet menutup idle connection setelah 5 menit
  • Pengiriman data juga tidak perlu melewati socket nyata; cukup ambil Transmit dari satu sisi lalu teruskan ke handle_input state machine di sisi lain
  • Firezone mengimplementasikan reference state machine yang merepresentasikan bagaimana connlib seharusnya 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 nyata connlib
  • 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, &mut bisa terasa lebih sulit ditangani dalam async Rust
  • Fungsi async Rust dikompilasi menjadi struktur data yang mengimplementasikan Future
  • Agar Future ini bisa di-spawn ke runtime seperti tokio, 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
  • 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 Mutex tidak 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 Mutex berkurang, 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_timeout pada 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 .await adalah 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 StunBinding tidak 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
  • boringtun memanggil Instant::now di 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

 
GN⁺ 2024-07-05
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 Rust
    Peningkatan 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)

    • Saya memang bermaksud membahas poin ini di bagian akhir tulisan. Kalau yang diimplementasikan sebagian besar adalah operasi I/O berurutan, model ini jadi cukup menyiksa
      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 .await tidak 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 spaghetti
    • Menurut saya penjelasan itu tidak sepenuhnya tepat. Poin penting di sini adalah state machine tidak melakukan I/O secara langsung. Ia selalu mendelegasikan pekerjaan itu ke event loop yang meng-host-nya, sehingga bisa diinterpretasikan dalam berbagai konteks
      Asumsi 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
    • Pada 1980-an, beginilah cara melakukan I/O dalam assembly. Memangnya bagaimana lagi menulis implementasi YMODEM berbasis interrupt?
    • Betul. Satu-satunya hal yang mengganggu dari async adalah harus menulis .await di mana-mana. Akan bagus kalau bisa dibalik: .await berjalan secara default, dan hanya tidak begitu dengan sintaks khusus
    • Tidak dikemas sebagai sesuatu yang revolusioner. Di tulisannya juga disebutkan: “Pola ini bukan kami yang menciptakannya! Di dunia Python bahkan ada situs web khusus untuk ini.”
      Meski 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 menjadi parser::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...

    • Bisakah menjelaskan lebih jauh bagian tentang mengubahnya menjadi 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
    • Saya juga pindah dari dunia OOP ke Rust, dan sekarang sudah 6 tahun. Selama 2–3 tahun pertama, saya membuat kode yang mengerikan
      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 itu

    • Poin 4 bisa diimplementasikan dengan meneruskan channel untuk mengembalikan hasil bersama pesannya. Kalau mau, sisi pengirim juga bisa diblokir terus sampai titik pemanggilan
      sans-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
    • Jika tidak masalah membawa software ke desain mirip aktor, channel bekerja dengan baik
      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-bawa tokio::Runtime agar 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

    • “Fungsi terenkapsulasi” di sini adalah struct StunBinding. Ia merepresentasikan fungsi dari STUN binding. Ini bukan satu fungsi tunggal yang bisa langsung dipanggil, melainkan membutuhkan event loop
      Intinya, StunBinding bisa berada di dalam library, dan pihak aplikasi bisa menggabungkannya ke dalam state machine program. Tentu saja dengan asumsi aplikasinya juga distrukturkan dalam gaya sans-IO
      Library snownet yang ditautkan melakukan persis hal ini. Domainnya adalah menggabungkan ICE + WireGuard tanpa I/O, dan dipakai di library connlib yang menyusun ACL di atasnya
      Sunting: Tidak ada busy waiting. Sebagai gantinya, StunBinding memiliki fungsi yang mengekspos apa yang sedang ia tunggu melalui poll_timeout. Terserah pemanggil, yaitu event loop, bagaimana merealisasikannya. Saat handle_timeout dipanggil dengan Instant yang sesuai, tindakan yang tepat akan terjadi
  • Oh, 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!

    • Terima kasih!
      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 sini
  • Ada bagian yang mengatakan workflow sekuensial membutuhkan lebih banyak kode. Di Rust, fungsi async dikompilasi menjadi state machine, dan setiap titik .await merepresentasikan 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 tahap
    Apakah 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 Pin

    • Rust 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 kompilasi
      Jika 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 di Vec, itu akan terasa menyakitkan

    • Jika suatu saat Rust memiliki sintaks generator native, mungkin ini bisa dilakukan. Karena di dalam konteks tugas async, kita bisa menulis yield transmit untuk “menulis” data. Dengan kata lain, setiap socket.write berubah menjadi yield transmit
      Saat 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 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; // Made up "suspend and resume with argument"-syntax.

let addr = parse_stun_response(res);

addr  
}
  • 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 fungsi async, referensi mutable tersebut akan ditangkap ke dalam tipe Future yang dibuat, dan tipe itu ada di seluruh tahap. Akibatnya, Rust tidak mengizinkan lebih dari satu pekerjaan seperti itu berjalan bersamaan
    Biasanya 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 .await untuk 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 .await melepaskan 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_std dan tanpa allocator, lalu menyerah karena tidak menemukan cara menghindari dynamic dispatch melalui Box, yaitu bagian yang membutuhkan allocator

  • https://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

    • Setelah mencari cepat, OpenSSL punya API “async job”. Namun itu tampaknya melakukan I/O. Bahkan disebutkan bahwa pembuatan job sangat mahal, jadi job harus digunakan ulang
      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 requests yang memungkinkan enumerasi NAT. Pada versi STUN berikutnya, fitur itu dihapus berkat “saran membantu” dari para engineer Cisco yang ikut menyumbang ke spesifikasi

    • Dalam konteks Rust, menurut saya keuntungan besarnya adalah menyelesaikan masalah warna fungsi. Seperti dijelaskan dalam artikel, pengujian juga menjadi sangat sederhana
      Saat 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