3 puan yazan GN⁺ 2024-07-05 | 1 yorum | WhatsApp'ta paylaş
  • Firezone’un Rust bağlantı kütüphanesi connlib, ağ bağlantıları ile WireGuard tünellerini yönetiyor ve sans-IO tasarımı sayesinde hızlı testler ile yüksek çalışma güvenilirliği sağlıyor
  • Protokoller, soketleri doğrudan ele almak yerine saf bir durum makinesi olarak uygulanıyor; olay döngüsü de handle_input, poll_transmit, handle_timeout, poll_timeout gibi API’leri çağırıyor
  • IO seçimi durum makinesinin dışına itildiğinde, Rust async’in function colouring yükü azalıyor; blocking IO, non-blocking IO ve belirli bir async runtime seçimi uygulamaya bırakılabiliyor
  • Soketler ve zaman soyutlandığında, gerçek portlar ya da bekleme süreleri olmadan yalnızca Instant ve Transmit ile zamanın geçişi, paket kaybı ve anormal yanıtlar doğrulanabiliyor
  • Buna karşılık olay döngüsünü doğrudan yönetmek gerektiği için ince hatalar oluşabiliyor, sıralı iş akışlarında durum makinesi kodu artıyor ve Rust ekosistemindeki sans-IO kütüphaneleri hâlâ sınırlı

Firezone connlib’in seçtiği sans-IO yapısı

  • Firezone, Android, macOS ve Linux’ta ölçeklenebilir güvenli uzaktan erişim oluşturmak için Rust kullanıyor
  • Her uygulamanın merkezinde connlib bulunuyor; bu kütüphane ağ bağlantılarını ve WireGuard tünellerini yöneterek trafiği koruyor
  • Firezone’un Rust yığını tokio, tungstenite, boringtun, rustls gibi bileşenleri kullanıyor ama iç yapısı tipik async Rust kodundan farklı
    • Neredeyse hiç tokio::spawn çağrısı yok
    • Tüm iletişim tek bir UDP soketi üzerinden çoklanıyor
    • Birden fazla katmanda handle_timeout, poll_transmit, handle_input gibi API’ler tekrar ediyor
  • Bu özellikler sans-IO tasarımının işaretleri; protokol mantığı doğrudan IO yapmıyor, onun yerine durumu ve giriş/çıkış niyetini ifade ediyor
  • Python ekosisteminde sans-IO’ya özel bir dokümantasyon sitesi var; Rust tarafında ise şu kütüphaneler bu deseni kullanıyor
    • quinn: bağımsız QUIC uygulaması
    • quiche: Cloudflare’ın QUIC uygulaması
    • str0m: sans-IO WebRTC uygulaması

async Rust ve function colouring yükü

  • Rust’taki async fonksiyonlar yalnızca başka async fonksiyonların içinden çağrılabildiği için, tüm çağrı zincirinin async’e dönüşmesine yol açan bir function colouring kısıtı ortaya çıkıyor
  • Bu kısıt, yürütmenin durdurulup daha sonra devam ettirilebilmesinin fonksiyonun API sözleşmesinin bir parçası olduğunu derleme zamanında zorunlu kılıyor
  • Çağrı yığınının derinlerindeki tek bir async fonksiyon yüzünden, çağrı yolundaki diğer fonksiyonlar da .await için async olmak zorunda kalabiliyor
  • Gerçek async işler genelde çağrı yığınının en alt seviyesinde oluşuyor
    • sokete yazmak
    • dosya okumak
    • zamanın geçmesini beklemek
  • Birçok async fonksiyon doğrudan asenkron iş yapmadığı halde, başka async fonksiyonlara bağlı olduğu için async oluyor
  • Firezone’un connlib’i, NAT traversal için ICE kullanıyor ve STUN ile server-reflexive candidate, yani genel adresi buluyor
  • STUN binding, sunucuya UDP paketi gönderip ardından sunucunun gördüğü IP ve portu içeren bir UDP yanıtı almaktan oluşan basit bir protokol
  • Aynı STUN örneği, tokionun async UdpSocketiyle de standart kütüphanenin blocking IO’suyla da neredeyse aynı şekilde yazılabiliyor
  • STUN işlevini bir kütüphane olarak sunmak isterseniz, async sürümle blocking sürüm arasında seçim yapmanız ya da ikisini birden eklemeniz gerektiği için tekrar oluşuyor
  • Örnek kod firezone/sans-io-blog-example içinde yer alıyor

