70-536 Managing Threads

Poniższy artykuł pochodzi z serii Przygotowań do egzaminu 70-536.

Dzisiaj chciałbym opowiedzieć o zarządzaniu wątkami. Zaimplementowanie wielu wątków które działają w tle nie jest rzeczą trudną. Lecz rzeczywista aplikacja musi mięć możliwość pauzowania, przerwania, wznawiania wątku czy też korzystania z wyników po zakończeniu jego przetwarzania. Tutaj możemy natrafić na konflikt zasobów a uniknięcie ich może nieraz okazać się skomplikowaną sztuką. Przejdę lepiej już do konkretów bo w dzisiejszym artykule jest dużo kodu do analizy a i teorii nie mało.

Uruchamianie i zatrzymywanie wątku

Jeśli nie potrzebujemy jakiejś większej kontroli nad wątkiem możemy go wywołać przy pomocy ThreadPool.QueueUserWorkItem. Jeśli potrzebujemy mieć możliwość zatrzymania wątku, przerwania go, obsłużenia wyjątków musimy skorzystać z klasy Thread. Do rozpoczęcia wątku służy metoda Start() natomiast do przerwania Abort() (wszystko intuicyjne jak widać). Do obsługi wyjątku służy klasa ThreadAbortException który musimy obsłużyć w standartowych bloku “try”. Ok spójrzmy na kod a następnie zobaczmy co on wyświeli:

   1: static void Main(string[] args)
   2: {
   3: // Create the thread object, passing in the
   4: // DoWork method using a ThreadStart delegate.
   5: Thread DoWorkThread = new Thread(new ThreadStart(DoWork));
   6: // Start the thread.
   7: DoWorkThread.Start();
   8: // Wait one second to allow the thread to begin to run
   9: Thread.Sleep(1000);
  10: // Abort the thread
  11: DoWorkThread.Abort();
  12: Console.WriteLine("The Main() thread is ending.");
  13: Thread.Sleep(6000);
  14: }
  15: public static void DoWork()
  16: {
  17: Console.WriteLine("DoWork is running on another thread.");
  18: try
  19: {
  20: Thread.Sleep(5000);
  21: }
  22: catch (ThreadAbortException ex)
  23: {
  24: Console.WriteLine("DoWork was aborted.");
  25: }
  26: finally
  27: {
  28: Console.WriteLine("Use finally to close all open resources.");
  29: }
  30: Console.WriteLine("DoWork has ended.");
  31: }

W konsol wyświetli się nam:

DoWork is running on another thread.
DoWork was aborted.
Use finally to close all open resources.
The Main() thread is ending.

Na ekranie nie ujrzymy nigdy “DoWork has ended” ponieważ przerywamy wątek. Jeśli chcesz aby się zakończył musisz za komentować metodę Abort().

Jest również możliwe wstrzymanie wątku przy pomocy metody Suspend() a następnie wznowienie go przy pomocy Resume(). Nie jest to jednak zalecane rozwiązanie bo może dojść do impasu bo np. wątek wstrzymany będzie zawierał zasoby potrzebne dla innego wątku. Zalecane jest używanie klasy Monitor o której powiem w dalszej części artykułu.

Oczywiście możemy ustawić priorytety wątków który może być zmieniony przed uruchomieniem wątku lub w czasie jego działania. Musimy ustawić własność ThreadPriority która korzysta z typu wyliczeniowego zdefiniowanego następująco:

   1: // Kolejność piorytetów od najwyższego do najniższego
   2: public enum System.Threading.ThreadPriority
   3: {
   4:   Highest, 
   5:   AboveNormal,
   6:   Normal, // Ten jest ustawiony domyślnie
   7:   BelowNormal,
   8:   Lowest
   9: }

 

Stan wątku

Można sprawdzić status wątku przy pomocy właściwości ThreadState, oto one:

Unstarted – stan początkowy, jeszcze przed uruchomieniem

Running- wątek jest aktywny i w czasie wykonywania

Stopped- wątek zatrzymany

WaitSleepJoin- wątek jest w stanie oczekiwania na inny wątek aby zakończyć się. Często tak się dzieje kiedy wywołamy ThreadJoin w innym wątku

