BETA
Aby się zalogować, najpiew wybierz portal.
Aby się zarejestrować, najpiew wybierz portal.
Podaj słowa kluczowe
Słowa kluczowe muszą mieć co najmniej 3 sąsiadujące znaki alfanumeryczne
Pole zawiera niedozwolone znaki

Baza wiedzy











Co dwa rdzenie to nie jeden, czyli o wątkach w .NET

28-12-2006 13:25 | User 89948
Chyba każdy programista, początkujący lub nie, kiedyś stworzył tak rozbudowaną aplikację, iż nieraz trzeba było czekać kilkanaście sekund aż skończy ona wykonywać pewne zlecone jej zadanie. Szczególnie jest to irytujące jeśli dotyczy aplikacji okienkowych, gdzie nasz UI potrafi się zamrozić nie pozwalając nam na anulowanie zadania czy chociażby na jakiekolwiek kliknięcie. A co, jeśli chcielibyśmy wykonywać dwie rzeczy na raz lub zwyczajnie kazać aplikacji by wykonała pewne działanie w tle? Z pom

Co dwa rdzenie to nie jeden, czyli o wątkach w .NET

 

WSTĘP

            Chyba każdy programista, początkujący lub nie, kiedyś stworzył tak rozbudowaną aplikację, iż nieraz trzeba było czekać kilkanaście sekund aż skończy ona wykonywać pewne zlecone jej zadanie. Szczególnie jest to irytujące jeśli dotyczy aplikacji okienkowych, gdzie nasz UI potrafi się zamrozić nie pozwalając nam na anulowanie zadania czy chociażby na jakiekolwiek kliknięcie. A co, jeśli chcielibyśmy wykonywać dwie rzeczy na raz lub zwyczajnie kazać aplikacji by wykonała pewne działanie w tle? Z pomocą na te wszystkie problemy przychodzą nam wątki, których obsługa pod .NET Framework jest wręcz bajecznie prosta.

 

STARY POMYSŁ, NOWE ZASTOSOWANIA

            Podział logiki aplikacji na wątki, które mogłyby być jednocześnie wykonywane jest chyba prawie tak samo stary jak sama współczesna informatyka – w końcu podstawową cechą każdego systemu operacyjnego jest możliwość uruchomienia wielu programów na raz. Przewrotny tytuł mojego artykułu sugeruje jednak, że prawdziwa zabawa z wątkami dopiero się zaczyna.

        Od kiedy producenci procesorów zamiast zwiększać częstotliwość taktowania zaczęli zwiększać liczbę rdzeni, każdy domowy użytkownik komputera może w pełni prawdziwie cieszyć się z dobrodziejstw aplikacji wielowątkowych, zarezerwowanych niedawno tylko dla stacji wieloprocesorowych. Piszę „prawdziwie”, gdyż jak dotąd za uruchomienie logiki wielowątkowej odpowiadały wyłącznie specjalistyczne algorytmy, które najpierw dzieliły nasze wątki na kawałeczki a potem sklejały je na przemian tworząc w rzeczywistości jeden wątek, który obsługiwany był przez jednordzeniowy procesor.

 

ALE DO RZECZY

            Zostawmy może już te techniczne rzeczy i przejdźmy do sedna sprawy: jakie ciekawe rzeczy możemy zrobić dzięki wątkom? Jest ich cała masa, a wśród nich:

  • wykonywanie kilku działań jednocześnie
  • brak efektu zamrożenia UI
  • pozostawienie długotrwałych obliczeń „w tle”
  • łatwa obsługa usług sieciowych (kilka wątków nasłuchujących kilka portów jednocześnie)
  • anulowanie/pauza danego wątku w każdym momencie
  • Progress Bar i inne wskaźniki postępu
            Zagadnienia Thread-safety i wątkowości jako takiej w aplikacjach pisanych pod .NET Framework są bardzo rozbudowane i dotyczą również wielu skomplikowanych kwestii jak operowanie na wspólnych zasobach itp. Ja omówię je, bez szczegółowego rozwodzenia się, tylko w rozdziale „Dla Zaawansowanych”, gdyż celem tego artykułu jest zachęcenie do korzystania z wątków tych czytelników spośród Was, którzy jeszcze do tej pory w ogóle nie mieli z nimi nic do czynienia.

 

KLASA THREAD

        Wątki w .NET Framework występują pod wieloma postaciami, ale my zajmiemy się na początek podstawową klasą System.Threading.Thread, która będzie reprezentować pojedynczy wątek. Jej kluczowym elementem jest konstruktor, który przyjmuje dwie wersje delegatów:  void ThreadStart() oraz void ParameterizedThreadStart(object). Jak nietrudno się domyśleć, będą one wskazywać na metodę, którą chcemy wywołać w oddzielnym wątku – albo bezparametrowej albo z jedynym parametrem typu object.

            Przykład użycia:

