- Futurelock, tek bir task birden fazla Future'ı aynı anda yönettiğinde, bunlardan birinin başka bir Future'ın kaynağına ihtiyaç duymasına rağmen artık poll edilmemesi sonucu ortaya çıkan bir deadlock olayıdır
tokio::select! yapısında referans verilen Future (&mut future) ile await içeren bir branch birlikte kullanıldığında kolayca ortaya çıkabilir
- Bu sorun, task ile Future sorumluluklarının ayrıştırılamamasından kaynaklanır; aynı task iki Future'ı da beklerken yalnızca birini poll eden yapı nedeniyle sistem durma noktasına gelir
- FuturesUnordered, bounded channel, Stream gibi yapılarda da benzer biçimde ortaya çıkabilir
- Güvenli eşzamansız tasarım için temel yaklaşım, Future'ı
tokio::spawn ile ayrı bir task'a ayırmak veya select içinde await kullanmaktan kaçınmaktır
Futurelock kavramı ve örnek
- Futurelock, Future A'nın elindeki kaynağa Future B ihtiyaç duyarken, bu iki Future'dan sorumlu task'ın artık A'yı poll etmemesi durumunda ortaya çıkar
- Örnek kodda
tokio::select! içinde &mut future1 ve sleep aynı anda beklenir; sleep önce tamamlanırsa future1 hâlâ lock bekleyen durumda kalır
- Sonrasında
future3 aynı lock'ı ister, ancak lock future1 için tahsis edilmiştir ve future1 poll edilmediği için program kalıcı olarak durur
tokio::select! ve Mutex etkileşimi
tokio::sync::Mutex, adil (fair) bir lock'tır ve lock'ı bekleme sırasına göre verir
- Lock
future1'e devredilir, ancak task artık yalnızca future3'ü poll ettiği için future1 çalışmaz
- Mutex yalnızca sıradaki bekleyen task'ı uyandırır; hangi Future'ın gerçekten poll edildiğini bilemez
Futurelock'ın genel nedenleri
- Task T'nin Future F1'i beklediği, F1'in F2'ye bağımlı olduğu ve F2'nin de yeniden T tarafından poll edilmeyi gerektirdiği döngüsel bağımlılık yapısı
- Özellikle şu durumlarda görülür
tokio::select! içinde &mut future kullanıp başka bir branch'te await yapmak
FuturesOrdered veya FuturesUnordered içinde bazı Future'lar tamamlandıktan sonra başka eşzamansız işler yapmak
- Elle yazılmış Future implementasyonlarında benzer davranışlar
Stream'lerde ve diğer yapılarda görülen örnekler
FuturesOrdered ya da FuturesUnordered içinde bir Future çıkarıldıktan sonra, onunla ilişkili kaynağı kullanan başka bir Future beklenirse Futurelock oluşabilir
join_all, tüm Future'ları poll etmeyi sürdürdüğü için Futurelock oluşturmaz
Gerçek vaka ve debugging
- Omicron#9259 vakasında tüm veritabanı erişim Future'ları Futurelock'a takıldı ve HTTP istekleri sonsuza kadar bekledi
mpsc channel gönderimi bloke olmuştu, ancak alıcı taraf boş görünüyordu; bu yüzden nedenin bulunması zordu
- Debugging sırasında
tokio-console gibi araçlar yardımcı olabilir, ancak çoğu durumda kök nedeni izlemek çok zordur
Futurelock'tan kaçınma yönergeleri
- Bir task birden fazla Future'ı poll ederken, başlatılmış bir Future'ın poll edilmesini durdurmamaya dikkat etmek gerekir
- Mümkünse Future'ları yeni bir task olarak spawn ederek bağımsız çalıştırın
JoinHandle'ı tokio::select! içine vermek Futurelock riskini ortadan kaldırır
tokio::select! kullanırken dikkat edilmesi gerekenler
&mut future ile await'i aynı anda kullanmayın
- Her iki koşul da varsa Futurelock riski yüksektir
Stream kullanırken her Future'ı ayrı bir task olarak çalıştırmak için JoinSet kullanın
bounded channel kapasitesini artırmak temel bir çözüm değildir
- Bunun yerine
try_send() kullanarak blocking'den kaçınılabilir
Hatalı kaçınma kalıpları
- Channel kapasitesini sonsuza kadar artırmak gerçekçi değildir ve gecikme ile bellek artışı gibi yan etkilere yol açar
- Future'lar arası bağımlılıkları kaldırmaya çalışmak, bakım sırasında yeni bağımlılıklar eklenebileceği için kırılgandır
- Güvenli olan tek yöntem,
tokio::spawn ile task'ları ayırmaktır
Gelecekteki iyileştirmeler ve güvenlik değerlendirmeleri
- Clippy lint'leriyle
tokio::select! içinde &mut future kullanımı veya await bulunması durumunda uyarı verme olasılığı gündeme getiriliyor
- Futurelock, bir hizmet engelleme (DoS) biçiminde kötüye kullanılabilir; ancak özünde anormal bir çalışma durumu olduğu için önlenmesi gerekir
1 yorum
Hacker News görüşleri
Belgeyi gözden geçirince oldukça şeffaf ve kapsamlı bir rapor gibi hissettirdi
Özellikle dipnotlar bölümü ilginçti
Rust'ın cancellation safety sorununu bilmeyen çok kişi vardı ve bu sorunun Omicron geneline yayılmış olma ihtimali etkileyiciydi
Rust'ın seçilme nedeni C'nin bellek güvenliği sorunlarından kaçınmaktı, ama bu kez çalışma zamanında yakalanması zor cancellation hatalarının ortaya çıkması ironik geldi
Derleyicinin yardımcı olamadığı dinamik özellikleri programcının bizzat garanti etmek zorunda olması özellikle can sıkıcıydı
Rust'ın eşzamanlılık modelinde de hâlâ deadlock olasılığı var gibi görünüyor
RAII tarzı kaynak yönetimi bunun gibi sorunları engeller sanılırdı ama pratikte öyle olmadığı kafa karıştırıcı
Bunun yalnızca uygulamaya özgü tesadüfi bir durum mu, yoksa Rust/Tokio modelinin yapısal bir sınırı mı olduğunu merak ediyorum
Bu, withoutboats'un FuturesUnordered yazısında açıklanan deadlock'un ince bir varyasyonu gibi görünüyor
“intra-task” eşzamanlılık kullanırken hiçbir future'ın açlık durumuna düşmemesine dikkat etmek gerekiyor
Temelde task spawn etmek daha güvenli; timeout'u
tokio::select!ile ele alırken de tüm pending future'ları onun içinde yönetmek gerekiyorFuturesUnorderedgerçekten bütün uç durumları test etmediğiniz sürece pek önerilmezBu, öncelik terslenmesi (priority inversion) sorununa benziyor
İşletim sistemlerinde düşük öncelikli bir thread lock'u tutarken yüksek öncelikli thread beklemek zorunda kalırsa, düşük öncelikli olan öncelik devralır ve çalıştırılır
Tokio'da da benzer bir kavram uygulanabilir mi diye merak ediyorum — örneğin çalışamayan bir future bir Mutex tutuyorsa, o future'ı onun yerine poll etmek gibi
Ama “çalışamayan” durumu algılamak epey ek yük doğuracak gibi duruyor
Bu tür bir yaklaşım Tokio'nun task düzeyinde belki mümkün olabilir
Ama task içindeki future'lara uygulanamaz
Çünkü async Rust'ın temel tasarımı “futures are inert” şeklinde — future yalnızca bir struct'tır ve runtime onun içini bilmez
Runtime'ın bildiği şey yalnızca task düzeyidir; iç future'ların durumunu hiç izlemez
Rust'ın async modeli stackless coroutine modelidir; bu yüzden zaten çalışmakta olan bir async fonksiyonun yürütmesini keyfi biçimde sürdürmek güvenli değildir
Stackless model, yerel durumu paylaşılan stack üzerinde tuttuğu için yalnızca LIFO sırasıyla güvenle çalıştırılabilir
Bu yüzden coloring gerekir ve stackful coroutine'lardaki gibi serbestçe yield edemez
Kod bana fazla karmaşık görünüyor
Erlang, Elixir, Go, hatta C ile yazılandan bile çok daha ayrıntılı ve uzun görünüyor
Bunun temel bir iki lock'lu deadlock durumuna benzediğini düşünüyorum
Tokio'nun Mutex bekleme kuyruğu ile task zamanlaması birbirine dolanıp kilitlenme yaratıyor
OS Mutex olsaydı başka bir bekleyen thread uyandırılarak çözülebilirdi ama async Rust'ta future'ın durum makinesi yapısı nedeniyle bunun zor olduğunu düşünüyorum
Bekleme kuyruğundaki future'ları sırayla poll ederek çözmek mümkün olabilir ama bu da beklenmedik yan etkilere yol açabilir
async Rust ekosisteminde bu tür sorunları birlikte ele alma deneyimim oldu
select!içinde referans kullanımını yasaklarsanız bu sorunlardan kaçınabilirsiniz, ama o zaman kuyruktaki yerini kaybetmeden tekrar tekrar select! çalıştırma deseni imkânsız hale gelirCancellation sorunlarıyla birlikte bu tür şeyler Rust uzmanları için bile beklenmedik tuzaklar olabilir
Yine de callback tabanlı koda kıyasla şaşırtıcı durumlar çok daha az
Evet, bizim ekip de bu deadlock'u analiz ettikten sonra “Bunu nasıl önleyebilirdik?” diye tartıştı ama sonunda kimsenin hatası olmadığı sonucuna vardık
Tokio'nun tüm primitive'leri amaçlandığı gibi çalıştı, kod da doğru yazılmıştı, ama birbirleriyle etkileşimleri beklenmedik bir deadlock yarattı
&mut futurekullanımınıselect!içinde yasaklamak bunu engelleyebilir, ama bu da pek çok normal kodu engellerSonunda acı bir şekilde bunun “sadece dikkat edilmesi gereken bir nokta” olduğu sonucuna vardık
İlgili tartışma bu yorumda da sürüyor
select!seçilmeyen future'ları drop etmeden geri döndürse durum kaybı yaşanmazdıAma bu kullanışsız olur ve kökten bir çözüm sayılmaz
Asıl neden, bu başlıkta açıklandığı gibi cancellation işlemenin eksik olmasıdır
SSS'deki “future1 iptal edilmiyor mu?” sorusu ilginçti
Cancellation iki aşamadan oluşur — poll'ün durması ve drop
Bu örnekte drop geciktiği için guard elde tutulmaya devam ediyor ve yan etkiler doğuyor
Bu iki davranışın her zaman aynı anda gerçekleşmesinin garanti edilip edilemeyeceğini merak ediyorum
Rust tasarımcılarına sormak isterim — neden actor modeli yerine async deseni seçildi
Erlang kullandığınızda actor modeli çok daha temiz ve güvenli hissettiriyor
JS dil yapısı nedeniyle async kullanmak zorundaydı ama Rust yeni bir dildi; neden bu yolu seçtiğini merak ediyorum
Rust'ın async tasarımında gömülü ortam desteği büyük bir etkendi
malloc ya da thread kullanmadan da çalışabilmesi gerektiğinden actor modeli mümkün değildi
Tokio ile actor tarzı kod yazılabilir ama çok doğal hissettirmiyor
Bir başka neden de performans
Actor modeli mesaj kopyalama maliyeti taşır; Rust ise performansın önemli olduğu bir sistem dili olduğu için async state machine ile zero-cost abstraction hedefledi
Erlang ve Go farklı tavizler veren dillerdir
Rust, C FFI çağrılarında ek yük kabul etmek istemediği için green thread tabanlı model dışarıda bırakıldı
async/await durum makinesine derlendiğinden ek yük düşüktür
Go'nun ilk dönemlerinde de preemption yoktu ve benzer açlık sorunları yaşanıyordu; sonradan scheduler bunu çözdü
Sonuçta her dilin hedefleri ve kısıtları farklıydı
Oxide'ın async'i benimsemesine ben de şaşırdım
Gömülü sistemler ya da HTTP sunucuları tarafında alışıldık ama Oxide gibi bir sistem şirketinin bunu bu kadar derin kullanacağını beklemiyordum
Belgeyi okurken anlamadığım nokta, neden lock'u tutan future yerine ana thread'in uyandığıydı
Adil bir lock ise future1'in uyanması gerekirdi; runtime neden başka bir thread seçti, bunu merak ettim
Yazı gerçekten çok ilgi çekiciydi
Örnek kod da açıktı ve böyle bir hatayı bulmak kâbus gibi olsa da, bulduğunuz anda yapboz parçalarının yerine oturması hissi var
Eliza, Sean, John ve Dave'in birlikte beyin fırtınası yapıp kök nedeni bulduğu anı görmek etkileyiciydi
Pazartesi günü bununla ilgili bir podcast bölümü yayımlayacağız
İlgili videolar RFD 537 ve bu etkinlik bağlantısında görülebilir
Rust'ın tüm etkin task'lerin aynı anda ilerlemesini sağlamaması anlaması zor ve hata üretmeye yatkın bir tasarım gibi görünüyor
Python'daki Trio gibi structured concurrency getirilse daha sezgisel olabilir
Rust da böyle bir modeli benimseyebilir mi diye merak ediyorum
Rust'ta da structured concurrency mümkün ama yalnızca task düzeyinde
Future'lar ancak poll edildiklerinde ilerleyen basit struct'lar olduğu için “etkin future” diye bir kavram yok
Her şeyi task olarak spawn etmek çözüm gibi görünse de, bu da bazı faydalı desenleri engeller
task ile future arasındaki ayrım önemli
Future poll edilmezse hiçbir şey yapmaz
Cancellation'ı “drop edilene kadar poll edilmeyen durum” diye tanımlarsanız, bu vakadaki gibi lock tutarken duran future'lar ortaya çıkar
Rust'ın RAII felsefesinde temizlik işleminin drop sırasında olması beklenir, ama poll durmuşsa o bile gerçekleşmez
Son zamanlarda Rust'ın async'inin fazla aceleyle yayımlanmış olabileceğini düşünmeye başladım
Pin ya da sözdiziminin bazı kısımları iyileştirilebilir ama alttaki yapının kökten değişmesine gerek yok
Hâlâ “ev tamamlanmadan önce atılmış temel” aşamasındayız; aceleye getirilmiş bir sonuç değil
Yine de genelleştirilmiş coroutine gibi daha alt seviye yapı taşlarına ihtiyaç olduğunu düşünüyorum