Ana içeriğe geç

Bölüm 10:Aynı Anda İki Şey

19 dakikalık okumaGüncelleme:

Bir önceki bölümde CPU’yu process’ler arasında paylaştırdık. Timer düzenli aralıklarla bir kesme üretiyor, kernel çalışan işin bütün durumunu kaydedip başkasınınkini yüklüyor, sonra hayat kaldığı yerden devam ediyordu. Preemption’ın bütün vaadi buydu: kesilen programın kesildiğinden haberi olmaz.

O vaadin bir ince yazısı var ve orada söylemedim. Program kesildiğini fark etmez, doğru. Ama kesmenin nerede geleceğini seçen sen değilsin. Timer senin hangi satırda olduğunu bilmez, umursamaz da. Yazdığın bir satır işlemcinin gözünde tek bir iş değilse ve söz tam ortasında elinden alınırsa ne olur?

Bir borcum daha var. Öncelik tersine dönmesini anlatırken “düşük öncelikli görev paylaşılan bir kaynağın kilidini alır” deyip geçmiştim. Kilidin ne olduğunu, neden var olduğunu ve onu koyan kişinin kendi ayağına nasıl sıktığını oraya sığdırmadım. Burada ödüyorum.

Donanım tarafını ise 8. bölümde MESI protokolünü konuşurken zaten görmüştük: iki çekirdek aynı cache line’a dokunduğunda kimsenin elinde bayat bir kopya kalmıyordu. Bu bölüm o resmin üstüne yazılım tarafını koyuyor. Çünkü donanımın verdiği söz ile senin istediğin şey aynı şey değil. Donanımın sözü tek bir adres hakkındadır: aynı yere bakan iki çekirdek farklı değerler görmez. Senin üç adımının arasına kimsenin girmediğini garanti etmez — bölümün sonunda göreceğin gibi, iki farklı adrese yaptığın yazmaların karşı çekirdeğe hangi sırayla görüneceğini de garanti etmez.

Bu bölümde neyi çözüyoruz?

  • Thread ile process arasındaki farkı ve bu farkın neden bütün sorunların kaynağı olduğunu netleştireceğiz.
  • Paylaşılan bir sayacı artırmanın neden tek bir iş olmadığını ve arada tam olarak neyin kaybolduğunu göreceğiz.
  • İki işin aynı yere aynı anda girmesini engelleyen bir çözümün hangi üç koşulu birden taşıması gerektiğini çıkaracağız.
  • Kilidin donanımdan tam olarak ne istediğini, atomik komut olmadan neden kurulamadığını göstereceğiz.
  • Kilitlerin kendi ürettiği iki yeni sorunu, kilitlenmeyi ve yavaşlamayı, birbirinden ayıracağız.
  • Donanımın tutarlılık sözünün nerede bittiğini, yazma sıralarının neden bozulduğunu ve kilidin aslında iki iş birden yaptığını göreceğiz.

Önce Terim: Thread Nedir?

Buraya kadar “process” dedim ve 3. bölümde tanımını da verdim: çalışan bir programın kendi belleği ve kendi durumu olan canlı örneği. Bu bölümde bir terim daha gerekiyor ve aradaki fark, birazdan anlatacağım bütün sorunların kaynağı.

Bir process’in içinde birden fazla yürütme akışı olabilir. Bunların her birine thread (iş parçacığı) denir. Aynı process’in thread’leri şunları paylaşır: aynı adres alanı, aynı açık dosyalar, aynı kod. Şunlar ise her birine ayrıdır: kendi instruction pointer’ı, kendi register seti ve kendi stack’i.

Stack’in ayrı olması zorunlu, çünkü 3. bölümde gördüğün gibi fonksiyon çağrıları dönüş adreslerini stack’e koyuyor. İki akış aynı stack’i kullansaydı biri diğerinin dönüş adresinin üzerine yazardı.

Farkı tek cümleye indirgeyelim: iki process birbirinin belleğini göremez, aynı process’in iki thread ise aynı belleğe bakar. Sanal bellek, process’ler arasına donanım destekli bir duvar çeker — bunu 16. bölümde ayrıntısıyla göreceğiz. Thread’ler arasında öyle bir duvar yoktur, çünkü olmaması istenir.

İşte bu bölümün konusu o duvarın yokluğundan doğuyor. Paylaşmak ucuzdur: iki thread aynı diziye, aynı sayaca, aynı yapıya doğrudan erişir; veri kopyalamaya, mesajlaşmaya gerek kalmaz. Bedeli ise şu: ikisi aynı anda aynı yere dokunduğunda kimse onları durdurmuyor.

