Sans-IO: Ağ hizmetleri için Rust’ın etkili sırrı
(firezone.dev)- 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_timeoutgibi 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
InstantveTransmitile 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
connlibbulunuyor; 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,rustlsgibi 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_inputgibi API’ler tekrar ediyor
- Neredeyse hiç
- 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
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
.awaitiç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 asyncUdpSocketiyle 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
- hedef
- Protokol kodu doğrudan sokete yazmak yerine
Transmitüretiyor - Gerçek
UdpSocket::sendveyasend_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,
SentveReceiveddurumlarına sahip bir durum makinesi olarak modellenebiliyor - Örnekteki durumlar şu enum ile ifade ediliyor
SentReceived { address: SocketAddr }
StunBinding, mevcut durumu ve gönderilmeyi bekleyen birTransmitkuyruğunu tutuyor- Temel API’lerin rolleri açık
handle_input:UdpSocket::recvsonucunda gelen paketi durum makinesine iletiyorpoll_transmit: durum makinesinin dışarı göndermek istediğiTransmitdeğerini olay döngüsü alıyorpublic_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_transmitile gönderilecek paket varsa onu sokete yolluyor; yoksa soketten okuduğu veriyihandle_inputile 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
Durationgerekir - Rust’taki
Instant, mevcut zamanı açığa çıkarmadan ikiInstantarasındakiDurationı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ürhandle_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 mevcutInstantdeğerini alıyor ve bunuState::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_timeoutsonucuna göre zamanlayıcıyı yeniden kuruyor
Bileşim ve API esnekliği
StunBinding’in temel API’leri olanhandle_timeout,handle_input,poll_transmit,poll_timeoutyalnı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
StunBindingoluşturulup sırayla çağrılabiliyor- Bu durumda STUN mesajlarının çoklanması uygun şekilde uygulanmalı;
TransactionIdveya sunucu adresi kullanılabilir
- Bu durumda STUN mesajlarının çoklanması uygun şekilde uygulanmalı;
- Firezone’un
snownetbileş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üphanesistr0mile neredeyse sans-IO olan WireGuard uygulamasıboringtunüzerine kurulmuş- Firezone’un ihtiyacı tüm WebRTC yığını değil, RFC 8445 uygulayan
IceAgent str0msans-IO yaklaşımını kullandığı için yalnızcaIceAgentı alıp mevcut kodun durum makinesine bileştirmek kolay oluyorsnownetiçindeki bağlantı,IceAgentile 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
sendmmsgkullanmak - birden fazla protokolü tek bir soket üzerinden çoklamak
- paket gönderirken sistem çağrısı sayısını azaltmak için
- 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ş
Instantdeğ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
Transmitalınıp diğer taraftaki durum makinesininhandle_inputfonksiyonuna 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 ileconnlib’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 selfile ifade etmek kolaylaşıyor ve borrow checker sayesinde kodun soundness’ı güvence altına alınabiliyor - Buna karşılık async Rust içinde
&mutkullanımı daha zorlayıcı hissedilebiliyor - Rust’ın async fonksiyonları,
Futureuygulayan veri yapılarına derleniyor - Bu veri yapılarının
tokiogibi runtime’larda spawn edilebilmesi için'staticolmaları gerektiğinden,&mutgibi referansları içeremiyorlar Futuredışındaki durumu değiştirmek için genelde şu yaklaşımlardan biri kullanılıyorArc<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 selfgerekiyor - Task ya da thread yoksa
Mutexgibi 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
Mutexkilitlediğ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_timeoutdö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
.awaitnoktası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
StunBindinggibi 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çerideInstant::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
Hacker News yorumları
Bu bir yenilik ve ileri adım gibi paketlenmiş, ama aslında async/await dil desteği gelmeden önce Rust dahil
$langiç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.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.
.awaitnoktası ç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.Ç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.
.awaityazmak zorunda olmak. Varsayılan olarak.awaitçalışsa ve yalnızca özel bir sözdizimiyle bunun tersi yapılabilse güzel olurdu.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.
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_handlerkanalı varsa, veriyi oraya göndermeniz yeterli; bir yerlerdeki özel handler mümkünse o mesajı gönderebilirsans-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
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 birtokio::Runtimemı 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_placebenzeri 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
StunBindingstruct’ı. STUN binding’in işlevini temsil ediyor. Basitçe çağrılabilen tek bir fonksiyon değil, bir event loop gerekiyorEsas 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ıyorBağlantı verilen
snownetkütüphanesi tam olarak bunu yapıyor. Alanı, I/O olmadan ICE + WireGuard’ı birleştirmek; bunun üzerinde de ACL’leri bileştirenconnlibkütüphanesinde kullanılıyorDüzenleme: Busy-wait yok. Bunun yerine
StunBinding,poll_timeoutaracı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 gelenInstantilehandle_timeoutçağrıldığında uygun davranış gerçekleşiyorOo, 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!
Evet, rust-libp2p ile benzerlikler var. Ancak orada gerçek stream’ler ve bağlantılar hâlâ
Futurebenzeri 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ğillerSı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
.awaitnoktası 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 gerekirasync 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
Pinyönetimi olacağını düşünüyorumRust’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::Coroutinetrait’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’daVeciçinde bir milyon elemana ihtiyaç duyarsanız acısını hissedersinizRust’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 transmitdiyebilirsiniz. Yani tümsocket.writeçağrılarıyield transmite dönüşmüş olurVeri 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
gensö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
};
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
awaitnoktası 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 birasyncfonksiyon olarak ifade ettiğiniz anda değiştirilebilir başvuru, oluşturulanFuturetipine 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
.awaitnoktaları 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_stdve 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 stunbiç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.
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ı.Ş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.