Menu

Instrukcja lock w C#: wyścigi, Interlocked i zakleszczenia

Instrukcja lock pozwala tylko jednemu wątkowi naraz wykonywać blok kodu. Zobacz, jak wyścig psuje licznik, napraw go za pomocą lock, dowiedz się, na jakim obiekcie blokować, użyj Interlocked do prostych liczników, unikaj zakleszczeń wynikających z kolejności blokad i sięgnij po SemaphoreSlim, gdy kod w środku używa await.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

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; readonly oznacza, ż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 obiekty Type są 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) na int się 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.TryEnter z 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 lock wokół await. To się nie kompiluje; użyj SemaphoreSlim.
  • 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ę.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