Kernel açısından bakıldığında scheduler’ın sıraya koyduğu şey process değil, thread’dir. Yani 9. bölümde anlattığım zaman dilimi, kesme ve context switch’in tamamı buraya olduğu gibi geçerlidir; tek fark, kesilen akışın komşusunun aynı belleğe bakıyor olması.

Bir Artırmanın Üç Adımı

Basit bir deney kuralım. İki thread var ve ortak bir sayaca bakıyorlar. İkisi de aynı işi yapıyor: sayacı bir artır, bunu bir milyon kez tekrarla. Beklenen sonuç iki milyon. Programı çalıştır ve çıktıya bak. Çoğu zaman daha az. Bazen bir tık az, bazen yüz binlerce az. Aynı program, aynı makine, her koşuda başka bir sayı.

Sebebi, o tek satırın tek bir iş olmaması. İşlemci belleğe dönüp “şunu bir artır” demez. Değeri kendi register’ına alır, orada artırır, sonra geri yazar:

mov eax, [sayac]   ; oku
add eax, 1         ; değiştir
mov [sayac], eax   ; yaz

Buna oku-değiştir-yaz (read-modify-write) denir ve arada iki tane gerçek boşluk vardır. Şimdi timer’ı tam o boşluklardan birine yerleştir. A thread’i sayacı okudu, değeri 41 buldu, register’ında 42’yi hesapladı. Tam geri yazmadan kesildi. Kernel devreye girdi, B thread’ini çalıştırdı. B aynı sayacı okuyor ve hâlâ 41 görüyor, çünkü A’nın 42’si daha kimsenin göremediği bir register’da duruyor. B 42’yi yazıp işini bitiriyor. Sıra tekrar A’ya geliyor ve A kaldığı yerden devam ediyor: elindeki 42’yi belleğe yazıyor.

İki artırma yapıldı, sayaç bir arttı. Kimse hata vermedi, kimse çökmedi. Bir sayı sessizce buharlaştı.

Kaybolan bir artırma
THREAD APAYLAŞILAN SAYAÇTHREAD BzamanA okureax ← 0sayaç = 0değişmediB okureax ← 0sayaç = 0hâlâ 0 görüyorA artırıp yazar[sayac] ← 1sayaç = 1A'nın yazdığıB artırıp yazar[sayac] ← 1sayaç = 1A'nın izi silindibeklenensayaç = 2bir artış buharlaştıkimse hata vermedigerçekleşensayaç = 1
A sayacı okur. Thread A sayacı bellekten kendi register'ına alıyor. Gördüğü değer 0. Henüz hiçbir şey değişmedi; bellekte de, A'nın elinde de aynı sayı duruyor.
  1. A sayacı okur. Thread A sayacı bellekten kendi register'ına alıyor. Gördüğü değer 0. Henüz hiçbir şey değişmedi; bellekte de, A'nın elinde de aynı sayı duruyor.
  2. B de aynı değeri okur. Timer tam bu boşlukta A'yı kesiyor ve sıra B'ye geliyor. B de sayacı okuyor ve yine 0 görüyor, çünkü A'nın hesabı hâlâ kimsenin göremediği bir register'da.
  3. A hesabını geri yazar. A kaldığı yerden devam ediyor: elindeki 0'ı 1 yapıp belleğe yazıyor. Sayaç artık 1. Buraya kadar her şey doğru görünüyor.
  4. B kendi kopyasını yazar. Sıra B'ye dönüyor. B'nin elindeki değer hâlâ 0'dı; onu 1 yapıp aynı yere yazıyor. A'nın yazdığı 1 sessizce eziliyor ve kimse hata vermiyor.
  5. Bir artış kayboldu. İki artırma yapıldı ama sayaç yalnızca bir arttı. Sonuç, işlerin hangi sırayla ilerlediğine bağlı; aynı program bir sonraki koşuda başka bir sayı verebilir.

Bu hataya yarış durumu (race condition) denir: sonucun, işlerin hangi sırayla ilerlediğine bağlı olması. Adı yerinde, çünkü ortada gerçekten bir yarış vardır ve kimin kazandığını kimse belirlemez.

Tek talimata indirsen kurtulamazsın. x86’da belleği doğrudan artıran tek bir talimat da vardır, ama o talimat da içeride okur ve yazar; çok çekirdekli bir makinede iki çekirdek aynı anda bunu yaparsa aynı şey olur. Talimatı gerçekten bölünmez yapan şey talimatın kendisi değil, önüne konan lock ön ekidir. Yani atomiklik bedava gelen bir özellik değil, açıkça istenen bir hizmet.