SuspendRequested- stan wątku który jest po wywołaniu Thread.Suspend . W tym czasie wątek wykonuje się do “bezpiecznego punktu” po czym wchodzi w stan wstrzymania.

Suspended -  wątek w stanie zawiesznia

AbortRequested- stan wątku przed przejściem w stan Thread.Abort

Aborted- wątek został zawiesozny

Może się zdarzyć, że wątek jest aktualnie w dwóch stanach. Na przykład, jeśli wątek jest
zablokowany po wywołaniu Monitor.Wait i inny wątek wywołuje Abort na tym samym wątku,
to jest on w obu stanach WaitSleepJoin AbortRequested w tym samym czasie. Wtedy wywali nam wyjątek ThreadAbortException.

Przekazywanie danych do i z wątku

Może się zdarzyć a zakładam, że na pewno zaistnieje potrzeba w poważniejszej aplikacji wielowątkowej, że wątek oczekuje na jakieś dane bądź jest uruchomiony z przekazanym parametrem. Aby przekazać dane do wątku możemy stworzyć klasę która przyjmuje jeden lub więcej parametrów i przechowuje informacje. Tworzymy instancje tej klasy a następnie przy tworzeniu obiektu w ThreadStart wykorzystać tą instancje. Aby odebrać dane z wątku należy stworzyć metodę która w parametrze przyjmie zwrócone dane. Następnie należy stworzyć delegata do tej metody. Podajemy delegata i w nim samą metodę jako dodatkowy parametr do konstruktora naszej klasy. Na szczęście jest przykład który pokazuję użycie tej techniki.  Konstruktor klasy Multiply akceptuje dane które będą przetwarzane przez nowy wątek. Metoda Multiply.ThreadProc() ma za zadanie przetworzyć dane a prościej mówiąc wyświetlić wiadomość tekstową oraz pomnożyć razy 2 liczbę całkowitą. Metoda ResultCallback akceptuje zwrócone wartości i ResultDelegate jest delegatem do metody ResultCallback. Oto omówiony przykład:

   1: class Program
   2: {
   3: static void Main()
   4: {
   5: // Supply the state information required by the task.
   6: Multiply m = new Multiply("Hello, world!", 13,
   7: new ResultDelegate(ResultCallback));
   8: Thread t = new Thread(new ThreadStart(m.ThreadProc));
   9: t.Start();
  10: Console.WriteLine("Main thread does some work, then waits.");
  11: t.Join();
  12: Console.WriteLine("Thread completed.");
  13: Console.ReadKey();
  14: }
  15: // The callback method must match the signature of the callback delegate.
  16: public static void ResultCallback(int retValue)
  17: {
  18: Console.WriteLine("Returned value: {0}", retValue);
  19: }
  20: }
  21: public class Multiply
  22: {
  23: // State information used in the task.
  24: private string greeting;
  25: private int value;
  26: // Delegate used to execute the callback method when the task is complete.
  27: private ResultDelegate callback;
  28: // The constructor obtains the state information and the callback delegate.
  29: public Multiply(string _greeting, int _value, ResultDelegate _callback)
  30: {
  31: greeting = _greeting;
  32: value = _value;
  33: callback = _callback;
  34: }
  35: // The thread procedure performs the tasks (displaying
  36: // the greeting and multiplying the value by 2).
  37: public void ThreadProc()
  38: {
  39: Console.WriteLine(greeting);
  40: if (callback != null)
  41: callback(value * 2);
  42: }
  43: }
  44: // Delegate that defines the signature for the callback method.
  45: public delegate void ResultDelegate(int value);

Po wykonaniu tego programu na ekranie zostanie wyświetlone:

Main thread does some work, then waits.
Hello, world!
Returned value: 26
Thread completed.

Synchronizacja dostępu do zasobów

