Synchronizacja wątków w C# (lock, Monitor), część 1

Najtrudniejszym zadaniem w programowaniu współbieżnym jest programowanie sekwencyjne a uściślając synchronizacja wątków;). Pewne operacje w naszych programach muszą być wykonywane w sposób sekwencyjny. Często dostęp do danych współdzielonych nie może odbywać się w sposób równoległy. Rozważmy klasyczny problem zwiększania liczby o jeden:

counter = counter + 1;

Jeśli zmienna counter jest współdzielona przez kilka wątków, powyższa  operacja jest niepoprawna. Dlaczego? Zacznijmy od początku. Zwiększanie liczby o jeden, tak naprawdę składa się z trzech operacji:

  1. Wczytanie zmiennej do rejestru procesora.
  2. Zwiększenie wartości w rejestrze o jeden.
  3. Przekopiowanie wartości rejestru z powrotem do zmiennej.

Załóżmy, że counter ma wartość 10. Wątek T1 chce zwiększyć wartość i wczytuje ją do rejestru. Następnie wątek T2 również chce zwiększyć wartość i kopiuje ją do rejestru(wciąż counter==10), zwiększa o jeden i przenosi wynik z powrotem do zmiennej. Zmienna zatem w tej chwili ma już wartość 11. Wątek T1 jednak w swoim rejestrze ma nieaktualną wartość 10. Bez synchronizacji, wątek T1 zwiększy wartość rejestru o 1 i otrzyma nieprawidłowy wynik 11(powinno być 12). Jeśli zamotałem, to mam nadzieję, że poniższy pseudokod rozjaśni problem:

;Wątek T1
Time1:    Kopiowanie wartości do rejestru.
Time4:    Zwiększenie rejestru o jeden. ;rejestr w tej chwili ma już nieaktualną wartość.
Time5:    Przekopiowanie rejestru do zmiennej counter.

;Wątek T2
Time1:    Kopiowanie wartości do rejestru.
Time2:    Zwiększenie rejestru o jeden.
Time3:    Przekopiowanie rejestru do zmiennej counter.

W programowaniu równoległym nie można zakładać, że wątki pracują w tym samym tempem. Każda operacja może trwać dłużej lub krócej w zależności od rdzenia procesora, priorytetu itp.

Zatem wszelkie operacje podzielne powinny być wykonywane w specjalnych obszarach kodu do których dostęp ma tylko jeden wątek jednocześnie –  tzw. sekcjach krytycznych.

C# posiada wiele mechanizmów synchronizacji. Zacznijmy od najpopularniejszego – operatora lock:

class Example
{
    private object m_SyncObject=new object();
    public void ThreadMethod()
    {    
        lock(m_SyncObject)
        {
            // sekcja krytyczna
        }
    }
}

Jak widać pewnie  ułatwienie dla synchronizacji posiada już sam język C# dostarczając słowo kluczowe lock. Niezbędnym jest dostarczenie obiektu, który stanowi pewne odniesienie dla synchronizacji. Za pomocą operatora lock blokujemy dostęp do dostarczonego obiektu (w tym przypadku m_SyncObject). Jeśli drugi wątek będzie chciał w tym samym momencie wejść do obszaru lock, będzie musiał poczekać aż pierwszy skończy wszelkie operacje zawarte w klauzuli lock. Wejście do obszaru lock z tym samym obiektem synchronizującym (m_SyncObject) możliwe jest wyłącznie przez jeden wątek .

Warto zaznaczyć, że nie powinno się używać referencji this oraz operatora lock:

class Example
{    
    virtual public void ThreadMethod()
    {    
        lock(this)
        {
            // sekcja krytyczna
        }
    }
}

Powyższy kod wywoła blokadę (deadlock)  w sytuacji gdy użytkownik klasy napiszę np.:

Example example=new Example();
lock(example)
{
    example.ThreadMethod();
}

Pierwszy lock(example) zadziała, jednak następny w ThreadMethod już nie zostanie nadany ponieważ referencje this oraz example odnoszą się do tego samego obiektu i tym samym lock(this) będzie czekał aż lock(example) zostanie zwolniony(co nie nastąpi nigdy).

Projektując biblioteki nie możemy przewidzieć w jaki sposób przyszli użytkownicy będą korzystać z naszej klasy więc powinniśmy pisać w sposób jak najbardziej elastyczny.

Następnym mechanizmem jest klasa Monitor. W rzeczywistości jest ona tym samym co lock. Słowo kluczowe lock  zostało wprowadzone po to aby programiści nie musieli pisać poniższego kodu:

class Example
{
   private object m_SyncObject = new object();
   public void ThreadMethod()
   {
       System.Threading.Monitor.Enter(m_SyncObject);
       try
       {
           // sekcja krytyczna
       }
       finally
       {
           System.Threading.Monitor.Exit(m_SyncObject);
       }
   }
}

Zwróćcie uwagę na try i finally. Bez tego awaria sekcji krytycznej(wyjątek) mogłaby spodować błędne zakończenie(brak wywołania metody Exit). Polecam wszystkim jednak używanie lock ponieważ trudniej w nim o popełnienie błędu takiego jak np. brak wywołania Exit. Warto jednak przyjrzeć się metodzie Monitor.TryEnter, która pozwala na sprawdzenie czy wejście do sekcji krytycznej jest możliwe.

W następnych postach planuje opisanie klas Mutex, Semaphore, AutoResetEvent, ManualResetEvent, Interlocked oraz o kilku nowościach wprowadzonych w .NET 4.0.

5 thoughts on “Synchronizacja wątków w C# (lock, Monitor), część 1”

  1. uruchomienie przykładu nie powoduje deadlocka !?

    Example example=new Example();
    lock(example)
    {
    example.ThreadMethod();
    }

  2. Zapewne tej pierwszej, w końcu brak deadlocka w drugiej to nic dziwnego. W pierwszej zresztą też, jeśli odpalimy wszystko dokładnie tak jak to jest tutaj napisane.

    Haczyk jest tutaj:
    “Pierwszy lock(example) zadziała, jednak następny w ThreadMethod już nie zostanie nadany ponieważ referencje this oraz example odnoszą się do tego samego obiektu i tym samym lock(this) będzie czekał aż lock(example) zostanie zwolniony(co nie nastąpi nigdy).”

    Drugi lock też zadziała, ponieważ jest wywoływany w tym samym wątku co pierwszy, więc jest już nadany. Blokada nastąpiłaby w przypadku, gdyby to był inny wątek. Np:

    lock (example)
    {
    ThreadStart ts = new ThreadStart(example.ThreadedMethod);
    IAsyncResult ar = ts.BeginInvoke(null, null);
    ts.EndInvoke(ar);
    }

    w takiej sytuacji deadlock nastąpi

Leave a Reply

Your email address will not be published.