En can sıkıcı tarafı ise şu: bu hata testte görünmez. Yüz koşunun doksan dokuzunda doğru sonucu alırsın, çünkü kesmenin tam o iki talimatın arasına düşmesi ihtimal işidir. Makineyi değiştirirsin, yük artar, çekirdek sayısı çoğalır ve hata bir gün üretimde ortaya çıkar. Kernel’ın kendisi bile bundan muaf değildir; yeni process’e numara dağıtan sayaç korunmazsa iki farklı process aynı kimliği alabilir.

Kritik Bölge: Girişi Tek Kapıya İndirmek

Kodun paylaşılan veriye dokunan parçasına kritik bölge (critical section) denir. Yukarıdaki üç satır bir kritik bölgedir. Kural basit görünür: bir iş kendi kritik bölgesindeyken, başka hiçbir iş kendisininkine giremez. Buna karşılıklı dışlama (mutual exclusion) denir ve aradığımız şeyin adı budur.

Ama karşılıklı dışlama tek başına yeterli bir hedef değil. Kapıyı kalıcı olarak kilitleyip anahtarı atmak da karşılıklı dışlama sağlar. Kullanılabilir bir çözümün üç koşulu birden taşıması gerekir:

Üçüncüsü çoğu zaman atlanır ve bedeli sonra ödenir. İlk ikisini sağlayan bir çözüm, şanssız bir işi sonsuza kadar kapının önünde tutabilir.

Akla gelen ilk kaba çözüm, kritik bölgeye girmeden kesmeleri kapatmaktır. Mantıklı görünüyor: 9. bölümde preemption’ın timer kesmesiyle yapıldığını anlatmıştık, kesme gelmezse kimse seni kesemez. Üç sebeple işe yaramaz. Uzun bir kritik bölge sistemin nefesini keser; bir saat süren bir işlem boyunca makine hiçbir kesmeye cevap veremez. İkincisi, ikinci bir çekirdek zaten kendi işine devam eder ve senin kapattığın kesmeler onu hiç ilgilendirmez. Üçüncüsü de yetki meselesi: 4. bölümde user mode ile kernel mode arasına çizdiğimiz sınırın anlamı tam olarak buydu, kesmeleri kapatmak ayrıcalıklı bir iştir ve sıradan bir programın eline verilmez.

Kilit: Donanımdan İstenen Söz

Uygulama programcısının gördüğü çözümün adı kilit, yaygın adıyla mutex (mutual exclusion lock). Arayüzü iki işlemden ibarettir: kilidi al, kilidi bırak. Alan varsa diğerleri bekler.

Şimdi bunu kendin yazmayı dene. Bir bayrak tutarsın, “kilitli mi?” diye bakarsın, değilse kilitli işaretlersin. Yani önce okursun, sonra yazarsın. Tanıdık geldi mi? Bu da bir oku-değiştir-yaz dizisidir ve iki iş aynı anda “kilitli değil” görüp ikisi birden içeri girebilir. Kilidi korumak için bir kilide ihtiyacın olur ve sonsuz döngüye girersin.

Yazılım tarafında bunu kurmak imkânsız değil. Peterson’ın algoritması, iki iş için, yalnızca sıradan okuma ve yazma kullanarak üç koşulu da sağlar. Ama iki varsayıma yaslanır: iki iş olacak ve talimatlar yazdığın sırayla yürütülecek. İkincisi modern bir işlemcide doğru değildir.

DerinleşmePeterson'ın algoritması modern işlemcide neden çöker?

Peterson’ın çözümünün özü iki adımdır: “hazırım” bayrağını kaldır, sonra sırayı karşı tarafa ver. Bu iki yazmanın sırası çözümün tamamıdır. Sıra bozulursa iki iş de aynı anda kritik bölgede bulunabilir.

İşlemci ve derleyici, aralarında bağımlılık görmedikleri işlemleri yeniden sıralar. Tek thread’de bu tamamen zararsızdır; sonuç her hâlükârda aynı çıkar. İki thread’de ise bir tarafın gördüğü sıra, diğer tarafın yazdığı sıra olmayabilir.

Basit bir örnek: bir thread x değişkenine yazıp sonra hazir bayrağını kaldırıyor, diğeri bayrağı bekleyip x’i okuyor. İki değişken arasında hiçbir bağımlılık yok, dolayısıyla yazmaların sırası korunmak zorunda da değil. Bayrak önce görünürse okuyan taraf x’in eski hâlini okur ve bunun için kimse hata vermez.

Çözümün adı bellek bariyeri (memory barrier): “bu noktadan öncekiler, sonrakiler başlamadan görünür olsun” diyen özel bir talimat. Sıradan bir uygulama yazarken bunu elle koymazsın; kütüphanenin sana verdiği kilit zaten içinde taşır. Ama neden orada olduğunu bilmek, kilitsiz kod yazmaya heveslenen birini bir kez düşündürür.

