Featured image of post Async, locki i monitory – gdzie leży problem?

Async, locki i monitory – gdzie leży problem?

W niniejszym artykule przedstawiam ciekawą pułapkę, która czeka na programistę .net w przypadku gdy potrzebuje on zabezpieczyć dostęp do kodu, a ten chciałby wykonać asynchronicznie...

Spis treści

W niniejszym artykule nie będę przedstawiał podstaw dotyczących pisania kodu wielowątkowego. Jest on adresowany do osób, które mają pojęcie jak rozpoczynać zadania w osobnych procesach. Nie wymagam jednak tutaj jakiejś szczególnej wiedzy, więc nawet początkujący powinni znaleźć coś dla siebie. Jeśli natomiast szukasz czegoś, co dałoby Ci użyteczne podstawy, to zapraszam: tutaj, tutaj oraz blog Stephen’a Cleary.

Gwoli ścisłości, wszystkie poniższe przykłady są uruchamiane za pomocą takiego kawałka kodu:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace asynclocks
{
    partial class Program
    {
        static void Start(string taskName, int noOfTasks, Func<string,Task> test)
        {
            Console.WriteLine($"### {taskName} example with {noOfTasks} tasks start ###");
            var tasks = new Task[noOfTasks];
            for (int i = 0; i < noOfTasks; i++)
            {
                tasks[i] = Task.Run(async () => {
                    var thread = Thread.CurrentThread.ManagedThreadId;
                    var task = Task.CurrentId;
                    var name = $"({task}-{thread})";
                    await test(name);
                });
            }
            Task.WaitAll(tasks);
        }
    }
}

gdzie noOfTasks mówi o tym, ile zadań ma być uruchomione równolegle, natomiast test jest funkcją do testowania. Dla każdego testu obiekt jest tworzony od nowa.

Jak to zrobić źle

lock()

Zacznijmy od początku. Normalnie kompilator nie pozwoli nam na wywołanie kodu asynchronicznego (wymagającego słówka await) wewnątrz struktury lock(…) {…} – zwróci nam lakoniczne error CS1996: Cannot await in the body of a lock statement. Taki niedziałający kod widać poniżej:

Kompilator nie pozwoli nam zbudować kodu, gdzie wewnątrz lock’a mamy słowo kluczowe await.
private object _locker = new object();
async Task NotWorkingLock()
{
    lock(_locker) {
        await Task.Delay(TimeSpan.FromSeconds(5));
    }
}

Monitor

Kod zawarty w tej sekcji jest niepoprawny i może powodować trudne do wykrycia błędy, mimo iż tak nie wygląda!

Niestety, programowanie współbieżne w C# jest wirusowe, to znaczy, że raz dodany async i await rozprzestrzeniają się dalej na nasze oprogramowanie, co może doprowadzić nas do sytuacji, gdzie koniecznie chcemy kontrolować wykonywanie pewnej metody asynchronicznej. Możliwe, że w takim momencie strzeli nam do głowy, aby napisać coś takiego:

Pozorna kontrola wywołania funkcji asynchronicznej
using System;
using System.Threading;
using System.Threading.Tasks;

class MonitorExample
{
    private object _locker = new object();
    public async Task MonitorNotProtected(string name)
    {
        bool lockTaken = false;
        Monitor.TryEnter(_locker, ref lockTaken);
        if (lockTaken)
        {
            try
            {
                Console.WriteLine($"With lock {name}!");
                await Task.Delay(TimeSpan.FromSeconds(10));
            }
            finally
            {
                Monitor.Exit(_locker);
            }
        }
        else
        {
            Console.WriteLine($"Without lock {name}!");
            await Task.Delay(TimeSpan.FromSeconds(5));  (1)
        }
        Console.WriteLine($"Exiting... {name}");
    }
}
1 Odkomentowanie tej linijki daje jeszcze bardziej kosmiczne wyniki jak na przykład wielokrotne wchodzenie do sekcji krytycznej…​

W momencie, gdy go uruchomimy, zobaczymy taki wynik jak poniżej. Wygląda on dobrze…​

Wynik pracy dwóch zadań
With lock (1-5)!
Without lock (2-4)!
Exiting... (2-4)
Exiting... (1-5)

Co się jednak stanie, gdy ilość wątków z 2 zmienimy na większą niż mamy wątków fizycznych procesora? Otóż pojawiają się poważny problem, gdyż w trakcie działania programu otrzymujemy wyjątek:

System.Threading.SynchronizationLockException : Object synchronization method was called from an unsynchronized block of code.

