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:
- Wczytanie zmiennej do rejestru procesora.
- Zwiększenie wartości w rejestrze o jeden.
- 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.
Z niecierpliwością czekam na odcinki o Semaforach i .NET 4.0
uruchomienie przykładu nie powoduje deadlocka !?
Example example=new Example();
lock(example)
{
example.ThreadMethod();
}
A której klasy Example używasz?( muszę na przyszłość dawać różne nazwy:))
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
Bardzo dobrze napisane, czytelnie i w sposób zrozumiały.