Gerçek çözüm donanımdan gelir. İşlemciler, okuma ile yazma arasında kimsenin araya giremeyeceği özel talimatlar sunar. En eskisi test-and-set: bir değişkeni oku, aynı bölünmez adımda içine “dolu” yaz, sana da bulduğun eski değeri döndür. Eski değer “boş” ise kilit senindir; “dolu” ise başkasındadır.

Daha güçlüsü compare-and-swap (karşılaştır-ve-değiştir). Bu talimat üç şey alır: bir adres, orada bulmayı beklediğin değer ve yazmak istediğin yeni değer. Beklediğin değer oradaysa yenisini yazar, değilse hiçbir şeye dokunmaz. Her iki durumda da ne bulduğunu sana söyler. Koşullu olması onu çok daha kullanışlı yapar; sıradan bir sayacı atomik yapmak bile bu talimatın üstüne kurulur.

Kilidi bekleyen bir iş ne yapmalı? İki cevap var ve seçim ucuz değil. Birincisi dönerek beklemektir: spinlock kilidi alana kadar döngüde kalır, CPU’yu bırakmaz. Kritik bölge çok kısaysa bu doğru tercihtir, çünkü uyuyup uyanmanın maliyeti beklemekten fazladır. Kritik bölge uzunsa felakettir; çekirdek boşuna dönerken kilidi tutan iş belki de çalışmayı bekliyordur. İkincisi uyumaktır: kernel’a haber ver, sıraya gir, kilit bırakılınca uyandırıl. Bunun bedeli ise 9. bölümde konuştuğumuz context switch’tir. Linux’un futex’i ikisini birleştirir; çekişme yoksa iş user-space’te biter, gerçekten beklemek gerekiyorsa kernel’a inilir.

Semafor: Sayacı Olan Kilit

Kilit tek bir izin dağıtır. Peki elinde beş özdeş kaynak varsa ve aynı anda beş işin çalışmasına izin vermek istiyorsan? Dijkstra’nın 1960’larda önerdiği semafor tam olarak bunun için var: içinde bir tam sayı tutan ve iki işlemle yönetilen bir izin sayacı.

İşlemlerin klasik adları wait ve signal. wait sayacı bir azaltır; sayaç sıfırın altına düşecekse çağıran iş sıraya girer ve uyur. signal sayacı bir artırır; sırada bekleyen varsa birini uyandırır. Sayaç bire kurulursa davranışı bir kilitten farksızdır, buna ikili semafor denir. Bire kurulmazsa sayan semafor olur ve kaynak havuzu yönetir.

Kilitten önemli bir farkı vardır ve karıştırılması pahalıya patlar: semafor sahiplik taşımaz. Kilidi alan bırakmak zorundadır. Semaforu ise bir iş indirip bambaşka bir iş artırabilir. Bu onu bir dışlama aracı olmaktan çıkarıp bir haberleşme aracına dönüştürür. “Sen bitirmeden ben başlamayayım” demenin en kısa yolu, sıfıra kurulmuş bir semafordur.

Bunun klasik gösterimi üretici-tüketici problemidir. Sabit boyutlu bir tampon var. Üretici içine veri koyuyor, tüketici alıyor. Üç şeyi aynı anda garantilemen gerekiyor: tampon doluyken üretici beklemeli, boşken tüketici beklemeli ve tamponun kendi iç yapısına aynı anda iki el dokunmamalı. Üç semaforla çözülür. Biri boş yerleri sayar, biri dolu yerleri sayar, üçüncüsü de tamponu koruyan sıradan bir kilittir. Üretici önce boş yer alır, sonra kilidi alır, veriyi koyar, kilidi bırakır ve dolu sayacını artırır. Tüketici aynı şeyi ters yönde yapar.

Sıraya dikkat et, çünkü bu bölümün geri kalanı orada saklı. Üretici önce kilidi alıp sonra boş yer beklerse, tampon dolu olduğunda kilidi elinde tutarak uyur. Tüketici de veri almak için o kilide ihtiyaç duyar. İkisi de sonsuza kadar bekler.

Ölümcül Kilitlenme: Dördü Birden

Yukarıdaki tuzağın adı ölümcül kilitlenme (deadlock) ve iki kilitle en yalın hâlini kurabilirsin:

// T1                 // T2
kilitle(A);           kilitle(B);
kilitle(B);           kilitle(A);

