Gdy kilka wątków jednocześnie czyta i zapisuje te same dane, aktualizacje mogą zginąć albo zostać zobaczone w połowie. Instrukcja lock sprawia, że blok kodu staje się wzajemnie wykluczający: dopóki jeden wątek jest w środku, każdy inny wątek, który dotrze do lock na tym samym obiekcie, czeka na swoją kolej.
Problem: wyścig
Cztery zadania dodają po 100 000 do wspólnego licznika. Wynik powinien wynosić 400 000:
Przykładowy wynik:
Expected 400000, got 245609
Suma zwykle wychodzi za mała, i to za każdym razem o inną wartość (na maszynie z jednym rdzeniem czasem może wyjść poprawnie, i właśnie dlatego takie błędy trudno wyłapać). count++ wygląda na jedną operację, ale to trzy: odczyt count, dodanie jedynki i zapis z powrotem. Dwa wątki mogą oba odczytać 500, oba obliczyć 501 i oba zapisać 501, więc jedna inkrementacja przepada.
Rozwiązanie: lock
Otocz sekwencję odczyt, modyfikacja, zapis instrukcją lock na wspólnym obiekcie:
Wynik:
Expected 400000, got 400000
Teraz w bloku może być tylko jeden wątek naraz, więc każda inkrementacja kończy się, zanim następna odczyta wartość. Blokada gwarantuje też widoczność: wątek wchodzący do blokady widzi każdy zapis wykonany przez poprzedniego właściciela, zanim ten ją opuścił.
Ochrona działa tylko wtedy, gdy każdy dostęp do count przechodzi przez tę samą blokadę. Jedno niezablokowane count++ gdziekolwiek indziej w programie przywraca wyścig.
Do czego kompiluje się lock
lock to skrót do klasy Monitor, z try/finally, dzięki czemu blokada jest zwalniana nawet wtedy, gdy blok rzuci wyjątek:
lock (sync)
{
count++;
}
// is compiled roughly as:
bool taken = false;
try
{
Monitor.Enter(sync, ref taken);
count++;
}
finally
{
if (taken) Monitor.Exit(sync);
}
Monitor oferuje też TryEnter(obj, timeout), które rezygnuje po upływie limitu czasu zamiast czekać w nieskończoność, oraz Wait/Pulse do sygnalizacji między wątkami. Wątek, który już trzyma blokadę, może wejść do niej ponownie (blokady są reentrant), więc zablokowana metoda może wywołać inną zablokowaną metodę na tym samym obiekcie.
Wybór obiektu blokady
Obiekt w lock (...) to tylko żeton, na który umawiają się wątki. Zasady:
- Używaj prywatnego pola tylko do odczytu typu
object.private readonly object sync = new object();. Prywatne oznacza, że żaden zewnętrzny kod nie weźmie tej samej blokady;readonlyoznacza, że żetonu nie da się podmienić, gdy wątek go trzyma. - Nigdy
lock (this). Każdy, kto ma referencję do twojego obiektu, też może na nim zablokować, a wtedy jego kod blokuje twój albo wpada z nim w zakleszczenie. - Nigdy nie blokuj na stringu ani na
typeof(...). Literały stringów są internowane (każde"orders"w procesie to ten sam obiekt), a obiektyTypesą wspólne dla całej aplikacji, więc niezwiązany kod może w końcu rywalizować o tę samą blokadę. - Nigdy nie blokuj na typie wartościowym.
lock (count)naintsię nie kompiluje (błąd CS0185), a ręczne opakowanie (boxing) za każdym razem tworzy nowy obiekt, więc niczego by nie blokowało. - Używaj jednej blokady na każdy zestaw danych, który musi pozostać spójny: statycznego pola blokady dla danych statycznych i pola instancji dla danych danej instancji.
Ochrona operacji wieloetapowych
lock najbardziej przydaje się tam, gdzie sprawdzenie i działanie muszą nastąpić razem. Bezpieczne wątkowo konto pokazuje zarówno wzorzec "sprawdź, a potem działaj", jak i spójny odczyt dwóch pól:
Wynik:
10 left after 33 withdrawals
Bez blokady dwa wątki mogłyby oba zobaczyć saldo 40, oba przejść sprawdzenie i oba wypłacić 30, zostawiając na koncie minus 20. Metoda Summary też blokuje, więc nigdy nie zgłosi balance sprzed wypłaty razem z licznikiem withdrawals sprzed niej.
Dictionary<TKey, TValue> i List<T> również nie są bezpieczne wątkowo: równoczesne zapisy mogą uszkodzić ich wewnętrzne tablice, a nie tylko zgubić aktualizacje. Chroń je blokadą albo użyj ConcurrentDictionary<TKey, TValue> i innych typów z System.Collections.Concurrent.
Interlocked dla pojedynczych wartości
Gdy współdzielony stan to jedna liczba całkowita lub jedna referencja, a aktualizacja ma jeden krok, klasa Interlocked wykonuje ją atomowo bez blokady:
Wynik:
Visits: 10000
Bytes: 5120000
Peak: 10000
Increment, Decrement, Add i Exchange odpowiadają atomowym operacjom procesora (pojedynczej instrukcji na x64). CompareExchange(ref location, newValue, expected) zapisuje tylko wtedy, gdy w danym miejscu nadal jest expected, i zwraca to, co tam zastało, co pozwala zbudować dowolną aktualizację jako pętlę ponowień. Wszystko, co dotyka dwóch zmiennych naraz, nadal wymaga lock.
Zakleszczenia
Zakleszczenie (deadlock) powstaje, gdy każdy z dwóch wątków trzyma blokadę potrzebną drugiemu:
// Thread 1 // Thread 2
lock (accountA) lock (accountB)
{ {
lock (accountB) { /* ... */ } lock (accountA) { /* ... */ }
} }
Jeśli wątek 1 weźmie accountA, a wątek 2 weźmie accountB, każdy będzie potem w nieskończoność czekał na drugi. Nie ma wyjątku ani limitu czasu; program się zawiesza. Przelew między dwoma kontami, który blokuje najpierw konto "z", a potem konto "do", daje dokładnie to, gdy jednocześnie wykonują się dwa przeciwne przelewy.
Standardowe zabezpieczenia:
- Blokuj w stałej, globalnej kolejności. Przy przelewie najpierw zablokuj konto o mniejszym ID, niezależnie od kierunku przepływu pieniędzy.
- Trzymaj blokady krótko i wykonuj wolną pracę (I/O, logowanie, wywołania sieciowe) poza nimi.
- Nie wywołuj nieznanego kodu, gdy trzymasz blokadę: zdarzenia, callbacki i metody wirtualne mogą brać własne blokady.
- Używaj
Monitor.TryEnterz limitem czasu tam, gdzie zawieszenie byłoby gorsze niż błąd.
Bez await w lock: użyj SemaphoreSlim
await jest niedozwolone wewnątrz bloku lock (błąd kompilatora CS1996). Po await metoda może kontynuować na innym wątku, a blokadę Monitor musi zwolnić ten wątek, który ją wziął. W kodzie asynchronicznym SemaphoreSlim z licznikiem 1 działa jak blokada zgodna z async:
Wynik:
5 saved, one at a time
Zawsze łącz WaitAsync z Release w finally, inaczej wyjątek zostawi bramkę zamkniętą na zawsze. SemaphoreSlim(3, 3) wpuszcza trzech wywołujących naraz; w ten sposób ogranicza się liczbę równoczesnych żądań do API z limitem zapytań.
System.Threading.Lock (.NET 9)
.NET 9 z C# 13 dodaje osobny typ System.Threading.Lock. Gdy obiekt w instrukcji lock jest typu Lock, kompilator zamiast Monitor używa jego szybszego API EnterScope:
private readonly Lock sync = new Lock();
public void Add(decimal amount)
{
lock (sync) // uses Lock.EnterScope(), not Monitor
{
balance += amount;
}
}
Zasady wyboru obiektu pozostają takie same. We wcześniejszych wersjach właściwym wyborem jest private readonly object.
Typowe błędy
- Blokowanie tylko części dostępów. Każdy odczyt i zapis współdzielonych danych musi brać tę samą blokadę.
lock (this),lock (typeof(X)),lock ("name"). Zewnętrzny kod może wziąć tę samą blokadę.- Wolne operacje I/O wewnątrz blokady. Każdy inny wątek czeka; trzymaj blok krótko.
- Branie dwóch blokad w różnej kolejności w różnych miejscach. Klasyczne zakleszczenie.
- Używanie
lockwokóławait. To się nie kompiluje; użyjSemaphoreSlim. - Blokada na każde wywołanie.
lock (new object())niczego nie chroni; obiekt musi być wspólny.
Najczęściej zadawane pytania
Co robi lock w C#?
lock (obj) { ... } pozwala tylko jednemu wątkowi naraz wykonywać blok dla danego obiektu blokady. Drugi wątek, który dotrze do lock na tym samym obiekcie, czeka, aż pierwszy opuści blok. Blokada jest zwalniana nawet wtedy, gdy blok rzuci wyjątek, bo lock kompiluje się do Monitor.Enter i Monitor.Exit wewnątrz try/finally.
Na jakim obiekcie blokować w C#?
Na osobnym prywatnym polu: private readonly object _sync = new object();. Musi to być typ referencyjny, wspólny dla wszystkich wątków, które dotykają chronionych danych, i niedostępny spoza twojej klasy. Nigdy nie blokuj na this, na Type (typeof(MyClass)) ani na stringu, bo inny kod może zablokować ten sam obiekt i zatrzymać cię albo doprowadzić do zakleszczenia.
Kiedy użyć Interlocked zamiast lock?
Gdy współdzielony stan to jedna liczba lub referencja, a operacja ma jeden krok: Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange albo CompareExchange. To atomowe operacje sprzętowe, szybsze niż blokada. Do wszystkiego, co dotyka kilku pól albo kolekcji, używaj lock.
Czy można użyć await wewnątrz lock w C#?
Nie, kompilator odrzuca await wewnątrz bloku lock (błąd CS1996), bo kod po await może wznowić się na innym wątku niż ten, który trzyma blokadę. Zamiast tego użyj SemaphoreSlim z licznikiem 1: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.
Jak powstają zakleszczenia przy lock?
Wątek 1 trzyma blokadę A i czeka na blokadę B, a wątek 2 trzyma blokadę B i czeka na blokadę A. Żaden nie może ruszyć dalej, a program zawiesza się bez wyjątku. Zapobiegniesz temu, zawsze biorąc kilka blokad w tej samej globalnej kolejności, trzymając blokady jak najkrócej i nigdy nie wywołując nieznanego kodu, gdy trzymasz blokadę.