Jeśli aplikacja zapisuje coś do pliku to na ten czas blokuje do niego dostęp. Blokowanie ma na celu zapobiegnięciu dostępu w tym samym czasie innej aplikacji do tego pliku. Jeśli inna aplikacja potrzebuje dostępu do danego pliku to albo czeka aż “blokada” z pliku zostanie zwolniona bądź po prostu anuluje swoje działanie. Aplikacja wielowątkowa stawia przed nami podobne zadania jeśli chodzi o dostęp do zasobów. .NET Framework udostępnia nam obiekty synchronizacji których możemy użyć do koordynacji zasobów podzielonych na różne wątki. Zasoby które wymagają synchronizacji:

  • Zasoby systemowe (takie jak porty komunikacyjne)
  • Środki dzielone przez wiele procesów (takie jak uchwyty plików)
  • Zasoby pojedynczych dziedzin aplikacji (takich jak dane globalne, statyczne i pola instancji) do których ma dostęp wiele wątków
  • Instancje obiektów, które mają dostęp do wielu wątków

 

 

Aby zrozumieć co może sie zdarzyć kiedy nie zsynchronizujemy dostępu do danych w aplikacji wielowątkowej spójrzmy na poniższy przykład. Konstruktor klasy Math przyjmuje dwie liczby całkowite a sama klasa udostępnia metody różnych obliczeń przy wykorzystaniu tych liczb. Jednak same obliczenia potrzebują sekundy aby się zakończyć i zmienna prywatna result od czasu wykonania obliczeń do czasu wyświetlenia danych może zostać nadpisana przez inne wątki.

   1: class Program
   2: {
   3: static void Main()
   4: {
   5: Math m = new Math(2, 3);
   6: Thread t1 = new Thread(new ThreadStart(m.Add));
   7: Thread t2 = new Thread(new ThreadStart(m.Subtract));
   8: Thread t3 = new Thread(new ThreadStart(m.Multiply));
   9: t1.Start();
  10: t2.Start();
  11: t3.Start();
  12: // Wait for the user to press a key
  13: Console.ReadKey();
  14: }
  15: }
  16: class Math
  17: {
  18: public int value1;
  19: public int value2;
  20: private int result;
  21: public Math(int _value1, int _value2)
  22: {
  23: value1 = _value1;
  24: value2 = _value2;
  25: }
  26: public void Add()
  27: {
  28: result = value1 + value2;
  29: Thread.Sleep(1000);
  30: Console.WriteLine("Add: " + result);
  31: }
  32: public void Subtract()
  33: {
  34: result = value1 - value2;
  35: Thread.Sleep(1000);
  36: Console.WriteLine("Subtract: " + result);
  37: }
  38: public void Multiply()
  39: {
  40: result = value1 * value2;
  41: Thread.Sleep(1000);
  42: Console.WriteLine("Multiply: " + result);
  43: }
  44: }

 

Po przeanalizowaniu przykładu wydawało by się, że na ekranie ujrzymy:

Add: 5
Subtract: -1
Multiply: 6

A widzimy :

Add: 6
Subtract: 6
Multiply: 6

Tak się dzieje, ponieważ metoda Multiply jest wywołana jako ostatnia a dwie poprzednie metody są w wątkach które są w stanie uśpienia i zmienna result jest podmieniona jeszcze przed jakimkolwiek wyświetleniem. No to teraz czas opowiedzieć o technikach synchronizacji do danych.

Monitor  

Możemy użyć klasy Monitor aby zablokować fragment kodu w obiekcie do którego można mieć dostęp dopiero po jego zwolnieniu. Pomimo, że używanie klasy Monitor jest prawidłowe to Monitor.Enter i Monitor.Exit można zastąpić słowem kluczowym lock którym obejmujemy do “zablokowania”, jest to po prostu wygodniejsze. Spójrzmy na poprawiony kod klasy Math z poprawną synchronizacją dostępu do danych: 

   1: class Math
   2: {
   3: public int value1;
   4: public int value2;
   5: private int result;
   6: public Math(int _value1, int _value2)
   7: {
   8: value1 = _value1;
   9: value2 = _value2;
  10: }
  11: public void Add()
  12: {
  13: lock (this)
  14: {
  15: result = value1 + value2;
  16: Thread.Sleep(1000);
  17: Console.WriteLine("Add: " + result);
  18: }
  19: }
  20: public void Subtract()
  21: {
  22: lock (this)
  23: {
  24: result = value1 - value2;
  25: Thread.Sleep(1000);
  26: Console.WriteLine("Subtract: " + result);
  27: }
  28: }
  29: public void Multiply()
  30: {
  31: lock (this)
  32: {
  33: result = value1 * value2;
  34: Thread.Sleep(1000);
  35: Console.WriteLine("Multiply: " + result);
  36: }
  37: }
  38: }