T1, A’yı aldı ve B’yi bekliyor. T2, B’yi aldı ve A’yı bekliyor. İkisi de bekledikleri şeyi ancak diğerinin bırakmasıyla alabilir, ikisi de bırakmak için önce almak zorunda. Sistem çökmez, CPU kullanımı sıfıra iner, iki iş de sonsuza kadar orada durur.

Bunun dört koşulu vardır ve ilginç olan şu: dördü aynı anda sağlanmadan kilitlenme oluşmaz.

Dördü birden gerektiğine göre birini kırmak yeter. Şimdi hangisinin kırılabileceğine bakalım, çünkü hepsi eşit derecede kırılabilir değil.

Karşılıklı dışlamayı kırmak kilidin varlık sebebini yok eder; korumak istediğin veri zaten paylaşılamadığı için oradadır. Tut-ve-bekle’yi kırmak için her işin ihtiyacı olan bütün kilitleri en baştan alması gerekir; bu, kaynak kullanımını yerlerde süründürür ve talihsiz bir işi süresiz bekletebilir. Kesintisizliği kırmak, yani kilidi zorla geri almak, yarıda kalmış bir değişikliği ortada bırakır. Geriye dairesel bekleme kalır.

Onu kırmak şaşırtıcı derecede ucuzdur: bütün kilitlere bir sıra numarası ver ve herkesin kilitleri artan numara sırasında almasını şart koş. Yukarıdaki örnekte A’ya 1, B’ye 2 dersen, T2’nin yazdığı sıra kural dışı kalır ve düzeltilir. Halka niye kurulamaz? Kurulabilmesi için zincirin bir yerinde birinin büyük numaralı bir kilidi tutarken küçük numaralı birini beklemesi gerekir. Kural bunu yasakladığı için döngü kapanamaz. Çok kilitli sistemlerde kilit sıralaması yazılı bir sözleşmedir ve ihlali derleme zamanında değil, üretimde ortaya çıkar.

Kilitlenmeyle başa çıkmanın başka yolları da var. Sistem her istekte “bu tahsis beni güvensiz bir duruma sokar mı?” diye hesaplayabilir; bankacı algoritması budur ve her işin en fazla ne kadar kaynak isteyeceğini önceden bilmeyi gerektirir. Ya da kilitlenmenin oluşmasına izin verip periyodik olarak arar, bulunca birini kurban seçip geri alırsın. Genel amaçlı işletim sistemlerinin çoğu ise üçüncü yolu seçer: görmezden gelir ve sorumluluğu programcıya bırakır. Veritabanları bu konuda daha titizdir, çünkü bir işlemi geri almanın maliyetini zaten ödemeye hazırdırlar.

Açlık ve Canlı Kilit

Bekleme her zaman kilitlenme değildir. Açlık (starvation) durumunda sistem gayet ilerler, kimse takılmaz, ama belli bir iş sırasını hiç alamaz. Sınırlı bekleme koşulunun ihlali tam olarak budur. Öncelik tersine dönmesi de bunun bir akrabasıdır; orada bekleyen iş sırasını başkasının önceliği yüzünden kaybediyordu.

Canlı kilit (livelock) daha da tuhaftır. İki iş de sürekli bir şeyler yapar, durumları sürekli değişir, ama hiçbiri ilerlemez. Koridorda karşılaşan ve ikisi de aynı yöne kaçmaya çalışan iki insan gibi. Kilitlenmeyi önlemek için yazılan “kilidi alamazsan elindekini bırak ve baştan dene” mantığı, iki iş aynı ritimde çalıştığında tam olarak buna dönüşür. Çözüm nezaket değil, rastgeleliktir: yeniden denemeden önce rastgele bir süre beklemek ritmi bozar.

Kilidin Bedeli

Kilit doğru kullanıldığında bile ücretsiz değildir ve faturanın büyük kısmı 8. bölümde anlattığımız cache mekanizmasından gelir.

Çekişme yoksa maliyet küçüktür. Kilit değişkeni zaten senin çekirdeğinin cache’indedir, atomik talimat birkaç çevrimde biter ve iş user-space’te kapanır. Çekişme başladığında tablo değişir. Kilit değişkeni bir cache line’ın içinde oturur ve o satıra yazmak isteyen her çekirdek, satırı önce kendi sahipliğine almak zorundadır. Sekiz çekirdek aynı kilidi denerken o tek satır çekirdekler arasında sürekli el değiştirir. Kritik bölge bir nanosaniye sürse bile kilidin kendisi trafiğe boğulur.

Bu, 8. bölümdeki false sharing’in yakın akrabası. Aradaki fark şu: orada paylaşım sahteydi, değişkenlerin yan yana durmasından kaynaklanıyordu. Burada paylaşım gerçek. Aynı satıra gerçekten herkes dokunmak istiyor ve çözüm hizalamayla gelmiyor.

