Artykuł

sie 26 2012
0

Wielowątkowość w C# - synchronizacja wątków cz. 1 (lock, Monitor, Mutex)

Kilka dni temu zainicjowałem cykl postów związanych z wielowątkowością. Rozpocząłem dość standardowo, bo od wyjaśnienia istoty tego zagadnienia i przedstawienia prostych wątków. Wiedza zawarta w tamtym wpisie, powinna w wielu przypadkach okazać się wystarczająca - szczególnie jeśli w naszej aplikacji mamy tylko jeden wątek dodatkowy, który stworzony jest tylko po to by odciążyć wątek główny.

Takie proste rozwiązanie nie będzie jednak zawsze do końca wystarczające. Problemy pojawią się w sytuacji gdy wątków będzie więcej niż jeden i gdy przynajmniej dwa wątki, będą mogły choćby teoretycznie korzystać z jednego współdzielonego zasobu w tym samym momencie. W tym momencie zapamiętajcie jedną ważną rzecz. Jeśli w programowaniu zakładamy, że coś może teoretycznie się wydarzyć, to w praktyce musimy się przed tym zabezpieczyć, bo szanse na tego typu zdarzenie są znacznie większe niż się pozornie wydaje;)

Reasumując - my jako programiści musimy zadbać oto, by w danej milisekundzie tylko jeden jedyny wątek mógł korzystać z określonego zasobu . W dalszej części tego wpisu, postaram się Wam powiedzieć jak tego dokonać za pomocą różnych mechanizmów synchronizacji.

Synchronizacja wątków - trochę teorii

Synchronizacja wątków to jedno z tych zagadnień w wielowątkowości, które może skutecznie zniechęcić do tego tematu. Generalnie nie jest to zagadnienie łatwe, choć oczywiście w pełni przyswajalne. Gorzej jest w tym przypadku z praktyką - bardzo łatwo przeoczyć określony scenariusz, doprowadzić do zakleszczenia, czy też wywołania określonych procedur działania w złym momencie.

Częstym zagadnieniem związanym z wykorzystaniem wielowątkowości jest problem producenta-konsumenta. Istnieje w naszej aplikacji pewien zasób, na którym mogą operować dwa wątki. Załóżmy że będzie to kolejka obiektów - obojętnie FIFO, czy LIFO. Wątek producenta generuje nowe obiekty, które trafiają do tej kolejki i sygnalizuje konsumentowi, że może sobie pobrać obiekt z tej kolejki. Tak wygląda idealne rozwiązanie. W praktyce może być tak, że producent będzie dodawać obiekty szybciej niż będzie w stanie je pobierać konsument. Może się również zdarzyć, że pewnych przyspieszeń dozna wątek konsumenta. W takim momencie istnieje bardzo duże prawdopodobieństwo, że oba wątki będą chciały się dobić do kolejki dokładnie w tym samym momencie. Naszym zadaniem jest im uniemożliwienie tego. Musimy programistycznie zadbać by zawsze zaczekały na swoją kolejkę.

Inny przykład. Posiadamy pewną rozbudowaną aplikację, na którą składa się kilka jednocześnie działających wątków. Wewnątrz aplikacji posiadamy również klasę opartą na wzorcu Singleton, która przechowuje aktualne ustawienia procesu. W danym momencie z funkcjonalności tej klasy mogą chcieć skorzystać przynajmniej dwa różne wątki, które będą albo zapisywać do tej klasy, albo coś z niej pobierać. Zadaniem programisty jest zadbanie oto, by delikatnie mówiąc nie wchodziły sobie w paradę.

Trochę nieoczekiwanym rezultatem byłoby pobranie wartości, którą w tym samym czasie zmienił inny wątek, bo po prostu mógł i zrobił to szybciej...

Dwa powyższe przykłady to tylko kropla w morzu zastosowań synchronizacji wątków, a nieoczekiwana zmiana wartości, to właściwie jeden z lżejszych problemów jakie mogą nas w tym aspekcie spotkać.

Przykładowy singleton dla locka i monitora

Problem synchronizacji wątków chyba najłatwiej zobrazować na wspomnianym wyżej singletonie. Spójrzmy zatem na przykładową klasę:

class ExampleSingleton
{
    private static ExampleSingleton m_oInstance = null;
    private string m_sTaskId = string.Empty;

    public static ExampleSingleton Instance
    {
        get
        {
            if (null == m_oInstance)
            {
                m_oInstance = new ExampleSingleton();
            }
            return m_oInstance;
        }
    }

    public string TaskId
    {
        get
        {
            return m_sTaskId; 
        }
        set
        {
            m_sTaskId = value;
        }
    }
}

Nie jest to zbyt rozbudowany przykład, ale wystarczy by pokazać problemy związane z synchronizacją. W obecnej postaci powyższy kod zadziała bardzo dobrze, ale tylko w sytuacji gdy nasz program jest jednowątkowy. Problem pojawi się w sytuacji gdy liczba wątków będzie większa niż jeden. W tym momencie będziemy musieli uporządkować dostęp do właściwości tejże klasy, bo jest to zasadnicza kwestia synchronizacji wątków. Tak na dobrą sprawę, na jednej klasie w danym momencie może operować dowolna liczba wątków. Mogą nawet pastwić się nad jedną metodą, pod warunkiem że programista uporządkował odpowiednio kod odpowiedzialny za pobieranie oraz ustawianie wartości poszczególnych zmiennych/obiektów/zasobów.

W przypadku gdy nasza klasa może trafić w szpony wątków, powinniśmy przerobić kod odpowiedzialny za pobieranie i ustawianie wartości poszczególnych pól wewnątrz właściwości. Następnie wewnątrz całej klasy (jak również na zewnątrz) powinniśmy korzystać tylko z właściwości, które będą już zabezpieczone przed działaniem złowrogich wątków. W tym celu skorzystać możemy z poniższych mechanizmów.

Lock

Lock to najprostszy z mechanizmów, który pozwala na zapewnienie odpowiedniego dostępu do zasobów. Stosując lock na wybranym fragmencie kodu, mamy pewność że w danym momencie tylko jeden wątek uzyska do niego dostęp. Tak jak wspomniałem wcześniej, najłatwiej rozwiązać ten problem w kontekście właściwości, ponieważ to one kierują wartościami pól, które determinują aktualny stan wskazanej klasy.

Spójrzmy na zmodyfikowany kod:

class ExampleSingleton
{
    private static volatile ExampleSingleton m_oInstance = null;
    private static readonly object m_oPadlock = new object();
    private string m_sTaskId = string.Empty;

    public static ExampleSingleton Instance
    {
        get
        {
            if (null == m_oInstance)
            {
                lock (m_oPadlock)
                {
                    if (null == m_oInstance)
                    {
                        m_oInstance = new ExampleSingleton();
                    }
                }
            }
            return m_oInstance;
        }
    }

    public string TaskId
    {
        get
        {
            string sRetString = string.Empty;
            lock (m_oPadlock)
            {
                sRetString = m_sTaskId;
            }
            return sRetString;
        }
        set
        {
            lock (m_oPadlock)
            {
                m_sTaskId = value;
            }
        }
    }
}

Pojawiło się tutaj kilka istotnych modyfikacji. Przede wszystkim przy deklaracji pola, które zawiera naszą instancję (3) pojawiło się słowo kluczowe volatile, która zapobiega różnym zawiłościom związanym z cache'owaniem. Generalnie rzec biorąc mogłaby się zdarzyć sytuacja, w której dwa wątki próbowały by jednocześnie dostać się do jeszcze nieutworzonej instancji. Jeden z nich by ją utworzył, a drugi w wyniku cache'owania obiektów niepotrzebnie założył by blokadę. Słowo volatile zapobiega tego rodzaju działaniom (szerzej pisze na ten temat Piotr Zieliński na swoim blogu).

Oprócz tego pojawił się nowy obiekt - tzw. kłódka (4). Kłódka będzie użyta w mechanizmie blokady lock (częstym błędem w tym przypadku jest stosowanie słowa kluczowego this, którego wywołanie w ten sposób może sprowadzić na nas różne złe konsekwencje, o których szerzej możecie poczytać w komentarzu użytkownika badamczewski). Zastosowanie tego obiektu umożliwia odnoszenie blokady do konkretnego - znanego zasobu.