sans-IO’nun özü, politika ile IO uygulamasını ayırmak

  • sans-IO’nun özü, nesne yönelimli dünyadaki bağımlılıkların tersine çevrilmesi ilkesine benziyor
  • “Ne yapılacağına” karar veren politika kodu, “nasıl yapılacağını” gerçekleştiren uygulama ayrıntılarına bağımlı olmamalı
  • Ağ mesajı göndermeye karar veren kod, gerçek soket gönderim koduna doğrudan bağlıysa, üst katmandaki kod da async ya da blocking IO seçimine bağlanıyor
  • STUN örneğinde politika kodu aynı kalıyor ama tokio::UdpSocket üzerinde kurulursa async, std::net::UdpSocket üzerinde kurulursa blocking IO oluyor
  • sans-IO, doğrudan UdpSocket::send çağırmak yerine, gönderim niyetini temsil eden bir soyutlama oluşturuyor
  • Örnekteki Transmit şu bilgileri taşıyor
    • hedef SocketAddr
    • gönderilecek payload
  • Protokol kodu doğrudan sokete yazmak yerine Transmit üretiyor
  • Gerçek UdpSocket::send veya send_to çağrılarıysa olay döngüsü tarafından yapılıyor
  • sans-IO kodunun ilerleyebilmesi için, Rust Futurelarının runtime tarafından poll edilmesi gerektiği gibi, olay döngüsü tarafından sürülmesi gerekiyor

STUN binding’i durum makinesine dönüştürmek

  • STUN binding isteği, Sent ve Received durumlarına sahip bir durum makinesi olarak modellenebiliyor
  • Örnekteki durumlar şu enum ile ifade ediliyor
    • Sent
    • Received { address: SocketAddr }
  • StunBinding, mevcut durumu ve gönderilmeyi bekleyen bir Transmit kuyruğunu tutuyor
  • Temel API’lerin rolleri açık
    • handle_input: UdpSocket::recv sonucunda gelen paketi durum makinesine iletiyor
    • poll_transmit: durum makinesinin dışarı göndermek istediği Transmit değerini olay döngüsü alıyor
    • public_address: alınmış genel adresi sorguluyor
  • Bu yapıda protokol mantığı, IO olmadan yalnızca programın davranışını modelliyor
  • Olay döngüsü, poll_transmit ile gönderilecek paket varsa onu sokete yolluyor; yoksa soketten okuduğu veriyi handle_input ile iletiyor
  • Olay döngüsünün, STUN’un request-response protokolü olduğu ayrıntısını bilmesine gerek yok
  • UDP güvenilmez bir protokol olduğu için paketler kaybolabiliyor ve STUN bunu hafifletmek adına yeniden iletim zamanlayıcısı gerektiriyor

Zamanı da soyutlamak

  • Ağ protokollerinde mevcut zamana ihtiyaç duyulan çoğu durumda, belirli bir referans anından sonra ne kadar zaman geçtiğini kontrol etmek gerekir
    • istek gönderildikten sonra 5 saniye geçti mi
    • son keep-alive’dan sonra 30 saniye geçti mi
  • Böyle durumlarda gerçek wall clock zamanı gerekmez; yalnızca önceki anla arasındaki Duration gerekir
  • Rust’taki Instant, mevcut zamanı açığa çıkarmadan iki Instant arasındaki Durationın ölçülmesini sağlar
  • sans-IO durum makinesi, zamana bağlı davranışlar için iki API’ye sahip olabilir
    • poll_timeout: olay döngüsünün bir sonraki wake-up zamanlayıcısını ne zaman kurması gerektiğini döndürür
    • handle_timeout: zamanlayıcının süresinin dolduğunu durum makinesine bildirir
  • Örnek, son yanıt alındıktan 5 saniye sonra yeni bir binding request gönderecek şekilde durum makinesini genişletiyor
  • handle_input, paketle birlikte mevcut Instant değerini alıyor ve bunu State::Received { address, at } biçiminde saklıyor
  • Olay döngüsü, soketten veri almayı ve zamanlayıcı süresinin dolmasını birlikte ele alıyor; sonra poll_timeout sonucuna göre zamanlayıcıyı yeniden kuruyor

Bileşim ve API esnekliği

  • StunBinding’in temel API’leri olan handle_timeout, handle_input, poll_transmit, poll_timeout yalnızca STUN’a özgü değil
  • Ağ protokollerinin çoğu bu biçimde ya da türevlerinde uygulanabildiği için durum makinesi bileşimi kolaylaşıyor
  • Genel IP’yi bulmak için 5 STUN sunucusuna sorgu göndermek gerekiyorsa, 5 adet StunBinding oluşturulup sırayla çağrılabiliyor
    • Bu durumda STUN mesajlarının çoklanması uygun şekilde uygulanmalı; TransactionId veya sunucu adresi kullanılabilir
  • Firezone’un snownet bileşeni, ICE ile WireGuard’ı birleştirerek çeşitli ağ ortamlarında çalışan bir IP tünelini uygulamaya sağlıyor
  • snownet, sans-IO WebRTC kütüphanesi str0m ile neredeyse sans-IO olan WireGuard uygulaması boringtun üzerine kurulmuş
  • Firezone’un ihtiyacı tüm WebRTC yığını değil, RFC 8445 uygulayan IceAgent
  • str0m sans-IO yaklaşımını kullandığı için yalnızca IceAgentı alıp mevcut kodun durum makinesine bileştirmek kolay oluyor
  • snownet içindeki bağlantı, IceAgent ile WireGuard tünelini içeriyor ve gelen mesajları bu ikisinden birine iletiyor