ReaderWriterLock

Użycie Monitora lub słowa kluczowego lock w prosty sposób blokuje dostęp innym wątkom do pewnych zasobów. Jednak klasa Monitor ma tą wadę, że nie odróżnia zapisu od odczytu zasobów, blokuje i koniec. Bo może być tak, że np. trzy wątki potrzebują tylko odczytać “wspólne” dane żeby na nich pracować a czwarty zapisuje. I tutaj z pomocą nam przychodzi użycie ReaderWriterLock. Pozawala nam na odczytywanie jednocześnie przez parę wątków danych pod warunkiem, że nie są one w trakcie zapisywania. Nie muszę chyba tłumaczyć jak to usprawnia prace aplikacji wielowątkowej. Training Kit przytacza nam następujące przykłady. Poniższa aplikacje wykorzystuje Monitor i wykonuje się ok 9s (dane z TK) :

   1: static void Main(string[] args)
   2: {
   3: MemFile m = new MemFile();
   4: Thread t1 = new Thread(new ThreadStart(m.ReadFile));
   5: Thread t2 = new Thread(new ThreadStart(m.WriteFile));
   6: Thread t3 = new Thread(new ThreadStart(m.ReadFile));
   7: Thread t4 = new Thread(new ThreadStart(m.ReadFile));
   8: t1.Start();
   9: t2.Start();
  10: t3.Start();
  11: t4.Start();
  12: }
  13: class MemFile
  14: {
  15: string file = "Hello, world!";
  16: public void ReadFile()
  17: {
  18: lock (this)
  19: {
  20: for (int i = 1; i <= 3; i++)
  21: {
  22: Console.WriteLine(file);
  23: Thread.Sleep(1000);
  24: }
  25: }
  26: }
  27: public void WriteFile()
  28: {
  29: lock (this)
  30: {
  31: file += " It's a nice day!";
  32: }
  33: }
  34: }

 

Przy modyfikacji klasy MemFile tzn. użyciu w niej ReaderWriteLock czas wykonania aplikacje spadnie do 6s. Zawdzięczamy to temu, że wiele wątków na raz może czytać dane. Spojrzymy:

   1: class MemFile
   2: {
   3: string file = "Hello, world!";
   4: ReaderWriterLock rwl = new ReaderWriterLock();
   5: public void ReadFile()
   6: {
   7: // Allow thread to continue only if no other thread
   8: // has a write lock
   9: rwl.AcquireReaderLock(10000);
  10: for (int i = 1; i <= 3; i++)
  11: {
  12: Console.WriteLine(file);
  13: Thread.Sleep(1000);
  14: }
  15: rwl.ReleaseReaderLock();
  16: }
  17: public void WriteFile()
  18: {
  19: // Allow thread to continue only if no other thread
  20: // has a read or write lock
  21: rwl.AcquireWriterLock(10000);
  22: file += " It's a nice day!";
  23: rwl.ReleaseWriterLock();
  24: }
  25: }

 

 

Interlocked

Jako alternatywę do blokowania zasobów można użyć klasy Interlocked  która pozwala wykonywać podstawowe operacje wątku w sposób bezpieczny. Atomowe operacje klasy Interlocked (które wymieniam poniżej) nie mogą zostać przerwane przez inny wątek. Oto one:

Increment – inkrementacja czyli nic innego jak val += 1.

Decrement – dekrementacja val –= 1.

Exchange- ustawia wartość obiektu. Jest to odpowiednik val = val2.

CompareExchange- ustawia wartość obiektu jeśli jego oryginalna wartość spełnia pewien warunek. W pseudo kodzie wygląda to następująco: if val == val2 then val = val3;

Add- Jest to odpowiednik val += val2.

Read- analizuje określoną zmienną a następnie zapisuje wynik. Jest to równoznaczne z odczytywaniem zmiennej.

