Mert Özen Her Satırda Daha İleri
Backend C# Asenkron Programlama — #13

C# Senkronizasyon: lock, Interlocked, SemaphoreSlim ve Concurrent Collection'lar

Mert Özen 7 Kas 2026 11 dk 1 görüntülenme
C# Senkronizasyon: lock, Interlocked, SemaphoreSlim ve Concurrent Collection'lar

sold++ neden bir milyonu bulmuyor: race condition ve check-then-act. lock'un kuralları ve içinde await yazılamaması, Interlocked ile atomik sayaç ve CompareExchange, async cache için SemaphoreSlim pattern'i, ConcurrentDictionary'de factory'nin birden çok kez çalışması ve Lazy<T> çözümü.

Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.

Önce Tahmin Et

Bir kampanya gününde satılan kitapları sayıyoruz. Bir milyon satış, her biri sayacı bir artırıyor, iş paralel yapılıyor. Ekranda hangi sayı görünür?

int sold = 0;

Parallel.For(0, 1_000_000, _ => sold++);

Console.WriteLine($"Sold: {sold}");

Race Condition: sold++ Neden Atomik Değil

Bu makinedeki çıktı:

Sold: 181345

Bir milyonun beşte biri bile değil. Aynı kodu üç kez daha çalıştırdım, her seferinde farklı ve yanlış bir sayı çıktı; altına aynı işi lock ve Interlocked ile yapan iki satır ekledim:

for (int run = 1; run <= 3; run++)
{
    int sold = 0;
    Parallel.For(0, 1_000_000, _ => sold++);
    Console.WriteLine($"sold++              -> {sold}");
}

int locked = 0;
object gate = new();
Parallel.For(0, 1_000_000, _ =>
{
    lock (gate)
    {
        locked++;
    }
});
Console.WriteLine($"lock                -> {locked}");

int atomic = 0;
Parallel.For(0, 1_000_000, _ => Interlocked.Increment(ref atomic));
Console.WriteLine($"Interlocked         -> {atomic}");
sold++              -> 117707
sold++              -> 134029
sold++              -> 268237
lock                -> 1000000
Interlocked         -> 1000000

sold++ tek bir satır ama tek bir işlem değil: değeri bellekten oku, bir ekle, sonucu geri yaz. İki thread aynı anda 41'i okursa ikisi de 42 yazar ve bir satış kaybolur. Buna race condition denir: sonuç, thread'lerin hangi sırayla çalıştığına bağlıdır. Önceki yazıda bundan kaçmak için sonuçları ayrı hücrelere yazmış ya da thread-local ara toplamlar kullanmıştık. Shared state'ten kaçamadığında elinde üç temel araç var: lock, Interlocked ve async kod için SemaphoreSlim. Bir de bu araçları senin yerine içinde kullanan concurrent collection'lar.

13-race-condition-tr.png

lock

lock, bir kod bloğuna aynı anda yalnızca bir thread'in girmesini sağlar. Diğerleri kapıda bekler. Asıl değeri, birden fazla adımı tek bir bütün gibi yapmasında. Stok kontrolünü düşün: "stok yeterli mi?" diye bak, yeterliyse düş. İki adım arasında başka bir thread araya girerse aynı son kitabı iki müşteriye satarsın:

Console.WriteLine(Sell(new UnsafeShelf(stock: 100)));
Console.WriteLine(Sell(new SafeShelf(stock: 100)));

static string Sell(IShelf shelf)
{
    int sold = 0;
    // 1.000 müşteri birer kopya almaya çalışıyor
    Parallel.For(0, 1_000, _ =>
    {
        if (shelf.TryReserve(1))
            Interlocked.Increment(ref sold);
    });
    return $"{shelf.GetType().Name}: sold {sold}, stock left {shelf.Stock}";
}

interface IShelf
{
    int Stock { get; }
    bool TryReserve(int quantity);
}

class UnsafeShelf : IShelf
{
    public UnsafeShelf(int stock) => Stock = stock;

    public int Stock { get; private set; }

    public bool TryReserve(int quantity)
    {
        if (Stock < quantity)        // kontrol et...
            return false;
        Thread.SpinWait(100);        // kontrol ile işlem arasında biraz iş
        Stock -= quantity;           // ...sonra işlemi yap
        return true;
    }
}