Olay döngüsünü doğrudan yazmanın avantajları

  • sans-IO kodu yalnızca sistem durumunu ifade eder, yan etkiler üretmez; bu yüzden olay döngüsünün durumu sorgulaması, işlemleri yürütmesi ve yeni girdileri iletmesi gerekir
  • Bu yapı ilk bakışta boilerplate gibi görünebilir ama olay döngüsünü doğrudan yazabilmek ince ayar kontrolü sağlar
  • Uygulama, aşağıdaki gibi çalışma biçimlerini kendisi seçebilir
    • paket gönderirken sistem çağrısı sayısını azaltmak için sendmmsg kullanmak
    • birden fazla protokolü tek bir soket üzerinden çoklamak
  • Kütüphane yazarları, async runtime tartışmalarına ya da soket seçenekleri için API sunmaya odaklanmak yerine protokol özelliklerini uygulamaya yoğunlaşabilir
  • str0m, ağ arayüzlerini listelemeyi bir IO meselesi olarak görür ve bunu uygulamaya bırakır
  • Onun yerine yalnızca soket adreslerini mevcut duruma ICE candidate olarak ekleyen API’yi sağlar
  • Firezone bu yapıyı kullanarak, bağlantı kurulmadan önce TURN candidate’larını önceden toplayan ve bağlantı kurulum gecikmesini azaltan bir optimizasyon uyguluyor
  • ICE’de her iki taraf da candidate, yani soketleri topladıktan sonra aralarındaki bağlantıyı test eder

Hızlı testler ve edge case doğrulaması

  • sans-IO kodu doğası gereği yan etkisiz olduğu için birim testlerine çok uygun
  • Soketler ve zaman soyutlandığından, testlerin gerçek port açmasına ya da zaman beklemesine gerek kalmıyor
  • 5 dakika sonraki davranışı test etmek için yalnızca değiştirilmiş Instant değerini fonksiyona verip durum değişimini doğrulamak yeterli
  • Firezone, snownetin 5 dakika sonra boşta kalan bağlantıyı kapatıp kapatmadığını test eden gerçek bir örnek sunuyor
  • Veri iletimi de gerçek soketlerden geçmeden yapılabiliyor; bir taraftaki Transmit alınıp diğer taraftaki durum makinesinin handle_input fonksiyonuna veriliyor
  • Firezone, connlib’in nasıl davranması gerektiğini gösteren bir referans durum makinesi uygulamış
  • Bu referans durum makinesi testler için ölçüt olarak kullanılıyor
  • proptestin state machine testing yaklaşımıyla, her CI çalışmasında binlerce senaryo deterministik biçimde örneklenip yürütülüyor ve referans durum makinesi ile connlib’in gerçek durumu karşılaştırılıyor
  • IO olmadığında, şu tür başarısızlıklar ve anormal davranışlar da kolayca test edilebiliyor
    • paket kaybı nedeniyle yanıt alınamaması
    • geçersiz yanıt alınması
    • sunucuya RTT’nin çok yüksek olması
    • çalışan IPv6 arayüzü bulunmaması
    • yalnızca IPv6 arayüzü bulunması
  • Protokol uygulaması ile gerçek IO yan etkileri ayrıldığında, hata tespiti ve işleme de durum makinesinin girdi işleme sürecinin bir parçası hâline geliyor