Spójrzmy na przykłady jak użyć klasy Interlocked

   1: int num = 0;
   2: Interlocked.Increment(ref num);
   3: Console.WriteLine(num.ToString());
   4: Interlocked.Add(ref num, 10);
   5: Console.WriteLine(num.ToString());
   6: Interlocked.Exchange(ref num, 35);
   7: Console.WriteLine(num.ToString());
   8: Interlocked.CompareExchange(ref num, 75, 35);
   9: Console.WriteLine(num.ToString());

 

No ale lepiej zrozumieć cała idee tej klasy użyjmy jej w aplikacji wielowątkowej spójrzmy na przykład:

   1: static void Main(string[] args)
   2: {
   3: myNum n = new myNum();
   4: for (int a = 0; a < 10; a++)
   5: {
   6: for (int i = 1; i <= 1000; Interlocked.Increment(ref i))
   7: {
   8: Thread t = new Thread(new ThreadStart(n.AddOne));
   9: t.Start();
  10: }
  11: Thread.Sleep(3000);
  12: Console.WriteLine(n.number);
  13: }
  14: Console.ReadKey();
  15: }
  16: public class myNum
  17: {
  18: public int number = 0;
  19: public void AddOne()
  20: {
  21: number += 1;
  22: }
  23: }

 

Aplikacja powinna wyświetlić:

1000
2000
3000
4000
5000
6000
7000
8000
9000
10000

Jednak w komputerach wielordzeniowych prawdopodobnie ujrzymy następujące wyniki:

1000
2000
2999
3999
4999
5998
6998
7998
8998
9997

Niektóre inkrementacje mogą zostać zgubione (co w poważnych aplikacjach wielowątkowych może okazać się krytyczne w skutkach) przez to że wiele wątków ma dostęp do metody AddOne() w tym samym czasie. Zwiększenie wartości (czyli nasza inkrementacja) wymaga dwóch kroków a mianowicie odczytanie pierwotnej wartości a następnie przypisania do niej nowego zasobu. W środowisku wielowątkowym może okazać się, że jeden wątek pracuje na pierwotnej wartości, nie przypisał nowej i wątek drugi pobiera do siebie również wartość pierwotną. Co w takim razie należy zrobić… linijkę kodu w metodzie AddOne gdzie jest number += 1; zamienić na Interlocked.Increment(ref number), i w ten sposób otrzymamy wyniki doskonałe.

Czekanie na wykonanie się wątku

Ok, często się zdarza, że nasz główny wątek musi poczekać na skończenie się jakiegoś wątku który działa w tle. Gdy to jest jeden wątek użyjemy Thread.Join i mamy z głowy. Co mamy zrobić kiedy musimy poczekać na zakończenie się paru wątków? Użyć statycznej metody WaitHandle.WaitAll tablicy AutoResetEvent. W poniższym kodzie jest zademonstrowane jej użycie. Dodatkowo użyjemy naszej klasy ThreadInfo która zapewnia wszystko co jest potrzebne wątkowi aby wykonał się w tle a w naszym przykładzie będziemy przechowywać ilość milisekund które musi czekać wątek i instancje klasy AutoResetEvent aby można było użyć AutoResetEvent.Set kiedy wątek się zakończy. Kiedy uruchomimy kod zobaczymy na ekranie:

Waited for 1000 ms.
Waited for 2000 ms.
Waited for 3000 ms.
Main thread is complete.

Napis, że główny wątek się wykonał wyświetlił sie ostatni co wskazuje na to, że rzeczywiście było oczekiwanie na zakończenie wykonywania się trzech wątków. Dla eksperymentu proponuje zakomentować sobie linię kodu WaitHandle.WaitAll(waitHandles); i zobaczyć co się stanie.

To tyle na dzisiaj. Dzięki za wytrwałość. Przyznam, że ten artykuł dotychczas zajął mi najwięcej pracy i czasu. Wchodzimy praktycznie w połowę kurs i zaczynają się rzeczy baardzo ciekawe także zapraszam w piątek.

Kolejny artykuł z serii to 70-536: Creating Application Domains

Tagi: , ,

Comments

trackback
dotnetomaniak.pl
12/2/2009 8:13:30 AM Permalink