Pratik sonuç, kilidin kapsamı üzerine düşünmeyi zorunlu kılar. Tek bir büyük kilit yazması kolaydır ve doğruluğu tartışılmaz; karşılığında programın paralel kısmını seri hâle getirir. Linux’un yıllarca taşıdığı ve sökmesi uzun süren Big Kernel Lock bunun en bilinen örneğidir. Kilidi parçalamak, örneğin her veri yapısına ayrı kilit vermek, paralelliği geri kazandırır ama iki yeni maliyet getirir: alınacak kilit sayısı artar ve kilitlenme ihtimali doğar. 6. bölümde çekirdek çoğaltmanın sınırını konuşurken andığım Amdahl yasasının tavan hesabı burada somutlaşıyor; programın seri kalan kısmı çoğu zaman tam olarak kilit tuttuğun bölgedir.

DerinleşmeKilitsiz veri yapısı kilidi ortadan kaldırmaz

“Lock-free” diye anılan veri yapıları kilit kullanmaz. Bunun yerine compare-and-swap talimatını bir döngü içinde çağırırlar: mevcut değeri oku, yeni değeri hesapla, “hâlâ okuduğum değer duruyorsa şunu yaz” de. Başka biri araya girdiyse talimat başarısız döner ve baştan denersin.

Kazanç gerçektir ama sandığın yerde değil. Kilitsiz yapının verdiği garanti, bir işin uykuya dalıp başkalarını kilitli bırakmamasıdır. Yani bir thread tam kritik bölgedeyken preempt edilirse diğerleri beklemez. Bu, gerçek zamanlı sistemlerde ve kernel içinde ciddi bir kazançtır.

Vermediği garanti ise hız. Çekişme altında CAS döngüsü boşa dönmeye başlar; her başarısız deneme yine aynı cache line’ı çekirdekler arasında dolaştırır. Sekiz çekirdeğin yarıştığı bir sayaçta kilitsiz sürüm, kilitli sürümden yavaş çıkabilir.

Bir de kendine has tuzağı var. CAS “değer beklediğim gibi mi?” diye sorar, “bu değer hiç değişti mi?” diye değil. Değer A iken B olup tekrar A’ya dönerse talimat başarılı sayar, oysa aradan bir dünya geçmiştir. Buna ABA problemi denir ve çözümü genellikle değere bir sürüm sayacı iliştirmektir. Kilitsiz programlama yazması zor, doğrulaması daha zor bir alandır; kütüphane yazmıyorsan sıradan bir kilit neredeyse her zaman doğru cevaptır.

Donanımın Sözü Nerede Bitiyor?

Bölümün başında donanımın garantisinin tek bir adres hakkında olduğunu söyleyip geçmiştim. O cümlenin arkasını şimdi açıyorum, çünkü buraya kadar öğrendiğin her şeyin neden işe yaradığı orada saklı.

Birbirine benzeyen iki ayrı garanti var ve karıştırılmaları bu alanın en pahalı hatasıdır:

Aralarındaki uçurumu, alanın klasikleşmiş küçük testi gösteriyor. İki thread, iki değişken, dört satır:

// başlangıçta: x = 0, y = 0

// Thread 1        // Thread 2
x = 1;             y = 1;
r1 = y;            r2 = x;

Soru şu: iş bittiğinde r1 ve r2 ikisi birden 0 olabilir mi?

Kâğıt üstünde imkânsız görünüyor. Dört satırı hangi sırayla dizersen diz, ikisinden en az biri diğerinin yazmasını görmek zorunda gibi. Deneyin cevabı ise şu: evet, olabilir — ve kendi x86-64 makinende olur.

Sebebi bir kez öğrenince bir daha unutulmuyor. Bir çekirdek yazdığı değerin cache’e yerleşmesini beklemez; öyle yapsaydı her yazmada yüzlerce clock cycle boşa giderdi. Yazma önce store buffer denen küçük bir kuyruğa düşer, çekirdek yoluna devam eder, kuyruk arkadan boşalır. Kendi okumaların bu kuyruğu da tarar — yani x = 1 yazan thread x’i sorduğunda 1 görür. Ama karşı çekirdek o kuyruğu göremez. Onun için x hâlâ 0’dır. İki thread de kendi yazmasını kuyrukta bırakıp diğerinin henüz güncellenmemiş değişkenini okursa, iki sıfır birden ortaya çıkar.