Co gorsza, nie pojawia się on zawsze, co niektóre uruchomienia powyższego kodu kończą się na moim komputerze poprawnie. W bardziej skomplikowanych przykładach może zdążyć się nawet wielokrotne wejście do sekcji krytycznej – aby to zaobserwować odkomentuj linijkę, na której końcu znajduje się cyfra (1). Wynik, jaki możesz zaobserwować to, na przykład:

Wynik pracy dziesięciu zadań
Without lock (4-6)!
Without lock (2-5)!
Without lock (3-11)!
Without lock (7-10)!
Without lock (8-4)!
Without lock (1-7)!
With lock (5-9)! (1)
Without lock (6-8)!
Without lock (10-4)!
With lock (9-9)! (2)
Exiting... (6-8)
Exiting... (7-10)
Exiting... (2-5)
Exiting... (3-11)
Exiting... (8-4)
Exiting... (4-6)
Exiting... (10-4)
Exiting... (1-7)

Zwróć uwagę, że (1) i (2) pokazują, że program wszedł do sekcji krytycznej dwa razy! Co więcej, taki scenariusz może pozostać niewykryty, dopóki ilość zadań uruchamianych asynchronicznie nie przekracza pewnej magicznej liczby w systemie, co może powodować trudne do wykrycia błędy!

Trochę wyjaśnienia

Wyjątki są powodowane tym problemem, że obiekt locker jest zwalniany przez inny wątek niż ten, który go zablokował. Dzieje się tak, gdyż mechanika async-await nie gwarantuje kontynuowania na tym samym wątku. Pisze o tym również Eric Lippert (który podaje w swoim profilu na SO, że pracował nad kompilatorem C#), co można przeczytać pod tym adresem: https://stackoverflow.com/a/7612714/6208972.

Jako ciekawostkę chciałbym wskazać, że na poprzednim wyniku programu, miejsca wskazane przez (1) i (2) mają ten sam identyfikator wątku, ale inny zadania! Może to sugerować, że mechanizm monitora oparty jest właśnie na identyfikatorze tego pierwszego.

Jak to zrobić dobrze

Całe szczęście rozwiązanie problemu asynchroniczności i ochrony sekcji krytycznej nie jest skazane na porażkę, mamy na to kilka sposobów.

Nie wywołuj kodu asynchronicznego w sekcji krytycznej

Może wydawać się to dziwne, że takie rozwiązanie polecam jako pierwsze, ale przez cały czas mojej edukacji kładziono mi do głowy, że sekcja krytyczna powinna być możliwe najkrótsza i najprostsza. Wiąże się to z tym, że im mniej synchronizacji wątków wymagamy, tym szybszy będzie nasz kod! Także, zanim sięgniesz po kolejne rozwiązania, rozważ, czy aby na pewno nie przesadzasz z zadaniami wykonywanymi w sekcji krytycznej.

Sekcja krytyczna tylko dla flagi

Sposób ten może nie jest najpiękniejszy, ale na pewno jest łatwo dostępny, gdyż nie wymaga ani nowej wersji .Neta ani zewnętrznych bibliotek. Do tego jest realizacją zasady, o której mówiłem powyżej. Przy okazji swojej złożoności pozwala nam na jasne określenie, czy możemy wejść do sekcji krytycznej, czy też nie, co daje nam dodatkowe możliwości kontroli wykonania programu.

Brzydka, acz w miarę bezpieczna kontrola wywołania asynchronicznej sekcji krytycznej
using System;
using System.Threading;
using System.Threading.Tasks;

class CriticalSectionForFlag
{
    private object _locker = new object();
    private bool isThereAnotherThread = false;
    public async Task SectionWithFlag(string name)
    {
        var doIhaveLock = false;
        try
        {
            lock (_locker)
            {
                if (!isThereAnotherThread)
                {
                    doIhaveLock = true;
                    isThereAnotherThread = true;
                }
            }

            if (doIhaveLock)
            {
                Console.WriteLine($"With lock {name}!");
                await Task.Delay(TimeSpan.FromSeconds(10));
            }
            else
            {
                Console.WriteLine($"Without lock {name}!");
                // await Task.Delay(TimeSpan.FromSeconds(5)); (1)
            }
        }
        finally
        {
            lock (_locker)
            {
                if (doIhaveLock)
                    isThereAnotherThread = false;
            }
        }

        Console.WriteLine($"Exiting... {name}");
    }
}
1 Nawet z takim paskudztwem implementacja działa tak, jak powinna.

Wady tej implementacji to na pewno złożoność: przekopiowywanie jej wewnątrz projektu może prowadzić do mnóstwa zdublowanych linijek kodu (czego nie lubimy), a ilość kodu niezbędna do działania powoduje, że łatwo w tym wszystkim zrobić błąd. Raz napisany kawałek może posłużyć przez wiele długich lat w postaci biblioteki.

Problem z ThreadAbortException

Jeśli zastanawiasz się, dlaczego powyżej napisałem "w miarę bezpieczna" to śpieszę z wyjaśnieniem. Otóż jest taki problem, że w dowolnym momencie wykonania powyższej funkcji może dojść do wywołania wyjątku ThreadAbortException, który może przerwać jej wykonywanie i zostawić w dziwnym stanie co w konsekwencji może doprowadzić nas do deadlocka. Całe szczęście jest to coraz mniej prawdopodobne, gdyż używanie metody Thread.Abort zostało uznane za złą praktykę, a w jego miejsce wprowadzono CancellationToken. Jeśli chcesz poczytać szczegółowe rozważania, jak zabezpieczyć się w takim przypadku zapraszam do odpowiedzi na pytanie na Stacku: https://stackoverflow.com/a/61806749/6208972.

SemaphoreSlim.WaitAsync

Pewnego dnia, ktoś, kto odpowiada za .Neta w końcu poszedł po rozum do głowy i stanowczo uprościł kwestię asynchroniczności i sekcji krytycznej

using System;
using System.Threading;
using System.Threading.Tasks;

class AsyncSemaphore
{
    SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1);
    public async Task SemaphoreAsync(string name)
    {
        Console.WriteLine($"Before trying lock {name}!");
        await semaphoreSlim.WaitAsync();
        try
        {
            Console.WriteLine($"With lock {name}!");
            await Task.Delay(TimeSpan.FromSeconds(10));
        }
        finally
        {
            semaphoreSlim.Release();
        }

        Console.WriteLine($"Exiting... {name}");
    }
}