70-536 Managing Threads | Eastgroup.pl

Dziękujemy za publikację - Trackback z dotnetomaniak.pl

Year
Year Poland
12/2/2009 9:44:09 AM Permalink

Mam sugestię dotyczącą ostatniego paragrafu "Czekanie na wykonanie się wątku". Nie wiem dlaczego, ale użycie metody WaitHandle.WaitAll dla wielu wątków w aplikacji WinForms kończy się wygenerowaniem następującego wyjątku: "Metoda WaitAll dla wielu dojść w wątku STA nie jest obsługiwana."    Nie znalazłem nigdzie informacji o takim zachowaniu się metody WaitAll. Być może aby wszystko działało prawidłowo należałoby zmienić atrybut [STAThread] na [MTAThread], ale tego nie testowałem. Po drugie w Trainig Kit (70-536) strona 375 jest przykład tablicy wątków, gdzie do czekania na wykonanie się wszystkich wątków użyto konstrukcji:

Thread[] theThreads = new Thread[5];
for (int x = 0; x < 5; ++x)
{
    // Creates, but does not start, a new thread
    theThreads[x] = new Thread(operation);
    // Starts the work on a new thread
    theThreads[x].Start();
}

foreach (Thread t in theThreads)
{
    t.Join();
}

Tak więc metoda Join w niektórych zastosowaniach będzie lepsza niż ta "przereklamowana" klasa WaitHandle.

Dawid Tulski
Dawid Tulski Poland
12/2/2009 10:44:43 AM Permalink

@Year
Jeśli chodzi o WF to domyślnie jak widzisz jest dodany atrybut [STAThread]. Jest on stosowany do jedno wątkowej aplikacji. Należy go oczywiście wyrzucić. Atrybut [MTAThread] można dodać ale nie jest to konieczne ponieważ c# dodaje go domyślnie Smile A jeśli chodzi o przykład ze strony 375 to ja takowego w swoim TK nie mam Smile Poza tym wątki w moim TK kończą się na stronie 313. Być może ten przykład pojawi się potem ale to zależy w jakim kontekście Smile Aha nie wiem dlaczego ale ucięło przykład z zastosowaniem Wait.Handle :/ Trudno...jest on w TK.

Year
Year Poland
12/2/2009 2:17:06 PM Permalink

Mam pytanie, czy widać gdzieś w kodzie źródłowym, że dodawany jest atrybut [MTAThread], ponieważ jak tworzę nowy projekt WinForms to w pliku Program.cs pojawia się atrybut [STAThread]? Czy może to działa tak, że jak nie ma atrybutu, to ustawiany jest domyślnie [MTAThread]?Zwykle nie zwracałem uwagi do czego on służy, a sprawa wyszła własnie podczas wywoływania metody WaitAll z klasy WaitHandle. Czy więc ustawienie [MTAThread] ma wpływ jakoś na działanie aplikacji, na jej wydajność, po za tym, że można wywoływać metodę WaitAll? Z drugiej strony tworzyłem wcześniej wątki w WinForms w trybie STAThread (bez usuwania atrybutu) i też to działało... Jeśli chodzi o podany przykład, to wziąłem go z wersji PDF-owej, którą akurat mam pod ręką (cóż...), jest tam zaraz prawie na samym początku w lekcji 1 dotyczącej wątków (rozdział 7) podrozdział "Using Thread.Join". W każdym bądź razie przepisałem kod tak jak był w książce. Nawet znalazłem na jakimś forum oryginalne obejście problemu użycia WaitAll dla STAThread, a być może wystarczyło tylko usunąć atrybut by wszystko zaczęło działać. Pozdrawiam

humanista
humanista Poland
12/4/2009 11:49:28 PM Permalink

za komentować -> raczej "zakomentować" lub "wykomentować" ewentualnie jakieś poprawne polskie słowo.

pingback
jarzynka.boo.pl
1/11/2010 12:15:55 PM Permalink

Pingback from jarzynka.boo.pl

Certyfikat 70-563 | DanielJarzynka.net

Add comment


(Will show your Gravatar icon)

  Country flag

biuquote
  • Comment
  • Preview
Loading