[Kod C#]

using System.Threading;

namespace HelloThread {

  class Program
  {
    static void Metoda() { /*...*/ }

    static void Main(string[] args) // nasz program jest głównym wątkiem
    {
       Thread thWatek = new Thread(Metoda); // tworzymy drugi wątek realizujący metodę “Metoda”
       thWatek.Start(); // metoda Start uruchamia wątek

       /* ....
       
*
        * jakieś operacje w głównym wątku
        */

       thWatek.Join(); // w tym miejscu główny wątek zaczeka aż thWatek zakończy działanie
  }

}

            Prawda, że proste? Dzięki temu, że w konstruktorze klasy Thread jest delegat możemy przesyłać do niej o wiele bardziej złożone konstrukcje, takie jak metody anonimowe czy też sekwencję metod zamiast jednej. Pisząc aplikację w Visual Studio, IntelliSense podpowie nam szereg innych metod i właściwości tej klasy, których nazwy są tak intuicyjne, że nie powinniście mieć problemów ze zrozumieniem tego, chyba najbardziej klasycznego, przykładu na wzajemne przeplatanie się wątków:

 

[Kod C#]

using System.Threading;

namespace Watki
{
    class Program
    {
        public class Watki
        {
            public Thread thWatekPierwszy;
            public Thread thWatekDrugi;

            public Watki()
            {
                // inicjalizacja wątków
                thWatekPierwszy = new Thread(PiszLiczby);
                thWatekDrugi = new Thread(PiszLitery);

                thWatekPierwszy.Name = "Numerak";   // Nadanie nazwy wątkom, niewymagane, tylko dla naszej informacji.
                thWatekDrugi.Name = "Literak";
                thWatekDrugi.IsBackground = true;   // Wątki działające "w tle" zostaną przerwane jak tylko
                                                    // inne, niebędące w tle, zakończą swoje działanie.
            }

            void PiszLiczby(object prefix) // wariant z parametrem
            {
                for (int i = 0; ; i++)
                {
                    Console.WriteLine(prefix.ToString() + i.ToString());
                    Thread.Sleep(500); // Usypia wątek na 500 milisekund
                }
            }

            void PiszLitery() // wariant bez parametrów
            {
                for (int i = 0; ; i++)
                {
                    if (i > 25) i = 0;
                    Console.WriteLine((char)(i + 'a'));
                    Thread.Sleep(500);
                }
            }

        }

        static void Main(string[] args)
        {
            Watki watki = new Watki();
            watki.thWatekPierwszy.Start("Liczba: ");
            watki.thWatekDrugi.Start();

            Thread.Sleep(4000); // usypiamy główny wątek na 4 sekundy
            StatusWatkow(watki.thWatekPierwszy, watki.thWatekDrugi); // sprawdzamy aktywność wątków
           
            Thread.Sleep(1000);
            watki.thWatekPierwszy.Abort(); // anulujemy działanie wątku

            Thread.Sleep(3000);
            StatusWatkow(watki.thWatekPierwszy, watki.thWatekDrugi);
            Thread.Sleep(2000);           

            Console.WriteLine("Wciąż działa jeden wątek w tle.");
            Console.ReadKey(true);  // Po naciśnieciu dowolnego klawisza program zakończy działanie
                                    // anulując jednocześnie thWatekDrugi, gdyż ustawiliśmy jego własność
                                    // IsBackground na true.
        }

        static void StatusWatkow(params Thread[] watki) // metoda sprawdzająca aktywność wątków
        {
            foreach (Thread watek in watki)
            {
                if (watek.IsAlive) // Zwraca true, jeśli wątek jest aktywny.
                    Console.WriteLine("Wątek {0} jest aktywny.", watek.Name);
                else
                    Console.WriteLine("Wątek {0} jest nieaktywny.", watek.Name);
            }
        }
    }
}

            Na ekranie otrzymamy taki oto wynik:

Liczba: 0
a
Liczba: 1
b
Liczba: 2
c
Liczba: 3
d
Liczba: 4
e
Liczba: 5
f
Liczba: 6
g
h
Liczba: 7
Wątek Numerak jest aktywny.
Wątek Literak jest aktywny.
i
Liczba: 8
j
Liczba: 9
k
l
m
n
o
p
q
Wątek Numerak jest nieaktywny.
Wątek Literak jest aktywny.
r
s
t
u
Wciąż działa jeden wątek w tle.
v
w
x
y



            Oprócz tego, mamy dostępne inne metody klasy Thread, m.in.:

  • Abort – wywołuje w wątku wyjątek klasy ThreadAbortException, co spowoduje zatrzymanie jej działania
  • Suspend – wstrzymuje działanie wątku
  • Resume – wznawia działanie wątku, który został wstrzymany

 

WSZYSTKO NA RAZ, CZYLI PULE WĄTKÓW

            Może się zdarzyć, że w naszej aplikacji posiadamy wiele zadań, niezbyt długich, które jednak chcielibyśmy wykonywać w oddzielnych wątkach, aby nie pozbawiać użytkownika kontroli nad aplikacją. W takim wypadku tworzenie kilkunastu obiektów klasy Thread nie jest zbyt eleganckim ani optymalnym rozwiązaniem. Skorzystajmy z możliwości jakie daje nam statyczna klasa ThreadPool. Dzięki niej za pomocą tylko jednej linijki po prostu „wrzucimy” pewną metodę do wątku, który automatycznie wystartuje i wykona zadanie. Standardowo mamy dostępną pulę 25 wątków, której rozmiar oczywiście można zmieniać.

            Do tego celu użyjemy statycznej metody ThreadPool.QueueUserWorkItem na przykład w taki sposób:

 

[Kod C#]

using System.Threading;

namespace Watki
{
    class Program
    {
        static void Main(string[] args)
        {
            // W pierwszym parametrze podajemy metodę do wywołania
            // w drugim (opcjonalnym) parametr, który ma być do niej przekazany
            ThreadPool.QueueUserWorkItem(WykonajObliczenia, 20);   
            ThreadPool.QueueUserWorkItem(WykonajObliczenia, 10);

            Console.WriteLine("Wątki działają w tle, a my możemy wykonywać inne operacje lub zakończyć program.");
            Console.ReadKey(true);
        }

        static void WykonajObliczenia(object ile)
        {         
            /* ... */
        }
    }
}

Rozwiązanie to pozwala uniknąć definiowania wielu wątków do drobnych operacji jak również zaoszczędzić czas przeznaczony na ich stworzenie. Nie ma jednak róży bez kolców: wszystkie wątki z puli są zawsze uruchamiane z własnością IsBackground = true (a więc zostaną przerwane gdy inne wątki nie będące w tle zakończa działanie). Poważniejszym minusem jest brak kontroli nad uruchamianym wątkiem – jest uruchamiany automatycznie i nie mamy dostępu do metod typu Join(), Abort() itp., ale z drugiej strony po co anulować coś, co ma z założenia trwać kilka chwil?

 

A MOŻE DA SIĘ JESZCZE PROŚCIEJ?

 A pewnie że się da! Wykonajmy wspólnie to, co wielu pewnie interesowało od dawna – pasek postępu, a dobrodziejstwem które nam w tym pomoże będzie BackgroundWorker. Klasa System.ComponentModel.BackgroundWorker pojawiła się w .NET Framework 2.0 i zdecydowanie ułatwia użytkownikom posługiwanie się wątkami. Cała idea polega na przypisaniu naszej klasie zdarzeń odpowiedzialnych za wykonanie zadania, powiadomienie o postępie w działaniu i powiadomienie o zakończeniu zadania.

Po utworzeniu przykładowej formatki (dla uproszczenia będzie to tylko ProgressBar i dwa Butony) wybieramy z Toolboxa komponent BackgroundWorker i przeciągamy go na dowolne wolne miejsce.

bw1.jpg


bw2.jpg

W tym momencie automatycznie został wygenerowany odpowiedni kod, a my będziemy mogli zmieniać właściwości w oknie Properties zamiast robić to ręcznie. Jeżeli chcemy, aby nasz worker powiadamiał o postępie (ba, no oczywiście że chcemy!) należy ustawić właściwość WorkerReportsProgress na true. Podobnie, ustawiając WorkerSupportsCancellation na true, będziemy mieć możliwość anulowania działania naszego wątku.

Teraz możemy już przejść do kodu, gdzie wszystkie ważne kwestie zawarłem w komentarzach. Naszym podstawowym zadaniem będzie obsługa odpowiednich zdarzeń realizowanych przez naszego workera. Jeżeli ktoś z Was nie jest jeszcze obeznany ze zdarzeniami i jest trochę przerażony nagłówkami metod to niech się niczym nie martwi. Po wpisaniu nazwy zdarzenia i znaków „+=” pojawi nam się tooltip z podpowiedzią od IntelliSense. Wtedy wystarczy, że zgodnie z nim naciśniemy odpowiednią ilość razy TAB i Visual Studio wygeneruje nam automatycznie prawidłowe nagłówki, a my zajmiemy się już tylko właściwym kodem realizującym nasze zadanie.

 

[Kod C#]

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
using System.Threading;

namespace Watki
{
    public partial class frmPrzyklad : Form
    {

        public frmPrzyklad()
        {
            InitializeComponent();

            // obsługujemy 3 zdarzenia: DoWork, ProgressChanged i RunWorkerCompleted

            // DoWork - metoda, którą chcemy wykonać
            bwRobol.DoWork += new DoWorkEventHandler(bwRobol_DoWork);
           
// ProgressChanged - metoda, która powiadomi o postępie (u nas zmieni pasek postępu)
            bwRobol.ProgressChanged += new ProgressChangedEventHandler(bwRobol_ProgressChanged);
           
// RunWorkerCompleted - metoda, która zostanie wywołana po zakończeniu zadania
            bwRobol.RunWorkerCompleted += new RunWorkerCompletedEventHandler(bwRobol_RunWorkerCompleted);
       
}

        // ----- METODY PRZYPISANE DO ZDARZEŃ BACKGROUNDWORKERA -----

       
void bwRobol_DoWork(object sender, DoWorkEventArgs e)
       
{
            for (int i = 0; i <= 100; i++)
            {
                Thread.Sleep(100); // symulujemy jakieś długie obliczenia

                // senderem jest nasz BackgroundWorker, którego wyłuskamy, aby dostać się do jego metod i właściwości
                BackgroundWorker bw = (BackgroundWorker)sender;
               
                if (bw.CancellationPending) // aplikacja wysłała prośbę o anulowanie zadania
                {
                    e.Cancel = true;    // zgadzamy się z aplikacją - anulujemy BackGroundworkera
                    return;
                }

                // powiadamiamy o postępie w działaniu - parametrem funkcji jest dowolna liczba typu int (najcześciej
                // wartość procentowa) mająca reprezentować aktualny poziom zaawansowania wątku
                bw.ReportProgress(i);
            }

            // Pod właściwość Result możemy, jeśli chcemy, postawić ewentualny wynik obliczeń, które wykonał nasz
            // BackgroundWorker, do którego będziemy mieć dostęp w metodzie RunWorkerCompleted
           
e.Result = 5;
        }

        void bwRobol_ProgressChanged(object sender, ProgressChangedEventArgs e)
       
{
            // we właściwości ProgressPercentage znajduje się liczba, która była parametrem funkcji ReportProgress
           
pbPasek.Value = e.ProgressPercentage;
        }

        void bwRobol_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
       
{
            if (e.Error != null) // jeżeli był rzucony jakiś wyjątek wewnątrz BackgroundWorkera zostanie to odnotowane w tej właściwości
            {
                MessageBox.Show("Podczas wykonywania zadania nastąpił błąd!");
            }
            else // nie było błędu
            {
                if (e.Cancelled) // zadanie zostało anulowane
                {
                    MessageBox.Show("Zadanie zostało anulowane!");
                }
                else // zadanie nie zostało anulowane ani nie było błędu
                {
                    MessageBox.Show("Robol melduje wykonanie zadania!");
               
}
            }
        }

        // ----- METODY PRZYCISKÓW STERUJĄCYCH BACKGROUNDWORKEREM -----

        private void btnStart_Click(object sender, EventArgs e)
        {
            bwRobol.RunWorkerAsync(); // uruchamiamy zadanie
        }

        private void btnStop_Click(object sender, EventArgs e)
       
{
            // wysyłamy prośbę o anulowanie zadania
            bwRobol.CancelAsync();
        }
    }
}

            I to jest cały nasz kod, z czego w gruncie rzeczy połowa została wygenerowana automatycznie przez designer Visual Studio. Po uruchomieniu aplikacji pojawi nam się formatka z dwoma przyciskami. Po naciśnięciu Start nasz pasek postępu zacznie się rozszerzać, co w każdym momencie możemy zatrzymać przyciskiem Stop. Za każdym razem otrzymamy na końcu odpowiedni komunikat:

bw3.jpg

bw4.jpg

            Niektórzy mogliby stwierdzić, że ten kod nie robi nic szczególnego, ale prawdziwą zaletę tego rozwiązania można zauważyc dopiero gdy naszemu workerowi zlecimy naprawdę ciężką pracę. Bez użycia wątków po naciśnięciu klawisza Start nasz pasek owszem, zacząłby się rozszerzać, ale cała aplikacja sprawiłaby wrażenie wręcz zawieszonej i naciśnięcie przycisku Stop byłoby bardzo trudne, a w wielu przypadkach wręcz niemożliwe.

 

DLA ZAAWANSOWANYCH

            Mam nadzieję, że opisane wyżej przykłady zachęciły wielu z Was do wykorzystywania wątków w swoich aplikacjach. Dzięki ich prostej obsłudze w .NET Framework, każdy z nas jest w stanie wykorzystać pełny potencjał nowoczesnych komputerów. Jednak to jeszcze nie koniec, gdyż jak obiecałem we wstępie, omówię jeszcze dwa zagadnienia, które uznałem za bardziej zaawansowane.

 

DELEGATY ASYNCHRONICZNE            

d1.jpg

            Jak się okazuje, istnieje kolejny sposób na wywołanie danej metody w oddzielnym wątku, w dodatku z użyciem jednego z ciekawszych instrumentów .NETa, a mianowicie delegatów. Wiadomo, że C# jest językiem silnie typowanym, w związku z czym również delegaty nie są po prostu wskaźnikami na dany typ funkcji ale obiektami zawierającymi wiele ciekawych metod (patrz screen po prawej).

            Standardowe wywołanie delegatu jest tak naprawdę odwołaniem do jego metody Invoke, która z kolei uruchomi w sposób synchroniczny metodę, na którą wskazuje delegat. Jednakże wskazywaną metodę można również wywołać w sposób asynchroniczny (w tym celu wykorzystany zostanie wątek pozyskany ze znanej nam już puli wątków) i do tego celu wykorzystamy metody BeginInvoke (rozpoczynającą asynchroniczne wywołanie) oraz EndInvoke (kończącą wywołanie i wyłuskującą ewentualny wynik metody wskazywanej przez delegat).

W ogólnej postaci nagłówek metody BeginInvoke jest następujący:

IAsyncResult BeginInvoke(..., AsyncCallback callback, object state);

gdzie wielokropek oznacza parametry delegatu (patrz screen), callback jest to metoda zwrotna, której zadaniem będzie zajęcie się wynikiem zwróconym przez delegat, natomiast state jest ewentualnym parametrem, który może być przesłany do metody zwrotnej. Nie mamy jednak obowiązku korzystania ani z metody callback ani z obiektu state, gdyż możemy pozyskać wynik delegatu bezpośrednio w wątku, który go wywołał (w takim przypadku ostatnie dwa parametry będą po prostu nullami). Jakikolwiek sposób wybierzemy, zawsze musimy wywołać metodę EndInvoke – albo w metodzie zwrotnej, albo w wątku który wywołał delegat. Różnica polega jedynie na tym, że wywołując EndInvoke bez użycia metody zwrotnej zachowa się ona podobnie do metody Join() klasy Thread, czyli program poczeka aż delegat skończy wszystkie obliczenia i dopiero ruszy dalej.

            Teoria teorią, ale wszystko to, co napisałem z pewnością będzie jaśniejsze gdy rzucicie okiem na przykładowy program korzystający z delegatów asynchronicznych. Stworzyłem dwa delegaty – DuzoDoLiczenia i Samoobsluga. Pierwszy wskazuje na istniejącą metodę LiczbaJestPierwsza, natomiast drugi na metodę anonimową zdefiniowaną już w metodzie Main. Dla lepszego zrozumienia, skorzystałem z obu sposobów na pozyskanie wyniku – wynik pierwszego delegatu zostanie wyłuskany w głównym programie, natomiast wynikiem drugiego zajmie się metoda zwrotna o bardzo oryginalnie nazwie – MetodaZwrotna ;).

 

[Kod C#]

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading; // do obsługi Thread.Sleep();
using System.Runtime.Remoting.Messaging; // do obsługi klasy AsyncResult (patrz niżej)

namespace Watki
{
    class Program
    {
        delegate bool DuzoDoLiczenia(int n);
        delegate int Samoobsluga(int ile);

        static void Main(string[] args)
        {
            Random rnd = new Random();
            int liczba = 123878963 + rnd.Next(500);

            DuzoDoLiczenia SprawdzPierwsza = LiczbaJestPierwsza;
            Samoobsluga Liczenie = delegate(int do_ilu) // zamiast podstawiać istniejącą metodę, możemy stworzyć metodę anonimową
            {
                Random los = new Random();
                for (int i = 0; i < do_ilu; i++)
                    Thread.Sleep(25); // symulujemy długie liczenie
               
                // Jakiś losowy wynik
                return los.Next(do_ilu);
            };        

            // odpalamy pierwszy delegat
            IAsyncResult status = SprawdzPierwsza.BeginInvoke(liczba, null, null);

            Console.WriteLine("Początek programu, w tle zaczęła sprawdzać się liczba pierwsza.");

            // Odpalamy drugi delegat - z wyniku nie korzystamu w głównym wątku więc wywołujemy bez podstawiania
            // pod zmienną typu IAsyncResult. Trzeci parametr (nieobowiązkowy) jest przekazywany do MetodyZwrotnej.
            Liczenie.BeginInvoke(20, MetodaZwrotna, null);
           
            Console.WriteLine("Drugi delegat rozpoczął działanie w tle.");         
           
            // Właściwość IsCompleted wskazuje nam, czy delegat SprawdzPierwsza zakończył działanie. Jeśli chcemy, możemy to wykorzystać
            // np. do stworzenia takiej pętli, gdyż metoda EndInvoke zatrzymałaby nam program aż do zakończenia działania delegatu.
            while (!status.IsCompleted)
            {
                Console.WriteLine("Liczba pierwsza dalej się sprawdza, więc możemy robić coś innego.");
                Thread.Sleep(1000);
            }

            // Metoda EndInvoke, której parametrem jest zmienna zwrócona przez BeginInvoke, zwraca nam wynik delegatu.
            bool rezultat = SprawdzPierwsza.EndInvoke(status);           
            Console.WriteLine("Obliczenia zakończone. Liczba {0} {1} liczbą pierwszą.", liczba, rezultat ? "jest" : "nie jest");

            Console.ReadKey(true);
        }

        static bool LiczbaJestPierwsza(int liczba)
        {
            // sprawdzamy w bardzo nieoptymalny sposób aby zasymulować dłuższą pracę komputera
            bool JestCzyNie = true;
            for (int i = 2; i < liczba; i++)
            {
                if (liczba % i == 0)
                    JestCzyNie = false;
            }
            return JestCzyNie;
        }

        // Nagłówek metody zwrotnej zgodny z delegatem AsyncCallback. Parametr ar przyjmie taką samą wartość,
        // jaką przyjęłaby zmienna zwracana przez BeginInvoke
       
static void MetodaZwrotna(IAsyncResult ar)
       
{
            // we właściwości ar.AsyncState znajduje się obiekt, który przekazaliśmy w SprawdzLiczenie.BeginInvoke jako trzeci parametr
            // my jednak nie wykorzystujemy go, gdyż przekazaliśmy tam nulla

            // ar jest w rzeczywistości typu AsyncResult, którego właściwość AsyncDelegate wskazuje nam na delegat, który
            // został wywołany przez BeginInvoke - wyłuskujemy go, aby wywołać EndInvoke i otrzymać wynik
            Samoobsluga nadawca = (Samoobsluga) ((AsyncResult)ar).AsyncDelegate;

            // pozyskujemy wynik delegatu
            int wynik = nadawca.EndInvoke(ar);

            Console.WriteLine("W międzyczasie policzyłem sobie co nieco i otrzymałem wynik: {0}.", wynik);
        }
    }
}

            W wyniku na ekranie otrzymamy takie coś:

Początek programu, w tle zaczęła sprawdzać się liczba pierwsza.
Drugi delegat rozpoczął działanie w tle.
Liczba pierwsza dalej się sprawdza, więc możemy robić coś innego.
Liczba pierwsza dalej się sprawdza, więc możemy robić coś innego.
W międzyczasie policzyłem sobie co nieco i otrzymałem wynik: 6.
Liczba pierwsza dalej się sprawdza, więc możemy robić coś innego.
Liczba pierwsza dalej się sprawdza, więc możemy robić coś innego.
Liczba pierwsza dalej się sprawdza, więc możemy robić coś innego.
Obliczenia zakończone. Liczba 123879263 nie jest liczbą pierwszą.

            A zatem oba nasze delegaty zostały uruchomione w oddzielnych wątkach i zaczęły działać jednocześnie. Oprócz tego mogliśmy wykonywać inne instrukcje w metodzie Main, która jest naszym wątkiem głównym. Delegat SprawdzPierwsza zakończył działanie jako pierwszy, dlatego jego metoda zwrotna „wepchnęła” się do pętli w głównym wątku informując nas o jego wyniku.

        Programując w .NET Framework spotkacie się również w wielu innych miejscach z parą metod Begin… i End… (np. BeginRead/BeginWrite i EndRead/EndWrite w klasie System.IO.FileStream). Jak można się domyślić, działają one na takiej samej zasadzie jak BeginInvoke i EndInvoke i można je wykorzystywać do asynchronicznych wywołań metod związanych zwykle z pozyskiwaniem dużej ilości danych w często długim czasie (jak np. odczyt dużego pliku we wspomnianej klasie FileStream).

 

OPEROWANIE NA WSPÓLNYCH ZASOBACH

            Prawdziwą bolączką piszących aplikacje wielowątkowe jest operowanie wspólnymi zasobami przez kilka wątków. Bez odpowiedniej kontroli aplikacje takie mogą zwracać nieprzewidywalne wyniki a nawet zawieszać się w przypadkowych momentach. Przeciwdziałanie temu jest kwestią dosyć skomplikowaną i istnieje wiele mechanizmów, które nam w tym pomagają. Jednym z nich, który właśnie omówię, będzie konstrukcja lock i klasa System.Threading.Monitor:

 

[Kod C#]

using System.Threading;

namespace Testing
{
    class Program
    {
        static readonly object zamek = new object();
       
static void Metoda()
        {
            Monitor.Enter(zamek); // zamek zostaje zablokowany
            // jakieś instrukcje operujące na tych samych zasobach
            Monitor.Exit(zamek); // zamek zostaje zwolniony     
       
}

        static void Main(string[] args)
        {
            Thread watek = new Thread(Metoda);
           
watek.Start();
            Monitor.Enter(zamek);
            // instrukcje operujące na tych samych zasobach
            Monitor.Exit(zamek);
        }
    }
}

            I analogicznie:

[Kod C#]

using System.Threading;

namespace Testing
{
    class Program
    {
        static readonly object zamek = new object();
       
static void Metoda()
        {
            lock (zamek) // zamek zostaje zablokowany
            {
                // jakieś instrukcje operujące na tych samych zasobach
            } // zamek zostaje zwolniony     
        }

        static void Main(string[] args)
        {
           
Thread watek = new Thread(Metoda);
           
watek.Start();
            lock (zamek)
            {
                // instrukcje operujące na tych samych zasobach
            }
        }
    }
}

            Jak widać, obie konstrukcje sa prawie identyczne. Różnica polega na tym, że jeśli wewnątrz bloku operującego na wspólnych zasobach zostanie rzucony jakiś wyjątek, to jeśli nie umieśliśmy Monitor.Exit w bloku finally, nie zostanie on wywołany, co może prowadzić do zacięcia się aplikacji. Konstrukcja lock automatycznie zwolni w takim przypadku zamek, jest zatem wygodniejsza i bezpieczniejsza.

Idea działania tych konstrukcji jest prosta – tworzymy sobie pewien dowolny obiekt, który będzie pełnił rolę „zamka”. Wątek po napotkaniu na lock lub Monitor.Enter wykonuje jedno z dwóch:

  • Jeśli zamek był „otwarty”, „zamyka” go i wykonuje kolejne instrukcje i po napotkaniu na koniec bloku lock lub Monitor.Exit otwiera zamek.
  • Jeśli zamek był „zamknięty”, zatrzymuje się i czeka do momentu, aż zostanie otwarty. Dalej zachowuje się tak jak w punkcie powyżej     

Oczywiście to nie wszystko, gdyż klasa Monitor oferuje nam kilka innych metod:

  • TryEnter(object zamek, TimeSpan timeout) – działa tak samo jak Enter, ale zamiast czekać w nieskończoność możemy określić czas, po którym wątek ma sobie „odpuścić”.
  • Wait(object zamek) + kilka przeładowanych wersji – po napotkaniu na taką instrukcję wątek zatrzymuje się i zwalnia zamek. Będzie czekał dotąd, aż inny wątek wykona metodę Pulse lub PulseAll, po czym ponownie zamyka zamek i kontynuuje od miejsca przerwania.
  • Pulse() – zwalnia zamek i powiadamia wątek z kolejki oczekujących (zablokowanych przez Wait()), że może kontynuować pracę.
  • PulseAll() – jak wyżej, tylko że powiadamia wszystkie wątki w kolejce oczekujących.

Ostatni już przykład w tym artykule rozwieje z pewnością wszelkie wątpliwości. Nasza aplikacja będzie symulować działanie kantoru z dwoma „okienkami”. Każde okienko może zarówno skupować jak i sprzedawać pewne waluty. Sednem sprawy będzie tutaj kasa, w której trzymane będą waluty dostępne w kantorze – tylko jedno okienko może korzystać w tym samym czasie z kasy, no i oczywiście jeśli kasa będzie pusta to musimy wstrzymać sprzedaż. Sprzedaż i kupno będą odbywać się ciągle w losowych odstępach czasu, aby zasymulować różne przypadki na jakie może natrafić nasza aplikacja.

[Kod C#]

using System.Threading;

namespace Watki
{
    public enum Waluta
    {
        Dolar = 0, Euro, Funt, Złoty, Frank, Rubel, Forint, Korona, Peso, Marka
    }

    class Program
    {
        static void Main(string[] args)
       
{
            Kantor kant = new Kantor(2);
            Thread PierwszaKasjerka = new Thread( SprzedajKlientowi ); // SprzedajKlientowi jest metodą zgodną z delegatem ParemeterizedThreadStart
            Thread DrugaKasjerka = new Thread( SprzedajKlientowi );

            // Uruchamiamy wątki, które nieustannie będą próbowały sprzedawać waluty
            PierwszaKasjerka.Start(kant.Okienka[0]);
            DrugaKasjerka.Start(kant.Okienka[1]);

            // Skupujemy waluty
            Random rnd = new Random();
            for (int i = 0; i < 20; i++) // będzie 20 operacji kupna/sprzedaży
            {
                int ktore_okienko = rnd.Next(2); // wybieramy losowe okienko
                int ile_walut = rnd.Next(2) + 1; // skupujemy losową ilość walut
                Waluta[] Waluty = new Waluta[ile_walut];

                for (int j = 0; j < ile_walut; j++) // wypisujemy na ekran które okienko jaką walutę kupuje
                {
                    Waluta DoKupienia = (Waluta)((i + j) % 10);
                    Console.WriteLine(kant.Okienka[ktore_okienko].Nazwa + " kupilo " + DoKupienia.ToString());
                    Waluty[j] = DoKupienia;
                }
                kant.Okienka[ktore_okienko].Skupuj(Waluty); // w tym miejscu okienko skupuje waluty
                Thread.Sleep(rnd.Next(1000)); // Robimy losową przerwę, bo przecież klienci nie przychodzą do kantoru regularnie ;)
            }

            Console.WriteLine("I po zakupach!");
           
Console.ReadKey(true);
        }

        // Metoda zgodna z delegatem ParameterizedThreadStart
       
static void SprzedajKlientowi(object KtoreOkienko)
        
{
            Kantor.Okienko okn = (Kantor.Okienko)KtoreOkienko;
           
Random rnd = new Random();
            for(int i = 0; i < 10; i++)
           
{
                Waluta SprzedanaWaluta = okn.Sprzedaj(); // Sprzedajemy
                Console.WriteLine("\t\t\t" + okn.Nazwa + " sprzedalo " + SprzedanaWaluta.ToString());
                Thread.Sleep(rnd.Next(1000)); // Robimy losową przerwę, bo przecież klienci nie przychodzą do kantoru regularnie ;)
            }
        }
    }

    public class Kantor
    {
        static List<Waluta> Kasa = new List<Waluta>(); // Kasa zawiera waluty dostępne w kantorze - jest ona naszym "wspólnym zasobem"
       
static readonly object lockKasa = new object(); // Zamek do kasy ;)

       
public Okienko[] Okienka;

        public Kantor(int ile_okienek) // W konstruktorze tworzymy okienka i nadajemy im nazwy
        {
            Okienka = new Okienko[ile_okienek];
            for (int i = 0; i < ile_okienek; i++)
                Okienka[i] = new Okienko("Okienko nr " + (i+1).ToString() );
        }

        public class Okienko
       
{
            public string Nazwa;

            public Okienko(string n)
           
{
                Nazwa = n;
            }
           
            public void Skupuj(params Waluta[] waluty)
            {
                lock (lockKasa) // tylko jedno okienko moze korzystac z kasy w tym samym czasie
                {
                    Kasa.AddRange(waluty); // Wrzucamy kupione waluty do Kasy
                    Monitor.Pulse(lockKasa); // Kasa wrzucona, można sprzedawać
                    // PulseAll nie robi roznicy w tym przypadku gdyż tylko jeden wątek czeka w kolejce
                }
            }

            public Waluta Sprzedaj()
            {
                lock (lockKasa) // lub Monitor.Enter(lockKasa);
                {
                    if (Kasa.Count == 0) // jeśli kasa jest pusta musimy poczekać
                    {
                        // W tym miejscu zamek lockKasa zostaje zwolniony, dając możliwość jego zamknięcia innemu wątkowi - u nas jest to
                        // główny program, w którym skupujemy waluty. Gdy wywoła on metodę Monitor.Pulse lub Monitor.PulseAll zamek z powrotem
                        // wraca do tego wątku, który kontunuuje działanie (u nas zaczyna sprzedawać).
                        Monitor.Wait(lockKasa);
                    }

                    Waluta DoSprzedania = Kasa[Kasa.Count - 1];
                    Kasa.RemoveAt(Kasa.Count-1);
                    return DoSprzedania; // Sprzedajemy ostatnio dodaną walutę
                } // lub Monitor.Exit(lockKasa);
            }

        }
    }
}

            Na ekranie otrzymamy następujący wynik:

Okienko nr 1 kupilo Dolar
                        Okienko nr 1 sprzedalo Dolar
Okienko nr 1 kupilo Euro
                        Okienko nr 2 sprzedalo Euro
Okienko nr 1 kupilo Funt
Okienko nr 1 kupilo Złoty
                        Okienko nr 1 sprzedalo Złoty
                        Okienko nr 1 sprzedalo Funt
Okienko nr 1 kupilo Złoty
Okienko nr 1 kupilo Frank
                        Okienko nr 2 sprzedalo Frank
                        Okienko nr 1 sprzedalo Złoty
Okienko nr 2 kupilo Frank
Okienko nr 2 kupilo Rubel
                        Okienko nr 2 sprzedalo Rubel
                        Okienko nr 2 sprzedalo Frank
Okienko nr 2 kupilo Rubel
Okienko nr 2 kupilo Forint
                        Okienko nr 1 sprzedalo Forint
Okienko nr 2 kupilo Forint
Okienko nr 2 kupilo Korona
                        Okienko nr 2 sprzedalo Korona
                        Okienko nr 1 sprzedalo Forint
                        Okienko nr 2 sprzedalo Rubel
Okienko nr 1 kupilo Korona
                        Okienko nr 1 sprzedalo Korona
Okienko nr 1 kupilo Peso
                        Okienko nr 2 sprzedalo Peso
Okienko nr 2 kupilo Marka
Okienko nr 2 kupilo Dolar
                        Okienko nr 1 sprzedalo Dolar
Okienko nr 1 kupilo Dolar
Okienko nr 1 kupilo Euro
                        Okienko nr 2 sprzedalo Euro
                        Okienko nr 1 sprzedalo Dolar
                        Okienko nr 2 sprzedalo Marka
Okienko nr 2 kupilo Euro
                        Okienko nr 1 sprzedalo Euro
Okienko nr 2 kupilo Funt
Okienko nr 2 kupilo Złoty
                        Okienko nr 2 sprzedalo Złoty
Okienko nr 2 kupilo Złoty
Okienko nr 1 kupilo Frank
Okienko nr 1 kupilo Rubel
Okienko nr 2 kupilo Rubel
Okienko nr 2 kupilo Forint
Okienko nr 1 kupilo Forint
Okienko nr 1 kupilo Korona
Okienko nr 1 kupilo Korona
Okienko nr 1 kupilo Peso
Okienko nr 1 kupilo Peso
Okienko nr 1 kupilo Marka
Okienko nr 1 kupilo Marka
Okienko nr 1 kupilo Dolar
I po zakupach!

Jak widać wszystko się ładnie zgadza i transakcje zostały przeprowadzone pomyślnie ;). Gdybyśmy nie zastosowali klasy Monitor aplikacja działałaby zgoła nieoczekiwanie – zdarzałoby się że okienko sprzedaje walutę, która została już przed chwilą sprzedana w drugim, a w gorszym przypadku okienko próbowałoby wyciągnąć walutę z pustej kasy, co zaowocowałoby rzuceniem wyjątku.

 

PODSUMOWANIE

            Praktycznie każda nowoczesna aplikacja jest w małym lub większym stopniu wielowątkowa. Dzięki narzędziom dostępnym w .NET Framework każdy programista może wykorzystywać wątki w swoich programach. Mój artykuł nie wyczerpuje całkowicie tego tematu. Jak wspomniałem we wstępie, o wiele więcej można jeszcze napisać np. o delegatach asynchronicznych, innych metodach klasy Thread czy sposobach unikania błędów podczas operowania na wspólnych zasobach (np. korzystając z klas Mutex, AutoResetEvent lub ManualResetEvent). Jest to jednak temat na kolejny artykuł, podczas gdy ten może posłużyć jako przewodnik dla wszystkich tych, którzy jeszcze nie spotkali się z tematyką wątków i nie wykorzystywali ich w swoich aplikacjach.

Podobne artykuły

Komentarze 2

Paczeek
Paczeek
253 pkt.
Junior
21-01-2010
oceń pozytywnie 0
Witam,

generalnie artykuł mi się podobał... troszke nie podoba mi się brak konsekwencji autora.
W jednym miejscu radzi żeby zamiast dzielenia(mnożenia) przez 2 lub wielokrotności 2 używać przesunięcia, a w drugim sam tego nie wykorzystuje.

Pozdrawiam
User 84835
User 84835
3 pkt.
Nowicjusz
21-01-2010
oceń pozytywnie 0
Wiesz, w trakcie tworzenia silnika nabiezaco mierzylem FPS tam gdzie wprowadzenie nowych funkcjonalnosci wprowadzalo wyrany spadek FramePerSecond zastanawialem sie czemu sie tak dzieje i jak mozna temu zaradzic. W artykule chodizlo mi o pokazanie ze czasmi mozna na kod spojrzec jeszcze raz z innego punktu widzenia i przyspieszyc go dyrastycznie. Czasami to wprowadza wyrazne skompilkwoanie sie kodu, ale zwykle jest gra warta swieczki. Pokazalem tematy znane (np przesuaniwe bitow) srednioznane (reprezentacja lizcb zmienno przecinkowych) jak i fakt ze kazdy projket jest problem-indepndent i trzeba samemu szukac bo nie do wszystkiego sa wzorce. Pamietaj ze to artykul w dziale podstawy - zalezalo mi aby osobom swiezym w programowaniu (zwlaszcza obiektowym) nie udzialal sie hiperoptymizm pt. "Hurra-Dziala-Koniec-Pracy" - gdyby tak byl programowanie sprowazdzlaoby sie do prostego rzemiosla ktrego malpe by mozna nauczyc. Ja uwazam ze w pogramowaniu jest cos wiecej.
pkt.

Zaloguj się lub Zarejestruj się aby wykonać tę czynność.