Birkaç thread aynı veriyi aynı anda okuyup yazdığında güncellemeler kaybolabilir ya da yarım kalmış görünebilir. lock ifadesi bir kod bloğunu karşılıklı dışlayıcı yapar: bir thread içindeyken, aynı nesne üzerinde bir lock'a ulaşan diğer her thread sırasını bekler.
Sorun: bir yarış durumu
Dört task'ın her biri paylaşılan bir sayaca 100.000 ekler. Cevap 400.000 olmalıdır:
Örnek çıktı:
Expected 400000, got 245609
Toplam genellikle eksik ve her çalıştırmada farklı bir miktarda çıkar (tek çekirdekli bir makinede bazen doğru olabilir; bu hataları yakalamayı zorlaştıran da budur). count++ tek bir işlem gibi görünür ama üç işlemdir: count'u oku, bir ekle, geri yaz. İki thread de 500 okuyabilir, ikisi de 501 hesaplayıp 501 yazabilir ve bir artırma kaybolur.
Çözüm: lock
Oku-değiştir-yaz işlemini paylaşılan bir nesne üzerinde bir lock içine alın:
Çıktı:
Expected 400000, got 400000
Artık aynı anda yalnızca bir thread bloğun içinde olabilir, bu yüzden her artırma bir sonraki değeri okumadan önce tamamlanır. Lock görünürlüğü de garanti eder: lock'a giren bir thread, önceki sahibin çıkmadan önce yaptığı her yazmayı görür.
Koruma yalnızca count'a her erişim aynı lock'tan geçerse çalışır. Programın başka bir yerindeki tek bir lock'suz count++ yarışı geri getirir.
lock neye derlenir
lock, blok istisna fırlatsa bile lock'un serbest bırakılması için bir try/finally içeren Monitor sınıfının kısaltmasıdır:
lock (sync)
{
count++;
}
// is compiled roughly as:
bool taken = false;
try
{
Monitor.Enter(sync, ref taken);
count++;
}
finally
{
if (taken) Monitor.Exit(sync);
}
Monitor ayrıca sonsuza dek beklemek yerine bir zaman aşımından sonra vazgeçen TryEnter(obj, timeout)'u ve thread'ler arasında sinyalleşme için Wait/Pulse'u sunar. Zaten bir lock tutan bir thread ona tekrar girebilir (lock'lar yeniden girilebilirdir), bu yüzden lock'lu bir metot aynı nesne üzerinde lock'lu başka bir metodu çağırabilir.
Lock nesnesini seçmek
lock (...) içindeki nesne yalnızca thread'lerin üzerinde anlaştığı bir işarettir. Kurallar:
objecttipinde private, salt okunur bir alan kullanın.private readonly object sync = new object();. Private, hiçbir dış kodun aynı lock'u alamayacağı anlamına gelir;readonlyise bir thread onu tutarken işaretin değiştirilemeyeceği anlamına gelir.- Asla
lock (this). Nesnenize referansı olan herkes onun üzerinde de lock yapabilir ve kodları sizinkini bloklar ya da onunla deadlock'a girer. - Asla bir string ya da
typeof(...)üzerinde lock yapmayın. String literal'leri intern edilir (süreçteki her"orders"aynı nesnedir) veTypenesneleri tüm uygulamada paylaşılır, bu yüzden ilgisiz kodlar aynı lock için yarışmaya başlayabilir. - Asla bir değer tipi üzerinde lock yapmayın. Bir
intüzerindelock (count)derlenmez (CS0185 hatası) ve onu elle box'lamak her seferinde yeni bir nesne oluşturur, bu yüzden hiçbir şeyi kilitlemez. - Tutarlı kalması gereken her veri kümesi için bir lock kullanın; statik veri için statik bir lock alanı, örnek başına veri için bir örnek alanı.
Çok adımlı işlemleri korumak
lock'a en çok, bir kontrol ile bir eylemin birlikte gerçekleşmesi gereken yerde ihtiyaç duyulur. Thread-safe bir hesap, hem kontrol et sonra yap kalıbını hem de iki alanın tutarlı bir anlık görüntüsünü gösterir:
Çıktı:
10 left after 33 withdrawals
Lock olmadan iki thread de 40 bakiye görebilir, ikisi de kontrolü geçip 30 çekebilir ve hesap eksi 20'de kalırdı. Summary metodu da lock yapar, böylece bir çekmeden sonraki balance'ı ondan önceki withdrawals sayısıyla birlikte asla bildirmez.
Dictionary<TKey, TValue> ve List<T> de thread-safe değildir: eşzamanlı yazmalar yalnızca güncellemeleri kaybettirmez, iç dizilerini de bozabilir. Onları bir lock ile koruyun ya da ConcurrentDictionary<TKey, TValue> ve System.Collections.Concurrent içindeki diğer tipleri kullanın.
Tek değerler için Interlocked
Paylaşılan durum tek bir tamsayı ya da tek bir referans olduğunda ve güncelleme tek adım olduğunda, Interlocked sınıfı bunu lock olmadan atomik olarak yapar:
Çıktı:
Visits: 10000
Bytes: 5120000
Peak: 10000
Increment, Decrement, Add ve Exchange atomik işlemci işlemlerine karşılık gelir (x64'te tek bir komut). CompareExchange(ref location, newValue, expected) yalnızca konum hâlâ expected'ı tutuyorsa yazar ve bulduğunu döndürür; bu da herhangi bir güncellemeyi bir yeniden deneme döngüsü olarak kurmanızı sağlar. Aynı anda iki değişkene dokunan her şey yine bir lock gerektirir.
Deadlock'lar
Bir deadlock, iki thread'in her biri diğerinin ihtiyaç duyduğu bir lock'u tuttuğunda olur:
// Thread 1 // Thread 2
lock (accountA) lock (accountB)
{ {
lock (accountB) { /* ... */ } lock (accountA) { /* ... */ }
} }
Thread 1 accountA'yı alırken thread 2 accountB'yi alırsa, her biri sonsuza dek diğerini bekler. İstisna ya da zaman aşımı yoktur; program takılır. Önce "from" sonra "to" lock'u alan iki hesap arasındaki bir transfer, iki ters transfer aynı anda çalıştığında tam olarak bunu üretir.
Standart savunmalar:
- Sabit bir global sırayla lock alın. Bir transfer için, para hangi yönde giderse gitsin önce ID'si küçük olan hesabı kilitleyin.
- Lock'ları kısa tutun ve yavaş işleri (G/Ç, log yazma, ağ çağrıları) onların dışında yapın.
- Bir lock tutarken bilinmeyen kodu çağırmayın: event'ler, callback'ler ve virtual metotlar kendi lock'larını alabilir.
- Takılmanın bir başarısızlıktan daha kötü olacağı yerde zaman aşımıyla
Monitor.TryEnterkullanın.
lock içinde await yok: SemaphoreSlim kullanın
Bir lock bloğunun içinde await'e izin verilmez (CS1996 derleyici hatası). Bir await'ten sonra metot farklı bir thread'de devam edebilir ve bir Monitor lock'u onu alan thread tarafından serbest bırakılmalıdır. Async kod için sayısı 1 olan SemaphoreSlim, async ile uyumlu bir lock gibi davranır:
Çıktı:
5 saved, one at a time
WaitAsync'i her zaman bir finally içindeki Release ile eşleştirin, yoksa bir istisna kapıyı sonsuza dek kapalı bırakır. Bir SemaphoreSlim(3, 3) aynı anda üç çağırana izin verir; hız sınırlı bir API'ye yapılan eşzamanlı istekleri böyle sınırlarsınız.
System.Threading.Lock (.NET 9)
C# 13 ile .NET 9, ayrılmış bir System.Threading.Lock tipi ekler. Bir lock ifadesindeki nesne bir Lock olduğunda derleyici Monitor yerine onun daha hızlı EnterScope API'sini kullanır:
private readonly Lock sync = new Lock();
public void Add(decimal amount)
{
lock (sync) // uses Lock.EnterScope(), not Monitor
{
balance += amount;
}
}
Nesneyi seçme kuralları aynı kalır. Önceki sürümlerde doğru seçim private readonly object'tir.
Yaygın hatalar
- Erişimlerin bir kısmını kilitleyip hepsini kilitlememek. Paylaşılan verinin her okuması ve yazması aynı lock'u almalıdır.
lock (this),lock (typeof(X)),lock ("name"). Dışarıdaki kod aynı lock'u alabilir.- Bir lock içinde yavaş G/Ç yapmak. Diğer her thread bekler; bloğu kısa tutun.
- İki lock'u farklı yerlerde farklı sırayla almak. Klasik deadlock.
await'in etrafındalockkullanmak. Derlenmez;SemaphoreSlimkullanın.- Çağrı başına bir lock.
lock (new object())hiçbir şeyi korumaz; nesne paylaşılmalıdır.
Sıkça Sorulan Sorular
C#'ta lock ne yapar?
lock (obj) { ... }, belirli bir lock nesnesi için bloğu aynı anda yalnızca bir thread'in çalıştırmasına izin verir. Aynı nesne üzerinde bir lock'a ulaşan ikinci bir thread, ilki bloktan çıkana kadar bekler. Blok istisna fırlatsa bile lock serbest bırakılır, çünkü lock bir try/finally içindeki Monitor.Enter ve Monitor.Exit'e derlenir.
C#'ta hangi nesne üzerinde lock yapmalıyım?
Ayrılmış, private bir alan: private readonly object _sync = new object();. Bir referans tipi olmalı, korunan veriye dokunan her thread tarafından paylaşılmalı ve sınıfınızın dışından erişilebilir olmamalıdır. Asla this, bir Type (typeof(MyClass)) ya da bir string üzerinde lock yapmayın, çünkü başka kod aynı nesne üzerinde lock yapıp sizi bloklayabilir ya da deadlock'a sokabilir.
lock yerine ne zaman Interlocked kullanmalıyım?
Paylaşılan durum tek bir sayı ya da referans olduğunda ve işlem tek adım olduğunda: Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange ya da CompareExchange. Bunlar atomik donanım işlemleridir ve bir lock'tan daha hızlıdır. Birkaç alana ya da bir koleksiyona dokunan her şey için lock kullanın.
C#'ta bir lock içinde await kullanılabilir mi?
Hayır, derleyici bir lock bloğunun içindeki await'i reddeder (CS1996 hatası), çünkü await'ten sonraki kod lock'u tutan thread'den farklı bir thread'de devam edebilir. Bunun yerine sayısı 1 olan SemaphoreSlim kullanın: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.
lock ile deadlock'lar nasıl oluşur?
Thread 1 A lock'unu tutar ve B lock'unu bekler, thread 2 ise B lock'unu tutar ve A lock'unu bekler. İkisi de ilerleyemez ve program hiçbir istisna olmadan takılır. Birden fazla lock'u her zaman aynı global sırayla alarak, lock'ları olabildiğince kısa tutarak ve bir lock tutarken bilinmeyen kodu asla çağırmayarak önleyin.