Klasa Monitor to chyba najpopularniejszy, najłatwiejszy i często najlepszy sposób synchronizacji danych w .NET. Większość programistów używa słowa kluczowego lock zamiast bezpośrednio Monitor.Enter. W większości przypadków jest to poprawne i zdecydowanie najbardziej przejrzyste. Dzisiaj chciałbym przyjrzeć się kilku sposobom konstrukcji Monitor.Enter\MonitorExit. Pierwszy, zdecydowanie najgorszy to:
Monitor.Enter(_sync); // sekcja krytyczna tutaj Monitor.Exit(_sync);
W powyższym kodzie brakuje obsługi błędów. Kolejnym sposobem jest:
Monitor.Enter(_sync); try { //sekcja krytyczna tutaj } finally { Monitor.Exit(_sync); }
Co w przedstawionym kodzie jest nie złego? Obsługujemy wyjątki – to jest na plus. Niestety, co jeśli jakiś wyjątek asynchroniczny (opisany kilka postów wcześniej ThreadAbortException) będzie miał miejsce między try a Monitor.Enter? Nigdy nie wyjedziemy do bloku try-catch a co tym idzie, nigdy nie zostanie wywołany Monitor.Exit. Spowoduje to na końcu deadlock ponieważ blokada nigdy nie zostanie zwolniona.
Przed C# 4.0, lock generował dokładnie taką konstrukcję. Skompilujemy następujący lock w .NET 3.5:
lock(_sync) { // sekcja krytyczna tutaj }
Na wyjściu będziemy mieli:
private static void Main(string[] args) { object CS$2$0000; Monitor.Enter(CS$2$0000 = _sync); Label_000E: try { goto Label_001A; } finally { Label_0012: Monitor.Exit(CS$2$0000); } Label_001A: return; }
W rzeczywistości CLR rozpoznane powyższy wzorzec i od kilku wersji wyjątki asynchroniczne nie będą miały miejsca między Enter a try. Microsoft miał świadomość, że wiele osób polega na takim wzorcu (lub na słowie lock) i sztucznie zapobiega jakimkolwiek instrukcjom między Enter a try.
Ktoś może zapytać, dlaczego nie umieścić Enter w try-catch? Spróbujmy:
try { Monitor.Enter(_sync); // sekcja krytyczna tutaj } finally { Monitor.Exit(_sync); }
Co prawda, nie ma możliwości, że Monitor.Exit nie zostanie wywołany. Jeśli wyjątek asynchroniczny będzie miał miejsce zaraz po Monitor.Enter i tak zostanie wywołana klauzula finally. Kod jednak jest nieodporny na inny scenariusz.Co jeśli wyjątek spowoduje funkcja Monitor.Enter? Wtedy wejdziemy do finally, spróbujemy wywołać Exit na blokadzie, która nie została uruchomiona, co spowoduje kolejny wyjątek.
Z tego względu lepszym rozwiązaniem jest:
bool lockTaken = false; try { Monitor.Enter(_sync, ref lockTaken); // ... } finally { if (lockTaken) Monitor.Exit(_sync); }
Jeśli skompilujemy w C# 4.0 konstrukcje ze słowem kluczowym lock dostaniemy właśnie powyższy kod:
lock(_sync) { // sekcja krytyczna tutaj } Reflector: private static unsafe void Main(string[] args) { bool <>s__LockTaken0; object CS$2$0000; bool CS$4$0001; <>s__LockTaken0 = 0; Label_0003: try { Monitor.Enter(CS$2$0000 = _sync, &<>s__LockTaken0); goto Label_0026; } finally { Label_0016: if ((<>s__LockTaken0 == 0) != null) { goto Label_0025; } Monitor.Exit(CS$2$0000); Label_0025:; } Label_0026: return; }
Kod gwarantuje, że zawsze wyjdziemy z sekcji krytycznej, nigdy nie wywołamy Exit gdy nie został prawidłowo wykonany Enter. Czy to znaczy, że nasz kod jest perfekcyjny? Moim zdaniem nie i osobiście staram się unikać lock w kodzie w wielu scenariuszach. Co jeśli nasz kod wywołał wyjątek w sekcji krytycznej? Używając lock, natychmiast wywołamy Exit POZWALAJĄC innym wątkom na dostęp do tych danych. Skoro wystąpił jakiś wyjątek to oznacza, że coś nie poszło po naszej myśli i być może mamy do czynienia z nieprawidłowym stanem aplikacji. W takiej sytuacji lepiej jest mieć deadlock, niż pozwolić innym wątkom zepsuć jeszcze bardziej nasze dane.
Drugi problem z lock to brak timeout. Moim zdaniem lepiej przekazać jakiś timeout, po którym wątek nie będzie blokował już więcej wykonania. Używając metody Enter.TryEnter mamy kontrole, ile czasu będziemy próbować wejść do sekcji krytycznej. Oczywiście, należy dobrze zaprojektować algorytm abyśmy mogli w łatwy sposób wykryć deadlocki a nie po prostu je ignorować.