Pozostałe modyfikacje dotykają właściwości. W przypadku pobierania instancji, sprawdzamy najpierw czy jest ona różna od wartości null (innymi słowami czy już została utworzona). Pisałem o tym wyżej przy okazji opisu volatile. Następnie blokujemy dostęp innym wątkom korzystając z locka i naszej kłódki. Zagłębiając się dalej, ponownie sprawdzamy czy obiekt nie został utworzony (teraz już na spokojnie w sekcji krytycznej) i tworzymy go w razie potrzeby.

Zmiany dosięgnęły również właściwości TaskId. Choć zadbaliśmy o bezpieczny dostęp do instancji, to w praktyce istnieje pewne prawdopodobieństwo, że różne operacje na niej wykonywane mogą trwać krócej lub dłużej. Przez to powinniśmy zadbać również o integralność konkretnych właściwości. W przypadku pobierania (27-35), wykorzystujemy zmienną pomocniczą, której wartość ustalamy wewnątrz locka i zwracamy już po wyjściu z blokady. Teoretycznie dopuszczalne jest zwracanie wartości wewnątrz locka. O różnicach w obu podejściach możecie poczytać tutaj.

Oczywiście lock nie jest w żadnym przypadku zarezerwowany dla klas singletona. Powinien on być zastosowany wszędzie tam, gdzie istnieje ryzyko że wybrany obiekt może być przetwarzany przez więcej niż jeden wątek w danym czasie. Singleton wydawał mi się jednak najprostszym sensownym przykładem do zobrazowania problemu.

Monitor

Monitor ma praktycznie takie samo zastosowanie jak lock, jednak jest odrobinę bardziej umolny w implementacji - łatwo można zapomnieć o pewnym jego elemencie, tymczasem locka właściwie trudno źle zaimplementować. Idea obu mechanizmów i cel jest taki sam, więc w przykładzie ograniczę się do pobierania wartości TaskId:

private static readonly object m_oPadlock = new object();
/// ...

public string TaskId
{
    get
    {
        string sRetString = string.Empty;
        Monitor.Enter(m_oPadlock);
        try
        {
            sRetString = m_sTaskId;
        }
        finally
        {
            Monitor.Exit(m_oPadlock);
        }
        return sRetString;
    }
}

W tym przypadku również skorzystaliśmy z kłódki celem blokady (Monitor.Enter), a następnie celem jej zakończenia (Monitor.Exit). W tym przypadku konieczne będzie zastosowanie klauzuli try-finally, które zapewni zdjęcie blokady nawet w przypadku błędu operacji wykonanej w sekcji krytycznej (w przypadku locka działo się to nie jako z automatu - podobnie jak w przypadku konstrukcji using). Przypisanie zmiennej nie powinno raczej spowodować błędu, ale chciałem Wam zaprezentować w pełni poprawną konstrukcję.

Monitor oprócz dwóch powyższych metod oferuje również kilka innych godnych uwagi. Metoda TryEnter sprawdza czy wejście do sekcji krytycznej jest możliwe i w razie takiej możliwości, automatycznie tam wchodzi:

if (Monitor.TryEnter(m_oPadlock))
{
    try
    {
        /// sekcja krytyczna
    }
    finally
    {
        Monitor.Exit(m_oPadlock);
    }
}

Na osobny akapit zasługuje kombinacja metod Wait oraz Pulse. Pierwsza z metod powoduje wstrzymanie określonego wątku do czasu aż druga z metod wykonywana w innym wątku na to nie pozwoli. Obie metody sprawdzą się świetnie w scenariusza producenta - konsumenta, o którym wspominałem już dzisiaj wcześniej w tym poście. Jeden z wątków dodaje obiekty do kolejki i po dodaniu obiektu sygnalizuje na Monitorze, że drugi wątek może sobie pobrać ten obiekt z tej kolejki. Odpowiedni przykład znajduje się na MSDNie, dlatego też pominę tutaj jego listing.

Mutex

Na zakończenie został nam Mutex, który w pewnym sensie podobny jest do wcześniej poznanych konstrukcji. Zasadniczą jego wadą jest to, że jego użycie kosztuje więcej pamięci oraz zasobów. Mutex posiada jednak za to pewną zaletę niedostępną dla dwóch powyższych rozwiązań, czyli możliwość blokowania dostępu do określonego zasobu pomiędzy różnymi procesami działającymi w systemie. Mutex może mieć zatem swego rodzaju zasięg globalny w obszarze całego systemu - nie tylko w pojedynczej instancji aplikacji.