class SafeShelf : IShelf
{
    private readonly object _gate = new();
    private int _stock;

    public SafeShelf(int stock) => _stock = stock;

    public int Stock
    {
        get { lock (_gate) return _stock; }
    }

    public bool TryReserve(int quantity)
    {
        lock (_gate)
        {
            if (_stock < quantity)
                return false;
            Thread.SpinWait(100);
            _stock -= quantity;
            return true;
        }
    }
}
UnsafeShelf: sold 123, stock left -2
SafeShelf: sold 100, stock left 0

100 kitaplık stoktan 123 kitap satıldı ve stok eksiye düştü; üstelik bazı düşüşler kaybolduğu için eksi sayı bile satılan fazlalıkla tutmuyor. Bu, check-then-act race condition'ı: kontrol doğruydu, ama sonuç işlem anına kadar geçerliliğini yitirdi. SafeShelf'te kontrol ve düşüş aynı lock içinde olduğu için hiçbir thread araya giremiyor.

Derleyici lock (x) { ... } bloğunu Monitor.Enter ve finally içinde Monitor.Exit çağrılarına çevirir; blokta exception olsa da lock bırakılır. Birkaç kuralı alışkanlık hâline getir. Lock object'i private readonly bir field olsun; dışarıdan erişilebilen bir şeyi lock'larsan başka bir kod da aynı lock'u alabilir. Blok kısa olsun; içinde I/O, uzun hesap ya da dışarıdan gelen bir callback çağırma. Okuma tarafını da unutma: SafeShelf.Stock property'si de lock alıyor, çünkü yazılan değerin başka bir thread'den tutarlı görünmesini lock garanti eder.

Bir kural da derleyiciden gelir: lock içinde await yazamazsın.

var cache = new PriceCache();
await cache.RefreshAsync();

class PriceCache
{
    private readonly object _gate = new();
    private Dictionary<string, decimal> _prices = new();

    public async Task RefreshAsync()
    {
        lock (_gate)
        {
            _prices = await LoadPricesAsync();
        }
    }

    private static async Task<Dictionary<string, decimal>> LoadPricesAsync()
    {
        await Task.Delay(100);
        return new Dictionary<string, decimal> { ["978-0132350884"] = 37.90m };
    }
}
Program.cs(13,23): error CS1996: Cannot await in the body of a lock statement

Sebebi şu: Monitor lock'u, onu alan thread'e bağlıdır. await sonrası kod başka bir thread'de devam edebilir ve lock'u, onu hiç almamış bir thread bırakmaya çalışırdı. Async kodda lock'a ihtiyacın varsa SemaphoreSlim'e geçmen gerekir. C# 13 ve .NET 9 ile lock için ayrı bir System.Threading.Lock tipi geldi; onu serinin .NET 9–10 yazısında göreceğiz, ama o da async değil.

Interlocked

Shared state tek bir sayıdan ibaretse lock fazla ağırdır. Interlocked class'ı, işlemciye tek bir atomik komutla yaptırılan küçük işlemler sunar:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

long revenueInCents = 0;
int highestPriceInCents = 0;
int orderNumber = 1000;
int importStarted = 0;

int[] pricesInCents = Enumerable.Range(0, 100_000).Select(i => 1_000 + i % 5_000).ToArray();

Parallel.For(0, pricesInCents.Length, i =>
{
    Interlocked.Add(ref revenueInCents, pricesInCents[i]);
    UpdateMax(ref highestPriceInCents, pricesInCents[i]);
});

int[] orderNumbers = new int[5];
Parallel.For(0, orderNumbers.Length, i => orderNumbers[i] = Interlocked.Increment(ref orderNumber));

// yalnızca ilk çağıran 0 geri alır ve import'u çalıştırır
int[] winners = new int[8];
Parallel.For(0, winners.Length, i => winners[i] = Interlocked.Exchange(ref importStarted, 1) == 0 ? 1 : 0);

Console.WriteLine($"Revenue: {revenueInCents / 100m:F2}");
Console.WriteLine($"Highest price: {highestPriceInCents / 100m:F2}");
Console.WriteLine($"Order numbers: {string.Join(", ", orderNumbers.OrderBy(n => n))}");
Console.WriteLine($"Import started by {winners.Sum()} caller(s)");

