Thread Yerine async/await’i Seçme Nedeni
(notgull.net)- Rust’ın
async/awaitmekanizması, thread’lerin basit bir ikamesi değil; I/O odaklı eşzamanlılık kodunu bileştirilebilir durum makineleri olarak ifade eden bir programlama modelidir - Web sunucuları gibi aynı anda birçok bağlantıyı ele alması gereken kodlarda yalnızca doğrusal yürütme hızla sınıra dayanır; thread’ler
thread::spawnile istemci işleme işini ayırarak eşzamanlı işlemeyi mümkün kılar async/await,awaitnoktalarında yürütmeyi devreder ve executor’ın başka işleri yürütmeye devam etmesini sağlayarak birçok işi tek bir runtime içinde dönüşümlü çalıştırır- 3 saniyelik timeout gibi gereksinimler
asynciçinderaceveTimerbileşimiyle eklenebilir; senkron thread kodunda iseTcpStream’e özel bir sarmalayıcı ve okuma/yazma timeout ayarları gerektiğinden genellik azalır asyncyalnızca performans ek yüküyle açıklanırsa CPU bound işlerde karşı örnekler ortaya çıkar; Rustasync’in gücü anlamsal ifade gücü ve ekosistem bileştirilebilirliğindedir
Rust eşzamanlılık probleminin çıkış noktası
- Rust’ın normal kodu varsayılan olarak doğrusal bir yürütme yapısına sahiptir
foo(),bar(),baz()gibi bir iş bittikten sonra sonraki iş çalışır
- Web sunucusu gibi aynı anda birden çok işi yürütmesi gereken durumlarda doğrusal yapı hızla sınıra ulaşır
TcpListener::accept()ile istemciyi kabul ediphandle_client()çalıştıran bir yapıda, ilk istemci işlenirken ikinci istemci beklemek zorunda kalırhandle_client()birkaç milisaniye sürüyorsa ve eşzamanlı 2 istemci varsa kısa bir bekleme oluşur- Eşzamanlı istemci sayısı 2 milyon ise kuyruğun sonundaki kullanıcı birkaç dakika bekleyebilir
Thread’lerin çözüm biçimi
- İşletim sistemi thread’leri register değerlerini ve program stack’ini belleğe kaydedebilir, başka bir rutini çalıştırdıktan sonra daha sonra özgün rutine devam edebilir
- Web sunucusu kodu istemci işlemeyi
thread::spawn(move || handle_client(client))biçiminde ayrı bir thread’e devreder- Ana thread yeni bağlantıları
accept()etmeye devam eder - İstemci işleme thread’i blocking olursa OS ana thread’e dönüp sonraki bağlantıyı alabilir
- İki istemci, birkaç mikrosaniye düzeyinde gecikmenin ardından paralel çalışabilir
- Ana thread yeni bağlantıları
- Üretim düzeyi bir web sunucusunda onlarca CPU çekirdeği varsa OS thread’lerin aynı anda çalışıyormuş gibi görünmesini sağlamanın yanı sıra gerçekten birden çok thread’i aynı anda çalıştırabilir
async/await nasıl çalışır
- Kullanıcı alanı eşzamanlılığı için olay tabanlı programlama, aktörler, coroutine’ler gibi çeşitli modeller vardır; Rust’ın seçtiği yöntem
async/await’tir - Basitleştirerek söylersek program, birbirinden bağımsız çalıştırılabilen durum makineleri kümesi olarak derlenir
async fngeleneksel bir fonksiyon değil, durum makinesi döndüren bir fonksiyondurawait, başka bir durum makinesini mevcut durum makinesinin adımlarından biri olarak içerir- İç fonksiyon yeni bağlantı beklemek gibi yürütmeyi devrederse tüm durum makinesi kontrolü üstteki executor’a bırakır
smol::Executorgibi executor’lar mevcut durum makinesi yerinespawnile oluşturulmuş başka bir durum makinesini çalıştırırasync move { handle_client(client).await }bloğumainden bağımsız yeni bir durum makinesidirmaindevrettiğinde istemci işlerinden biri çalışır; o iş tekrar devrettiğinde sıradaki işe geçilir
- Bu yapıyla milyonlarca istemci aynı anda ele alınabilir; ancak executor, task, durum makinesi gibi kavramlar eklendiği için karmaşıklık da artar
Timeout örneğinde görülen bileştirilebilirlik
- Rust’ın güçlü yanlarından biri bileştirilebilirliktir
Iteratorçeşitli combinator’lar eklenip sonucu tekrarIteratoralan fonksiyonlara verilebilirmpsc::channel()içindekirecv.try_iter().filter(...).map(...)gibi değerler filtrelenip dönüştürülerek listeye eklenebilir
async/awaitbu bileştirilebilirliği I/O bound fonksiyonlara da uygulamayı sağlarhandle_client();read_to_end,do_something_with_data,write_allçağrılarınıawaiteden asenkron bir fonksiyonsa 3 saniyelik timeout iki Future bileştirilerek uygulanabilir- Bu yöntem yalnızca
TcpStream’e bağlı değildirimpl AsyncRead + AsyncWriteuygulayan herhangi bir hedefe aynı kalıp uygulanabilir- Normal bir stream üzerindeki GZIP stream’i, Unix socket’i, dosya gibi hedefler de ikame edilebilir
Aynı timeout’u senkron thread kodunda uygularken kısıtlar
- Blocking kodda genel olarak
readveyawritesistem çağrılarını kesmek zordur; dosya tanıtıcısını kapatma gibi yöntemler Rust’ta kullanılamaz TcpStream,set_read_timeoutveset_write_timeoutsağlar- Okuma ve yazma için ayrı ayrı timeout ayarlanabilir
- Ancak istemci her 2,9 saniyede 1 bayt gönderirse basit timeout sürekli sıfırlanabilir
- Buna karşı korunmak için
TcpStream’i saranDeadlineStreamgibi bir tip oluşturup, toplam deadline’a kalan süreyi her seferinde hesaplayarak okuma/yazma timeout’una ayarlamak gerekir - Bu yaklaşım çalışabilir, fakat kısıtları büyüktür
TcpStream’e bağlıdır- Rust’ta
set_read_timeoutveset_write_timeoutkullanımını soyutlayan bir trait yoktur - Genel bir writer’a uygulamak için çok fazla ek çalışma gerekir
- Timeout ayarlamak için ek sistem çağrıları devreye girer
- Gerçek web sunucusu mantığında kullanımı daha zahmetli olabilir
Rust async ekosisteminden örnekler
- HTTP ekosisteminin istemciler de dahil olmak üzere
async/await’i ana runtime mekanizması olarak benimsemesinin arkasında fonksiyon bileştirilebilirliği vardır- HTTP çağrısı oluşturan fonksiyonlar çeşitli boşluklara ve kullanım durumlarına uyacak şekilde yerleştirilebilir
tower,async/awaitbileştirilebilirliğini gösteren başlıca örneklerden biridir- Servisler
asyncfonksiyon olarak uygulanırsa timeout, hız sınırlama, yük dengeleme, hedging, backpressure işleme eklenebilir - Hangi runtime’ın kullanıldığından veya servisin içeride ne yaptığından bağımsız olarak
toweruygulanıp dayanıklılık artırılabilir
- Servisler
macroquad, Rust için küçük bir oyun motorudur ve motoru çalıştırmak için ana fonksiyondaasync/awaitkullanır- Rust’ta bir işi beklemek için doğrusal fonksiyonu durdurmanız gereken durumları ifade etmeye
async/awaituygundur - Aynı thread üzerinde oyun sunucusu ağ bağlantısını ve GUI framework’ünü aynı anda polling eden bir yapı mümkündür
- Rust’ta bir işi beklemek için doğrusal fonksiyonu durdurmanız gereken durumları ifade etmeye
async’i yalnızca performansla açıklamanın sınırları
- Rust Async Book, OS thread’lerinin programlama modelini değiştirmeden eşzamanlılığı ifade etmeyi kolaylaştırdığını; ancak thread’ler arası senkronizasyonun zor, performans ek yükünün yüksek olduğunu ve thread pool’ların da büyük ölçekli I/O bound iş yüklerini yeterince desteklemekte zorlandığını karşılaştırır
asynctopluluğunda, OS thread’leri yerine nedenasynckullanıldığı sorusuna “ek yükü düşük, geri kalanı aynı” tarzında yanıt verme eğilimi vardır- Web sunucusu yazarlarının
async/await’e geçme nedeni C10k problem’i çözmekti; ancak tüm kullanıcılarınasync/awaitseçme gerekçesi performans olmak zorunda değildir - Performans avantajı duruma göre ortadan kalkabilir
- CPU bound işlerde thread tabanlı workflow, eşdeğer
asyncworkflow’dan daha hızlı olabilir - Rust
async’in geçici performans avantajı gereğinden fazla vurgulanmış, anlamsal avantajları ise olduğundan az değer görmüştür
- CPU bound işlerde thread tabanlı workflow, eşdeğer
async/awaitniş vakalara yönelik bir araç değil; senkron Rust’ta onlarca thread ve channel olmadan özlü biçimde ifade edilmesi zor kalıpları ele alan güçlü bir programlama modelidir
sync Rust gibi yapmaya çalışmak yerine farkı kabul etmek
- Rust proje yol haritasında
async Rustyazmanın, zaman zamanasyncveawaitanahtar kelimelerini kullanmak dışında senkron kod yazmak kadar kolay olması gerektiği yönünde bir hedef vardır - Ancak
async Rust’ı “sync Rust ile tıpatıp aynı” yapma çerçevesinin temelde zor olduğunu düşünen bir görüş de vardır- %99’a kadar benzer kılınabilse bile ortalama bir kullanıcının farkı fark etmesi kaçınılmazdır
- Rust’ın
async/awaitekosistemi senkron Rust ile aynı olmaya çalışmak yerine, bileştirilebilirlik ve ifade gücü güçlü yanlarını daha net ortaya koymalıdır - Eşzamanlılık gerektiğinde
async/await’in varsayılan seçenek olmasını sağlamak için bu modeli teknik performans nedenlerinden çok anlamsal nedenlerle açıklamak gerekir
1 yorum
Hacker News görüşleri
Tek iş parçacıklı async/await basit ve iyi bilinen bir modeldir; JavaScript de bu şekilde çalışır
İş parçacıkları, sorunun çözümüne birden fazla CPU’yu dahil etmeyi sağlar ve Rust kilit yönetiminde yardımcı olur. Öncelikleri farklı iş parçacıkları da kullanılabildiğinden, hesaplama ağırlıklı işler için gerekli olabilir
Buna karşılık çok iş parçacıklı async/await dağınık hale gelir. Hesaplama ağırlıklı bölümler ciddi biçimde işin içine girerse, diğer işlerle paylaşılan iş parçacığını fiilen bloke eder; bu yüzden model kolayca çökmeye meyillidir
Rust’ta hesaplama ağırlıklı çok iş parçacıklı kullanım, beklendiği kadar iyi çalışmaz. Depolama ayırıcısında birden fazla iş parçacığı aynı kilide yüklenirken futex yoğunluğu çöküşü yaşanabilir; özellikle tampon büyütme sırasında tüm ayırıcı kilitli tutulup yeniden kopyalama yapılırsa bu çok pahalı hale gelir. Wine’ın Microsoft kütüphanesi emülasyon .DLL’i içindeki kütüphane ayırıcısı bu soruna yatkındı; CPU zamanı tamamen spinlock’lara gidiyor, performans iki basamaklı katlar mertebesinde düşüyordu; Microsoft’un uygulamasında ise böyle değildi
Ayrıca standart
Mutexvecrossbeam-channelkanallarında adil olmayan mutex açlığı oluşabilir. Birden fazla iş parçacığı bir kaynağı kilitleyip iş yapıp kilidi bırakmayı tekrarladığında, bir iş parçacığı sürekli kazanırken diğerleri geride kalabilir. Adil bir mutex gerekiyorsaparking-lotvar; ancak standart mutex’in, iş parçacığı panic ettiğinde sağladığı poisoning güvenliği yokI/O ağırlıklı değilse işler çok daha karmaşıklaşır
https://users.rust-lang.org/t/mutex-starvation/89080
I/O aktarım kapasitesi yine sınırsa, bir milyon coroutine’in ne anlamı var diye düşünüyorum. Boyutu 10 olan bir DB bağlantı havuzunu hemen tüketiyorsanız coroutine’ler sizi kurtaramaz; yalnızca hata ayıklamayı ve etrafından dolaşmayı daha zor, akıl yürütmeyi de daha güç hale getirir
CPU ağırlıklı sorunlar sistematik kesme/sürdürme mekanizmasına indirgeniyor gibi; çalışmakta olan n yürütme iş parçacığı için adil ve verimli kuyruk tabanlı bağlam geçişi mümkün olsaydı, örneğin devam eden n bağlama sahip bir CPU’da, sorun acaba bir kaynak tahsisi sorununa dönüşmez miydi diye merak ediyorum
Sorumlu tasarlanmış bir dil olarak gördüğüm Go bile başta işbirlikçi yaklaşımla başladı ama sonunda öncelikli (preemptive) modele geçmek zorunda kaldı. Bu, işbirlikçi çoklu görevin işe yaramaz olduğu anlamına gelmez; ancak üzerinde uyarı etiketi olmalı, hatta daha ileri gidip belirli türdeki kodların çalıştırılması statik olarak engellenebilir
İlgili yazı olarak “What color is your function”ı ekliyorum
https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
async/await’in çok iş parçacıklı yürütücüsü açlığı yeterince ele alabilir; .NET uygulaması ise bloklayan çağrılarla asenkronu karıştıran çok kötü kodlara bile dayanabiliyor
https://news.ycombinator.com/item?id=39530435
https://news.ycombinator.com/item?id=39786142
https://news.ycombinator.com/item?id=39721626
https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
Yüksek ölçeklenebilirlik gerektiren bir yazılım değilse async’in ödünleri buna değmeyebilir
async/await ile thread tartışmasının özü hangisinin daha karmaşık olduğu değil; ekosistemi ikiye bölmesi ve taraflardan birinin ikinci sınıf vatandaş hâline gelmesi, projede yanlış seçim yapıldığında sürtüşme doğurmasıdır.
Birlikte kullanılabilirler, ama gerektiğinde bu hacky ve verimsiz olur. Bugünkü Rust ekosisteminde I/O devreye girdiğinde fiilen her şey async/await ekosistemine bağlanacak şekilde karar verilmiş durumda; Rust’ta yapmak istediğiniz neredeyse her şey de nadiren istisna olmak üzere I/O içerdiğinden, uygulamanın geri kalanı async istese de istemese de asenkron olmayan kütüphaneleri çoğunlukla görmezden gelmek gerekir.
Rust async/await’ten daha iyi bileştirilebilir bir soyutlama kullansaydı ve bu bileştirilebilirlik başka şeylerin de async/await olmasını gerektirmeseydi, şikâyetlerin çoğu muhtemelen ortadan kalkardı.
Daha kötüsü, ekosistemin ikiye bölünmekle kalmaması; async kodun içinde de genellikle yürütücüye, çoğu zaman da Tokio’ya güçlü biçimde bağlanması. Fonksiyon rengi problemini genişletirsek, mavi (I/O olmayan), yeşil (bloklayan I/O), kırmızı (async I/O) yerine pratikte mavi, yeşil, kırmızı (Tokio), mor (async-std), turuncu (smol) gibi bir duruma dönüşüyor.
Bu sorun için sans-I/O deseninin en iyi çözüm olduğunu düşünüyorum. Tüm mavi kodu ayırıp I/O ve zaman için kontrolün tersine çevrilmesini kullanırsanız, çekirdek protokol mantığını I/O’dan habersiz hâle getirebilir ve farklı I/O biçimleriyle sarmalamayı kolaylaştırabilirsiniz.
0: https://hugotunius.se/2024/03/08/on-async-rust.html
Async bir uygulamada bloklayan kod kullanmak beklendiği kadar pürüzsüz değildir, ama zor da değildir.
foo()yerinetokio::spawn_blocking(foo).awaitkullanırsanız yeni kodu ayrı bir thread’de çalıştırır ve o thread bittiğinde tamamlanan bir future döndürür.I/O için asenkron olmayan seçenekler olsa da, asenkron seçeneği kullanırsanız pratikte
Main()’e kadar her şeyin async olmasını dayatır. Senkron bir metottan async bir metodu güvenli biçimde çağırmanın yolları da vardır, ama hata ayıklamayı aşırı derecede zorlaştırır.Yazıda eksik kalan çok şey var.
async/await tek bir thread bağlamında çalıştığı için kilitlere veya senkronizasyona ihtiyaç yoktur; ancak CPU çekirdeklerinden yararlanmak için async/await’i birden çok thread’de çalıştırırsanız kilitler ve senkronizasyon yeniden gerekli hâle gelir. Bu karmaşıklık dış koda gizlenebilir. Örneğin tek bir DB bağlantısına erişimi senkronize etmek yerine her async görev için bir DB bağlantısı açmak daha kolaydır, ama SQLite veya PostgreSQL’de bu performansı etkileyebilir.
async/await’te hata yayılımı net değildir. Özellikle async görevleri bir araya getirmeye çalışırken böyledir; Happy Eyeballs bunun tipik bir örneğidir.
Ağ I/O’sundan söz ediyorsak backpressure da ele alınmalıdır. CPython’ın async/await uygulaması ağ backpressure’ının yetersizliğiyle kötü üne sahiptir ve bunun yol açtığı sorunlar vardır.
Gang of Four’un “Design Patterns” kitabı çoğunlukla C++ kusurlarını aşmak için bir tarif kitabıydı; insanlar ise bu kusurların olmadığı dillere de o desenleri uyguladı.
Rust JavaScript değildir ve birden çok thread’i iyi çalıştırabilir. Bu yüzden mutlaka async/await kullanmak zorunda değildi; bir sistem dili olarak başka çözümleri deneyebilirdi.
Ama JavaScript programcılarına Rust’ı benimsetmek için async/await gerekliydi. without.boats, “Rust’ın hayatta kalmasının bu özelliğe bağlı olduğu varsayımına duyulan samimi tutkuyla async/await’i savundum” diye yazmıştı.
https://without.boats/blog/why-async-rust/
async/await’in Rust’a teknik olarak iyi uyup uymaması önemli değildi; JavaScript programcıları async/await’e alışkın olduğundan Rust’ın da async/await’e sahip olması gerektiği düşünülmüş gibi görünüyor.
Bir kod bloğu münhasır erişime dayanıyorsa ve içinde
awaitolmadığı için bu garanti ediliyorsa, arayaawaiteklemek kodu bozar. Threading en azından neye münhasır erişim gerektiğini kodda açıkça belirtmenizi sağlar.async aynı zamanda kendi thread zamanlamanızı kendiniz yöneteceğiniz anlamına da gelir. I/O yoğun ve CPU-bound kod kısa ise sorun yoktur; ama CPU-bound kod varsa veya ara sıra bile ortaya çıkıyorsa scheduler oyunu oynamaya başlarsınız.
Birden çok servis öylece ABEND ile çöküyordu; Java’dan gelen biri olarak böyle bir eksiklik şaşırtıcıydı. Ekibe düzeltme yöntemini anlatmak da zordu.
Hata yayılımı yüzünden, seçme şansım varsa async/await kullanmam. Tek başıma yaptığım bir projede belki; ama başkalarıyla birlikte çalışıyor, kütüphaneler kullanıyor ve herkesi aynı anlayışta buluşturmak zorundaysam kesinlikle istemem.
Dil düzeyinde yapısal eşzamanlılığı henüz ciddi biçimde kullanmadım, ama Java’nın Loom Project’inden umutluyum. Görünüşe göre bu tartışmayı anlamsız kılacak.
Yazıda sorunlar var
Örnek yalnızca tek bir web sunucusundan ibaret ve thread tarafındaki çözüm yanlış ele alınmış. Ayrıca soru, sanki insanlar async/await yerine OS thread’leri istiyormuş gibi varsayıyor
Programcının istediği şey kavramsal ve semantik thread’ler. Sıralı mantık yazmak ve
asyncgibi tuhaf anotasyonlar kullanmamak. async/await bu kadar iyiyse tüm fonksiyonları örtük olarak async yapıpawaityerine normal fonksiyon çağrısı kullansak olmaz mı? O zaman fiilen thread’lerle programlama yapmış oluruzOS thread’leri statik olarak ayrılmış stack yüzünden pahalı; bizim istediğimiz tek bir CPU’da milyonlarcasını çalıştırabileceğimiz ucuz thread’ler. Tabii hantal
async/awaitsözcükleri olmadan.wait, bir olayı ya da başka bir thread’in tamamlanmasını bekleyen klasik anlamdaki bloklayıcı bekleme için kalabilir, ama fonksiyon çağrılarında istemiyoruzWeb sunucusu örneğine dönersek, zaman aşımı
driver.race(timeout).awaitile uygulandığında,racezaman aşımı hatasını bildirdikten sonra istemci soketine ne oluyor? Açık ve bağlı halde sızdırılıyor olmasınThread sürümündeki zaman aşımı da
threaded_race(client_thread, timeout).waitgibi async/await’e neredeyse benzer şekilde yapılabilir.threaded_race, bir zamanlayıcıyla zaman aşımını thread’e paralel olarak izler ve süre dolunca Java tarzıclient_thread.interrupt()çağırır.Thread.interrupt(), thread bloklanmamışsa yalnızca bir bayrak set eder; bir I/O çağrısında bloklanmışsaInterruptedExceptionfırlatır. Bu checked exception olduğu için derleyiciclient.read_to_end(&mut data)çağrısının try/catch ile sarılmasını ya dahandle_clientiçinde istisnanın bildirilmesini zorunlu kılar; böylece programcı istemci soketini kapatmayı unutmazrace()içindeki değerlerDropedilir vedriver’ın kendisi kalırTipi olduğu gibi kullanırsanız Rust,
Result’ın işlenmediğinden şikâyet eder; future içinde yeni bir soket yerel olarak oluşturulduysa temizlenirRust future’larının iyi yanı, etrafındaki tüm davranışı tanımlayabilmenizdir. Tüm fonksiyonların bloklayıcı olduğu modelin aksine Rust, yürütmeyi iş kuyruğundaki sonraki işe ne zaman erteleyeceğinizi belirlemenize izin verir ve açıkça saklanan duruma (
Futureyapısı) sahip olarak işi keyfi biçimde hızlı poll eder. Bu yüzden thread’lerdeki gibi yield etmek içinsleep()yapmaya gerek kalmaz; hızlıdır ve akıl yürütmesi kolaydırJava’daki
Thread.interruptda sonuçta sleep döngüsüne yakın bir şeydir ve çoğu uygulama için sorun olmayabilir. Ama Rust bir sistem dili olduğundan gömülü sistemlerde böyle bir yaklaşımı bulunduramazsınız; kernel veya düşük gecikmeli uygulamalar için de arzu edilir değildirÇoğu durumda bunun OS’in bloklayıcı sistem çağrısı mı yoksa bloklamayan sistem çağrısı mı olduğu pek umursanmaz; ama okuduğunuz programın kontrol akışını anlamak, nerede beklediğini ve nasıl paralel çalıştırılabileceğini bilmek istersiniz
Hatta bloklayıcı fonksiyonlarla çalışırken
blocking/blockanahtar sözcüğü ikilisi olmasını isterdim. Bloklayıcı çağrılar sinsice bütünü yavaşlatabilir ve UI thread’ine bloklayıcı sistem çağrısı girip bunaltıcı derecede yavaş hissettiren çok fazla uygulama gördümSenkron ve asenkronu birleştirmeye yönelik tüm girişimler başarısız oldu. Tek bir thread içinde çalışan kod, thread’ler arasında çalışan kod, hatta bilgisayarlar arasında çalışan kod arasında büyük semantik farklar vardır. Bunu soyutlamaya çalışınca sonunda yetersiz kalır; bu yüzden en baştan düzgünce öğrenmek daha iyidir
poll()veselect()ile yazmanın da bir yolu var; o da ayrı bir yaklaşımasync/await’in itibarını kurtarmaya yönelik neredeyse pazarlama gibi bir kampanya görmek ilginç
Benim deneyimime göre bu yalnızca teknik bir hata olmakla kalmadı, topluluğa da büyük bir bedel ödetti. Rust’ın çabası gerçekten yararlı dil özelliklerine odaklanmak yerine bu karmaşaya sapıp yoldan çıktı
Yine de dile hâlâ büyük umut bağlıyorum ve elimizdekiler içinde en iyisi olduğunu düşünüyorum. Sadece bu kavganın sonsuza kadar sürmesinden endişeliyim
Not:
AsyncWrite/AsyncReadörneği makul görünüyor, ama aslında *nix ile sınırlarsanız aynı şey thread’ler ve file descriptor’larla da yapılabilirBöyle bir genellemenin dayanağı zayıf ve belli bir iş yüküne fazla yanlı görünüyor
async ile hiç ilgilenmeyen ve Rust hakkında bir şeyler okumak isteyen biri için bu epey kötü
poll()dan daha hızlı olduğunu düşünüyorsanız hangi kullanım senaryosu olduğunu bilmek isterim. Hayatımda hiç böyle bir durum görmedimHata, buna ihtiyaç duymayan kullanım senaryolarının büyük çoğunluğuna da dayatılmasında
Birçok büyük Rust projesi, yalnızca thread tabanlı alternatiflere göre büyük performans artışları nedeniyle değil, tasarım özellikleri nedeniyle de async’e dayanıyor. Başlıca I/O-bound iş yüklerinde bu avantajlar kolayca görülüyor. Gerçek dünya sorunlarını çözen akıllı insanların başlıca crate’lerde async’i yaygın biçimde benimsemiş olması, async’in gerçekten yararlı bir dil özelliği olduğuna dair güçlü bir işaret bence
Kavga daha çok Hacker News ve reddit’te, async’e ihtiyacı olmayan insanların kullandığı I/O crate’lerinin artık async istemesine kızması şeklinde yaşanıyor. Bunun keyifsiz bir durum olduğunu anlıyorum; async’in gerçek sorunları olduğu ve bunların hâlâ çözülmekte olduğu da doğru. Mükemmel değil. Ama forumlarda görülen async ayrışmasının gerçek projelerdeki kadar geniş ya da dramatik olmadığı hissindeyim
Büyük ölçüde atlanan konu iptal
Future’ları iptal etmek çok kolaydır. Buna karşılık thread iptali köstebek vurma oyunu gibi dağınıktır; bir thread’i zorla durdurmak da kilitlerin kilitli kalması riski yüzünden güvenilir değildir.
Rust’ın async modelinde her future’a dışarıdan timeout eklenebilir. Uçtaki her I/O fonksiyonunun timeout seçeneği desteklemesine de, bu timeout’u tüm çağrı yığını boyunca aktarmaya da gerek yoktur.
Devam eden durum yönetiminde Rust’ın en iyi uygulaması olan
Dropguard’larıyla birlikte kullanıldığında, büyük ve karmaşık işler bile kolay ve güvenilir biçimde iptal edilebilir.Örneğin
spawn_blockingkullanan bir şeyi iptal etseniz (drop) bile arka planda çalışmaya devam eder ve kullanıcı bunu bilmeyebilir. Thread pool ile uygulanmış async dosya sistemi işleri de iptal edilse bile çalışmaya devam eder.Bu tür şeyler, “O dosyayı hiçbir şeyin yazmadığından eminim; o hâlde servis neden dosya kullanımda diye hata veriyor?” gibi anlaşılması zor hatalara yol açabilir.
Sorun oldukça izomorfik. Sistem çağrıları zordur; ama ister thread içinde ister async future içinde yapın, aynı sistem çağrısını yaptığınızda tam olarak aynı iptal sorunu ortaya çıkar.
Tüm thread’lerin erişebileceği bir flag gibi bir durum tutup, iş döngüsünde bu flag’in kontrol edilmesini sağlamak yeterli. false ise geri dönersiniz ve thread’i join edersiniz; biter.
Bir iş artık faydalı değilse, bu bilginin eninde sonunda o iş adına çağrılan fonksiyona görünmesi yeterlidir. RPC başlatmak gibi çok pahalı bir işin hemen öncesi değilse ayrıca kontrol etmem.
Daha iyi soru bence “neden fiber değil de async/await?” olmalı
Rust’ın 1.0 öncesinde green thread’leri olduğunu ve bunları bilinçli olarak kaldırdığını biliyorum; ama fiber tabanlı eşzamanlılık uygulamak için, dile ağır bir runtime gömmeyi gerektirmeyen yöntemler de dahil olmak üzere çeşitli yaklaşımlar var.
Yazıyı doğru anladıysam, future’ların herhangi bir anda drop edilebilmesini özellikle övüyor gibi. Thread’lerde bariz nedenlerle benzer bir şey yapılamaz; teknik olarak mümkün olsa bile aşırı derecede güvensiz olur. Ama bu yeteneğin çok büyük bir maliyeti var.
io-uringgibi tamamlama tabanlı yürütücülerle stack tabanlı dizileri birlikte kullanamamak ya da alt işleri başka yürütücü thread’lerinde çalıştıramamak bir yana, senkron Rust kullanırken çok tatsız sürprizlere dönüşen ince tuzaklar ve güvenilirlik sorunları da doğuruyor.https://smallcultfollowing.com/babysteps/blog/2022/06/13/asy...
İş iptalinin temelde işbirlikçi iptal olması gerektiğini düşünüyorum; işbirlikçi olmayan iptal ise yüzeyde kullanışlı görünse de altında derin sorunlar barındıran hatalı bir özellik gibi.
Ayrıca async/await’in bileştirilebilirliğini övmek de garip. Düzgün bir effect system’i olmayan mevcut Rust’ta, bulaşıcı doğası yüzünden bileştirilebilir olmaktan epey uzak. Örneğin standart kütüphanedeki
mapmetodunu async closure ile birlikte kullanmayı ya da standartio::Read/Writetrait’lerini kullanmayı deneyin.Stack’leri spaghetti stack olarak mı uygulayacaksınız, süreç düzeyinde bellek eşleme kütüphanesi mi gerektireceksiniz, yoksa sabit boyutlu stack’lerle mi sınırlayacaksınız gibi seçimler gerekir.
Bu üç yaklaşım da farklı ABI’ye sahip dillerin kodlarıyla etkileşime girerken sorun çıkarır. Örneğin bir fiber’dan C kodu çağırdığınızda ve o C kodu başka bir fiber’ı sürdürmeye çalıştığında işler epey karmaşıklaşabilir.
async/await’in avantajlarından biri
awaitanahtar sözcüğünün kendisidir. Açık bekleme noktaları sayesinde eşzamanlı programların etkileşimini gerçekten akıl yürüterek takip edebilirsiniz.Yield eden fiber, eşzamanlılık dünyasının
gotosu gibidir. Bir metodu çağırdığınızda yan etki olarak yürütmenin durup durmayacağını, devam ettiğinde dünyanın durumunun değişmiş olup olmayacağını bilemezsiniz. Dış dünyayla temas ederken savunmacı yazmak gerektiğinden, fiber’lar izole çalışıp sonuçla iletişim kuran işler için daha uygundur.Green thread’ler, fiber’lar ve coroutine’ler burada aynı sorunu paylaşır. Kullanıcı alanında işbirlikçi eşzamanlılık, eşzamanlılığın zor kısmını çözmekten çok masadaki evrakları yer değiştirmeye benzer. Rust async/await daha açıktır; diğer mekanizmaların gizlediği yan etkileri gizlemez.
Fiber tabanlı kodda, yürümekte olan thread’i zihnimde takip etmem gerektiği için izlemesi zor geliyor. En azından benim için tamamlanacak değeri takip etmek çok daha kolay.
C++ komitesi için Gor Nishanov’un incelemesine http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p136... bakın. https://devblogs.microsoft.com/oldnewthing/20191011-00/?p=10... adresinde de bağlantı verilmiş. Özeti de açık: DO NOT USE FIBERS!
https://m.youtube.com/watch?v=qKQcUDEo-ZI
async/await’in bulaşıcı ve hantal olduğunu, fiber’ın en azından Ruby uygulamasında çok daha iyi bir paradigma olduğunu epey iyi gerekçelendirdiğini düşünüyorum.
Bir optimizasyon algoritması kök bulma problemini çağırabilir, o da bir ODE integrator’ını çağırabilir; böyle iç içe geçebilir ve herhangi bir aşama çok uzun sürebilir. İptal token’larını her yere geçirmek gerekir ama sayısal hesaplama framework’leri genelde bunu desteklemez.
Her algoritmaya yineleme sayısı sınırı koyabilirsiniz ve koymalısınız; ancak iç içe algoritmalarda bu, “bu yıl içinde durur” düzeyinde bir garanti verir, 5 saniye içinde duracağını garanti etmek zordur.
Bu tür problemlerde yaptığım işin yalnızca büyük miktarda matematik hesabı, allocation ve bunun sonucu page fault’lar, I/O olmaması, iptal etmeyeceğim Main thread’de işlenecek standart kütüphane Queue nesnesine log string’leri yazmak olduğunu garanti edebilirim. Gereken diğer işlevleri de Queue üzerinden ana thread’e geri gönderebilirim.
21. yüzyılda bu sorunun, iptal token’larını her yere zorla taşımadan ve token kontrolü olmadan uzun süren kodu savunmacı yazmaya mecbur bırakmadan çözülebilmesi gerektiğini düşünüyorum.
Bir başka async/await tartışmasında insanlar async/await’i anlamıyor; tek bir thread’de neden eşzamanlılık mekanizmasına ihtiyaç duyulacağını hayal edemiyor ve kimsenin buna ihtiyacı olmadığını varsayıyorlar.
UI programlama, GPU ile iletişim ve runtime’lar arası iletişim iyi örnekler; muhtemelen daha fazlası da vardır.
Thread’ler, green thread olsun ya da olmasın, bu durumlara uygun değil; async/await ise uygun.
Aynı binary’yi üretirken dil kullanıcıları için daha az zahmetli başka dil özellikleri uygulanabiliyorsa, bunu da konuşmaya değer.
Rust async/await’in başlıca avantajlarından biri, thread’lerin veya dinamik belleğin olmadığı durumlarda bile çalışabilmesidir.
Bir mikrodenetleyicide, bir interrupt’ın belirli bir buffer’a gelen I2C verisini okumasını bekleyen sade bir kod yazmak için bile rahatça kullanılabilir. Alttaki runtime ile etkileşimi fazla açığa çıkarmadan eşzamanlılığı kullanmayı sağlayan daha üst düzey bir soyutlamadır.
Üzerinde çalıştığım tüm büyük yazılımlar bunun bir biçimini uyguluyordu. Modern C++ coroutine kavramı olmayan kodlarda bile Apple Grand Central Dispatch, Intel Threading Building Blocks vb. kullandık. Aksi halde iş mantığı I/O’da çok verimsiz biçimde bloklanır, aşırı fazla thread yüzünden geliştirme ve hata ayıklama cehenneme döner, alttaki runtime’ın uygulama ayrıntılarıyla kaplanır ya da bunların üçünün bir karışımı olur.
Dilin kendisindeki veya kütüphanelerdeki mevcut soyutlamaları kullanmazsanız sonunda kendiniz yazarsınız; bu da zordur ve yaygın kullanılan çözümlerden genel olarak daha kötü olma ihtimali yüksektir. Geçmişte C++ için kendim de yazmıştım: https://github.com/goto-opensource/asyncly
Yazar iki şeyi birbirine karıştırıyor gibi görünüyor.
Biri kullanıcı alanı thread’leri/green thread’ler, diğeri ise yapısal eşzamanlılık.
İlki async/await’in avantajıdır, ama ona özgü bir avantaj değildir. Go veya Java Loom gibi, function coloring sorunu olmadan bunun mümkün olduğu örnekler var.
İkincisi hem OS thread’leriyle hem de green thread’lerle uygulanabilir. Java’nın Structured Concurrency JEP’ine bakılabilir.
https://openjdk.org/jeps/462