Takie rozwiązanie może być bardzo przydatne np. w sytuacji gdy jedna aplikacja może mieć w systemie wiele instancji i każda z tych instancji może zapisywać dane do jednego pliku. W takim przypadku istnieje bardzo duże ryzyko, że w jednym momencie dostęp do pliku będzie chciało uzyskać kilka różnych procesów o czym przekonałem się już raz osobiście podczas jednego z projektów w mojej pracy;)

Spójrzmy na przykład:

class Logger
{
    private readonly string MUTEX_GUID = "e1ffff8f-c91d-4188-9e82-c92ca5b1d057";
    private Mutex m_oLoggerMutex = null;

    public Logger()
    {
        m_oLoggerMutex = new Mutex(false, MUTEX_GUID);
    }

    public void Log()
    {
        m_oLoggerMutex.WaitOne();
        {
            StreamWriter oFile = null;
            try
            {
                oFile = File.AppendText("logger.log");
                oFile.WriteLine("Przykładowa linia...");
                oFile.Flush();
            }
            finally
            {
                if (null != oFile)
                {
                    oFile.Close();
                    oFile.Dispose();
                }
            }
        }
        m_oLoggerMutex.ReleaseMutex();
    }
}

Zacznijmy od tego, że aby nasz Mutex zadziałał poprawnie w aspekcie systemowym, to musi mieć odpowiednią nazwę. Nazwa może być dowolna (pod warunkiem, że wszystkie aplikacje/wątki będą się do niej stosować), ale zalecam stosowanie w miarę unikalnej. Dobrym rozwiązaniem może być dlatego GUID. Przykładowy możemy sobie wygenerować za pomocą specjalnego generatora.

Mutex tworzymy w konstruktorze (choć równie dobrze wszystkie operacje w tym przypadku moglibyśmy zawrzeć w jednej metodzie). Wybieramy jedną z jego przeciążonych wersji w której wyłączamy automatyczne ustawienie i wskazujemy jego nazwę.

Właściwie operacje wykonujemy w metodzie Log (11-32). Najpierw oczekujemy na możliwość wejścia do sekcji krytycznej za pomocą metody WaitOne (13). Nasza aplikacja będzie musiała poczekać na swoją kolej. Kiedy tylko dostęp będzie możliwy, nasza instancja zablokuje Mutex dla siebie, a następnie otworzy plik (18) i zapisze w nim przykładową linie (19). Później powinniśmy zamknąć plik i koniecznie zwolnić Mutex (31). W tym przypadku nie było takiej konieczności, ale w swoich aplikacjach możecie dla pewności umieścić zwalnianie Mutexa w sekcji Finally. Jeśli tego nie zrobicie, to możecie sobie przypadkiem skutecznie zablokować dostęp do określonego zasobu aż do restartu Waszego komputera!

Dzięki zastosowaniu Mutexa do operacji na pliku logu, mamy pewność, że nawet 100 równocześnie działających instancji nie spowoduje błędu zapisu do tego pliku spowodowanego próbą dostępu przez kilka różnych procesów jednocześnie.

Podsumowanie

W dzisiejszym wpisie zajęliśmy się bardzo poważnym zagadnieniem w aspekcie współbieżności, czyli tytułową synchronizacją wątków. Przedstawiłem Wam podstawowe możliwości blokowania dostępu do zasobów za pomocą locka, a także przedstawiłem klasy Monitor oraz Mutex. Mam nadzieję, że materiał jest przedstawiony w miarę przystępnie i niczego nie zagmatwałem;) W razie pytań/sugestii/zażaleń zapraszam do komentarzy - szczególnie jeślibyście dostrzegli jakieś błędy w logice/rozumowaniu.

W kolejnym odcinku zajmiemy się następnymi zagadnieniami związanymi z synchronizacją.

Data ostatniej modyfikacji: 03.09.2012, 15:41.

Podoba Ci się ten wpis? Powiedz o tym innym!

Send to Kindle

Komentarze

blog comments powered by Disqus