static void UpdateMax(ref int target, int candidate)
{
    int current = Volatile.Read(ref target);
    while (candidate > current)
    {
        // okuduğumuzdan beri kimse değiştirmediyse yaz
        int seen = Interlocked.CompareExchange(ref target, candidate, current);
        if (seen == current)
            return;
        current = seen;
    }
}
Revenue: 3499500.00
Highest price: 59.99
Order numbers: 1001, 1002, 1003, 1004, 1005
Import started by 1 caller(s)

Increment ve Add oku-ekle-yaz işini bölünmez yapar; Increment yeni değeri döndürdüğü için benzersiz sipariş numarası üretmek için de birebirdir. Exchange değeri yazar ve eskisini döndürür; "ilk gelen import'u başlatsın, diğerleri geri dönsün" için yeterli. CompareExchange ise hepsinin temeli: "değer hâlâ beklediğim gibiyse yenisini yaz, değilse bana şu anki değeri söyle". UpdateMax'teki döngü bu pattern: oku, karşılaştır, yazmayı dene, başkası araya girdiyse yeni değerle tekrar dene. Volatile.Read ise derleyicinin değeri bir register'da tutup eskimiş bir kopyayla çalışmasını ve okumanın sonraki işlemlerle yer değiştirmesini engelliyor.

Tutarı kuruş cinsinden long olarak tuttuğumuza dikkat et. Interlocked'ın aritmetik method'ları yalnızca tamsayı tipleri için var; decimal ile denersen:

decimal revenue = 0m;
Interlocked.Add(ref revenue, 37.90m);
Program.cs(2,21): error CS1503: Argument 1: cannot convert from 'ref decimal' to 'ref int'
Program.cs(2,30): error CS1503: Argument 2: cannot convert from 'decimal' to 'int'

Para için ya kuruş cinsinden tamsayıyla çalışırsın ya da toplamı lock ile korursun. İki ayrı değişkeni birlikte güncellemen gerekiyorsa (stok ve rezerv gibi) Interlocked yetmez; iki ayrı atomik işlem, bir bütün olarak atomik değildir. O zaman da cevap lock.

SemaphoreSlim: async Koda Uygun Lock

SemaphoreSlim bir sayaçtır: aynı anda en fazla N kişinin içeride olmasına izin verir. N bir olduğunda lock gibi davranır ve en önemli farkı, WaitAsync ile thread bloklamadan beklenebilmesidir. Klasik kullanım yeri, async yüklenen bir cache. Cache boşken aynı anda gelen beş istek fiyat listesini beş kez veritabanından çekmesin:

var catalog = new CatalogCache();

// cache boşken aynı anda beş istek geliyor
Task<IReadOnlyDictionary<string, decimal>>[] requests = Enumerable.Range(0, 5)
    .Select(_ => catalog.GetPricesAsync(CancellationToken.None))
    .ToArray();
await Task.WhenAll(requests);

Console.WriteLine($"Requests served: {requests.Length}, loads from database: {catalog.LoadCount}");

class CatalogCache
{
    private readonly SemaphoreSlim _gate = new(1, 1);
    private IReadOnlyDictionary<string, decimal>? _prices;
    private int _loadCount;

    public int LoadCount => _loadCount;

    public async Task<IReadOnlyDictionary<string, decimal>> GetPricesAsync(CancellationToken cancellationToken)
    {
        if (_prices is not null)
            return _prices;

        await _gate.WaitAsync(cancellationToken);
        try
        {
            // tekrar kontrol et: biz beklerken başka bir çağıran yüklemiş olabilir
            if (_prices is null)
                _prices = await LoadFromDatabaseAsync(cancellationToken);
            return _prices;
        }
        finally
        {
            _gate.Release();
        }
    }

    private async Task<IReadOnlyDictionary<string, decimal>> LoadFromDatabaseAsync(CancellationToken cancellationToken)
    {
        Interlocked.Increment(ref _loadCount);
        await Task.Delay(200, cancellationToken);
        return new Dictionary<string, decimal> { ["978-0132350884"] = 37.90m, ["978-0135957059"] = 42.50m };
    }
}
Requests served: 5, loads from database: 1