Rust ile sans-IO neden iyi uyuşuyor

  • Rust, hangi bileşenin ya da fonksiyonun hangi değere sahip olduğunu açıkça belirtmeyi zorunlu kılıyor
  • UdpSocketten okuma yapılırken, gerçek baytların yazılacağı alan olarak &mut [u8] sağlamak gerekiyor
  • Bir değerin sahibi olan taraf, onu mutable yapabilir ya da başka fonksiyonlara geçici mutable reference verebilir
  • Bu açık sahiplik ve değiştirilebilirlik modeli, borrow checker gibi Rust özelliklerinin temelini oluşturuyor
  • sans-IO tasarımındaki durum makinesi API’lerinin tamamı senkron fonksiyonlar; IO ya da zamanı beklerken block etmiyorlar
  • Durum makinesi yalnızca bir veri yapısı olduğu için, durum değişimini &mut self ile ifade etmek kolaylaşıyor ve borrow checker sayesinde kodun soundness’ı güvence altına alınabiliyor
  • Buna karşılık async Rust içinde &mut kullanımı daha zorlayıcı hissedilebiliyor
  • Rust’ın async fonksiyonları, Future uygulayan veri yapılarına derleniyor
  • Bu veri yapılarının tokio gibi runtime’larda spawn edilebilmesi için 'static olmaları gerektiğinden, &mut gibi referansları içeremiyorlar
  • Future dışındaki durumu değiştirmek için genelde şu yaklaşımlardan biri kullanılıyor
    • Arc<Mutex<T>> gibi referans sayımlı işaretçi ve mutex yapıları
    • birden çok task spawn edip bunları channel ile bağlayan actor yaklaşımı
  • Her iki yaklaşımın da runtime maliyeti var
    • lock contention oluşturabilir
    • channel üzerinden mesaj iletimi kopyalama gerektirebilir
  • Birden fazla task’ın runtime içinde deterministik olmayan sırayla çalışması, race condition ve deadlock’a yol açabiliyor
  • sans-IO protokol kodu task spawn etmediği için, durum değişiklikleri için yalnızca &mut self gerekiyor
  • Task ya da thread yoksa Mutex gibi senkronizasyon primitive’lerine ihtiyaç kalmıyor; channel yoksa veri kopyalama ihtiyacı da azalıyor
  • Firezone, sans-IO’ya geçtikten sonra channel’ın karşı ucu, kapanmış channel’lar veya hangi kodun Mutex kilitlediğini takip etme ihtiyacının azaldığını; bunun da kodun anlaşılmasını kolaylaştırdığını düşünüyor

Dezavantajlar ve kullanım alanı

  • sans-IO her soruna uyan sihirli bir çözüm değil
  • Olay döngüsünü elle yazmak güçlü kontrol sağlasa da, başlangıçta fark edilmesi zor ince hatalara yol açabiliyor
  • Örneğin durum makinesinin poll_timeout dönüş değeri ileri gitmiyorsa, olay döngüsü busy loop’a girebilir
  • Sıralı iş akışları daha fazla kod gerektiriyor
  • Rust’ın async fonksiyonları, her .await noktasının farklı bir duruma geçiş olduğu durum makinelerine derlendiği için, geliştirici non-blocking IO ile sıralı kodu kolayca birlikte yazabiliyor
  • sans-IO’da ise bu adımların geliştirici tarafından doğrudan durum makinesi olarak modellenmesi gerekiyor
  • StunBinding gibi request-response protokollerinde bu zor değil ama daha büyük sıralı iş akışlarını ifade etmek yorucu olabiliyor
  • Rust topluluğunda sans-IO tasarımı henüz yaygın değil
  • Çoğu kütüphane sans-IO yerine blocking IO veya non-blocking IO uyguluyor
  • boringtun, içeride Instant::now çağırdığı için kısmen saf olmayan bölümlere sahip; ilgili issue cloudflare/boringtun#391 altında yer alıyor

Sonuç

  • sans-IO kodu ilk başta alışılmadık gelebilir ama zamanla Rust’ın durum makinesi modelleme araçlarıyla iyi uyum sağlıyor
  • Hataları başka girdiler gibi ele almaya zorlayan bu yapı, ağ kodu yazma biçimiyle de iyi örtüşüyor
  • async Rust yazmanın başka yolları da var; structured concurrency, sans-IO ile burada ele alınan async Rust yaklaşımı arasında bir yerde duruyor
  • structured concurrency hakkında withoutboats’un Let futures be futures yazısına bakılabilir

