Race Condition ve Data Race: Check-Then-Act, Read-Modify-Write ve Atomicity
Data race, senkronizasyonsuz ve en az biri yazma olan eşzamanlı bellek erişimidir; race condition ise sonucun zamanlamaya bağlı olmasıdır ve biri olmadan diğeri olabilir. Gerçek thread'lerle count++'ın artışların %82'sini kaybettiğini, ConcurrentDictionary ile yazılan stok kontrolünün iki bin turun 1403'ünde fazla sattığını ve 32 byte'lık bir fiyat struct'ının ARM64'te yarısı eski, yarısı yeni okunduğunu gösteriyoruz.
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ı. Çıktılar ARM64 (Arm'ın 64-bit işlemci mimarisi) üzerinde alınmıştır; x64 için söylenenler .NET memory model spec'ine ve Intel dokümanlarına dayanır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) satılan kitap sayısını tutan bir sayaç var: _sold++. Sekiz thread bu sayacı birer milyon kez artırıyor. Sonuç kaç çıkar? Sekiz milyona yakın bir şey mi, yoksa çok daha az mı?
İkinci soru: Dune'un stoğunda 100 kitap var. Sekiz thread aynı anda ellişer rezervasyon deniyor ve kod her seferinde önce if (Available > 0) diye bakıyor. En kötü durumda kaç kitap satılır? Aynı kodu ConcurrentDictionary ile yazarsak sorun biter mi? Son soru: bir decimal fiyatı bir thread yazarken başka bir thread okuyorsa, hiç yazılmamış bir değer görebilir mi? Önceki yazıda yirmi sıranın on sekizinde bir artışın kaybolduğunu simülasyonla görmüştük; bu yazıda aynı hataları gerçek thread'lerle üretip isimlendiriyoruz.
İki Tanım: Data Race ve Race Condition
Bu iki terim sık karıştırılır ama farklı şeyleri anlatır. Data race, bellekle ilgili teknik bir tanımdır: iki thread aynı bellek konumuna aralarında hiçbir senkronizasyon olmadan erişiyor ve en az biri yazıyor. Senkronizasyon derken lock, Interlocked, Volatile gibi erişimler arasında sıra kuran mekanizmaları kastediyoruz. Data race'i kodun kendisine bakarak, sonucu bilmeden tespit edebilirsin.
Race condition ise doğrulukla ilgilidir: programın doğru çalışıp çalışmadığı, olayların zamanlamasına ya da sırasına bağlıdır. Yani race condition bir anlam hatasıdır; tespit etmek için programın ne yapması gerektiğini bilmen gerekir. John Regehr'in 2011 tarihli "Race Condition vs. Data Race" yazısındaki ana fikir de bu: ikisi örtüşür ama biri olmadan diğeri olabilir. Bu yazıda her iki yönde de örnek göreceğiz.
Bu ayrım dillerde farklı sonuçlar doğurur. C++'ta data race undefined behavior'dır (davranışı standardın tanımlamadığı durum): derleyici her şeyi yapabilir. .NET'te durum daha yumuşak: memory model spec'i, okumanın o konuma yazılmış bir değeri göreceğini söyler, yoktan bir değer uydurmaz. Ama bu, data race'in zararsız olduğu anlamına gelmez; hangi değerin görüleceği tanımsızdır ve aşağıda göreceğimiz gibi değerin parçalanması da mümkündür. Memory model'in ayrıntısı serinin altıncı yazısının konusu.
Atomicity Violation: count++ Üç Adımdır
İlk hata sınıfı atomicity violation: tek bir adım sandığımız işlem aslında birden çok adımdan oluşur ve araya başka bir thread girer. En bilinen örneği read-modify-write:
public sealed class SoldCounter
{
private int _sold;
public int Sold => _sold;
// Data race: okuma, ekleme ve yazma üç ayrı adım
public void RecordSale() => _sold++;
public void RecordSaleAtomic() => Interlocked.Increment(ref _sold);
}
Deneylerde thread'leri bir Barrier ile aynı anda serbest bırakan küçük bir RaceRunner.RunTogether yardımcısı kullanıyorum; böylece işler gerçekten üst üste biniyor:
dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- lost-update 8 1000000
expected=8,000,000 plain=1,451,805 atomic=8,000,000
Sekiz milyon artışın yalnız 1,45 milyonu kaldı; artışların %82'si kayboldu. İki thread ile denediğimde iki milyondan 1.099.348 kaldı. _sold++ derlendiğinde değeri register'a okuyan, bir ekleyen ve geri yazan ayrı makine talimatlarına dönüşür. İki core aynı eski değeri okursa ikisi de aynı yeni değeri yazar ve bir satış kaybolur. Bu ARM64'e özgü değil: Intel'in yazılım geliştirici kılavuzuna göre x64'te de bellek üzerinde çalışan bir artırma talimatı, lock öneki olmadan diğer işlemcilere karşı atomik değildir. Interlocked.Increment ise üç adımı donanımın atomik talimatıyla tek bir adım yapar ve sonuç her seferinde tam sekiz milyondur.
Check-Then-Act: Stok Kontrolü
İkinci sınıf check-then-act: bir koşula bakıp ona göre davranmak. Kontrolle eylem arasında başka bir thread durumu değiştirebilir ve eylem artık geçersiz bir kontrole dayanır:
public sealed class StockItem(string isbn, int available)
{
public string Isbn { get; } = isbn;
public int Available { get; private set; } = available;
private readonly Lock _gate = new();
// Check-then-act: eylem çalıştığında kontrol eskimiş olabilir
public bool TryReserve()
{
if (Available > 0)
{
Available--;
return true;
}
return false;
}
// Kontrol ve eylem aynı critical section içinde
public bool TryReserveLocked()
{
lock (_gate)
{
if (Available > 0)
{
Available--;
return true;
}
return false;
}
}
}
dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- oversell 2000
rounds=2000 StockItem oversold in 372 rounds (worst: 350 sold of 100)
rounds=2000 ConcurrentDictionary oversold in 1403 rounds
İki bin turun 372'sinde 100'den fazla kitap satıldı; en kötü turda 350. İkinci koşuda 407 tur ve en kötü durumda 400 gördüm, yani denenen her rezervasyon başarılı oldu. Burada iki hata üst üste biniyor: iki thread aynı anda Available > 0 görebiliyor (check-then-act) ve Available-- kendisi bir read-modify-write olduğu için azaltmalar da kayboluyor (atomicity violation). Kilitli sürümde kontrol ve eylem tek bir critical section'da (aynı anda yalnız bir thread'in çalışabildiği kod bölgesi) olduğu için testlerde iki yüz turun hiçbirinde fazla satış yok.
Data Race Olmadan Race Condition
Şimdi ekipten Ayşe'nin önerisini deneyelim: stokları ConcurrentDictionary'de tutalım, o zaman thread-safe olur:
// Her çağrı tek başına thread-safe; çağrıların sırası değil
public bool TryReserve(string isbn)
{
if (_available[isbn] > 0)
{
_available[isbn] = _available[isbn] - 1;
return true;
}
return false;
}
Bu kodda data race yok: her okuma ve yazma ConcurrentDictionary'nin kendi senkronizasyonundan geçiyor. Ama yukarıdaki çıktıya göre iki bin turun 1403'ünde fazla satış oldu; ilk sürümden bile kötü. Sebep, üç ayrı ve tek tek güvenli çağrının arasına başka thread'lerin girmesi. Race condition sözleşmenin seviyesinde: "stok sıfırın altına inmez" kuralı tek bir çağrıyla değil, çağrıların dizisiyle korunuyor.
Çözüm, kontrol ve güncellemeyi tek bir atomik adıma indirmek. TryUpdate, değeri yalnız beklediğimiz eski değer hâlâ yerindeyse değiştirir; değilse baştan deneriz:
// Bütün adıma tek bir atomik karşılaştır-ve-güncelle karar verir
public bool TryReserveAtomic(string isbn)
{
while (true)
{
int current = _available[isbn];
if (current == 0)
return false;
if (_available.TryUpdate(isbn, current - 1, current))
return true;
}
}
Bu döngü compare-and-swap fikrinin ta kendisi; donanım tarafını üçüncü yazıda, lock-free algoritmaları yedinci yazıda ele alacağız. ConcurrentDictionary'nin AddOrUpdate ve GetOrAdd gibi API'lerini C# Asenkron Programlama serisinin senkronizasyon yazısında anlatmıştık.
Ters yön de mümkün: data race olup race condition olmayan kod. Rapor job'ındaki worker'lar iş buldukça ortak bir _hadActivity bool alanına senkronizasyonsuz true yazıyor ve bu alan yalnız bütün worker'lar Join ile bittikten sonra okunuyor. Birden çok thread aynı konuma kilitsiz yazdığı için tanım gereği data race var; ama hepsi aynı değeri yazıyor ve okuma, Join'in kurduğu sıradan sonra geliyor. Sonuç zamanlamaya bağlı değil. Bu tür bir kodu yine de Volatile.Write ile yazmayı tercih ederim; bir sonraki geliştirici "data race zararsızmış" diye okuyup aynı şeyi bir sayaçta denemesin.
Order Violation ve Lazy Initialization
Üçüncü sınıf order violation: A'nın B'den önce olması gerekiyor ama kod bunu zorlamıyor. Shan Lu ve arkadaşlarının 2008'de 105 gerçek concurrency bug'ını incelediği çalışmada, deadlock dışındaki bug'ların yaklaşık üçte biri bu türdendi. Kargo takibini yapan bir sınıf constructor'da arka plan thread'ini başlatıyor ama kullandığı dictionary'yi ondan sonra atıyor:
// Order violation: worker thread, _carriers atanmadan çalışabilir
public ShipmentTracker()
{
_worker = new Thread(Poll);
_worker.Start();
_carriers = new() { ["TR"] = "Yurtici", ["DE"] = "DHL" };
}
dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- init-order 10000
runs=10000 worker saw an uninitialized field in 5429 runs
On bin denemenin yarısından fazlasında worker null bir alanla karşılaştı. Bu oran makineden makineye çok değişir: thread başlatmanın yavaş olduğu bir ortamda constructor neredeyse her zaman önce biter ve hata yıllarca saklanabilir. Çözüm, sırayı kodla ifade etmek: thread'i nesne tam kurulduktan sonra başlatmak ya da bir Start method'una taşımak.
Lazy initialization yarışı da check-then-act'in özel bir hâli:
// Lazy initialization yarışı: iki thread de null görebilir
public static SearchIndex Get()
{
if (_index is null)
_index = new SearchIndex();
return _index;
}
dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- lazy-init 10000
rounds=10000 index created more than once in 165 rounds
Dört thread'in aynı anda istediği on bin turun 165'inde arama index'i birden fazla kez oluşturuldu. Index ucuz ise bu yalnız israf; ama constructor bir dosya kilitliyor, bir bağlantı açıyor ya da sayaç artırıyorsa sonuç bozulur. Doğru araç Lazy<T>; neden doğru olduğunu ve double-checked locking'in inceliklerini memory model yazısında göreceğiz.
Torn Read: Değerin Yarısı Eski, Yarısı Yeni
Son sınıf en sinsisi. .NET memory model spec'ine göre doğru hizalanmış primitive tiplerin ve reference'ların okunup yazılması, pointer boyutuna kadar atomiktir: 64-bit bir process'te long ve double tek parça okunur, 32-bit bir process'te ise bu garanti yoktur. Spec decimal ve kendi struct'ların için hiçbir atomiklik sözü vermez. Yani bir thread 16 byte'lık bir decimal yazarken diğeri okursa, bir yarısı eski, bir yarısı yeni değerden gelen bir sayı görebilir. Buna torn read denir. Bir yazıcı iki geçerli değeri sırayla yazarken bir okuyucunun hiç yazılmamış değerleri saydığı bir deney kurdum:
Thread reader = new(() =>
{
while (!Volatile.Read(ref done))
{
decimal seen = box._price;
if (seen != 1.00m && seen != 99_999.99m)
torn++;
}
});
Aynı deneyi 16 byte'lık iki long'dan oluşan PriceRange(Min, Max) ve 32 byte'lık, iki decimal'dan oluşan ShelfPrice(List, Campaign) için de yaptım. Her yazılan değerde Min ≤ Max ve kampanya fiyatı ≤ liste fiyatı; okuyucu bu invariant'ı bozan değerleri sayıyor:
dotnet run -c Release --project src/Bookstore.ConcurrencyLab -- torn 50000000
decimal: 0 torn reads in 50,000,000 writes
PriceRange: 0 reads with Min > Max in 50,000,000 writes
ShelfPrice: 1,959,926 reads with Campaign > List in 50,000,000 writes
16 byte'lık iki tipte 50 milyon yazmada tek bir parçalanma görmedim; 32 byte'lık ShelfPrice'ta ise yaklaşık iki milyon okuma, kampanya fiyatının liste fiyatından yüksek olduğu ve hiç yazılmamış bir fiyat çifti gördü. JIT'in (kodu çalışma anında makine koduna çeviren compiler) ürettiği koda DOTNET_JitDisasm ile baktığımda ARM64'te 16 byte'ın tek bir ldp/stp talimat çiftiyle, 32 byte'ın ise iki ayrı çiftle kopyalandığını gördüm. Arm mimarisi bu çift talimatı ancak belirli koşullarda (işlemcinin FEAT_LSE2 özelliği ve 16 byte'lık hizalama) tek parça görünür kılıyor. Yani decimal'de sıfır görmemiz bu işlemci ve bu JIT çıktısı için bir gözlem, garanti değil: spec bir şey vaat etmiyor, x64'te de garanti yalnız 8 byte'a kadar.
Torn read'in pratik sonucu şu: birden çok alandan oluşan bir değeri (fiyat aralığı, para tutarı ve para birimi, koordinat) kilitsiz paylaşıyorsan, her alanı tek tek doğru yazsan bile okuyan taraf hiç var olmamış bir kombinasyon görebilir. Çözüm ya lock ya da değeri immutable bir class olarak tutup reference'ını değiştirmek; reference ataması atomiktir ve okuyan ya eski ya yeni nesnenin tamamını görür.
Invariant ile Düşünmek ve Çözüm Yelpazesi
Bütün bu örneklerin ortak dili invariant: her zaman doğru olması gereken bir kural. "Satılan + kalan = başlangıç stoğu", "kampanya fiyatı ≤ liste fiyatı", "index tek bir kez oluşturulur". Race'i analiz etmenin en verimli yolu, "hangi invariant, hangi iki adım arasında, hangi thread için bozuk görünüyor?" sorusunu sormak. Kod bir invariant'ı geçici olarak bozan adımlar içeriyorsa (stok düşüldü ama satış henüz kaydedilmedi), o adımlar başka thread'lerin göremeyeceği şekilde bir arada çalışmalı. Critical section kavramı tam olarak budur ve bir sonraki yazının konusu.
Çözümler pahalıdan ucuza değil, en güvenliden başlayarak şöyle sıralanır. Paylaşmamak: her worker kendi verisinde çalışır, sonuçlar sonda birleşir (ilk yazıdaki rapor böyleydi). Immutable veri: değer değişmez, değişiklik yeni bir nesne üretir ve reference'ı atomik olarak değiştirilir. Atomik işlem: tek bir alanın güncellemesi Interlocked ya da TryUpdate gibi bir compare-and-swap ile yapılır. Lock: birden çok alanı ya da bileşik bir kontrol-eylem dizisini korumanın genel yolu. API'lerin kendisi C# Asenkron Programlama serisinin senkronizasyon yazısında; bu seride hangisinin hangi invariant'ı neden koruduğuna odaklanıyoruz.
Üretimde Dikkat
Testte görünmeyen race production yükünde çıkar. Bizim deneylerde sekiz thread'i bir bariyerle aynı mikrosaniyede serbest bıraktık; tipik bir unit test ise tek thread'de ya da thread'ler zamanda birbirinden uzakken çalışır ve hiçbir şey görmez. Production'da yüzlerce istek aynı stok kaydına aynı saniyede gelir; kampanya başlangıcında bu bir anda olur. Concurrency testlerinde thread'leri bariyerle aynı anda başlat, turu binlerce kez tekrarla ve sonuç yerine invariant'ı doğrula: "satılan ≤ 100" ve "satılan + kalan = 100".
Race condition yalnız bellek içi değildir. Aynı check-then-act veritabanında "önce SELECT, sonra UPDATE" olarak, iki ayrı HTTP isteği arasında da "önce bakiyeyi kontrol et, sonra harca" olarak karşına çıkar. İş mantığındaki bu türden açıkları (çift harcama, kupon tekrarı) Web Güvenliği (OWASP Top 10) serisinin A06 yazısında anlatmıştık; çözümü orada da aynı: kontrol ve eylemi tek atomik adıma indirmek, örneğin koşullu bir UPDATE ... WHERE Available > 0.
Log'larda race'in izi tutarsızlıktır. Negatif stok, toplamı tutmayan rapor, "bu sipariş iki kez kargoya verilmiş" kaydı tek başına bir bug raporu değil, bir invariant'ın bozulduğunun kanıtıdır. Bu tür tutarsızlıkları sessizce düzelten bir job yazmadan önce, onu üreten interleaving'i bulmaya çalış.
Sık Düşülen Tuzaklar
ConcurrentDictionary kullanınca kodun otomatik thread-safe olduğunu sanmak. Deneyde en kötü sonucu concurrent collection verdi: iki bin turun 1403'ünde fazla satış. Thread-safe collection yalnız kendi tek tek işlemlerini korur, senin bileşik işlemini değil.
"Bende hiç olmadı" demek. Order violation deneyi bu makinede %54 hata verdi; thread başlatmanın daha yavaş olduğu bir ortamda aynı kod yıllarca hatasız görünebilir. Çalışmış olması doğru olduğunu göstermez; doğru olduğunu ancak sıralar üzerinden akıl yürüterek gösterebilirsin.
Atomikliği tipin boyutundan tahmin etmek. 16 byte'lık decimal bu ARM64 işlemcide parçalanmadı diye spec'in vermediği bir garantiye güvenme; aynı kod 32-bit bir process'te long ile, başka bir işlemcide decimal ile bozulabilir.
Her alanı ayrı ayrı korumak. List ve Campaign fiyatlarını ayrı ayrı Interlocked ile yazmak invariant'ı korumaz; ikisi birlikte değişmesi gereken bir çift. Koruma birimini alan değil, invariant belirler.
Küçük Bir Deneme
★☆☆ lost-update komutunu 1, 2, 4 ve 8 thread ile çalıştır ve kaybolan artış oranını tabloya dök. Thread sayısı arttıkça oran neden artıyor?
★★☆ ShelfPrice deneyini lock ile koru, sonra lock yerine ShelfPrice'ı bir sealed record class yapıp alanı reference ataması ile güncelle. İki çözümde de invariant'ı bozan okuma sayısının sıfır olduğunu doğrula ve hangisinin okuyucuyu bekletmediğini açıkla.
★★★ CatalogStock'a iki kitabı tek seferde rezerve eden bir TryReserveBoth(isbn1, isbn2) method'u ekle: ikisi de varsa ikisi düşülsün, biri yoksa hiçbiri. TryUpdate ile bunu neden doğru yazamadığını göster ve lock ile çöz. Invariant'ı doğrulayan bir stres testi yaz.
Bu yazıda race'leri isimlendirdik ve çözümün özünün "kontrol ile eylemi başka thread'lerin göremeyeceği tek bir adıma indirmek" olduğunu gördük. Bir sonraki yazıda bunun teorisine iniyoruz: critical section probleminin üç şartı, yalnız okuma ve yazmayla çözüm denemeleri, Peterson algoritmasının bu ARM64 makinede neden bozulduğu, compare-and-swap ve semaphore ile monitor'ün nereden geldiği.