Futex olmadan bir anlamı yok
(h4x0r.org)- The Art of Multiprocessor Programming ders kitabının futex kavramını ele almamasının üzücü olduğu yönünde bir itiraz dile getiriliyor
- Futex, modern paralel programlamada verimli senkronizasyonun temel bileşenlerinden biri ve eski System V tabanlı kilitlere göre daha yüksek performans sunuyor
- Futex, kilit edinme ile bekleme/uyandırma işlevlerini ayırarak gereksiz sistem çağrılarını ve ek yükü azaltan bir yapıya sahip
- Futex tabanlı olarak spinlock, mutex, özyinelemeli kilit gibi çeşitli eşzamanlılık primitiflerinin doğrudan nasıl uygulanacağına dair örnekler ve teknikler yer alıyor
- Yazar, kitabın gerçek mühendislik pratiği için zorunlu olan güncel senkronizasyon yöntemlerini ele almamasını, akademi ile sektör arasındaki kopukluğun bir göstergesi olarak görüyor
Giriş
- Phil Eaton, 'The Art of Multiprocessor Programming, 2nd Edition' için bir kitap kulübü başlattı
- Bu kitap paralel programlama alanında otoriter bir ders kitabı olarak görülse de yazar, içeriğin pratik faydadan yoksun olduğunu belirtiyor
- Özellikle, 4. sınıf lisans öğrencileri ve yüksek lisans öğrencilerine yönelik olduğu söylenmesine rağmen futex gibi temel bir senkronizasyon tekniğini ele almaması eleştiriliyor
Futex nedir – neden önemlidir
- Futex, “fast user space mutex” ifadesinin kısaltmasıdır; ancak gerçekte bir mutex'ten çok modern kilit uygulamaları için işletim sistemi destekli bir senkronizasyon ilkel bileşenidir
- Geçmişte kilitlerin çoğu System V IPC'nin semafor temelli yapısıyla uygulanıyordu ve bu da verimlilik ile ölçeklenebilirlik açısından sınırlamalar doğuruyordu
- Futex'in 2002'de Linux'a eklenmesiyle, 1000 eşzamanlı iş yükü ortamında System V kilitlerine kıyasla 20 ila 120 kat daha yüksek performans elde edildi
- Windows (2012) ve macOS (2016) gibi diğer işletim sistemleri de benzer mekanizmaları kullanıma aldı
- Günümüzde yaygın biçimde kullanılan pthreads gibi sistem kütüphanelerindeki kilitler futex kullanıyor
Futex'in çalışma mantığı ve farkı
- Geleneksel semaforlar kilitleme ile beklemeyi birleştirirken, futex kilit edinme ile bekleme/uyandırmayı ayırır
- Bu sayede gereksiz gecikmeler ve sistem çağrıları azaltılabilir; kilit bırakılırken bekleyen iş parçacığı olmadığı kesinse çekirdeğe girilmesine gerek kalmaz
- Futex'in wait çağrısı, “belirli bir bellek adresindeki değer istenen durumdaysa ancak o zaman bekleme” davranışı gösterir ve zaman aşımını da destekler
- Futex wake çağrısı, belirli bir bellek adresine bağlı iç bekleme listesinden istenen sayıda iş parçacığını uyandırır
- Bellek adresindeki gerçek değerin doğrulanmasını istemesi, durum zaten değişmişse gereksiz beklemeyi önler
Futex'in pratik kullanımı – doğrudan uygulama
- Futex düşük seviyeli bir ilkel olduğundan, derleyici ve donanımdaki bellek işlemi sıralaması sorunları dikkate alınarak
atomicveri türleri kullanılır - Linux'ta futex sistem çağrısını
syscallile doğrudan çağırmak gerekir; macOS'ta__ulockarayüzü kullanılır (yakın zamanda daha kolay API'ler de eklendi) - Temel olarak futex wait başarılı olduğunda 0, başarısız olduğunda hata kodu (zaman aşımı vb.) döndürür
- Futex tabanlı temel işlemler:
h4x0r_futex_wait_timespec(): Beklenen değer eşleşiyorsa bekler, zaman aşımı uygulanabilirh4x0r_futex_wake(): 1 ya da tüm bekleyenleri uyandırır
Mutex/spinlock/özyinelemeli kilit uygulamalarına dair pratik örnekler
Spinlock
- En basit kilit türüdür; yalnızca tek bir bit (
atomic_fetch_or) ile çalışır - Kilidi alana kadar sonsuz döngüde (“spin”) bekler; ancak yüksek çekişme durumlarında CPU israfı, hatalı bırakma ve özyinelemeli çağrılarda deadlock riski gibi yapısal sorunlar vardır
Hibrit mutex (‘unsafe’ mutex)
- Genellikle önce spinlock ile denenir, belirli sayıda başarısızlıktan sonra verimli bloklama için futex'e geçilir
- Bekleyen yoksa gereksiz sistem çağrılarından kaçınılabilir ve bekleyenler için uyandırma sistem çağrıları en aza indirilebilir
- Sıkı sahiplik doğrulaması ya da özyineleme yönetimi eksik olduğundan “unsafe” adı kullanılır
Bekleyen sayaçlı mutex
- Bir bit kilit durumunu, geri kalan kısım ise bekleyenlerin sayısını tutmak için kullanılır; amaç gereksiz uyandırma sistem çağrılarını azaltmaktır
- Hâlâ sahiplik ve özyineleme yönetimi yoktur
Sahiplik yönetimi içeren mutex
pthread_tdeğeri üzerinden kilit sahibini ve durumu açıkça izleyerek hatalı unlock işlemlerini veya özyinelemeli kullanımdaki sorunları yakalar- Kilit edinme, bırakma ve bekleyen yönetimi tamamen katı atomic işlemlerle kontrol edilir
Özyinelemeli kilit
- İş parçacığı başına iç içe geçme sayacı (depth) eklenerek aynı iş parçacığının kilidi tekrar tekrar almasına izin verilir
- unlock sırasında depth azaltılır; 0 olduğunda gerçek unlock ve uyandırma yapılır
- Her işlem atomic işlemler ve sıkı sahiplik denetimiyle uygulanır
Kalan sorunlar ve gerçek mühendislik pratiği
- Kilidi elinde tutan iş parçacığı anormal biçimde sonlanır ya da ölürse, kilit yönetimi için ayrı bir yönetim listesi, çıkış callback'leri gibi ek mekanizmalar gerekir
- Süreçler arası paylaşılan mutex kullanıldığında da durum değişikliklerinin yönetimi için ek değerlendirmeler gerekir
- POSIX RW lock'larda özyinelemeli iç içe kullanım tanımlı değildir ve uygulamadan uygulamaya değişir; bu yüzden pratikte güvenliği sağlamak zordur
- Yazar, kitabın pratikte gerçekten önemli olan eşzamanlılık meselelerini (futex, özyinelemeli kilitler, asenkron runtime'lar vb.) müfredata dahil etmemesini eleştiriyor
Sonuç
- 'The Art of Multiprocessor Programming', tarihsel veya teorik bakışa fazla yaslandığı için modern paralel programlama pratiğinde önemli olan bilgileri yeterince içermiyor
- Sistemde gerçekten çalışan futex gibi temel senkronizasyon bileşenlerini düzgün biçimde ele almamak, yeni nesil geliştiriciler için somut zararlar doğurabilir
- Yazar, güncel kavramların yansıtılması ve içeriğin pratik açıdan güçlendirilmesi gerektiğini vurguluyor
Kaynaklar
- Tüm kod örnekleri codeberg üzerinde incelenebilir
1 yorum
Hacker News görüşü
Windows'ta
WaitForMultipleObjectsdiye bir özellik var; Linux da bunu 5.16'da (2021 sonu) Futex2 ile getirdi.İlgili bağlantı
Son dönemde Futex2 üzerinde çeşitli iyileştirmeler yapıldı.
NUMA desteği de sonunda eklendi.
NUMA ile ilgili bağlantı 1
NUMA ile ilgili bağlantı 2
NUMA performans açısından çok önemli bir unsur.
io_uring, 6.7'de (2024) futex'e uygulanınca PostgreSQL AIO performansının artmasına yardımcı oldu.İlgili yazı
6.7'de ayrıca small requeue ve single wait özellikleri de eklendi.
İlgili bağlantı
Windows bu
WaitForMultipleObjectsözelliğini sonradan eklemedi; en başından beri, 30 yılı aşkın süredir buna sahipti.WaitForMultipleObjects, UNIX'e kıyasla Windows NT'nin avantajlarından biriydi ama IBM PL/I'da da 1965'te benzer bir özellik zaten vardı.UNIX'teki
waitişlevi, IBM PL/I'dakiwaitin basitleştirilmiş bir sürümüydü; Multics'ten miras alınan diğer pek çok özellik gibi, orijinal modele göre daha zayıf kalıyordu.MS'in
WaitForSingleObjectveWaitForMultipleObjectsyapıları da verimli bir uygulamaya sahip değildi; bu yüzden sonunda Linux'taki futex'e denk olanWaitOnAddress'i getirmek zorunda kaldı.Linux futex'in 32 bit boyut sınırı var ve yalnızca tek bir olayı bekleyebiliyor.
Atomik bit işlemleri kullanılarak birden fazla olay için bekleme uygulanabilir ama bu verimli değil; bu da 32 bit boyut sorununu daha önemli hale getiriyor.
futexeWaitForMultipleObjects'in bazı avantajlarını katma girişimi sevindirici.Bu tür girişimler Windows'u taklit etmek değil; aslında Microsoft'tan çok daha eski, 50 yılı aşkın süredir iyi bilinen klasik bir tekniğin yeniden uygulanması.
Hâlâ
futex_swapözelliğinin olmaması üzücü.İlgili tartışma 1
İlgili kaynak 2
Futex'in WFMO (
WaitForMultipleObjects) ile ilgisi yok; daha çok keyed events ile eşdeğer bir kavram.Linux'ta WFMO'ya karşılık gelen şey
select/poll/epoll.io_uringiçinde futex desteği gerçekten harika bir özellik.Bunu Ruby fibers ile çalışırken mutex ve queue uygulamalarında kullandım.
Kaynak koda bakın
Kitap, senkronizasyon yapılarını doğrudan kendin yazmak yerine, kütüphane/dil/sistem tarafından sağlanan yapıları kullanman gerektiğini açıkça söylüyor.
Kitabın ana odağı belirli bir platform değil, genel concurrency kavramları.
Yazının yazarının meseleyi biraz abartılı bir karşıtlık çerçevesinde ele alması üzücü.
Bu yazı, "TAoMP'nin söylemedikleri" gibi daha işbirlikçi bir bakış açısından ele alınsaydı daha iyi olurdu.
Bu blogun yeni açılmış olması, Phil'in bu yazıyı yayımlaması ve Phil'in başka yazıları da öne çıkarması dikkat çekici.
O yazıyı ben yazdım; kitabı okurken hayal kırıklığına uğradığım için yazdım.
Hem akademide hem de sektörde gerçekten işe yarar şeyler öğrenemiyor oluşumuzun sorun olduğunu düşündüm.
Yani niyetim "hadi futex öğrenelim!" değildi.
Gerçekten kitaba hayal kırıklığı duyduğum için diğer yazıları erteleyip bunu önce yazdım.
Phil'le geçmişte birlikte çalıştığımız için bir bağlantımız var ama şimdiye kadar yazılarım için okur bulmakta pek zorlanmadım.
Geçmişte sysv tarzını dinozora bile benzetmemek gerektiğini söyleyen ifade fazla sertti diye düşünüyorum.
Burada biraz daha alçakgönüllülük gerek.
Futex'in en havalı yanı, handle-less bir yapı olması.
syscallüzerinden ayırma/serbest bırakma gerektirmeyen, çekirdek tabanlı bir bellek gözetleyicisi olarak çok kullanışlı bir temel davranış sunuyor.Bekleyen thread yoksa her şey temizce ortadan kalkıyor; çekişme yoksa çekirdek mutex'in varlığını bile bilmiyor.
Çekirdeğin futex'i yüksek performansla nasıl yönettiğine dair ayrıntılı bir analiz merak ediyorum.
Futex2'yi bugün ilk kez duydum.
İlgili belge
Evet; ayrıca bir thread lock üzerinde bloklandığında her seferinde çekirdeğin
malloc()çağırıp veri ayırmasını da istemezsiniz.Bunu önlemek için birçok işletim sistemi, thread oluşturulurken bir "queue object" ayırır; thread çekişmeli bir lock'a rastladığında bu nesne lock'a bağlanır.
Yani birden fazla thread için, lock'a bağlı linked list biçiminde queue object'ler olur ve thread uyandırıldıkça bunları birer birer alıp çıkar.
Thread sonlanırken, başlangıçta oluşturduğu nesneyi geri alacağının garantisi yoktur; nesneler bu süreçte karışır.
Solaris bu yapıyı (
turnstile) ilk getiren sistemdi; BSD'ler de aynı yöntemi benimsedi.solaris internals kaynağı
BSD pdf kaynağı
Unix çekirdeğinin ilk dönemlerindeki bekleme kuyrukları da böyleydi.
2002 tarihli orijinal futex makalesinde futex'in verimliliği açıkça gösterilmişti; 1000 paralel görev testinde sysv lock'lara göre 20 ila 120 kat daha iyi performans gösteriyordu.
Ama gerçekte uygun baseline sysv lock'lar değil.
Pratikte futex olmayan ortamlarda da lock uygulamalarının çoğu hızlı yolda çekirdeğe girmez, yalnızca yavaş yolda çekirdekte beklemeye geçer; futex'in asıl iyileştirmesi, lock'un bekleme durumunu gösteren kullanıcı alanı veri yapısının küçülmüş olmasıdır.
Diğer alternatifler olarak thin locks (JVM'in kullandığı yaklaşım) ve ParkingLot (tamamen userland uygulaması) da işletim sistemi futex'i olmadan çalışabilir.
Benim deneyimimde çoğu kişi pratikte mevcut temel primitive'leri öğreniyor; dolayısıyla odağınız kullandığınız dilin standart kütüphanesinin ne sunduğu oluyor.
Yani baskın akış sysv'den futex'e geçiş yönünde; yakın dönemde özel yöntemler de var ama ana akım futex.
Kullanıcı alanında doğrudan bir scheduler yazacaksanız ayrı bir uygulama da mümkün olabilir ama çoğu durumda bir file descriptor'a yazıp kuyruğu kendiniz yönetme yoluna gidersiniz gibi geliyor.
Bunun ne kadar kazanç sağlayacağı konusunda şüpheliyim.
Pratikte modern herhangi bir lock da sonunda içeride futex kullanıyor sayılır, tabii destekleniyorsa.
Linux'ta futex en verimli bekleme yöntemi olduğu için, yavaş (
down) yolda her zaman futex kullanmak tercih edilir.Bir dildeki
thread.park()gibi şeylerin de büyük ihtimalle sonunda futex üzerinde çalıştığını varsayabilirsiniz.JVM'in hâlâ thin lock kullanıp kullanmadığını merak ediyorum.
Eskiden JVM'in futex çağırdığına dair bir referans bulmuştum; acaba thin lock'a mı geçti diye merak ediyorum.
Stack Overflow ilgili tartışma
[recursive locks] için gerçek uygulamalar, standartlar arasında bile tutarlı değil; üstelik bu zor diye çoğu yerde tanım bile yapılmıyor.
Bu yaklaşım oldukça sinir bozucu.
Mantık şu gibi: "İşletim sistemi ya da dil geliştiricisi feature X'i düzgün uygulayamayabilir, o yüzden bunu uygulama geliştiricisi kendi halletsin."
Sonuçta aşağı akıştaki kullanıcıların, satıcı değiştirmek dışında pek bir seçeneği kalmıyor.
Standartta aşırı kısıtlama koymak, daha iyi uygulama ihtimallerini kapatabilir.
Örneğin C++ standart hash table ve regex uygulamaları, fazla kısıt nedeniyle üçüncü taraf alternatiflerden çok daha yavaş.
Belirli kısıtlar koymak ya da bazı özellikleri garanti etmek, alternatif yüksek performanslı uygulamaları engelleyebilir.
Recursive rwlock için de performanstan ödün veren ya da daha az denetim yapan uygulamalar mümkün; bence farklı yönelimlerin önü kapatılmamalı.
Şahsen recursive lock'ların en baştan kullanılmaması gerektiğini düşünüyorum; bu yüzden destek özelliğinin standartta yer almasını gerekli görmüyorum.
worse is better olgusunu daha iyi anlamak istiyorsanız Vikipedi'ye bakın.
Pek hoşuma gitmiyor ama kaçınılmaz bir gerçek.
Linux'ta futex'in neden yalnızca 32 bit
intdesteklediğini merak edip araştırdım.64 bit desteği tartışmalarında Linus, kullanıcı alanında 64 bit atomik kullanıp alt 32 biti futex olarak kullanmanın yeterli olduğunu söylüyor.
Ama C/C++ tarafında mixed-size atomic kullanımı undefined behavior sayılıyor ve glibc semaphore uygulaması da fiilen böyle çalışıyor.
64 bit tamsayıda üst 32 bit waiter count, alt 32 bit ise semaphore değeri olarak kullanılıyor; futex yalnızca alt 32 bit üzerinde çalışıyor.
Bunun gcc'de tanımlı davranış olup olmadığını, yoksa süreç sınırı (çekirdek süreci) nedeniyle önemsiz mi olduğunu, ya da glibc'nin bile undefined behavior mı kullandığını merak ediyorum.
Anthony Williams'ın C++ Concurrency in Action kitabını da öneririm; futex ya da senkronizasyon primitive'lerini doğrudan nasıl yazacağınızı anlatmıyor ama bellek sıralaması ve lock-free yapılar için gereken SMR gibi daha gerçek dünyaya yakın konuları ele alıyor.
Daha donanım odaklı bir bakış istiyorsanız, Paul McKenney'nin ücretsiz kitabı "Is Parallel Programming Hard, And, If So, What Can You Do About It?" de tavsiye edilir.
Bu kitap da futex'i derinlemesine işlemiyor ama sizi Ulrich Drepper'ın "Futexes Are Tricky" metnine yönlendiriyor.
TAOMPP, yüksek seviyeli concurrency kavramlarını anlatmak için uygun; işletim sistemi seviyesindeki uygulama ayrıntılarını içermesi zaten beklenmez.
Her hâlükârda Peterson ya da bakery lock pratik kullanımda işe yaramasa da, bunların ispatlarını öğrenmek bile gerçek concurrency algoritmalarını anlamakta çok yardımcı olur.
Reader/writer spin lock da uygulanabilir ama katı FIFO olur.
Kullanıcı alanında bakery lock spin wait ile futex'i birleştirmek mümkün ama çok verimsiz olur.
Futex zaten bu kullanım amacı için, yani spin bekleme için tasarlanmadı.
Lock-free yapılar, hazard pointer'lar ve RCU* gibi şeyler de hâlâ zordur.
Wait-free hazard pointer bile gerçekten yapılabilir.
*RCU tarafında copy-on-write sezgisel görünür ama güncelleme sıklaştıkça maliyet artar.
Windows 8'de futex benzeri bir yapı gelmiş olsa da, aslında eski Win32 critical section çekirdek semaphore'una dayanıyordu.
Peki Vista ile gelen SRW lock nasıl bir yapıya sahipti, merak ediyorum.
CRITICAL_SECTIONveSRWLock, çekişme yoksa ikisinde de çekirdeğe giriş olmaz.SRWLockkeyed event tabanlıdır;CRITICAL_SECTIONise başarısız olursa isteğe bağlı bir çekirdek nesnesi oluşturup çağrı yapar, sonra keyed event'e fallback eder.2014'te Linux futex uygulamasında Pinkie Pie'ın bulduğu açıkta, requeue-once kuralı yalnızca
futex_wait_requeue_pi'ye verilen futex için geçerliydi.A'dan B'ye, sonra tekrar B'den C'ye requeue yapılamıyordu ama B'den B'ye yeniden atama mümkündü.
Bu sırada belirli koşullar sağlanınca cleanup işlevinin çağrılmamasına yol açan bir hata vardı ve pointer dangling duruma düşüyordu.
İlgili örnek görülebilir.
İlgili issue
Bazı insanlar çöken bir thread'in veri bütünlüğünü bozmasını dert etmiyor ama tüm process ölmedikçe lock cleanup sorunu ortada kalıyor.
Bunun çözümü robust lock.
Kernel'e elde tutulan futex listesini kaydediyorsunuz;
sys_set_robust_listile thread sonlandığında ilgili bit işleniyor ve bekleyen taraf uyandırılıyor.Robust lock yaklaşımının en büyük dezavantajı, lock'un koruduğu kaynağın kendisinin zaten tutarsız hâle gelmiş olabilme ihtimalinin yüksek olması.
Thread'in neden çöktüğünü kesin olarak bilmiyorsanız veriler bozulmuş olabilir ve kurtarma imkânsız hâle gelebilir.
Bu yüzden tüm uygulamayı birlikte öldürmek daha pratik olabilir.
Robust lock ile cleanup/recovery yapılabilmesi başlı başına etkileyici ama muhtemelen mühendislerin %95'i gerçekten robust veri yapıları tasarlamayacaktır.
%4'ünün buna vakti olmaz, kalan %1 ise bunu doğru yapıp büyük ödül kazanır.
Süreçler arası futex (cross-process state) kullanırken, bir watchdog process her process için bir Unix domain socket (
SOCK_STREAMveyaSOCK_SEQPACKET) açıp crash durumunu tespit ederek process başına durumu temizleyebilir.Ben de mutex tartışmasını özellikle process sınırında kestim; çünkü daha derine inince sonu gelmeyen bir tartışmaya dönüşmesinden çekindim.