Przykład ten jest dużo prostszy, a po jego uruchomienia otrzymujemy jasny i klarowny wynik:

Before trying lock (2-5)!
Before trying lock (1-4)!
With lock (2-5)!
Exiting... (2-5)
With lock (1-4)!
Exiting... (1-4)

Na plus na pewno jest zwięzłość takiego przykładu w najprostszej formie. Jednak gdybyś chciał osiągnąć taki sam efekt jak w przypadku Monitor.TryEnter(), to całość rozrosłaby się podobnie jak w poprzednim przypadku. Na minus muszę zwrócić Ci uwagę, że to rozwiązanie również może powodować problemy – w szczególności podczas pracy z wyjątkowo starym kodem (jeszcze sprzed .Net Framework 4). Po więcej informacji zajrzyj powyżej do ramki zatytułowanej "Problem z ThreadAbortException".

Ostrożnie ze wzorcem if-lock-if

Na koniec chciałbym wspomnieć o dość popularnym wzorcu blokady z podwójnym zatwierdzeniem. Już sama wikipedia mówi nam, że w niektórych przypadkach (między innymi w języku Java i C++), gdzie może dojść do trudnych do wykrycia problemów z wyścigiem wątków. Jeszcze więcej na ten temat można przeczytać tutaj (po angielsku. Także lepiej omijać ten sposób z daleka.

Podsumowanie

Programowanie współbieżne nie jest łatwe, a do tego pułapki czekają wszędzie. Nie zdziwię się, jeśli nawet i w tym artykule znajdzie się jakiś błąd, który wyjdzie w jakimś szczególnym przypadku. Jeśli coś takiego zauważyłeś, to proszę, daj znać w komentarzach!

Wybór odpowiedniej metody synchronizacji zależy od tego, na jakim etapie prac jesteśmy. Jeśli zaczynamy projekt – warto rozejrzeć się za gotową biblioteką lub przygotować coś własnego, w innym przypadku prostota może nas uratować. Jeśli dwie przytoczone powyżej, to za mało warto może jeszcze rozejrzeć się za bibliotekami pokroju AsyncEx, która rozwija możliwości pracy asynchronicznej (z tej przytoczonej tutaj jeszcze nie korzystałem, także nie wypowiem się, czy jest dobra).

Warto jednak zawsze mieć w głowie jedną z zasad Roberta C. Martina z książki "Czysty kod. Podręcznik dobrego programisty", że zarządzenie dostępem do sekcji krytycznej jest jedną odpowiedzialnością a sama sekcja krytyczna osobną. Taki podział zadań pozwoli nam na skupienie się na jakości każdego z elementów.

comments powered by Disqus
Proszę pamiętaj, że blog jest na ten moment w wersji poglądowej i może zawierać wiele błędów (ale nie merytoycznych!). Mimo to mam nadzieję, że blog Ci się podoba! Wiele ilustracji pojawiło się na blogu dzięki unsplash.com!
Zbudowano z Hugo
Motyw Stack zaprojektowany przez Jimmy