Pattern'i ezberle: await _gate.WaitAsync(cancellationToken), hemen ardından try, Release() mutlaka finally içinde. WaitAsync'i try'ın içine koyarsan bekleme iptal edildiğinde hiç almadığın bir lock'u bırakmış olursun. Lock'un içinde tekrar kontrol etmek de pattern'in parçası: ilk istek veriyi yüklerken bekleyen dört istek, sıra kendilerine geldiğinde verinin artık dolu olduğunu görüp yüklemeyi atlıyor. N'i birden büyük verirsen aynı class concurrency sınırlayıcı olur; önceki yazıdaki Parallel.ForEachAsync'ten önce sınırlı concurrency böyle kurulurdu.

Concurrent Collection'lar

System.Collections.Concurrent namespace'indeki collection'lar, kilitlemeyi senin yerine içeride ve çoğu zaman lock'tan daha ince taneli yapar. En sık kullanılanı ConcurrentDictionary<TKey, TValue>:

using System.Collections.Concurrent;

string[] sales = Enumerable.Range(0, 100_000)
    .Select(i => i % 3 == 0 ? "978-0132350884" : i % 3 == 1 ? "978-0135957059" : "978-0321125217")
    .ToArray();

var salesByIsbn = new ConcurrentDictionary<string, int>();
Parallel.ForEach(sales, isbn => salesByIsbn.AddOrUpdate(isbn, 1, (_, count) => count + 1));
foreach (KeyValuePair<string, int> pair in salesByIsbn.OrderBy(p => p.Key))
    Console.WriteLine($"{pair.Key}: {pair.Value}");

int factoryCalls = 0;
var covers = new ConcurrentDictionary<string, string>();
Parallel.For(0, 10, _ => covers.GetOrAdd("978-0132350884", isbn =>
{
    Interlocked.Increment(ref factoryCalls);
    Thread.Sleep(50); // pahalı bir kapak indirme işlemi
    return $"cover-{isbn}.jpg";
}));
Console.WriteLine($"GetOrAdd: {covers.Count} entry, factory ran {factoryCalls} times");

int lazyCalls = 0;
var lazyCovers = new ConcurrentDictionary<string, Lazy<string>>();
string[] results = new string[10];
Parallel.For(0, results.Length, i => results[i] = lazyCovers.GetOrAdd("978-0132350884", isbn => new Lazy<string>(() =>
{
    Interlocked.Increment(ref lazyCalls);
    Thread.Sleep(50);
    return $"cover-{isbn}.jpg";
})).Value);
Console.WriteLine($"GetOrAdd + Lazy: {results.Distinct().Count()} distinct result, factory ran {lazyCalls} time");
978-0132350884: 33334
978-0135957059: 33333
978-0321125217: 33333
GetOrAdd: 1 entry, factory ran 10 times
GetOrAdd + Lazy: 1 distinct result, factory ran 1 time

AddOrUpdate "yoksa ekle, varsa güncelle" işini atomik yapar; ISBN bazında satış saymak için bire bir. Ama ikinci satıra dikkat: dictionary'de tek bir girdi var, kapak indirme fonksiyonu ise on kez çalıştı. Dokümantasyon bunu açıkça söyler: GetOrAdd ve AddOrUpdate'e verdiğin factory, lock dışında çağrılır ve aynı key için birden çok kez çalışabilir; dictionary'ye yalnızca biri yazılır. Factory ucuz ve yan etkisizse sorun yok. Pahalıysa ya da yan etkisi varsa (HTTP isteği, veritabanına yazma) değeri Lazy<T> ile sar: dictionary'de yarışan şey ucuz Lazy object'leri olur, Value ise yalnızca bir kez hesaplanır.

Diğer üyeler daha dar işler için var. ConcurrentQueue<T> FIFO, ConcurrentStack<T> LIFO çalışır; ikisi de TryDequeue/TryPop ile "varsa al" der. ConcurrentBag<T> sırasızdır ve aynı thread'in hem ekleyip hem aldığı senaryolar için optimize edilmiştir. BlockingCollection<T> ise producer/consumer için tasarlanmıştır ama adı üstünde, bekleyen thread'i bloklar. Async dünyadaki karşılığı bir sonraki yazının konusu.

Hangi Araç Ne Zaman