1 yorum

 
GN⁺ 2024-07-05
Hacker News yorumları
  • Bu bir yenilik ve ileri adım gibi paketlenmiş, ama aslında async/await dil desteği gelmeden önce Rust dahil $lang içinde asenkron işlemler böyle ele alınıyordu.
    Rust gömülü firmware geliştirmede en büyük üretkenlik artışı, her I/O işlemi arasında durum makinesini elle yazmayı ve yerel değişkenleri özel durumlara taşımayı bırakıp Rust’ın async/await sözdizimiyle bunu bizim yerimize yapmasını sağlayabildiğimizde geldi.
    Rust’ta async nihayetinde I/O (await) noktaları arasındaki değerleri saklayan otomatik bir durum makinesine indirgenir.

    • Yazının son kısmında buna değinmek istemiştim. Uyguladığınız şey çoğunlukla sıralı I/O işlemleri ise bu model epey acı verici hale geliyor.
      Ama her zaman böyle değil. QUIC, WebRTC, IP gibi paket odaklı kullanım durumlarında gerçek I/O’nun kendisi kolaydır. Tek tek paketleri/datagramları gönderip alırsınız.
      .await noktası çok olmadığı için derleyicinin üreteceği şey de pek yoktur. Aynı anda birden fazla yönün çalışması gerektiğinden her birinin kendi future/task’ine girmesi gerekir; bu yüzden bu future’lar genelindeki durum yönetimi kolayca spagetti koda dönüşür.
    • Bu açıklamanın tam olarak doğru olduğunu sanmıyorum. Buradaki önemli nokta, durum makinesinin I/O’yu doğrudan yapmaması. Bunu her zaman kendisini barındıran event loop’a devreder; bu yüzden birden fazla bağlamda yorumlanabilir.
      Çalışma zamanı ortamına dair varsayımlar azaldığı için test etmesi ve birleştirmesi daha kolay hale gelir.
      Teoride async/await’in durum makinesi oluşturma biçimiyle de aynı şey yapılabilir, ama pratikte oldukça sancılıdır ve çoğu async/await kodu saf değildir.
      Eff, Koka, Frank gibi deneysel diller bu programlama biçimine çok iyi destek verir. Haskell’in I/O tartışmalarının temelinde de free monad ve türevleri gibi tekniklere yapılan derin yatırımlar yatar.
      Son zamanlarda Unison ilginç bir dil; birçok yeni kavramı araştırırken çekirdeğine genişletilebilir bir etki sistemi koyarak bu tür kodlamayı dil düzeyinde iyi destekliyor.
    • 1980’lerde assembly dilinde I/O böyle yapılıyordu. Kesme tabanlı bir YMODEM uygulamasını başka nasıl yazacaktınız?
    • Doğru. async’te beni rahatsız eden tek şey, her yere .await yazmak zorunda olmak. Varsayılan olarak .await çalışsa ve yalnızca özel bir sözdizimiyle bunun tersi yapılabilse güzel olurdu.
    • Bunu devrimsel diye paketlemiş değiller. Yazıda da şöyle deniyor: “Bu kalıbı biz icat etmedik! Python dünyasında bunun için özel bir web sitesi bile var.”
      Buna rağmen pratikte protokol kütüphanelerinin I/O’yu doğrudan yaptığını çok sık görüyorum :-(
  • Bu problem alanını sürekli kafamda evirip çeviriyordum; düşündüğüm yönle çok iyi örtüşen bir yaklaşım. Yalnız yazının 3. dipnotunda olduğu gibi hâlâ elden geçirilmesi gereken yerler var.
    Beni bu düşünceye götüren şey, fonksiyon rengi tartışmaları ve tesadüfi bir keşifti. Bir VT100 kütüphanesi yaparken birim testleri çok zordu; çünkü fiilen parser::new(stdin()) yapıyordum. Üçüncü ya da dördüncü yeniden yazımda pek düşünmeden parser’ı parser::push(data) haline getirdim ve o anda Rust’ın, benim “kapsülleme takıntısı” demeye başladığım kurumsal OOP tarzı antipattern’i cezalandırdığını fark ettim.
    Artık bu kalıbı ve zararlarını yalnızca I/O’da değil, her yerde görüyorum.
    İronik biçimde bu çözüm üniversite öncesi eğitimde de, üniversitenin başlarında da öğretilen bir şey. Bilgisayarın en basit açıklaması, girdi alıp veriyi işleyen/dönüştüren ve çıktı üreten bir makine olduğudur. Fonksiyon rengi tartışmasıyla ilgili olmasının nedeni, rengi önemsemesi gereken şeylerin yalnızca girdi ve çıktı olması; çekirdek mantığın ise genellikle veri dönüşümü olmasıdır.
    Bariz bir şey, ama fonksiyon rengi “tartışmasının” ölçeğine bakınca birçok insanın sorunları önce kapsüllemeyle çözmeye şartlandırıldığı için bu gerçeği kaçırdığı ya da unuttuğu anlaşılıyor. Fonksiyonel taraftakiler bu noktada epey memnun olacaktır.
    Rust benim için öğrenme yolculuğundan çok bir şeyleri bırakıp yeniden öğrenme yolculuğu oldu. İyi bir kalıp ve bundan sonra benimsemeyi düşünüyorum.
    Düzenleme: ilgili kod: https://codeberg.org/jcdickinson/termkit/src/branch/main/src...

    • parser::push(data) haline getirdiğin kısım ile Rust’ın kurumsal OOP tarzı antipattern’i cezalandırdığı kısmı biraz daha açıklayabilir misin?
      Rust’a yeni başlayan biri olarak bu kalıbın bariz sorununun ne olduğunu ve Rust’ın bunu nasıl cezalandırdığını pek göremiyorum.
    • Ben de OOP dünyasından Rust’a geçtim ve üzerinden şimdiden 6 yıl geçti. İlk 2-3 yıl korkunç kodlar yazdım.
      Her yerde tip parametreleri ve trait’ler vardı; struct’ları işlev sağlayan sınıflara benzer yapılar gibi kötüye kullandım.
      Rust, mümkün olduğunca tip parametrelerinden ve kendi trait tanımlarınızdan kaçındığınızda daha iyi oturuyor.
      Değişmez koşulları koruma anlamındaki kapsülleme iyidir. Bu yazı, “parse, don’t validate” aklıma geliyor: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
  • Bu tasarım, verileri özel bir handler’a göndermek için kanal kullanma yöntemiyle karşılaştırıldığında nasıl? Kanal kullanırken çeşitli sorunlar vardı
    (1) Takip etmesi zor, örümcek ağı gibi kodlar sıkça ortaya çıkıyor
    (2) Ağ üzerinden gönderilebilecek mesajlara dönüştürülebilen mesaj tiplerini kendiniz uygulamanız gerekiyor
    (3) İlgilenen ya da izin verilen varlıklara göndericiyi açıkça geçirmeniz gerekiyor
    (4) Kanal mesajı gönderiminin başarısız olup olmadığını bilebilirsiniz, ama o mesajın ağ üzerinden iletiminin başarısız olup olmadığını bilemezsiniz
    Yine de oldukça kullanışlı. Örneğin bir ws_handler kanalı varsa, veriyi oraya göndermeniz yeterli; bir yerlerdeki özel handler mümkünse o mesajı gönderebilir

      1. madde, mesajla birlikte sonucu geri gönderecek bir kanal geçirerek uygulanabilir. İsterseniz gönderen tarafı çağrı noktasına kadar tamamen bekletebilirsiniz
        sans-IO uygulamalarda da kullanılabilir, ama özellikle kütüphaneler için faydalı olduğunu düşünüyorum. Kütüphanelerde tüketiciye I/O yöntemini dayatmadığı için çok daha kullanışlı hale geliyor
        Rust’ta senkron I/O ile asenkron I/O arasında zaten bir ekosistem bölünmesi var; buna farklı asenkron runtime’lar da eklenince bu nokta önem kazanıyor
    • Yazılımı aktör benzeri bir tasarıma götürmek sizin için uygunsa kanallar iyi çalışır
      Ancak söylediğiniz gibi sorunları var. Örneğin aktörler/kanallar kopabilir. Ayrıca backpressure istiyorsanız mutlaka sınırlı kapasiteli kanal gerekir. Üstelik kopyalama gerektiğinden yüksek throughput elde etmek zorlaşabilir
  • Birlikte bakmaya değer: monad’lar, özellikle Free(r) monad’ları ve effect system’leri[0]
    Mantık ile yürütmeyi ayırma fikri Haskell ekosisteminde zaten yeterince ele alınmış büyük bir konu
    Düzenleme: Zamanla ilgili işlem gerektiğinde ortaya çıkan tokio::select! çağrısını nasıl kapsüllediklerinden bahsetmemiş. Dış kodun async olmasına gerek kalmadan döngü kodunu async yapmak için yanında bir tokio::Runtime mı taşıyor?
    Düzenleme2: Belki de kapsüllenmiş kütüphanenin bunu yaptığını göstermek değil, dış uygulamanın async bağlamda binding’i kullanabileceğini göstermek istemiş olabilir
    sans-IO tarzında, bir eylemi ya da timer’ı beklemesi gereken kapsüllenmiş bir fonksiyonun nasıl uygulanabileceğini daha çok merak etmiştim. Yoksa beklenen yanıt busy-wait olabilir; ya da aslında block_in_place benzeri bir şekilde busy-wait yerine geçen kendi async runtime örneğini yanında taşıyor olabilir
    [0]: https://okmij.org/ftp/Computation/free-monad.html

    • Buradaki “kapsüllenmiş fonksiyon” StunBinding struct’ı. STUN binding’in işlevini temsil ediyor. Basitçe çağrılabilen tek bir fonksiyon değil, bir event loop gerekiyor
      Esas nokta, StunBinding’in kütüphane içinde bulunabilmesi ve uygulama tarafında bunun programın state machine’ine bileştirilerek kullanılabilmesi. Elbette uygulamanın da sans-IO tarzında yapılandırıldığı varsayılıyor
      Bağlantı verilen snownet kütüphanesi tam olarak bunu yapıyor. Alanı, I/O olmadan ICE + WireGuard’ı birleştirmek; bunun üzerinde de ACL’leri bileştiren connlib kütüphanesinde kullanılıyor
      Düzenleme: Busy-wait yok. Bunun yerine StunBinding, poll_timeout aracılığıyla neyi beklediğini dışarı açan bir fonksiyona sahip. Çağıranın, yani event loop’un, bunu nasıl gerçekleştireceği çağırana kalmış. Karşılık gelen Instant ile handle_timeout çağrıldığında uygun davranış gerçekleşiyor
  • Oo, thomaseizinger!
    rust-libp2p’nin içlerine daha önce baktığım için yazıyı okurken bu pattern bana epey tanıdık gelmişti; demek tesadüf değilmiş
    Firezone harika görünüyor. Her şeyi bağlayın!

    • Teşekkürler!
      Evet, rust-libp2p ile benzerlikler var. Ancak orada gerçek stream’ler ve bağlantılar hâlâ Future benzeri bir yapının içinde bulunduğundan daha iç içe geçmiş durumda; buradaki sans-IO gibi katı biçimde ayrılmış değiller
  • Sıralı workflow’ların daha fazla kod gerektirdiğini söyleyen bir bölüm var. Rust’ta async fonksiyonlar state machine’e derlenir ve her .await noktası başka bir duruma geçişi temsil eder. Bu yüzden geliştiricilerin sıralı kod ile non-blocking I/O’yu birlikte kullanması kolaydır. async yoksa birden fazla adımı ifade etmek için state machine’i elle yazmak gerekir
    async ile sans-IO’yu birleştirmeyi deneyen biri var mı? En azından kavramsal olarak, sans-IO’yu bilen helper’ları await eden async fonksiyonlar yazıldığında, tamamı iyi bir sans-IO arayüzüne sahip bir struct içindeki state machine’e derlenip async olmayan koddan da kolayca çağrılabilmeli gibi geliyor
    Kendim denemedim ama beklenen başlıca sorunların iyi kullanılabilirlik ve Pin yönetimi olacağını düşünüyorum

    • Rust’ta söylediğiniz kullanım senaryosunu bir ölçüde ele alabilecek generator/coroutine’ler var, ama şu anda çok kararsız bir özellik
      Ne yazık ki mevcut biçimiyle coroutine’lerin yalnızca std::ops::Coroutine trait’i üzerinden dışa açılması gibi can sıkıcı bir sınırlama var. Bu yüzden derleyicinin ürettiği iç state machine’i doğrudan ayıramazsınız. State machine’in boyutu dışarıdan bakıldığında derleme zamanı sabiti gibi görünse bile durum böyle
      Ömrü tanımlı bir fonksiyonun içinde kalan tek bir coroutine ise sorun değil. Derleyici bunu anlayıp state machine’i stack üzerinde ayırabilir
      Ama coroutine’lerin en faydalı kullanım alanı, event loop düzeneğinin kuyruk elemanları olarak görülebilir. Bu uygulama, coroutine’i boxing yapmadan mümkün değil. Vec> cache dostu bir veri yapısı değil ve aşırı yüksek eşzamanlı I/O’da Vec içinde bir milyon elemana ihtiyaç duyarsanız acısını hissedersiniz

    • Rust’a bir gün yerel generator söz dizimi gelirse mümkün olabilir. Çünkü async işin bağlamı içinde kalırken veriyi “yazmak” için yield transmit diyebilirsiniz. Yani tüm socket.write çağrıları yield transmite dönüşmüş olur
      Veri okurken generator duraklatılır (.await) ve gelen veriyle birlikte devam ettirilmeyi bekler. nightly’de böyle bir söz dizimi var mı bilmiyorum ama kabaca şöyle görünmeli:

      // Uydurma gen söz dizimi: 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; // Uydurma bir "argümanla askıya al ve sürdür" sözdizimi.

let addr = parse_stun_response(res);

addr  
}
  • Yığının üst seviyelerinde ikisi oldukça iyi uyum sağlıyor. Bloklamayan I/O, yani async, soket I/O’sunu ve zamanı aynı anda beklemeyi kolaylaştırıyor. Bloklayan I/O’da da sokete okuma zaman aşımı ayarlayarak bunu yapabilirsiniz ama async ilkel araçlarını kullanmak biraz daha kolay.
    Ben de ikisini nasıl birleştireceğimi düşünmeye devam ettim. Karşıma çıkan sorunlardan biri, async fonksiyonların opak tipler olarak derlenmesi. Bu yüzden derleyicinin durum makinesini kod olarak üretme özelliğini kullanmak zor ya da imkânsız oluyor. Çünkü oluşturulduktan sonra o durum makinesiyle etkileşime giremiyorsunuz. Bu, bir bakıma ödünç alma denetleyicisini de bozuyor.
    Örneğin birden çok aşaması, yani birden çok await noktası olan bir async işiniz olduğunu ve bu aşamalardan yalnızca birinde paylaşılan bir veri yapısına değiştirilebilir başvuru gerektiğini varsayalım. Bunu bir async fonksiyon olarak ifade ettiğiniz anda değiştirilebilir başvuru, oluşturulan Future tipine yakalanır ve o tip tüm aşamalar boyunca var olur. Sonuç olarak Rust, böyle bir işten iki tanesinin aynı anda çalıştırılmasına izin vermez.
    Normalde bu durumda verilen tavsiye “değiştirilebilir başvuruyu mümkün olduğunca kısa süreyle yakala” olur, ama async’te bunu yapamazsınız. Async fonksiyonu birden çok parçaya bölmek de dağınık hâle gelir ve en başta her şeyi tek bir fonksiyon olarak ifade etmek istemenizin amacını bir ölçüde bozar.

  • Daha önce HTTP/1.1 protokolünü, I/O için .await noktaları olan bir Sans-IO durum makinesi olarak kodlamayı denemiştim ama fazla ilerleyemedim. Ancak o I/O, async runtime’a bir waker kaydetmek yerine, kullanıcının I/O’yu bizzat gerçekleştirmesi için denetimi geri veren bir yöntemdi. .await’in “aşağı” değil “yukarı” doğru çözüldüğünü düşünebilirsiniz.
    HTTP/1.1 bağlamında async kod, kullanıcının çağrı davranışını nasıl istediğine dair bir tür taslak hâline gelmişti. O dönemde bunun mutlaka no_std ve ayırıcı olmayan ortamlarda çalışmasını sağlamaya çalışıyordum; Box üzerinden dinamik dispatch, yani ayırıcı gerektiren kısımlardan kaçınmanın yolunu bulamadığım için vazgeçtim.

  • https://news.ycombinator.com/item?id=40879547
    async fn stun biçiminde yazılmış bir örnek var. Çalışan kodun tamamı burada: https://gist.github.com/joshka/af299be87dbd1f64060e47227b577...

  • Güzel iş! Durumu açığa çıkarırsanız herhangi bir async fonksiyonu saf hâle getirebilirsiniz. Kullanıcının yalnızca durum makinesini bir sonraki duruma itmesi yeterli.
    Daha önce OpenSSL’i async Rust’a bağlamayı denemiştim; onun async API’si de benzer bir tasarımı izliyor.

    • Hızlıca bakınca OpenSSL’de bir “async job” API’si olduğunu gördüm. Ancak bu I/O yapıyor gibi görünüyor. Hatta job oluşturmanın çok pahalı bir işlem olduğu, bu yüzden job’ların yeniden kullanılması gerektiği belirtilmiş.
      Benzer gördüğünüz kısım, job olarak zamanlanan işin kendisinin nasıl çalıştırıldığından bağımsız olması mı?
      Bu örneğe [0] bakınca, o async API Rust’ın future’larına çok daha benzer görünüyor.
      Job içinden bir “wait context”e erişebiliyor, belirli koşullarda duraklatabiliyor ve yürütmenin devam etmesi için uyandırmayı tetikleyebiliyorsunuz.
      [0]: https://www.openssl.org/docs/man1.1.1/man3/ASYNC_is_capable....
  • Bu, coroutine yerine callback kullanan sıradan asenkron I/O sadece.

  • Yazıyı ve bazı yorumları okuyunca, hexagonal architecture ya da port/adapter mimarisi tarzını yeniden icat etmişler gibi geliyor.

  • Buradan ne çıkarmam gerektiğinden pek emin değilim. Anlatılanların hepsi zaten temel ağ programlama.
    Daha üst düzey tesisat işlerine odaklanıp durum yönetimine gereğinden fazla kapılıyor gibi görünüyor; bu bir tercih meselesi, ağ iletişimiyle ilgisi yok.
    Yazıdan öğrendiğim en ilginç şey, Cloudflare’in herkese açık bir STUN sunucusu çalıştırdığı. Ama bu da pek faydalı değil. STUN protokolünün “iyi” ve “yararlı” sürümü, NAT numaralandırmasını mümkün kılan change requests özelliğini destekleyen ilk sürümdü. Sonraki STUN sürümlerinde bu özellik, spesifikasyona katkı yapan Cisco mühendislerinin “yardımcı önerileri” sayesinde kaldırıldı.

    • Rust bağlamında büyük avantajın fonksiyon rengi problemini çözmesi olduğunu düşünüyorum. Yazıda açıklandığı gibi testler de gerçekten çok basitleşiyor.
      Şu anda Rust’ta örneğin Tokio async runtime’ını kullanan bir WebRTC kütüphanesi geliştirirseniz, senkron I/O kullananlar, başka runtime’lar (smol, async-std vb.) kullananlar veya iouring’i doğrudan kullananlar için onu kullanmak çok zahmetli olur.
      Bu yaklaşımı kullanırsanız tüketiciye I/O seçimini dayatmazsınız; böylece kütüphaneyi daha fazla kişi için kullanışlı hâle getirebilirsiniz.