Store buffer: iki sıfır nereden çıkıyor?
Çekirdek 1x = 1;r1 = y;Çekirdek 2y = 1;r2 = x;store bufferstore bufferortak bellekbaşta: x=0 y=0x = 1 bekliyory = 1 bekliyory okunur: 0→ 0x okunur: 0→ 0sonra: x=1 y=1
Thread 1 x'e yazar. Çekirdek 1, x = 1 yazmasının cache'e yerleşmesini beklemez: yazmayı kendi store buffer'ına bırakır ve bir sonraki talimata geçer.
  1. Thread 1 x'e yazar. Çekirdek 1, x = 1 yazmasının cache'e yerleşmesini beklemez: yazmayı kendi store buffer'ına bırakır ve bir sonraki talimata geçer.
  2. Thread 2 y'ye yazar. Aynı anda çekirdek 2 de y = 1'i kendi store buffer'ına bırakır. İki yazma da şu an yalnızca kendi çekirdeğinde görünüyor.
  3. Thread 1 y'yi okur: 0. Çekirdek 1 y'yi ortak bellekten okur. Çekirdek 2'nin yazması hâlâ onun kuyruğunda beklediği için gelen değer 0. r1 = 0.
  4. Thread 2 x'i okur: 0. Aynısı öbür tarafta: çekirdek 2 x'i okur ve 0 bulur. r2 = 0. Kâğıt üstünde imkânsız görünen sonuç ortada.
  5. Kuyruklar boşalır. Birkaç nanosaniye sonra iki yazma da ortak belleğe ulaşır: x = 1, y = 1. Ama iki okuma çoktan yapıldı. Hiçbir kopya bayat değildi; bozulan şey sıraydı.

Dikkat et: burada hiçbir cache line bayat değil. Coherence kusursuz çalışıyor. Bozulan şey sıra.

DerinleşmeAynı kod x86-64’te çalışıyor, Apple Silicon’da bozuluyor

Her mimari aynı ölçüde gevşek değil. x86-64 görece güçlü bir model kullanır (TSO — Total Store Order): yalnızca “yazmadan sonra gelen okuma” öne geçebilir, diğer üç kombinasyon korunur. Yukarıdaki testin çalışmasının sebebi de zaten kalan o tek boşluktur.

AArch64 ise belirgin biçimde zayıf bir model kullanır: yazmalar birbirine göre de yeniden sıralanabilir, okumalar da öyle. Karşılığında donanım daha az senkronizasyon devresi taşır, daha az güç harcar.

Pratik sonucu şu: kilitleri ve atomik değişkenleri doğru kullanmadığın bir kod x86-64 masaüstünde yıllarca sorunsuz koşup Apple Silicon’a ya da ARM sunucuya taşındığında bozulabilir. Kod değişmedi, altındaki sözleşme değişti. “Bizde çalışıyordu” cümlesinin en sinsi versiyonlarından biri budur. 5. bölümde Apple’ın Rosetta için M serisi çiplerine x86 sıralamasını taklit eden bir kip eklediğini söylemiştim; sebebi tam olarak bu. Çevrilmiş x86 kodu TSO’nun sözüne güvenerek yazılmıştır ve o söz ARM’ın kendi modelinde yoktur.

Bu yüzden bugünün dilleri donanımın modelini doğrudan sana göstermez; kendi bellek modellerini tanımlar ve derleyici, hedef mimaride ne gerekiyorsa onu üretir. Sen mutex ya da atomic dersin, derleyici x86-64’te çoğu zaman hiçbir ek talimat üretmez, AArch64’te ise gereken bariyeri koyar.

İkinci Sıra Bozucu: Derleyici

İşin can sıkıcı tarafı, CPU bu işe girişmeden önce bir yeniden sıralayıcının daha devrede olması. Derleyici, tek başına çalışan bir thread için sonucu değiştirmeyen her düzenlemeyi yapmakta serbesttir: satırların yerini değiştirir, bir değişkeni bellek yerine register’da tutar, döngüden değişmeyen okumayı dışarı çıkarır.

Tek thread’de bunların hiçbiri fark edilmez — zaten kural da odur. Ama “tek thread’de fark edilmez” ile “hiç fark edilmez” aynı şey değil. Derleyici senin başka bir thread’in o değişkene baktığını bilmiyorsa, onu bir kez okuyup register’da tutmayı tamamen meşru sayar ve döngün sonsuza kadar bayat bir değere bakar.

Buradan C ve C++‘ın en sert kuralı çıkıyor: senkronize edilmemiş erişimlerin en az biri yazma ise, buna data race denir ve dildeki karşılığı “tuhaf bir değer okursun” değil, tanımsız davranıştır. Derleyici, kodunda data race olmadığını varsayarak iyileştirme yapar. Varsayım bozulduğunda ortaya çıkan şey yanlış bir sayı değil, anlamını kaybetmiş bir programdır.

