Async Rust hiçbir zaman MVP durumunu aşamadı
(tweedegolf.nl)- Async Rust, executor’dan bağımsız kodun sunucularda ve mikrodenetleyicilerde birlikte çalışmasını mümkün kılıyor; ancak derleyicinin ürettiği durum makinesi nedeniyle özellikle gömülü tarafta ikili dosya boyutundaki artış belirgin oluyor
bar()gibi iki adet await noktası olan basit bir örnek bile 360 satırlık MIR ileUnresumed,Returned,Panicked,Suspend0,Suspend1durumlarını üretiyor; eşzamanlı sürüm ise yalnızca 23 satır gerektiriyor- Tamamlanmış bir future yeniden poll edildiğinde
panicyerinePoll::Pendingdöndürecek şekilde değiştirmek, unsafe davranış olmadan sözleşmeyi karşılamayı mümkün kılıyor ve deneylerde gömülü firmware’in ikili dosya boyutunu %2 ila %5 azaltıyor - Await içermeyen
async { 5 }bile şu anda varsayılan üç durumlu bir durum makinesi üretiyor; ancak her seferindePoll::Ready(5)döndürecek şekilde optimize edilirse gömülü ikili boyutu %0,2 azalıyor - Önerilen Project Goal, sürüm modunda tamamlanma sonrası panic’in kaldırılması, await içermeyen async block’larda durum makinesinin kaldırılması, tek await’li future’ın inline edilmesi ve aynı durumların derleyicide birleştirilmesini hedefliyor
Async Rust’ta derleyici seviyesinde şişme sorunu
- Async Rust, executor’dan bağımsız kodun sunucularda ve mikrodenetleyicilerde aynı anda çalışmasını sağlıyor; ancak küçük mikrodenetleyicilerde ikili dosya boyutundaki artış özellikle dikkat çekiyor
- Rust blogu async/await’i sıfır maliyetli soyutlama olarak tanıttı; ancak async gerçekte ciddi miktarda şişme yaratıyor ve masaüstü ile sunucu tarafında da aynı sorun var, sadece bellek ve işlem kaynakları fazla olduğu için daha az görünür kalıyor
- Async kod yazarken şişmeyi önlemeye yönelik geçici çözümlerin ardından, sorunu derleyici tarafında çözmek için bir Project Goal sunuldu
- Future’ın gerekenden fazla büyümesi ve çok sayıda kopyalama yapılması kapsam dışında bırakıldı
- Bu sorun zaten biliniyor ve bir kısmını ele alan bir PR açık durumda: https://github.com/rust-lang/rust/pull/135527
Üretilen future’ın yapısı
- Örnek kodda
foo()async { 5 }döndürüyor vebar()isefoo().await + foo().awaitçalıştırıyor- Godbolt örneği: godbolt
bariçinde iki await noktası bulunduğundan durum makinesinde en az iki durum gerekse de, gerçekte daha fazla durum üretiliyor- Rust derleyicisi çeşitli geçişlerde MIR dökümü yapabiliyor ve
coroutine_resumegeçişi son async’e özgü MIR geçişi- Async, LLVM IR’de artık yer almasa da MIR’de kalıyor; dolayısıyla async’in durum makinesine dönüştürülme süreci MIR geçişlerinde gerçekleşiyor
barfonksiyonu 360 satırlık MIR üretiyor; eşzamanlı sürüm ise yalnızca 23 satır kullanıyor- Derleyicinin çıktıladığı
CoroutineLayout, fiilen enum biçimindeki bir durum kümesiUnresumed: başlangıç durumuReturned: tamamlanmış durumPanicked: panic sonrası durumSuspend0: ilk await noktası vefoofuture’ını saklıyorSuspend1: ikinci await noktası ve ilk sonucu ile ikincifoofuture’ını saklıyor
Future::pollgüvenli bir fonksiyon olduğundan, future zaten tamamlandıktan sonra tekrar çağrılsa bile UB’ye yol açmamalı- Şu anda
Suspend1sonrasındaReadydöndürüp future’ıReturneddurumuna geçiriyor - Bu durumda yeniden poll edilirse panic oluşuyor
- Şu anda
Panickeddurumu, async fonksiyon panic verdikten sonra bu paniccatch_unwindile yakalandığında ilgili future’ın tekrar poll edilmesini engellemek için var gibi görünüyor- Panic sonrasında future eksik bir durumda kalabilir; bu yüzden yeniden poll etmek UB’ye yol açabilir
- Bu mekanizma mutex poisoning’e oldukça benziyor
Panickeddurumuna ilişkin bu yorum için kesin bir belge bulmak zor; bu yüzden buna dair güven düzeyi yaklaşık %90
Tamamlandıktan sonra poll edildiğinde gerçekten panic gerekli mi?
Returneddurumundaki future şu anda panic veriyor, ancak bunun zorunlu olması gerekmiyor- Gerekli olan tek koşul, UB’ye yol açmaması
- Panic görece maliyetli ve optimizasyonla kaldırılması zor olan yan etkili bir yol ekliyor
- Tamamlanmış future yeniden poll edildiğinde
Poll::Pendingdöndürmek, unsafe davranış olmadanFuturetürünün sözleşmesini karşılayabiliyor - Derleyici bu yaklaşımla değiştirilip deney yapıldığında, async gömülü firmware’de ikili dosya boyutunda %2 ila %5 azalma görüldü
- Bu davranışın, tıpkı tamsayı taşmasındaki
overflow-checks = falsegibi bir anahtarla sunulması öneriliyor- Hatalı davranışı hemen görünür kılmak için debug derlemelerinde panic devam ediyor
- Release derlemelerinde ise daha küçük future’lar elde edilebiliyor
panic=abortkullanıldığındaPanickeddurumunun kendisinin tamamen kaldırılması mümkün olabilir; bunun etkisi ayrıca değerlendirilmeli
Await olmasa bile her zaman durum makinesi üretiliyor
foo()yalnızcaasync { 5 }döndürdüğünden, elle yazılmış en iyi uygulama durum içermeyen ve her zamanPoll::Ready(5)döndüren bir future olurdu- Ancak derleyicinin ürettiği MIR’de yine de
Unresumed,Returned,Panickedolmak üzere temel üç durum bulunuyor- Poll sırasında mevcut durumun discriminant’ı kontrol edilip dallanılıyor
- Tamamlandıktan sonra yeniden poll edilirse
`async fn` resumed after completionassert’i ile panic veriliyor
- Bu durumda durum makinesi üretmek yerine her seferinde
Poll::Ready(5)döndürecek şekilde optimize etmek mümkün - Bu değişiklik derleyiciye deneysel olarak uygulandığında gömülü ikili boyutu %0,2 azaldı
- Kazanç büyük değil, ancak basit bir optimizasyon olduğu için uygulanmaya değer olabilir
- Bu optimizasyon davranışı bir miktar değiştiriyor, ancak etkilenecek olanlar yalnızca sözleşmeye uymayan executor’lar
- Mevcut derleyici sonraki poll işleminde panic veriyor
- Optimizasyondan sonra ise future her zaman
Readydöndürüyor
Yalnızca LLVM yeterli değil
- MIR çıktısı verimsiz olsa bile LLVM’nin her şeyi temizleyebildiği durumlar var, ancak koşullar sınırlı
- Future yeterince basit olmalı
opt-level=3kullanılmalı
- Future karmaşıklaştıkça LLVM bunu kaldıramıyor; deyimsel async Rust kodunda future’lar derin biçimde iç içe geçtiği için karmaşıklık hızla büyüyor
- Gömülü sistemler veya wasm gibi boyut optimizasyonunun sık yapıldığı ortamlarda LLVM her şeyi optimize edemiyor
- Godbolt örneği: https://godbolt.org/z/58ahb3nne
- Üretilen assembly’de LLVM,
foo’nun 5 döndürdüğünü biliyor amabarsonucunu 10’a optimize edemiyor fooiçinpollfonksiyonu çağrısı da hâlâ duruyor- Bunun nedeni, derleyicinin tamamen çözemediği potansiyel panic yolları
- LLVM,
foo’nun pratikte yalnızca bir kez çağrıldığını ve panic vermediğini bilmiyor
- Üretilen assembly’de LLVM,
- IR içindeki panic dalları yorum satırına alındığında optimizasyon daha iyi oluyor: https://godbolt.org/z/38KqjsY8E
- LLVM’den sonradan optimizasyon beklemek yerine, derleyicinin LLVM’ye daha iyi girdi vermesi gerekiyor
Future inline etme iyi çalışmıyor
- Inline etme, sonrasındaki optimizasyon geçişlerini mümkün kıldığı için önemli; ancak üretilen Rust future’ları şu anda erken aşamada inline edilmiyor
- Her future kendi implementasyonunu aldıktan sonra LLVM ve linker inline etme fırsatı yakalıyor, ancak önceki sorunlar nedeniyle bu aşama artık çok geç kalıyor
- En doğrudan inline fırsatı,
bar()fonksiyonunun yalnızcafoo(blah).awaityapması durumu- Trait kullanılarak soyutlama kurulurken sık görülen bir desen
- Mevcut derleyici
bariçin bir durum makinesi oluşturuyor ve onun içindefoodurum makinesini çağırıyor - Daha verimli yaklaşımda
bar, doğrudanfoofuture’ı olabilir
- Preamble ve postamble olduğunda durum daha karmaşık
- Örneğin
bar(input),input > 10ileblaholuşturuyor, ardındanfoo(blah).awaityapıyor ve sonuca* 2uyguluyor - Bu, özellikle trait implementasyonlarında async fonksiyonları farklı imzalara dönüştürürken sık görülüyor
- Örneğin
- Bu tür bir
barda kendi async durumuna ihtiyaç duymuyor- Tek await noktasını aşarak korunması gereken veri,
fooiçinde yakalanan değerler dışında yok - Yine de
bardoğrudanfoo’nun kendisi olamaz; fakat durumun büyük bölümüfooya bırakılabilir
- Tek await noktasını aşarak korunması gereken veri,
- Elle yazılmış bir uygulamada
BarFut,Unresumed { input }veInlined { foo: FooFut }durumlarına sahip olabilir- İlk poll’da preamble çalıştırılır,
foo(blah)oluşturulur veInlineddurumuna geçilir - Sonrasında
foo.poll(cx)sonucuna postamble uygulanır
- İlk poll’da preamble çalıştırılır,
- İlk await noktasından önce kodu önceden çalıştırmak mümkün olsaydı
Unresumeddurumu da kaldırılabilirdi; ancak future’ın poll edilmeden önce hiçbir şey yapmaması garanti edildiği için bu değiştirilemez - Poll edilmekte olan bir future’ın özellikleri sorgulanabilse ek inline optimizasyonları mümkün olabilir
- Örneğin future’ın ilk poll’da her zaman ready döndürdüğü bilinseydi, çağıran future içinde o await noktası için durum oluşturmaya gerek kalmazdı
- Bu tür optimizasyonlar özyinelemeli biçimde uygulanırsa birçok future çok daha basit durum makinelerine indirgenebilir
- Mevcut
rustcyapısında her async block ayrı ayrı dönüştürülüyor ve sonrasında ilgili veriler korunmadığı için bu tür sorgular mümkün görünmüyor - Future inline etme henüz deneysel olarak uygulanmadı, ancak ikili boyut ve performans açısından büyük fayda sağlaması bekleniyor
Aynı durumların birleştirilmesi
- Async block içindeki her await noktası, durum makinesine ek bir durum ekliyor
- Aşağıdaki gibi bir kod doğal görünse de iki dalda da aynı async fonksiyon await edildiği için iki özdeş durum oluşuyor
CommandId::A => send_response(123).awaitCommandId::B => send_response(456).await
- Bu durumda
CoroutineLayoutiçindesend_responseiçin aynı coroutine türünü tutan_s0,_s1alanları ayrı ayrı oluşuyor veSuspend0,Suspend1adlı iki durum yaratılıyor - Bu fonksiyonun MIR’i 456 satır uzunluğunda ve birçok temel blok fiilen yinelenmiş durumda
- Kod önce yalnızca yanıt değerini hesaplayıp sonra tek kez
send_response(response).awaityapacak şekilde elle yeniden düzenlenirse yinelenen durumlar ortadan kalkıyorCommandId::Aiçin123CommandId::Biçin456- Sonrasında
send_response(response).await
- Yeniden düzenleme sonrasında
CoroutineLayoutiçinde depolanan tek bir future kalıyor ve yalnızca birSuspend0durumu bulunuyor - Toplam MIR uzunluğu 302 satıra düşüyor ve tekrar ortadan kalkıyor
- Bu nedenle aynı kod yollarını ve durumları bulup tekilleştiren bir optimizasyon geçişi faydalı görünüyor
- Bu optimizasyonun future inline etme geçişiyle iyi birleşmesi muhtemel
Deney bağlantıları ve ek benchmark’lar
- İki deney birlikte uygulandığında,
smolexecutor kullanan x86 sentetik benchmark’ta yaklaşık %3 performans artışı görülüyor - No panics in poll after ready: https://github.com/rust-lang/rust/compare/main...diondokter:rust:resume-pending
- No await, no statemachine: https://github.com/rust-lang/rust/compare/main...diondokter:rust:no-statemachine-when-no-await
Project Goal için destek çağrısı
- Bu çalışma, derleyici tarafında ilerletilmek üzere bir Project Goal olarak sunuldu: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
- Finansman olmadan çok fazla ilerleme kaydetmek zor olduğu için, bu çalışmadan fayda sağlayacak şirket veya kuruluşların kısmi ya da tam desteğine ihtiyaç var
- İletişim adresi
dion@tweedegolf.com - İş kapsamı ve gerekli finansman miktarı esnek, ancak €30k ile işin tamamının ya da önemli bir bölümünün bitirilebileceği tahmin ediliyor
1 yorum
Lobste.rs görüşleri
Sadece başlığa bakınca beklediğimden çok daha yapıcı bir yazıymış
Umarım bu işi yapmak isteyen kişi ihtiyaç duyduğu desteği alır
Bu sorunun ele alındığını görmek güzel. Şu anda rustc’nin LLVM’e fazla miktarda kod verdiğini ve optimizer’ın her şeyi halletmesini beklediğini söyleyen birkaç yazı görmüştüm; özellikle bu yazı bu iş için finansman da talep ediyor
Aman Tanrım, ben aptalmışım
async’in bir şekilde runtime, iş takibi ve tamamlanmayı kontrol eden polling gerektirdiği için özünde hep “şişkin” olduğunu düşünüyordum. Sonuçta bu overhead sıfır değil
Burada sözü edilen “sıfır maliyetli soyutlama”nın dil özelliğiyle ilgili olduğunu, sonradan eklenen runtime’dan ayrı düşünmek gerektiğini varsayıyordum
LLVM’e vermeden önce rustc’nin gerçekte ne ürettiğine bakmayı hiç düşünmemişim
async Rust’a aşina olmayanlar için:
Bu gerçekten doğru. İç içe geçmiş async çağrı ağaçları, maksimum optimizasyondan sonra içinde durum makinesi bulunan tek bir struct hâline kadar katlanabiliyor. Gerçekten çok zekice bir yaklaşım
Release build’de bu duruma gelindiğinde bir tür deadlock mu oluşuyor? Yoksa sürekli
Pendingdönen bir işi bekleyen task’lar yüzünden sızıntı da olabilir mi?.awaitile yanlış polling yapılamazAklıma birkaç şey geliyor:
panic=unwindtercihini sevmiyorum. Bazı test harness’leri dışında,panic=abortyerine bunu seçmenin maliyeti karşılayacak kadar büyük bir faydasını neredeyse hiç görmedim. Hatta test harness için bile Linux’tapthread_joinyerine çalışan thread’iwaitetmek için tuhaf bir şekildeclonekullanılarak benzer bir tercih yapılabileceğini düşünüyorum. Bu noktada yanılıyor olabilirimLink başkasında da az önce bozuldu mu?
Düzeltme: blog yazısı yaklaşık yarım saniye görünüp sonra 404 sayfasına düşüyor
Düzeltme 2: Blog yazıları listesine gidip etrafa tıkladım; listede duran o yazıyı açınca da 404 sayfasına gidiyor. Statik bir sayfa olan ya da en azından öyle olması gereken bir blogu nasıl bu kadar bozabilirsiniz?
Bu arada aynı yeniden üretim adımlarını izledim sanırım ama bende hiç 404 çıkmadı. Telefonda ve masaüstünde, JavaScript açıkken de kapalıyken de denedim. Yani yaşadığınız şey göründüğünden daha karmaşık olabilir