Önce shared state'ten kaçınıp kaçınamayacağına bak: her iş kendi sonucunu üretsin, sonuçları sonda birleştir. Bu mümkün değilse, tek bir sayaç ya da flag için Interlocked. Birden fazla değişkenin birlikte tutarlı kalması gereken, içinde await olmayan kısa bir blok için lock. Lock bloğunda await gerekiyorsa ya da aynı anda N işe izin vermek istiyorsan SemaphoreSlim. Thread'ler arasında paylaşılan bir dictionary, queue ya da bag lazımsa kendi lock'unu yazmak yerine concurrent collection.

Sık Düşülen Tuzaklar

lock (this), lock (typeof(Book)) ya da lock ("stock") yazmak. Bunlar senin kontrolünde olmayan object'ler: this'i dışarıdaki herhangi bir kod kilitleyebilir, string literal'ler intern edildiği için programın başka bir yerindeki aynı string aynı object'tir. Her zaman private readonly object _gate = new();.

İki lock'u farklı sırayla almak. Bir method önce _ordersGate, sonra _stockGate alırken diğeri tersini yaparsa iki thread birbirini sonsuza kadar bekler; bu, serinin deadlock yazısında gördüğümüzden farklı ama aynı derecede sessiz bir deadlock. Birden çok lock gerekiyorsa her yerde aynı sırayla al, mümkünse tek lock'a indir.

SemaphoreSlim'i lock gibi reentrant sanmak. lock'u alan thread aynı lock'u tekrar alabilir; SemaphoreSlim ise kimin aldığını bilmez:

object monitor = new();
lock (monitor)
{
    lock (monitor)
    {
        Console.WriteLine("lock: re-entered on the same thread");
    }
}

var gate = new SemaphoreSlim(1, 1);
await gate.WaitAsync();
bool acquiredAgain = await gate.WaitAsync(TimeSpan.FromMilliseconds(100));
Console.WriteLine($"SemaphoreSlim: second wait acquired = {acquiredAgain}");
lock: re-entered on the same thread
SemaphoreSlim: second wait acquired = False

Timeout vermeseydik ikinci WaitAsync sonsuza kadar bekleyecekti. Lock tutan bir method'un içinden aynı lock'u alan başka bir method'u çağırma; lock'u dıştaki method'da al, içteki yardımcıyı lock'suz yaz.

Normal Dictionary'yi thread'ler arasında paylaşmak. Sadece okuma yapılıyorsa sorun yok; bir thread bile yazıyorsa dictionary'nin iç yapısı bozulabilir, sonsuz döngüye girebilir ya da exception atabilir. Ya lock ile koru ya da ConcurrentDictionary'ye geç.

Concurrent collection kullanınca her şeyin güvende olduğunu sanmak. if (!dict.ContainsKey(k)) dict.TryAdd(k, v); iki ayrı atomik çağrı; arasında yine check-then-act race condition'ı var. Tek çağrıda yapan method'u kullan: GetOrAdd, AddOrUpdate, TryUpdate.

Küçük Bir Deneme

★☆☆ Tahmin sorusundaki sayacı önce lock, sonra Interlocked.Increment ile düzelt. Ardından önceki yazıdaki thread-local Parallel.For overload'uyla üçüncü bir çözüm yaz. Hangisi lock'suz, hangisi her iterasyonda senkronizasyon yapıyor?

★★☆ SafeShelf'e Restock(int quantity) ve TryReserve(string isbn, int quantity) ekle; stokları ISBN'e göre bir Dictionary<string, int>'te tut. Tek bir lock mu, ISBN başına ayrı lock mu? İkisinin artısını ve eksisini yaz.

★★★ CatalogCache'i, cache'in 5 dakika sonra bayatladığı ve yeniden yüklendiği bir hâle getir. Yenileme sırasında eski veriyi sunmaya devam etmek mi, istekleri bekletmek mi daha iyi? LoadFromDatabaseAsync exception atarsa bekleyen istekler ne görmeli?

Concurrent collection'ları anlatırken BlockingCollection'ın async dünyada bir karşılığı olduğunu söylemiştik. Bir sonraki yazıda System.Threading.Channels ile producer/consumer kuruyoruz: siparişleri bir uçtan yazıp diğer uçtan okumak, queue dolunca producer'ı yavaşlatmak ve birden çok consumer'la işi paylaşmak.