Buradaki en yaygın yanılgı volatile. C ve C++‘ta volatile derleyicinin okumayı silmesini engeller, ama ne atomiklik verir ne de sıra garantisi — thread senkronizasyonu için tasarlanmamıştır. (Java’da aynı kelime bambaşka ve çok daha güçlü bir anlam taşır; iki dili birbirine karıştırmak klasik bir tuzaktır.)

Kilit Aslında İki İş Birden Yapıyor

Şimdi bölümün en önemli cümlesi. Buraya kadar kilidi “aynı anda tek kişi girsin” diye anlattım. Bu doğru ama eksik. Kilit bir iş daha yapar: sırayı da onarır.

Bir kilidi almak acquire, bırakmak release anlamı taşır. Kilidi bırakan thread’in o ana kadar yaptığı bütün yazmalar, o kilidi sonra alan thread için görünür hâle gelmiş sayılır. Yani kilit yalnızca kritik bölgeyi çitle çevirmez, çitin içinde yaptığın işin dışarıya hangi anda göründüğünü de tanımlar.

Bu yüzden doğru kullanılmış bir kilitle yukarıdaki store buffer sorununu hiç yaşamazsın: kilidin kendisi, derleyiciye ve donanıma “buradan öteye taşıma” diyen bariyeri zaten içinde taşır. Aynı şey dillerin atomik tiplerinde de geçerlidir; std::atomic, Java’nın volatile’ı ya da Rust’ın Ordering parametresi tam olarak bu sıra sözleşmesini seçmene yarar. En katı seçenek olan sequential consistency, “herkes bütün işlemleri tek bir ortak sırada görsün” der; en ucuzu ve en tehlikelisi olan relaxed ise yalnızca atomikliği verir, sıra hakkında hiçbir şey söylemez.

Pratik özet üç cümle:

Özet

Peki, ne öğrendik?

  • Aynı process’in thread’leri adres alanını paylaşır, stack’leri ise ayrıdır. Paylaşılan bellek hem bu modelin ucuzluğu hem de bu bölümdeki sorunların tamamının kaynağıdır.
  • Paylaşılan bir değeri artırmak oku-değiştir-yaz dizisidir; araya girilirse bir güncelleme sessizce kaybolur. Buna yarış durumu denir.
  • Paylaşılan veriye dokunan koda kritik bölge denir ve çözümün karşılıklı dışlama, ilerleme ve sınırlı bekleme koşullarını birden sağlaması gerekir.
  • Kilit yazılımla kurulamaz; test-and-set ve compare-and-swap gibi atomik donanım talimatlarına dayanır.
  • Semafor sayacı olan bir izin aracıdır ve sahiplik taşımadığı için haberleşme amacıyla da kullanılır.
  • Ölümcül kilitlenme dört koşulun aynı anda sağlanmasını ister; pratikte kırılan koşul dairesel beklemedir ve çaresi kilitleri sabit sırada almaktır.
  • Kilitlenme olmadan da beklenebilir: açlık sırayı hiç alamamak, canlı kilit ise sürekli hareket edip hiç ilerlememektir.
  • Çekişmeli bir kilit yavaşlatır, çünkü kilit değişkeninin cache line’ı çekirdekler arasında sürekli el değiştirir.
  • Coherence tek bir adresin değeri hakkındadır, consistency farklı adreslerin sırası hakkında. MESI birincisini verir, ikincisini vermez.
  • Yazmalar store buffer’da beklediği için, iki thread’in yazmaları karşı tarafa yazdığın sırayla görünmeyebilir. Derleyici de aynı işi bir kat daha erken yapar.
  • Kilit yalnızca dışlama değil, sıra da sağlar: bırakılan kilit, o ana kadarki bütün yazmaları sonraki sahibine görünür kılar.

İki thread’in sırası bozulduğunda ne olduğunu gördük ve o sırayı bozan faillerden ikisine — store buffer’a ve derleyiciye — baktık. Geriye üçüncüsü kaldı: talimatları yazdığın sıradan bambaşka bir sırayla yürüten çekirdeğin kendisi. Peterson’ın algoritmasını anlatırken bunu söyleyip geçmiştim; o kapıyı şimdi açıyorum. Bu sefer soru “başkası ne görüyor” değil, “işlemci bunu neden yapıyor”.

Tek bir çekirdek, talimatlarını yazdığın sıradan bambaşka bir sırayla yürütüyorsa, programa sonucun sıralı olduğunu nasıl inandırıyor?

11. bölüme devam et: İşlemciyi Hızlandıran Hileler