.NET Memory Model: Happens-Before, Acquire/Release, volatile ve Güvenli Object Publication
.NET memory model'inde sıradan erişimler yeniden sıralanabilir; Volatile.Read acquire, Volatile.Write release, Interlocked ve lock ise sıra garantisi veren noktalardır. Release build'de volatile olmayan bir stop flag döngüsünün JIT yüzünden hiç durmadığını, bool bayrakla yazılmış double-checked locking'in ARM64'te 20 milyon çağrıda 36 kez null döndürdüğünü gösteriyor, referans kontrolünün neden doğru olduğunu spec'ten çıkarıyor ve Java, C++ ve Go ile karşılaştırıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (C# 14) ile derlenip denenmiştir; testler xUnit v3 4.0.3 ile yazıldı. Kurallar dotnet/runtime deposundaki .NET memory model spec'inden alındı. Çıktılar ARM64 (Arm'ın 64-bit işlemci mimarisi) üzerinde alınmıştır; x64 için söylenenler bu spec'e ve Intel dokümanlarına dayanır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) kargo worker'ı bir döngüde çalışıyor ve her turda bir bool _stop alanına bakıyor. Uygulama kapanırken ana thread _stop = true yazıyor. Alan sıradan bir alan; volatile yok, lock yok. Release build'de, ana thread bayrağı kaldırdıktan sonra worker ne kadar sürede durur: bir milisaniye, bir saniye, hiç?
İkinci soru: fiyat listesini ilk ihtiyaç anında yükleyen bir cache var. Double-checked locking ile yazılmış: önce kilitsiz bir bool _loaded bayrağına bakıyor, yüklenmemişse kilidi alıp tekrar bakıyor, yüklüyor ve bayrağı kaldırıyor. Bu Get() çağrısı hiç null döndürebilir mi? Önceki yazıda donanımın aynı kodu x64 ve ARM64'te farklı sıralarla çalıştırdığını gördük. Bu yazıda .NET'in bütün bu donanımların üzerine koyduğu sözleşmeye, yani .NET memory model'ine bakıyoruz.
Language Memory Model Neden Var
Bir programlama dili yalnız tek bir CPU için tasarlanmaz. C# kodu x64'te, ARM64'te, x86'da ve ARM32'de (Arm'ın 32-bit mimarisi) çalışabilir; arada da kodu dönüştüren bir JIT (kodu çalışma anında makine koduna çeviren compiler) var. Language memory model, bu üç taraf arasındaki sözleşmedir: programcıya hangi sıraların garanti olduğunu, JIT'e hangi optimizasyonların serbest olduğunu, donanıma da runtime'ın ondan ne beklediğini söyler.
Sözleşmenin iki ucu var. Çok güçlü bir model (her şeyi sequential consistency ile çalıştır) ARM64'te neredeyse her bellek erişimine bir barrier koymayı gerektirir ve programı yavaşlatır. Çok zayıf bir model ise programcının doğru kod yazmasını imkânsız hâle getirir. Java, C++ ve Go'nun vardığı ortak nokta, DRF (data-race-free; data race içermeyen) ve SC (sequentially consistent; sanki tek bir sırayla çalışıyormuş gibi) kelimelerinden DRF-SC diye anılan fikirdir: programında data race yoksa, sıralamayı düşünmek zorunda değilsin. Race varsa, dil ne olacağını kendi kurallarıyla sınırlar ya da hiç sınırlamaz.
.NET'in hikâyesi biraz farklı. ECMA (bilgi ve iletişim sistemleri için standart yayımlayan Avrupa kuruluşu) tarafından yayımlanan ECMA-335 standardı, yani CLI'nin ve .NET runtime'ının standart tanımı, çok zayıf bir model tanımlıyordu. dotnet/runtime deposundaki memory model belgesi, yıllar içinde runtime'ın fiilen verdiği garantilerin bu standarttan daha güçlü olduğunu ve belgenin bugünkü uygulamayı ve gelecekte korunması beklenen davranışı yazıya döktüğünü söylüyor. Bu yazıdaki kurallar o belgeden, kendi cümlelerimizle.
Happens-Before ve Acquire/Release
Memory model'leri konuşurken kullanılan temel kavram happens-before. Distributed Systems Temelleri serisinin logical clock yazısında Lamport'un 1978'de dağıtık sistemler için tanımladığı aynı ilişkiyi görmüştük: A, B'den önce olduysa ve B'yi etkileyebiliyorsa, A happens-before B. Tek bir thread'in içinde program sırası bu ilişkiyi kurar. İki thread arasında ise ilişki ancak bir senkronizasyon noktasıyla kurulur: bir thread'in release yazması, diğer thread'in aynı değişkene yaptığı ve o yazının değerini gören acquire okumasıyla eşleşir. Release'ten önce yapılan her şey, acquire'dan sonra yapılan her şeye görünür.
Önceki yazıdaki message passing testi bunun en sade örneği: tutar yazıldı, bayrak Volatile.Write ile (release) kaldırıldı, okuyan taraf bayrağı Volatile.Read ile (acquire) gördü ve tutarı okudu. Düz erişimde ARM64'te 10 milyon turun 6.291.785'inde bayrak 1, tutar 0 görülmüştü; release/acquire ile hiç görülmedi.
.NET spec'i Java ve C++'ın aksine happens-before'u resmî bir ilişki olarak tanımlamaz; kuralları acquire, release ve full fence semantiği üzerinden anlatır. Ama düşünme aracı olarak aynı şey: "bu okuma o yazıyı görmek zorunda mı?" sorusunu, aralarında bir release-acquire zinciri var mı diye bakarak cevaplarsın. lock'tan çıkış bir release, aynı kilide giriş bir acquire olduğu için kilitle korunan kodda bu zincir kendiliğinden kurulur. Thread.Start, Join ve bir Task'ın tamamlanmasını beklemek de pratikte bu zinciri kuran noktalardır.
.NET Spec'inin Kuralları
Spec'teki kuralları gruplarsam şöyle bir liste çıkıyor:
- Sıradan okuma ve yazmalar, tek thread'in gördüğü sonucu değiştirmediği sürece hem JIT hem donanım tarafından yeniden sıralanabilir.
- Bu serbestliğin sınırları var: spekülatif yazma yapılamaz, programda olmayan bir okuma eklenemez; ama kullanılmayan okumalar silinebilir ve aynı adrese yapılan ardışık volatile olmayan okumalar (ya da yazmalar) tek işlemde birleştirilebilir.
- Volatile okuma acquire'dır, volatile yazma release'tir. Volatile semantiği işlemi atomik yapmaz ve yazının ne kadar hızlı görüneceği hakkında bir şey söylemez; yalnız göründüğünde hangi sırayla görüneceğini söyler.
Thread.MemoryBarrierve bütünInterlockedmethod'ları full fence'tir. Kilit almak acquire, bırakmak release sayılır.- Paylaşılan bir konuma object referansı atamak, o object'in alanlarına göre bir release'tir ve referans üzerinden yapılan okumalar (data-dependent read) referansın okunmasından önce yapılamaz. Instance constructor'larının ise kendi başına hiçbir sıralama etkisi yoktur.
- Pointer boyutuna kadar olan, doğru hizalanmış primitive tiplerin ve referansların okunup yazılması atomiktir.
VolatileveInterlockedmethod'ları platformdan bağımsız olarak atomiktir.
Bu kuralların ARM64'teki karşılığını önceki iki yazıda JIT çıktısında görmüştük:
| .NET işlemi | Spec'teki anlamı | ARM64'te JIT'in ürettiği (ölçüm) |
|---|---|---|
| Düz okuma/yazma | Sıra garantisi yok | ldr / str |
Volatile.Read | Acquire | ldapr / ldapur |
Volatile.Write | Release | stlr / stlur |
Interlocked.MemoryBarrier | Full fence | dmb ish |
x64'te tablo daha sade: TSO (Total Store Order; önceki yazıda gördüğümüz x64 modeli) zaten acquire ve release'i ek maliyetsiz verdiği için volatile okuma ve yazma sıradan mov talimatlarıdır; JIT'in yaptığı tek şey bu erişimleri kendisi de yeniden sıralamamaktır. Bu yüzden "x64'te volatile bedavadır" denir ve aynı sebeple x64'te unutulan bir Volatile çoğu zaman fark edilmez.
Vaka 1: Hiç Bitmeyen Döngü
Baştaki ilk sorunun kodu bu:
public void RunPlain()
{
long spins = 0;
while (!_stop)
spins++;
Spins = spins;
}
public void RunVolatileRead()
{
long spins = 0;
while (!Volatile.Read(ref _stop))
spins++;
Spins = spins;
}
Deney worker'ı başlatıyor, 200 ms sonra bayrağı kaldırıyor ve worker'ın bitmesini en fazla 3 saniye bekliyor. Üçüncü satır, alanı C#'ın volatile anahtar kelimesiyle tanımlanmış aynı döngü:
dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- stop-flag
plain field still running after 3 s
Volatile.Read stopped after 202 ms, 876,317,428 spins
volatile field stopped after 203 ms, 874,082,497 spins
Düz alanla yazılmış döngü hiç durmadı. Sebebini JIT çıktısı gösteriyor. Döngü önce Tier0 (hızlı derlenen, optimize edilmemiş kod) ile başladı; Tier0 kodu her turda alanı bellekten okuyordu. Döngü uzun sürünce runtime OSR (On-Stack Replacement; çalışmakta olan bir döngüyü optimize edilmiş koda taşıma) ile optimize edilmiş sürüme geçti ve o sürüm şöyle:
; PollingWorker:RunPlain():this (Tier1-OSR)
ldrb w2, [x0, #0x10]
cbnz w2, G_M000_IG04
G_M000_IG03:
b G_M000_IG03
Alan bir kez okunuyor; 0'sa kod kendi üzerine atlayan bir b talimatında sonsuza kadar dönüyor. Spec bu optimizasyona açıkça izin veriyor: aynı adrese yapılan ardışık volatile olmayan okumalar birleştirilebilir ve döngü içindeki milyarlarca okuma, döngüden önceki tek bir okumaya indirgenebilir. Bu bir donanım meselesi değil; aynı kod x64'te de aynı şekilde takılır. Volatile.Read ya da volatile alan okumayı birleştirilemez kılar ve döngü bayrak kalktıktan 2-3 ms sonra durur. Spec, C#'ın volatile anahtar kelimesinin runtime için ayrı bir anlamı olmadığını, yalnız compiler'ın o alana yapılan erişimleri IL'de (Intermediate Language; C# compiler'ının ürettiği ara kod) volatile. önekiyle üretmesini sağladığını da not ediyor.
Bu bug'ın sinsi tarafı Debug build'de görünmemesi. Debug'da JIT optimizasyon yapmaz, alan her turda okunur ve döngü durur. Kod Release'e geçince ya da tiered compilation ayarı değişince takılır.
Vaka 2: Double-Checked Locking
İkinci soru klasik bir lazy initialization pattern'inin yanlış yazılmış hâli:
// Bozuk: ayrı bir bool "yüklendi" diyor; bayrak ile referans arasında hiçbir sıra yok
public PriceList? Get()
{
if (!_loaded)
{
lock (_gate)
{
if (!_loaded)
{
_list = new PriceList(29.90m);
_loaded = true;
}
}
}
return _list;
}
Kilidin içindeki iki yazma birbirine göre sıralı değil ve kilidi hiç almayan okuyucunun iki okuması da sıralı değil. Bu, önceki yazıdaki message passing testinin ta kendisi: okuyucu bayrağı 1, referansı null görebilir. Deneyde iki thread yeni oluşturulmuş bir cache'in Get() method'unu aynı anda çağırıyor; ikinci thread'e 0 ile 20 arasında rastgele bir SpinWait ekledim ki birinci thread başlatma yaparken farklı anlarda gelsin. Doğru yazılmış iki sürümle karşılaştırdım:
// .NET memory model'ine göre doğru: kontrol edilen şey referansın kendisi, bir kez local'e okunuyor
public PriceList? Get()
{
PriceList? list = _list;
if (list is null)
{
lock (_gate)
{
list = _list;
if (list is null)
{
list = new PriceList(29.90m);
_list = list;
}
}
}
return list;
}
dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- double-check 10000000
bool flag calls=20,000,000 null or empty price list=36
reference check calls=20,000,000 null or empty price list=0
Lazy<T> calls=20,000,000 null or empty price list=0
Bayraklı sürüm 20 milyon çağrının 36'sında null ya da boş bir fiyat listesi döndürdü. İkinci koşuda bu sayı 2'ydi. Message passing testindeki milyonlarla karşılaştırınca küçük bir sayı, çünkü hatanın penceresi yalnız ilk yüklemenin yapıldığı birkaç nanosaniye. Gerçek bir uygulamada bu, ayda bir görülen ve kimsenin tekrar üretemediği bir NullReferenceException demek.
Referansı kontrol eden sürüm neden doğru? Spec'teki iki kural sayesinde: object referansını paylaşılan bir alana yazmak, o object'in constructor'da yazılmış alanlarına göre bir release'tir; okuyan tarafta da referans üzerinden yapılan okumalar, referansın okunmasından önce yapılamaz. Okuyucu referansı null değil gördüyse, object'in içini tam görür. Bunu ayrı bir deneyle de ölçtüm: bir thread yeni bir PriceList'i düz bir alana yazıyor, diğeri alanı okuyup ücreti kontrol ediyor.
dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- publish 10000000
not yet=5,982,090 complete object=4,017,910 default fee=0
Okuyucunun object'i gördüğü 4 milyon turun hiçbirinde ücret varsayılan değerde değildi. Java'da bu garanti, memory model'i yeniden tanımlayan ve 2004'te kabul edilen JSR (Java Specification Request; Java platformu için resmî değişiklik önerisi) 133'ten önce yoktu ve double-checked locking'in "bozuk" ününün kaynağı orası. .NET'te ise doğru yazılmış hâli çalışıyor.
Yine de tavsiyem kendi double-checked locking'ini yazmaman. Lazy<T> varsayılan modunda (ExecutionAndPublication) aynı işi yapar, factory'nin yalnız bir kez çalışmasını garanti eder ve deneyde de hatasızdı. Design Patterns serisinin Singleton yazısında Lazy<T>'yi ve DI (Dependency Injection; bağımlılıkların dışarıdan verilmesi) singleton lifetime'ını bu açıdan karşılaştırmıştık. Spec'teki "referans kontrolü" kuralı, okuduğun koddaki bir double-check'in doğru olup olmadığını değerlendirmen için gerekli; yeni kod için değil.
Diğer Dillerde Aynı Kavramlar
Aynı kavramlar her dilde farklı adlarla ve farklı varsayılanlarla karşına çıkar:
| Dil | Data race'in anlamı | "volatile" ya da atomic'in varsayılan sırası |
|---|---|---|
| C# / .NET | Tanımlı ama zayıf: okuma yazılmış bir değeri görür, sırası serbest | Volatile: acquire/release (SB'yi engellemez) |
| Java (JLS 17) | Tanımlı; race'siz programlar sequentially consistent | volatile: sequentially consistent (SB'yi engeller) |
| C++11 | Undefined behavior | std::atomic varsayılanı memory_order_seq_cst; acquire, release, relaxed seçilebilir |
| Go (2022 modeli) | Tanımlı ama sınırlı; çok word'lük değerlerde bellek bozulabilir | sync/atomic: sequentially consistent |
En çok karıştırılan nokta Java ile C#'ın volatile'ı. Java Language Specification'ın 17. bölümüne göre volatile okuma ve yazmalar bütün thread'lerin üzerinde anlaştığı tek bir senkronizasyon sırasına girer; bu yüzden önceki yazıdaki store buffering sonucu Java'da iki volatile alanla imkânsızdır. C#'ın Volatile'ı ise yalnız acquire/release verir ve aynı test ARM64'te 10 milyon turda 11.272 kez iki thread'e de 0 okuttu. Java'dan gelen biri C#'ta volatile ile bir Dekker ya da Peterson tarzı protokol yazarsa, kodu bozuk olur.
C++11 diğer uçta: iki thread'in aynı konuma senkronizasyonsuz erişmesi undefined behavior'dır ve compiler'ın bu durumda programa bir anlam vermesi gerekmez. İkinci yazıda gördüğümüz gibi .NET daha yumuşak. Go'nun 2022'de yenilenen memory model belgesi de DRF-SC'yi esas alıyor, race'li programların sonuçlarını sınırlıyor ama birden çok word'den oluşan değerlerde (slice, interface, string) race'in bellek bozulmasına yol açabileceğini kabul ediyor. Aynı belge okuyucuya "zekice kod yazmaya çalışma" diye tavsiye ediyor; davranışını anlamak için memory model'i okumak zorunda kaldığın bir program, fazla zekice yazılmıştır.
Üretimde Dikkat
Kendine bir hiyerarşi koy. Paylaşılan durum için önce paylaşmamayı, sonra immutable veriyi, sonra lock'u, sonra Interlocked'ı düşün. Volatile'ı yalnız tek bir bayrak ya da tek bir referansın yayınlanması gibi basit durumlarda kullan; Thread.MemoryBarrier'ı ise kodunda gördüğünde nedenini yazan bir yorum ara. Kilitle ve Interlocked ile yazılmış kod bu yazıdaki hiçbir kuralı düşünmeni gerektirmez; C# Asenkron Programlama serisinin senkronizasyon yazısında anlattığımız araçlar tam bunun için var.
Code review'da kilitsiz okunan alanları ara. Bir alan bir thread'de yazılıp başka bir thread'de kilitsiz okunuyorsa iki soru sor: okuma bir döngüde mi (birleştirilme riski), ve okunan değer başka bir alanla birlikte anlam taşıyor mu (sıralama riski)? İkisinden birinin cevabı evetse düz alan yetmez.
Testlerini ARM64'te de çalıştır. Bayraklı double-check'in hatası x64 donanımında görünmez; ARM64'te bile 20 milyon çağrıda birkaç kez görünüyor. Bu tür kodun doğruluğunu testle değil, spec'e göre gerekçelendirerek göster.
Sık Düşülen Tuzaklar
volatile'ı atomicity sanmak. volatile int _sold alanında _sold++ hâlâ oku, artır, yaz adımlarından oluşur:
dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- volatile-count
expected=8,000,000 sold=2,315,624
Sekiz thread'in yaptığı 8 milyon satışın 2.315.624'ü kaldı. Volatile sırayı korur, araya başka thread'in girmesini engellemez. Atomik artış için Interlocked.Increment.
ECMA-335 ile bugünkü CLR (Common Language Runtime; .NET'in çalışma ortamı) davranışını karıştırmak. Eski kaynaklar ECMA'nın zayıf modelini anlatır ve referans yayınlamanın güvensiz olduğunu söyler; bugünkü runtime ise yukarıdaki garantileri veriyor. Tersi de bir tuzak: spec'in garanti etmediği bir şeye (örneğin x64 JIT'inin bugün düz erişimleri yeniden sıralamamasına) güvenmek.
Constructor'da this'i dışarı vermek. Spec'e göre constructor'ların kendi sıralama etkisi yok; garanti yalnız referans constructor bittikten sonra paylaşılan bir alana yazıldığında geçerli. İkinci yazıdaki kargo takibi örneğinde olduğu gibi constructor'ın ortasında bir thread başlatıp this'i ona verirsen, o thread yarım kalmış bir object görebilir.
Debug'da çalışan döngüye güvenmek. Stop flag bug'ı Debug build'de görünmez; yalnız optimize edilmiş kodda, üstelik tiered compilation'ın döngüyü yeniden derlediği andan sonra ortaya çıkar.
Küçük Bir Deneme
★☆☆ stop-flag komutunu Debug build'de (-c Debug) çalıştır. Düz alanlı döngü durdu mu? Sonra Release'te DOTNET_TieredCompilation=0 ortam değişkeniyle dene ve farkı açıkla.
★★☆ FlagCheckedCache'i yalnız _loaded alanına Volatile.Write ve Volatile.Read ekleyerek düzelt. double-check komutuyla hata sayısının sıfıra indiğini doğrula ve neden yalnız bayrağın volatile olmasının yettiğini release/acquire üzerinden açıkla.
★★★ Java'nın volatile'ı ile C#'ın Volatile'ı arasındaki farkı ölç: önceki yazıdaki store buffering testinin bir sürümünü, okuma ve yazmaları Interlocked.Exchange ve Interlocked.CompareExchange(ref x, 0, 0) ile yapacak şekilde yaz. Sonuç neden değişti? Bu sürümün maliyetini Volatile sürümüyle karşılaştır.
Bu yazıda kilitlerin ve Interlocked'ın neden doğru çalıştığını memory model'den çıkardık. Peki kilit hiç kullanmadan, yalnız CAS (compare-and-swap; değer hâlâ beklenen değerdeyse yenisini yazan atomik işlem) ile doğru bir veri yapısı yazabilir miyiz? Serinin sıradaki yazısında lock-free ve wait-free algoritmalara geçiyoruz: CAS döngüsü, Treiber stack, ABA (değerin A'dan B'ye, sonra yine A'ya dönerek CAS'ı yanıltması) problemi ve doğruluk ölçütü olarak linearizability.