Skip to content

10. Wątki wykonawcze

10.1. Klasa Thread

Po uruchomieniu aplikacja działa w strumieniu wykonania zwanym wątkiem. Klasą .NET modelującą thread jest klasa System.Threading.Thread, która ma następującą definicję:

Konstruktory

W poniższych przykładach będziemy używać wyłącznie konstruktorów klasy [1,3]. Konstruktor [1] przyjmuje jako parametr metodę o sygnaturze [2], c.a.d, posiadającą parametr typu object i niezwracającą wyniku. Konstruktor [3] przyjmuje jako parametr metodę o sygnaturze [4], c.a.d, która nie ma parametrów i nie zwraca wyniku.

Właściwości

Kilka przydatnych właściwości:

  • Wątek CurrentThread: właściwość statyczna, która zwraca odwołanie do wątku, w którym znajduje się kod, który zażądał tej właściwości
  • string Name: nazwa wątku
  • bool IsAlive: wskazuje, czy wątek jest aktualnie wykonywany, czy nie.

Metody

Najczęściej używane metody to:

  • Start(), Start(object obj): uruchamia asynchroniczne wykonywanie wątku, ewentualnie przekazując mu informacje w typie object.
  • Abort(), Abort(object obj): służy do wymuszonego zakończenia działania wątku
  • Join(): wątek T1, który wykonuje T2.Join, jest zablokowany do momentu zakończenia działania wątku T2. Istnieją warianty pozwalające zakończyć oczekiwanie po upływie określonego czasu.
  • Sleep(int n): metoda statyczna – wątek wykonujący tę metodę zostaje zawieszony na n milisekund. W tym czasie traci dostęp do procesora, który zostaje przekazany innemu wątku.

Przyjrzyjmy się pierwszej aplikacji ilustrującej istnienie głównego wątku wykonania, w którym wykonywana jest funkcja Main klasy:


using System;
using System.Threading;

namespace Chap8 {
    class Program {
        static void Main(string[] args) {
            // inicjacja bieżącego wątku
            Thread main = Thread.CurrentThread;
            // wyświetlanie
            Console.WriteLine("Thread courant : {0}", main.Name);
            // zmiana nazwy
            main.Name = "main";
            // weryfikacja
            Console.WriteLine("Thread courant : {0}", main.Name);

            // pętla nieskończona
            while (true) {
                // wyświetlanie
                Console.WriteLine("{0} : {1:hh:mm:ss}", main.Name, DateTime.Now);
                // tymczasowe zatrzymanie
                Thread.Sleep(1000);
            }//while        
        }
    }
}
  • wiersz 8: pobieramy odwołanie do wątku, w którym wykonywana jest metoda [main]
  • wiersze 10–14: wyświetla się i modyfikuje nazwę tego wątku
  • wiersze 17–22: pętla wyświetlająca wynik co sekundę
  • wiersz 21: wątek, w którym wykonywana jest metoda [main], zostanie zawieszony na 1 sekundę

Wyniki wyświetlane na ekranie są następujące:

1
2
3
4
5
6
7
8
Thread courant :
Thread courant : main
main : 04:19:00
main : 04:19:01
main : 04:19:02
main : 04:19:03
main : 04:19:04
^CAppuyez sur une touche pour continuer...
  • wiersz 1: bieżący wątek nie miał nazwy
  • wiersz 2: ma już nazwę
  • wiersze 3–7: komunikat wyświetlany co sekundę
  • wiersz 8: program jest przerywany przez klawisze Ctrl-C.

10.2. Tworzenie wątków wykonawczych

Istnieją aplikacje, w których fragmenty kodu są wykonywane „równocześnie” w różnych wątkach wykonawczych. Stwierdzenie, że thread są wykonywane równocześnie, jest często nadużyciem językowym. Jeśli komputer ma tylko jeden procesor, co nadal często ma miejsce, procesy thread dzielą ten procesor: każdy z nich ma do niego dostęp po kolei przez krótką chwilę (kilka milisekund). To właśnie stwarza iluzję równoległości wykonywania. Czas przydzielony procesowi thread zależy od różnych czynników, w tym od jego priorytetu, który ma wartość domyślną, ale może być również ustalony programowo. Gdy proces thread ma dostęp do procesora, zazwyczaj wykorzystuje go przez cały przydzielony mu czas. Może jednak zwolnić go przed upływem tego czasu:

  • przechodząc w stan oczekiwania na zdarzenie (Wait, Join)
  • przechodząc w stan uśpienia na określony czas (Sleep)
  1. Wątek T jest najpierw tworzony przez jedną z funkcji konstruktorowych przedstawionych powyżej, na przykład:
Thread thread=new Thread(Start);

gdzie Start jest metodą o jednej z dwóch następujących sygnatur:

void Start();
void Start(object obj);

Utworzenie wątku nie powoduje jego uruchomienia.

  1. Wykonanie wątku T jest uruchamiane przez funkcję T.Start(): metoda Start przekazana do konstruktora T zostanie następnie wykonana przez wątek T. Program wykonujący instrukcję T.Start() nie czeka na zakończenie zadania T: natychmiast przechodzi do następnej instrukcji. Mamy zatem dwa zadania wykonywane równolegle. Często muszą one mieć możliwość komunikowania się między sobą, aby wiedzieć, na jakim etapie znajduje się wspólna praca do wykonania. Jest to problem synchronizacji wątków.
  2. Po uruchomieniu wątek T działa autonomicznie. Zakończy działanie, gdy metoda Start, którą wykonuje, zakończy swoją pracę.
  3. Można wymusić zakończenie działania wątku T:
    1. T.Abort() nakazuje wątku T zakończenie działania.
  4. Można również poczekać na zakończenie jego wykonywania za pomocą funkcji T.Join(). Jest to instrukcja blokująca: program, który ją wykonuje, pozostaje zablokowany do momentu zakończenia pracy zadania T. Jest to sposób na synchronizację.

Przyjrzyjmy się poniższemu programowi:


using System;
using System.Threading;

namespace Chap8 {
    class Program {
        public static void Main() {
            // inicjowanie bieżącego wątku
            Thread main = Thread.CurrentThread;
            // nadanie nazwy wątku
            main.Name = "Main";

            // tworzenie wątków wykonawczych
            Thread[] tâches = new Thread[5];
            for (int i = 0; i < tâches.Length; i++) {
                // tworzenie wątku i
                tâches[i] = new Thread(Affiche);
                // ustalanie nazwy wątku
                tâches[i].Name =  i.ToString();
                // uruchamianie wątku i
                tâches[i].Start();
            }

            // koniec procedury głównej
            Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}",main.Name,DateTime.Now);
        }

        public static void Affiche() {
            // wyświetlenie początku wykonywania
            Console.WriteLine("Début d'exécution de la méthode Affiche dans le Thread {0} : {1:hh:mm:ss}",Thread.CurrentThread.Name,DateTime.Now);
            // przejście w stan uśpienia na 1 s
            Thread.Sleep(1000);
            // wyświetlenie zakończenia wykonywania
            Console.WriteLine("Fin d'exécution de la méthode Affiche dans le Thread {0} : {1:hh:mm:ss}", Thread.CurrentThread.Name, DateTime.Now);
        }
    }
}
  • wiersze 8–10: nadajemy nazwę wątku, który wykonuje metodę [Main]
  • wiersze 13–21: tworzymy 5 wątków i uruchamiamy je. Odwołania do wątków są zapisywane w tablicy, aby można je było później odzyskać. Każdy wątek wykonuje metodę Affiche z wierszy 27–35.
  • wiersz 20: uruchamiany jest wątek nr i. Operacja ta jest nieblokująca. Wątek nr i będzie działał równolegle do wątku metody [Main], który go uruchomił.
  • wiersz 24: wątek wykonujący metodę [Main] kończy działanie.
  • wiersze 27–35: metoda [Affiche] wyświetla informacje. Wyświetla nazwę wątku, który ją wykonuje, a także godzinę rozpoczęcia i zakończenia wykonywania.
  • wiersz 31: każdy wątek wykonujący metodę [Affiche] zatrzyma się na 1 sekundę. Procesor zostanie wówczas przekazany innemu wątku oczekującemu na procesor. Po upływie sekundy zatrzymania wątek, który został zatrzymany, stanie się kandydatem do uzyskania dostępu do procesora. Otrzyma go, gdy nadejdzie jego kolej. Zależy to od różnych czynników, w tym od priorytetu innych wątków oczekujących na procesor.

Wyniki są następujące:

Début d'exécution de la méthode Affiche dans le Thread 0 : 10:30:44
Début d'exécution de la méthode Affiche dans le Thread 1 : 10:30:44
Début d'exécution de la méthode Affiche dans le Thread 2 : 10:30:44
Début d'exécution de la méthode Affiche dans le Thread 3 : 10:30:44
Début d'exécution de la méthode Affiche dans le Thread 4 : 10:30:44
Fin du thread Main à 10:30:44
Fin d'exécution de la méthode Affiche dans le Thread 0 : 10:30:45
Fin d'exécution de la méthode Affiche dans le Thread 1 : 10:30:45
Fin d'exécution de la méthode Affiche dans le Thread 2 : 10:30:45
Fin d'exécution de la méthode Affiche dans le Thread 3 : 10:30:45
Fin d'exécution de la méthode Affiche dans le Thread 4 : 10:30:45

Wyniki te są bardzo pouczające:

  • przede wszystkim widać, że uruchomienie wątku nie powoduje blokady. Metoda Main uruchomiła równolegle 5 wątków i zakończyła swoje działanie przed nimi. Operacja
                // uruchamiamy wątek i
                tâches[i].Start();

uruchamia wątek tâches[i], ale po wykonaniu tej czynności realizacja kodu jest natychmiast kontynuowana od następnej instrukcji, bez oczekiwania na zakończenie działania tego wątku.

  • Wszystkie utworzone wątki muszą wykonać metodę Affiche. Kolejność wykonywania jest nieprzewidywalna. Nawet jeśli w tym przykładzie kolejność wykonywania wydaje się odpowiadać kolejności żądań wykonania, nie można z tego wyciągać ogólnych wniosków. System operacyjny dysponuje tutaj 6 wątkami i jednym procesorem. Rozdzieli on zasoby procesora między te 6 wątków zgodnie z własnymi regułami.
  • W wynikach widać efekt działania metody Sleep. W tym przykładzie to wątek 0 jako pierwszy wykonuje metodę Affiche. Wyświetlany jest komunikat o rozpoczęciu wykonywania, a następnie wątek ten wykonuje metodę Sleep, która zawiesza go na 1 sekundę. W ten sposób traci on dostęp do procesora, który staje się dostępny dla innego wątku. Przykład pokazuje, że procesor ten przejmie wątek 1. Wątek 1 przejdzie tę samą ścieżkę, podobnie jak pozostałe wątki. Gdy upłynie sekunda zawieszenia wątku 0, jego wykonywanie może zostać wznowione. System przydziela mu procesor i może on zakończyć wykonywanie metody Affiche.

Zmodyfikujmy nasz program tak, aby zakończyć metodę Main instrukcjami:


            // koniec obsługi
            Console.WriteLine("Fin du thread " + main.Name);
            // zatrzymanie wszystkich wątków
Environment.Exit(0);

Wynik wykonania nowego programu jest następujący:

1
2
3
4
5
6
Début d'exécution de la méthode Affiche dans le Thread 0 : 10:33:18
Début d'exécution de la méthode Affiche dans le Thread 1 : 10:33:18
Début d'exécution de la méthode Affiche dans le Thread 2 : 10:33:18
Début d'exécution de la méthode Affiche dans le Thread 3 : 10:33:18
Début d'exécution de la méthode Affiche dans le Thread 4 : 10:33:18
Fin du thread Main à 10:33:18
  • wiersze 1–5: wątki utworzone przez funkcję Main rozpoczynają działanie i są zawieszane na 1 sekundę
  • wiersz 6: wątek [Main] przejmuje procesor i wykonuje instrukcję:
        Environment.Exit(0);

Ta instrukcja zatrzymuje wszystkie wątki aplikacji, a nie tylko wątek Main.

Jeśli metoda Main chce poczekać na zakończenie wykonywania wątków, które utworzyła, może skorzystać z metody Join klasy Thread:


        public static void Main() {
...
            // oczekiwanie na wszystkie wątki
            for (int i = 0; i < tâches.Length; i++) {
                // oczekiwanie na zakończenie wykonywania wątku i
                tâches[i].Join();
            }
            // koniec procedury głównej
            Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}", main.Name, DateTime.Now);
}
  • wiersz 6: wątek [Main] czeka na każdy z wątków. Najpierw jest zablokowany w oczekiwaniu na wątek nr 1, następnie na wątek nr 2 itd. Ostatecznie, gdy wychodzi z pętli w wierszach 2–5, oznacza to, że wszystkie 5 uruchomionych przez niego wątków zakończyło działanie.

Otrzymujemy wówczas następujące wyniki:

Début d'exécution de la méthode Affiche dans le Thread 0 : 10:35:18
Début d'exécution de la méthode Affiche dans le Thread 1 : 10:35:18
Début d'exécution de la méthode Affiche dans le Thread 2 : 10:35:18
Début d'exécution de la méthode Affiche dans le Thread 3 : 10:35:18
Début d'exécution de la méthode Affiche dans le Thread 4 : 10:35:18
Fin d'exécution de la méthode Affiche dans le Thread 0 : 10:35:19
Fin d'exécution de la méthode Affiche dans le Thread 1 : 10:35:19
Fin d'exécution de la méthode Affiche dans le Thread 2 : 10:35:19
Fin d'exécution de la méthode Affiche dans le Thread 3 : 10:35:19
Fin d'exécution de la méthode Affiche dans le Thread 4 : 10:35:19
Fin du thread Main à 10:35:19
  • wiersz 11: wątek [Main] zakończył się po zakończeniu wątków, które uruchomił.

10.3. Znaczenie wątków

Skoro już wskazaliśmy na istnienie domyślnego wątku – tego, który wykonuje metodę Main – i wiemy, jak tworzyć kolejne, zastanówmy się nad korzyściami płynącymi z wątków oraz nad powodami, dla których je tutaj omawiamy. Istnieje pewien rodzaj aplikacji, które dobrze nadają się do wykorzystania wątków – są to aplikacje klient-serwer w Internecie. Przedstawimy je w następnym rozdziale. W internetowej aplikacji typu klient-serwer serwer znajdujący się na komputerze S1 odpowiada na żądania klientów znajdujących się na zdalnych komputerach C1, C2, ..., Cn.

Codziennie korzystamy z aplikacji internetowych działających zgodnie z tym schematem: serwisy internetowe, poczta elektroniczna, fora dyskusyjne, przesyłanie plików... W powyższym schemacie serwer S1 musi obsługiwać klientów Ci jednocześnie. Jeśli weźmiemy za przykład serwer FTP (File Transfer Protocol), który dostarcza pliki swoim klientom, wiemy, że transfer pliku może czasami trwać kilka minut. Oczywiście nie wchodzi w grę, aby jeden klient samodzielnie monopolizował serwer przez tak długi czas. Zazwyczaj serwer tworzy tyle wątków wykonawczych, ile jest klientów. Każdy wątek jest wówczas odpowiedzialny za obsługę konkretnego klienta. Ponieważ procesor jest cyklicznie dzielony między wszystkie aktywne wątki na maszynie, serwer poświęca nieco czasu każdemu klientowi, zapewniając w ten sposób równoczesność obsługi.

W praktyce serwer korzysta z puli wątków o ograniczonej liczbie, na przykład 50. 51. klient jest wówczas proszony o oczekiwanie.

10.4. Wymiana informacji między wątkami

W poprzednich przykładach wątek był inicjowany w następujący sposób:

Thread t=new Thread(Run);

gdzie Run była metodą o następującej sygnaturze:

void Run();

Można również użyć następującej sygnatury:

void Run(object obj);

Pozwala to na przekazywanie informacji do uruchomionego wątku. Tak więc

t.Start(obj1);

uruchomi wątek t, który następnie wykona metodę Run, przypisana mu domyślnie, przekazując jej rzeczywisty parametr obj1. Oto przykład:


using System;
using System.Threading;

namespace Chap8 {
    class Program4 {
        public static void Main() {
            // inicjowanie bieżącego wątku
            Thread main = Thread.CurrentThread;
            // nadajemy nazwę wątku
            main.Name = "Main";

            // tworzenie wątków wykonawczych
            Thread[] tâches = new Thread[5];
            Data[] data = new Data[5];
            for (int i = 0; i < tâches.Length; i++) {
                // tworzenie wątku i
                tâches[i] = new Thread(Sleep);
                // ustalanie nazwy wątku
                tâches[i].Name = i.ToString();
                // uruchamianie wątku i
                tâches[i].Start(data[i] = new Data { Début = DateTime.Now, Durée = i+1 });
            }
            // oczekiwanie na zakończenie wszystkich wątków
            for (int i = 0; i < tâches.Length; i++) {
                // oczekiwanie na zakończenie wykonywania wątku i
                tâches[i].Join();
                // wyświetlanie wyniku
                Console.WriteLine("Thread {0} terminé : début {1:hh:mm:ss}, durée programmée {2} s, fin {3:hh:mm:ss}, durée effective {4}",
                    tâches[i].Name,data[i].Début,data[i].Durée,data[i].Fin,(data[i].Fin-data[i].Début));
            }        
            // koniec procedury
            Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}", main.Name, DateTime.Now);
        }

        public static void Sleep(object infos) {
            // odbieramy parametr
            Data data = (Data)infos;
            // przejście w stan uśpienia na czas określony w sekundach
            Thread.Sleep(data.Durée*1000);
            // koniec wykonywania
            data.Fin = DateTime.Now;
        }
    }

    internal class Data {
        // różne informacje
        public DateTime Début { get; set; }
        public int Durée { get; set; }
        public DateTime Fin { get; set; }
    }
}
  • wiersze 45–50: informacje typu [Data] przekazane do wątków:
    • Début: czas rozpoczęcia wykonywania wątku – ustalony przez wątek uruchamiający
    • Durée: czas trwania w sekundach operacji Sleep wykonanej przez uruchomiony wątek – ustalony przez wątek uruchamiający
    • Fin: czas rozpoczęcia wykonywania wątku – ustalony przez wątek uruchamiający
  • wiersze 35–43: metoda Sleep wykonywana przez wątki ma sygnaturę void Sleep(object obj). Parametr rzeczywisty obj będzie typu [Data] zdefiniowanego w wierszu 45.
  • wiersze 15–22: utworzenie 5 wątków
  • wiersz 17: każdy wątek jest powiązany z metodą Sleep z wiersza 35
  • wiersz 21: obiekt typu [Data] jest przekazywany do metody Start, która uruchamia wątek. W obiekcie tym zapisano czas rozpoczęcia wykonywania wątku oraz czas w sekundach, przez który ma on pozostawać w stanie uśpienia. Obiekt ten jest zapisywany w tablicy w wierszu 14.
  • wiersze 24–30: wątek [Main] oczekuje na zakończenie wszystkich wątków, które uruchomił.
  • wiersze 28–29: wątek [Main] pobiera obiekt data[i] z wątku nr i i wyświetla jego zawartość.
  • wiersze 35–42: metoda Sleep wykonywana przez wątki
  • wiersz 37: pobierany jest parametr typu [Data]
  • wiersz 39: pole Durée parametru służy do ustalenia czasu trwania Sleep
  • wiersz 41: pole Fin parametru zostało zainicjowane

Wyniki wykonania są następujące:

1
2
3
4
5
6
Thread 0 terminé : début 11:18:50, durée programmée 1 s, fin 11:18:51, durée effective 00:00:01.0156250
Thread 1 terminé : début 11:18:50, durée programmée 2 s, fin 11:18:52, durée effective 00:00:02
Thread 2 terminé : début 11:18:50, durée programmée 3 s, fin 11:18:53, durée effective 00:00:03
Thread 3 terminé : début 11:18:50, durée programmée 4 s, fin 11:18:54, durée effective 00:00:04
Thread 4 terminé : début 11:18:50, durée programmée 5 s, fin 11:18:55, durée effective 00:00:05
Fin du thread Main à 11:18:55

Ten przykład pokazuje, że dwa wątki mogą wymieniać się informacjami:

  • wątek uruchamiający może kontrolować przebieg wykonywania wątku uruchomionego, przekazując mu informacje
  • wątku uruchomionego może przekazywać wyniki z powrotem do wątku uruchamiającego.

Aby wątek uruchomiony wiedział, kiedy oczekiwane wyniki będą dostępne, musi zostać powiadomiony o zakończeniu działania wątku uruchamiającego. W tym przypadku czekał na jego zakończenie, korzystając z metody Join. Istnieją inne sposoby osiągnięcia tego samego celu. Omówimy je w dalszej części.

10.5. Współbieżny dostęp do zasobów współdzielonych

10.5.1. Współbieżny dostęp niesynchronizowany

W akapicie poświęconym wymianie informacji między wątkami informacje były wymieniane tylko między dwoma wątkami i w ściśle określonych momentach. Było to klasyczne przekazywanie parametrów. Istnieją inne przypadki, w których informacja jest współdzielona przez wiele wątków, które mogą chcieć ją odczytać lub zaktualizować w tym samym momencie. Powstaje wówczas problem integralności tej informacji. Załóżmy, że współdzieloną informacją jest struktura S zawierająca różne dane: I1, I2, ... In.

  • Wątek T1 rozpoczyna aktualizację struktury S: modyfikuje pole I1 i zostaje przerwany przed zakończeniem pełnej aktualizacji struktury S
  • Wątek T2, który przejmuje procesor, odczytuje następnie strukturę S w celu podjęcia decyzji. Odczytuje strukturę znajdującą się w stanie niestabilnym: niektóre pola są aktualne, inne nie.

Sytuację tę nazywamy dostępem do zasobu współdzielonego – w tym przypadku struktury S – i często jest ona dość trudna do opanowania. Rozważmy następujący przykład, aby zilustrować problemy, które mogą się pojawić:

  • aplikacja wygeneruje n wątków, gdzie n jest przekazywane jako parametr
  • współdzielonym zasobem jest licznik, który musi zostać zwiększony przez każdy wygenerowany wątek
  • po zakończeniu działania aplikacji wyświetlana jest wartość licznika. Powinno się zatem uzyskać wynik n.

Program wygląda następująco:


using System;
using System.Threading;

namespace Chap8 {
    class Program {

        // zmienne klasy
        static int cptrThreads = 0;    // licznik wątków

        //main
        public static void Main(string[] args) {
            // instrukcja obsługi
            const string syntaxe = "pg nbThreads";
            const int nbMaxThreads = 100;

            // sprawdzanie liczby argumentów
            if (args.Length != 1) {
                // błąd
                Console.WriteLine(syntaxe);
                // zatrzymanie
                Environment.Exit(1);
            }
            // sprawdzanie jakości argumentu
            int nbThreads = 0;
            bool erreur = false;
            try {
                nbThreads = int.Parse(args[0]);
                if (nbThreads < 1 || nbThreads > nbMaxThreads)
                    erreur = true;
            } catch {
                // błąd
                erreur = true;
            }
            // błąd?
            if (erreur) {
                // błąd
                Console.Error.WriteLine("Nombre de threads incorrect (entre 1 et 100)");
                // koniec
                Environment.Exit(2);
            }
            // tworzenie i generowanie wątków
            Thread[] threads = new Thread[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // tworzenie
                threads[i] = new Thread(Incrémente);
                // nazewnictwo
                threads[i].Name = "" + i;
                // uruchomienie
                threads[i].Start();
            }//for
            // oczekiwanie na zakończenie działania wątków
            for (int i = 0; i < nbThreads; i++) {
                threads[i].Join();
            }
            // wyświetlanie licznika
            Console.WriteLine("Nombre de threads générés : " + cptrThreads);
        }

        public static void Incrémente() {
            // zwiększa licznik wątków
            // odczyt licznika
            int valeur = cptrThreads;
            // monitorowanie
            Console.WriteLine("A {0:hh:mm:ss}, le thread {1}  a lu la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
            // oczekiwanie
            Thread.Sleep(1000);
            // inkrementacja licznika
            cptrThreads = valeur + 1;
            // śledzenie
            Console.WriteLine("A {0:hh:mm:ss}, le thread {1}  a écrit la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
        }
    }
}

Nie będziemy się zatrzymywać na części dotyczącej generowania wątków, którą już omówiliśmy. Skupmy się raczej na metodzie Incrémente z linii 59, wykorzystywanej przez każdy wątek do zwiększania wartości statycznego licznika cptrThreads z linii 8.

  1. linia 62: odczytywany jest licznik
  2. wiersz 66: wątek zatrzymuje się na 1 s. W związku z tym traci dostęp do procesora
  3. wiersz 68: licznik jest zwiększany

Krok 2 służy wyłącznie do wymuszenia utraty dostępu do procesora przez wątek. Dostęp ten zostanie przekazany innemu wątku. W praktyce nic nie gwarantuje, że wątek nie zostanie przerwany w okresie między odczytaniem licznika a jego zwiększeniem. Nawet jeśli zapiszemy cptrThreads++, stwarzając w ten sposób iluzję pojedynczej instrukcji, istnieje ryzyko utraty dostępu do procesora między momentem odczytania wartości licznika a momentem zapisania jego wartości zwiększonej o 1. W rzeczywistości operacja wysokiego poziomu cptrThreads++ zostanie rozłożona na kilka instrukcji elementarnych na poziomie procesora. Etap 2, polegający na sekundowym zawieszeniu, ma zatem na celu jedynie zminimalizowanie tego ryzyka.

Wyniki uzyskane przy użyciu 5 wątków są następujące:

A 12:00:56, le thread 3  a lu la valeur du compteur : 0
A 12:00:56, le thread 2  a lu la valeur du compteur : 0
A 12:00:56, le thread 1  a lu la valeur du compteur : 0
A 12:00:56, le thread 0  a lu la valeur du compteur : 0
A 12:00:56, le thread 4  a lu la valeur du compteur : 0
A 12:00:57, le thread 3  a écrit la valeur du compteur : 1
A 12:00:57, le thread 2  a écrit la valeur du compteur : 1
A 12:00:57, le thread 1  a écrit la valeur du compteur : 1
A 12:00:57, le thread 0  a écrit la valeur du compteur : 1
A 12:00:57, le thread 4  a écrit la valeur du compteur : 1
Nombre de threads générés : 1

Analizując te wyniki, wyraźnie widać, co się dzieje:

  • wiersz 1: pierwszy wątek odczytuje licznik. Znajduje wartość 0. Zatrzymuje się na 1 s, tracąc w ten sposób dostęp do procesora
  • wiersz 2: drugi wątek przejmuje wtedy procesor i również odczytuje wartość licznika. Nadal wynosi ona 0, ponieważ poprzedni wątek jeszcze jej nie zwiększył. On również zatrzymuje się na 1 s i z kolei traci dostęp do procesora.
  • wiersze 1–5: w ciągu 1 s wszystkie 5 wątków zdąży się uruchomić i odczytać wartość 0.
  • wiersze 6–10: kiedy wątek po wątku będą się budzić, zwiększą odczytaną wartość 0 i zapiszą wartość 1 w liczniku, co potwierdza program główny (Main) w wierszu 11.

Skąd bierze się problem? Drugi wątek odczytał nieprawidłową wartość, ponieważ pierwszy został przerwany przed zakończeniem swojej pracy, polegającej na zaktualizowaniu licznika w oknie. To prowadzi nas do pojęcia zasobu krytycznego i sekcji krytycznej programu:

  • zasób krytyczny to zasób, który w danym momencie może być posiadany tylko przez jeden wątek. W tym przypadku zasobem krytycznym jest licznik.
  • Sekcja krytyczna programu to sekwencja instrukcji w strumieniu wykonania wątku, podczas której wątek uzyskuje dostęp do zasobu krytycznego. Należy zapewnić, aby w trakcie tej sekcji krytycznej tylko ten wątek miał dostęp do zasobu.

W naszym przykładzie sekcją krytyczną jest kod znajdujący się pomiędzy odczytem licznika a zapisem jego nowej wartości:


            // odczyt licznika
            int valeur = cptrThreads;
            // oczekiwanie
            Thread.Sleep(1000);
            // przyrost licznika
cptrThreads = valeur + 1;

Aby wykonać ten kod, wątek musi mieć gwarancję, że jest sam. Może zostać przerwany, ale podczas tej przerwy żaden inny wątek nie może wykonywać tego samego kodu. Platforma .NET oferuje różne narzędzia zapewniające wyłączną dostępność do krytycznych sekcji kodu. Omówimy teraz kilka z nich.

10.5.2. Klauzula lock

Klauzula lock pozwala wyznaczyć sekcję krytyczną w następujący sposób:

lock(obj){section critique}

obj musi być odwołaniem do obiektu widocznym dla wszystkich wątków wykonujących sekcję krytyczną. Klauzula lock gwarantuje, że sekcję krytyczną będzie wykonywał tylko jeden wątek naraz. Powyższy przykład można przepisać w następujący sposób:


using System;
using System.Threading;

namespace Chap8 {
    class Program2 {

        // zmienne klasy
        static int cptrThreads = 0;    // licznik wątków
        static object synchro = new object(); // obiekt synchronizacji

        //main
        public static void Main(string[] args) {
    ...
            // oczekiwanie na zakończenie wątków
            Thread.CurrentThread.Name = "Main";
            for (int i = nbThreads - 1; i >= 0; i--) {
                Console.WriteLine("A {0:hh:mm:ss}, le thread {1} attend la fin du thread {2}", DateTime.Now, Thread.CurrentThread.Name, threads[i].Name);
                threads[i].Join();
                Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a été prévenu de la fin du thread {2}", DateTime.Now, Thread.CurrentThread.Name, threads[i].Name);
            }
            // wyświetlanie licznika
            Console.WriteLine("Nombre de threads générés : " + cptrThreads);
        }

        public static void Incrémente() {
            // zwiększa licznik wątków
            // żądany jest wyłączny dostęp do licznika
            Console.WriteLine("A {0:hh:mm:ss}, le thread {1}  attend l'autorisation d'entrer dans la section critique", DateTime.Now, Thread.CurrentThread.Name);
            lock (synchro) {
                // odczyt licznika
                int valeur = cptrThreads;
                // monitorowanie
                Console.WriteLine("A {0:hh:mm:ss}, le thread {1}  a lu la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
                // oczekiwanie
                Thread.Sleep(1000);
                // inkrementacja licznika
                cptrThreads = valeur + 1;
                // monitorowanie
                Console.WriteLine("A {0:hh:mm:ss}, le thread {1}  a écrit la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
            }
            Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a quitté la section critique", DateTime.Now, Thread.CurrentThread.Name);
        }
    }
}
  • wiersz 9: synchro to obiekt, który umożliwi synchronizację wszystkich wątków.
  • wiersze 16–23: metoda [Main] oczekuje na wątki w kolejności odwrotnej do kolejności ich utworzenia.
  • wiersze 29–40: sekcja krytyczna metody Incrémente została otoczona klauzulą lock.

Wyniki uzyskane przy użyciu 3 wątków są następujące:

A 09:37:09, le thread 0 attend l'autorisation d'entrer dans la section critique
A 09:37:09, le thread 0 a lu la valeur du compteur : 0
A 09:37:09, le thread 1 attend l'autorisation d'entrer dans la section critique
A 09:37:09, le thread 2 attend l'autorisation d'entrer dans la section critique
A 09:37:09, le thread Main attend la fin du thread 2
A 09:37:10, le thread 0 a écrit la valeur du compteur : 1
A 09:37:10, le thread 1 a lu la valeur du compteur : 1
A 09:37:10, le thread 0 a quitté la section critique
A 09:37:11, le thread 1 a écrit la valeur du compteur : 2
A 09:37:11, le thread 1 a quitté la section critique
A 09:37:11, le thread 2 a lu la valeur du compteur : 2
A 09:37:12, le thread 2 a écrit la valeur du compteur : 3
A 09:37:12, le thread 2 a quitté la section critique
A 09:37:12, le thread Main a été prévenu de la fin du thread 2
A 09:37:12, le thread Main attend la fin du thread 1
A 09:37:12, le thread Main a été prévenu de la fin du thread 1
A 09:37:12, le thread Main attend la fin du thread 0
A 09:37:12, le thread Main a été prévenu de la fin du thread 0
Nombre de threads générés : 3
  • wątek 0 jako pierwszy wchodzi do sekcji krytycznej: wiersze 1, 2, 6, 8
  • dwa pozostałe wątki będą zablokowane, dopóki wątek 0 nie opuści sekcji krytycznej: wiersze 3 i 4
  • Następnie przechodzi wątek 1: wiersze 7, 9, 10
  • Następnie przechodzi wątek 2: wiersze 11, 12, 13
  • wiersz 14: wątek Main, który czekał na zakończenie wątku 2, zostaje powiadomiony
  • wiersz 15: wątek Main czeka teraz na zakończenie wątku 1. Ten już się zakończył. Wątek Main zostaje o tym natychmiast powiadomiony, wiersz 16.
  • wiersze 17–18: ten sam proces ma miejsce w przypadku wątku 0
  • wiersz 19: liczba wątków jest prawidłowa

10.5.3. Klasa Mutex

Klasa System.Threading.Mutex również pozwala wyodrębnić sekcje krytyczne. Różni się ona od klauzuli lock pod względem widoczności:

  • klauzula lock pozwala synchronizować wątki tej samej aplikacji
  • klasa Mutex pozwala na synchronizację wątków z różnych aplikacji.

Będziemy korzystać z następującego konstruktora i metod:

public Mutex()
tworzy obiekt Mutex typu M
public bool WaitOne()
Wątek T1, który wykonuje operację M.WaitOne(), żąda własności obiektu synchronizacyjnego M. Jeśli obiekt Mutex M nie jest w posiadaniu żadnego wątku (co ma miejsce na początku), zostaje on „przyznany” wątku T1, który o to poprosił. Jeśli nieco później wątek T2 wykona tę samą operację, zostanie zablokowany. W rzeczywistości obiekt Mutex może należeć tylko do jednego wątku. Zostanie on odblokowany, gdy wątek T1 zwolni posiadany przez siebie Mutex M. W ten sposób wiele wątków może zostać zablokowanych w oczekiwaniu na Mutex M.
public void ReleaseMutex()
Wątek T1, który wykonuje operację M.ReleaseMutex(), rezygnuje z posiadania muteksu M o nazwie Mutex. Gdy wątek T1 straci dostęp do procesora, system będzie mógł przydzielić go jednemu z wątków oczekujących na muteks M. Tylko jeden z nich otrzyma go w kolejności, pozostałe, oczekujące na M, pozostaną zablokowane

Wątek Mutex M zarządza dostępem do zasobu współdzielonego R. Wątek żąda zasobu R za pomocą funkcji M.WaitOne() i zwraca go za pomocą funkcji M.ReleaseMutex(). Krytyczny fragment kodu, który może być wykonywany tylko przez jeden wątek naraz, stanowi zasób współdzielony. Synchronizacja wykonywania tego krytycznego fragmentu może przebiegać w następujący sposób:

M.WaitOne();
// tylko ten wątek może tu wejść
// sekcja krytyczna
....
M.ReleaseMutex();

gdzie M jest obiektem typu Mutex. Należy pamiętać o zwolnieniu obiektu Mutex, który stał się zbędny, aby inny wątek mógł wejść do sekcji krytycznej; w przeciwnym razie wątki oczekujące na nigdy nie zwolniony obiekt Mutex nigdy nie uzyskają dostępu do procesora.

Jeśli zastosujemy w praktyce to, co właśnie omówiliśmy, na podstawie poprzedniego przykładu, nasza aplikacja będzie wyglądać następująco:


using System;
using System.Threading;

namespace Chap8 {
    class Program3 {

        // zmienne klasy
        static int cptrThreads = 0;    // licznik wątków
        static Mutex synchro = new Mutex(); // obiekt synchronizacji

        //main
        public static void Main(string[] args) {
    ...
        }

        public static void Incrémente() {
....
            synchro.WaitOne();
            try {
...
            } finally {
...
                synchro.ReleaseMutex();
            }
        }
    }
}
  • wiersz 9: obiektem synchronizacji wątków jest teraz Mutex.
  • wiersz 18: początek sekcji krytycznej – może do niej wejść tylko jeden wątek. Blokujemy się, dopóki obiekt Mutex synchro nie zostanie zwolniony.
  • wiersz 33: ponieważ obiekt Mutex musi zawsze zostać zwolniony, niezależnie od tego, czy wystąpi wyjątek, czy nie, sekcja krytyczna jest obsługiwana za pomocą bloku try / finally w celu zwolnienia obiektu Mutex w obiekcie finally.
  • wiersz 23: zmienna Mutex jest zwalniana po zakończeniu sekcji krytycznej.

Uzyskane wyniki są takie same jak poprzednio.

10.5.4. Klasa AutoResetEvent

Obiekt AutoResetEvent stanowi barierę przepuszczającą tylko jeden wątek na raz, podobnie jak dwa poprzednie narzędzia: lock i Mutex. Obiekt AutoResetEvent tworzy się w następujący sposób:

AutoResetEvent barrière=new AutoresetEvent(bool état);

Wartość logiczna état wskazuje, czy bariera jest zamknięta (false), czy otwarta (true). Wątek, który chce przejść przez barierę, zgłosi to w następujący sposób:

barrière.WaitOne();
  • jeśli bariera jest otwarta, wątek przechodzi, a bariera zostaje ponownie zamknięta za nim. Jeśli czekało kilka wątków, mamy pewność, że przejdzie tylko jeden.
  • Jeśli bariera jest zamknięta, wątek zostaje zablokowany. Inny wątek otworzy ją w odpowiednim momencie. Moment ten zależy całkowicie od rozwiązywanego problemu. Bariera zostanie otwarta za pomocą operacji:
barrière.Set(); 

Może się zdarzyć, że wątek będzie chciał zamknąć barierę. Będzie mógł to zrobić za pomocą:

barrière.Reset(); 

Jeśli w poprzednim przykładzie zastąpimy obiekt Mutex obiektem typu AutoResetEvent, kod będzie wyglądał następująco:


using System;
using System.Threading;

namespace Chap8 {
    class Program4 {

        // zmienne klasy
        static int cptrThreads = 0;    // licznik wątków
        static EventWaitHandle synchro = new AutoResetEvent(false); // obiekt synchronizacji

        //main
        public static void Main(string[] args) {
....
            // otwieramy barierę sekcji krytycznej
            Console.WriteLine("A {0:hh:mm:ss}, le thread {1} ouvre la barrière de la section critique", DateTime.Now, Thread.CurrentThread.Name);
            synchro.Set();
            // oczekiwanie na zakończenie działania wątków
...
            // wyświetlanie licznika
            Console.WriteLine("Nombre de threads générés : " + cptrThreads);
        }

        public static void Incrémente() {
            // zwiększa licznik wątków
            // żądany jest wyłączny dostęp do licznika
...
            synchro.WaitOne();
            try {
...
            } finally {
                // zwolniono zasób
...
                synchro.Set();
            }
        }
    }
}
  • wiersz 9: bariera zostaje utworzona w stanie zamkniętym. Zostanie ona otwarta przez wątek Main w wierszu 16.
  • wiersz 27: wątek odpowiedzialny za zwiększanie licznika wątków prosi o zezwolenie na wejście do sekcji krytycznej. Poszczególne wątki będą gromadzić się przed zamkniętą barierą. Gdy wątek Main ją otworzy, jeden z oczekujących wątków przejdzie.
  • wiersz 33: po zakończeniu pracy wątek ponownie otwiera barierę, umożliwiając wejście kolejnemu wątkowi.

Otrzymujemy wyniki analogiczne do poprzednich.

10.5.5. Klasa Interlocked

Klasa Interlocked pozwala nadać atomowość grupie operacji. W grupie operacji atomique albo wszystkie operacje zostaną wykonane przez wątek realizujący tę grupę, albo żadna. Nie dochodzi do sytuacji, w której niektóre operacje zostałyby wykonane, a inne nie. Obiekty synchronizacji lock, Mutex, AutoResetEvent mają na celu uczynienie z atomique grupy operacji. Wynik ten osiąga się kosztem blokowania wątków. Klasa Interlocked pozwala, w przypadku prostych, ale dość częstych operacji, uniknąć blokowania wątków. Klasa Interlocked udostępnia następujące metody statyczne:

Image

Metoda Increment ma następującą sygnaturę:

public static int Increment(ref int location);

Pozwala ona na zwiększenie o 1 wartości parametru location. Operacja ta jest gwarantowana jako atomique.

Nasz program do zliczania wątków może zatem wyglądać następująco:


using System;
using System.Threading;

namespace Chap8 {
    class Program5 {

        // zmienne klasy
        static int cptrThreads = 0;    // licznik wątków

        //main
        public static void Main(string[] args) {
...
        }

        public static void Incrémente() {
            // zwiększa licznik wątków
            Interlocked.Increment(ref cptrThreads);
        }
    }
}
  • wiersz 17: licznik wątków jest zwiększany w sposób atomowy.

10.6. Współbieżny dostęp do wielu zasobów współdzielonych

10.6.1. Przykład

W naszych poprzednich przykładach pojedynczy zasób był współdzielony przez różne wątki. Sytuacja może się skomplikować, jeśli zasobów jest kilka i są one od siebie zależne. W szczególności może dojść do sytuacji wzajemnego blokowania. Sytuacja ta, zwana również deadlock, polega na tym, że dwa wątki czekają na siebie nawzajem. Rozważmy następujące działania, które następują po sobie w czasie:

  • wątek T1 uzyskuje własność muteksu M1, aby uzyskać dostęp do zasobu współdzielonego R1
  • wątek T2 uzyskuje własność muteksu M2, aby uzyskać dostęp do zasobu współdzielonego R2
  • wątek T1 żąda muteksu M2. Zostaje zablokowany.
  • Wątek T2 próbuje uzyskać muteks M1. Zostaje zablokowany.

W tym przypadku wątki T1 i T2 oczekują na siebie nawzajem. Sytuacja ta występuje, gdy wątki potrzebują dwóch współdzielonych zasobów: zasobu R1 kontrolowanego przez muteks M1 oraz zasobu R2 kontrolowanego przez muteks M2. Jednym z możliwych rozwiązań jest jednoczesne żądanie obu zasobów za pomocą pojedynczego muteksu M. Nie zawsze jest to jednak możliwe, jeśli na przykład wiąże się to z długotrwałym zajęciem kosztownego zasobu. Innym rozwiązaniem jest sytuacja, w której wątek posiadający zasób M1, który nie może uzyskać zasobu M2, zwalnia wówczas zasób M1, aby uniknąć wzajemnego blokowania.

  1. Mamy tablicę, do której niektóre wątki zapisują dane (pisarze), a inne je odczytują (czytelnicy).
  2. Zapisujące wątki są sobie równe, ale działają na zasadzie wyłączności: w danym momencie tylko jeden wątek zapisujący może umieszczać swoje dane w tablicy.
  3. Czytelnicy są sobie równi, ale wykluczają się wzajemnie: w danym momencie tylko jeden czytelnik może odczytywać dane zapisane w tablicy.
  4. Czytelnik może odczytać dane z tablicy tylko wtedy, gdy zapisujący je tam umieścił, a zapisujący może umieścić nowe dane w tablicy tylko wtedy, gdy te, które się w niej znajdują, zostały odczytane przez czytelnika.

Można wyróżnić dwa rodzaje zasobów współdzielonych:

  • tabela do zapisu: w danym momencie dostęp do niej może mieć tylko jeden użytkownik zapisujący.
  • tabela do odczytu: w danym momencie dostęp do niej może mieć tylko jeden odczytujący.

oraz kolejność korzystania z tych zasobów:

  • odczytujący musi zawsze następować po zapisującym.
  • piszący musi zawsze następować po czytającym, z wyjątkiem pierwszego razu.

Dostęp do tych dwóch zasobów można kontrolować za pomocą dwóch barier typu AutoResetEvent:

  • bariera peutEcrire będzie kontrolować dostęp zapisujących do tablicy.
  • Bariera peutLire będzie kontrolować dostęp czytelników do tablicy.
  • Szlaban peutEcrire zostanie utworzony w stanie początkowo otwartym, umożliwiając przejście pierwszemu pisarzowi i blokując wszystkich pozostałych.
  • Bariera peutLire zostanie utworzona w stanie początkowo zamkniętym, blokując wszystkich czytelników.
  • Gdy zapisujący zakończy swoją pracę, otworzy barierę peutLire, aby wpuścić odczytującego.
  • Gdy czytelnik zakończy swoją pracę, otworzy barierę peutEcrire, aby wpuścić pisarza.

Program ilustrujący tę synchronizację opartą na zdarzeniach wygląda następująco:


using System;
using System.Threading;

namespace Chap8 {
    class Program {
        // wykorzystanie wątków odczytujących i zapisujących
        // ilustruje wykorzystanie zdarzeń synchronizacyjnych


        // zmienne klasy
        static int[] data = new int[3];    // zasób współdzielony między wątkami odczytu i zapisu
        static Random objRandom = new Random(DateTime.Now.Second);    // generator liczb losowych
        static AutoResetEvent peutLire;    // wskazuje, że można odczytać zawartość zmiennej data
        static AutoResetEvent peutEcrire;    // sygnalizuje, że można zapisywać zawartość data

        //main
        public static void Main(string[] args) {

            // liczba wątków do wygenerowania
            const int nbThreads = 2;

            // inicjalizacja flag
            peutLire = new AutoResetEvent(false);    // nie można jeszcze odczytać
            peutEcrire = new AutoResetEvent(true);    // można już zapisywać

            // tworzenie wątków odczytu
            Thread[] lecteurs = new Thread[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // tworzenie
                lecteurs[i] = new Thread(Lire);
                lecteurs[i].Name = "L" + i.ToString();
                // uruchomienie
                lecteurs[i].Start();
            }

            // tworzenie wątków zapisu
            Thread[] écrivains = new Thread[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // utworzenie
                écrivains[i] = new Thread(Ecrire);
                écrivains[i].Name = "E" + i.ToString();
                // uruchomienie
                écrivains[i].Start();
            }

            //zakończenie operacji
            Console.WriteLine("Fin de Main...");
        }

        // odczyt zawartości tablicy
        public static void Lire() {
...
        }

        // zapis do tablicy
        public static void Ecrire() {
....
        }
    }
}
  • wiersz 11: tablica data jest zasobem współdzielonym przez wątki odczytujące i zapisujące. Jest ona współdzielona w trybie odczytu przez wątki odczytujące, a w trybie zapisu przez wątki zapisujące.
  • wiersz 13: obiekt peutLire służy do powiadamiania wątków odczytujących, że mogą odczytać tablicę data. Jest on ustawiany na wartość true przez wątek zapisujący, który wypełnił tablicę data. Jest on inicjowany na wartość false w wierszu 23. Wątek zapisujący musi najpierw wypełnić tablicę, zanim przekaże zdarzenie peutLire do vrai.
  • wiersz 14: obiekt peutEcrire służy do powiadamiania wątków zapisujących, że mogą zapisywać do tablicy data. Jest on ustawiany na wartość true przez wątek odczytujący, który wykorzystał całą zawartość tablicy data. Jest on inicjowany w true, wiersz 24. W rzeczywistości tablica data jest dostępna do zapisu.
  • wiersze 27–34: utworzenie i uruchomienie wątków odczytujących
  • wiersze 37–44: tworzenie i uruchamianie wątków zapisujących

Metoda Lire wykonywana przez wątki odczytujące wygląda następująco:


public static void Lire() {
            // monitorowanie
            Console.WriteLine("Méthode [Lire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
            // należy poczekać na uprawnienie do odczytu
            peutLire.WaitOne();
            // odczyt tabeli
            for (int i = 0; i < data.Length; i++) {
                //oczekiwanie 1 s
                Thread.Sleep(1000);
                // wyświetlanie
                Console.WriteLine("{0:hh:mm:ss} : Le lecteur {1} a lu le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
            }
            // można zapisywać
            peutEcrire.Set();
            // monitorowanie
            Console.WriteLine("Méthode [Lire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
        }
  • wiersz 5: czeka się, aż wątek zapisujący zasygnalizuje, że tablica została wypełniona. Po otrzymaniu tego sygnału tylko jeden z wątków odczytujących oczekujących na ten sygnał będzie mógł przejść do działania.
  • wiersze 7–12: wykorzystanie tablicy data z elementem Sleep w środku, aby wymusić utratę dostępu do procesora przez wątek.
  • wiersz 14: informuje wątki zapisujące, że tablica została odczytana i można ją ponownie zapełnić.

Metoda Ecrire wykonywana przez wątki zapisujące wygląda następująco:


public static void Ecrire() {
            // monitorowanie
            Console.WriteLine("Méthode [Ecrire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
            // należy poczekać na zezwolenie na zapis
            peutEcrire.WaitOne();
            // zapis do tablicy
            for (int i = 0; i < data.Length; i++) {
                //oczekiwanie 1 s
                Thread.Sleep(1000);
                // wyświetlanie
                data[i] = objRandom.Next(0, 1000);
                Console.WriteLine("{0:hh:mm:ss} : L'écrivain {1} a écrit le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
            }
            // można odczytywać
            peutLire.Set();
            // monitorowanie
            Console.WriteLine("Méthode [Ecrire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
        }
  • wiersz 5: oczekuje się, aż wątek odczytujący zasygnalizuje, że tablica została odczytana. Po otrzymaniu tego sygnału tylko jeden z wątków zapisujących oczekujących na ten sygnał będzie mógł przejść do działania.
  • wiersze 7–13: wykorzystanie tablicy data z elementem Sleep w środku, aby wymusić utratę dostępu do procesora przez wątek.
  • wiersz 15: informuje wątki odczytujące, że tablica została wypełniona i można ją ponownie odczytać.

Wykonanie daje następujące wyniki:

Méthode [Lire] démarrée par le thread n° L0
Méthode [Lire] démarrée par le thread n° L1
Méthode [Ecrire] démarrée par le thread n° E0
Méthode [Ecrire] démarrée par le thread n° E1
Fin de Main...
02:29:18 : L'écrivain E0 a écrit le nombre 607
02:29:19 : L'écrivain E0 a écrit le nombre 805
02:29:20 : L'écrivain E0 a écrit le nombre 650
Méthode [Ecrire] terminée par le thread n° E0
02:29:21 : Le lecteur L0 a lu le nombre 607
02:29:22 : Le lecteur L0 a lu le nombre 805
02:29:23 : Le lecteur L0 a lu le nombre 650
Méthode [Lire] terminée par le thread n° L0
02:29:24 : L'écrivain E1 a écrit le nombre 186
02:29:25 : L'écrivain E1 a écrit le nombre 881
02:29:26 : L'écrivain E1 a écrit le nombre 415
Méthode [Ecrire] terminée par le thread n° E1
02:29:27 : Le lecteur L1 a lu le nombre 186
02:29:28 : Le lecteur L1 a lu le nombre 881
02:29:29 : Le lecteur L1 a lu le nombre 415
Méthode [Lire] terminée par le thread n° L1

Można zauważyć następujące kwestie:

  • w danym momencie działa tylko jeden wątek odczytujący, mimo że traci on dostęp do procesora w sekcji krytycznej Lire
  • rzeczywiście w danym momencie działa tylko jeden zapisujący, choć traci on dostęp do procesora w sekcji krytycznej Ecrire
  • odczytujący odczytuje tylko wtedy, gdy w tablicy jest coś do odczytania
  • zapisujący zapisuje tylko wtedy, gdy tablica została w całości odczytana

10.6.2. Klasa Monitor

W poprzednim przykładzie:

  • istnieją dwa współdzielone zasoby do zarządzania
  • dla danego zasobu wątki są równe.

Gdy wątki zapisujące są zablokowane na instrukcji peutEcrire.WaitOne, jeden z nich – dowolny – zostaje odblokowany przez operację peutEcrire.Set. Jeśli poprzednia operacja ma otworzyć barierę dla konkretnego wątku zapisującego, sytuacja staje się bardziej skomplikowana.

Można tu dostrzec analogię do placówki obsługującej klientów przy okienkach, gdzie każde okienko ma swoją specjalizację. Kiedy klient przychodzi, pobiera numerek z automatu do numerków na okienko X, a następnie siada. Każdy numerek jest ponumerowany, a klienci są wywoływani według numeru przez głośnik. W trakcie oczekiwania klient robi, co chce. Może czytać lub drzemać. Za każdym razem budzi go głośnik, który ogłasza, że nr Y jest wywoływany do okienka X. Jeśli chodzi o niego, klient wstaje i podchodzi do okienka X, w przeciwnym razie kontynuuje to, co robił.

W tym przypadku można postępować analogicznie. Weźmy na przykład pisarzy:

plusieurs écrivains attendent pour un même guichet
ich wątki są zablokowane
le guichet se libère et le n° de l'écrivain suivant est appelé
Wątek korzystający z tablicy w trybie odczytu sygnalizuje wątkowi zapisującemu, że tablica jest dostępna. Ten wątek lub inny wątek zablokował wątek zapisujący, który musi przejść przez barierę.
chaque écrivain regarde son n° et seul celui qui a le n° appelé
va au guichet. Les autres se remettent en attente.
każdy wątek sprawdza, czy to właśnie on został wybrany. Jeśli tak, przechodzi przez barierę. Jeśli nie, wraca do stanu oczekiwania.

Klasa Monitor pozwala na realizację tego scenariusza.

Image

Opiszemy teraz standardową konstrukcję (pattern), zaproponowaną w rozdziale Threading książki „C# 3.0”, do której odwołano się we wstępie do niniejszego dokumentu, umożliwiającą rozwiązanie problemów związanych z barierą z warunkiem wejścia.

  • Po pierwsze, wątki, które współdzielą zasób (okienko, ...), uzyskują do niego dostęp za pośrednictwem obiektu, który nazwiemy tokenem. Aby otworzyć barierę prowadzącą do okienka, trzeba posiadać token umożliwiający jej otwarcie, a token ten jest tylko jeden. Wątki muszą zatem przekazywać token między sobą.
object jeton=new object();
  • Aby dostać się do okienka, wątki najpierw proszą o token:
Monitor.Enter(jeton);

Jeśli żeton jest wolny, otrzymuje go wątek, który wykonał poprzednią operację; w przeciwnym razie wątek jest umieszczany w kolejce oczekujących na żeton.

  • Jeśli dostęp do okienka odbywa się w sposób nieuporządkowany, c.a.d. w przypadku, gdy nie ma znaczenia, która osoba wchodzi, wystarczy poprzednia operacja. Wątek posiadający token udaje się do okienka. Jeśli dostęp odbywa się w sposób uporządkowany, wątek posiadający token sprawdza, czy spełnia warunek umożliwiający udanie się do okienka:
while (! jeNeSuisPasCeluiQuiEstAttendu) {Monitor.Wait(jeton);}

Jeśli wątek nie jest tym, którego oczekuje się przy okienku, rezygnuje ze swojej kolejki, zwracając token. Przechodzi w stan zablokowany. Zostanie wybudzony, gdy tylko token znów będzie dla niego dostępny. Wówczas ponownie sprawdzi, czy spełnia warunek przejścia do okienka. Operacja Monitor.Wait(token), która zwalnia token, może zostać wykonana tylko wtedy, gdy wątek jest właścicielem tokenu. Jeśli tak nie jest, zgłaszany jest wyjątek.

  • Wątek, który sprawdza warunek przejścia do okienka, przechodzi do niego:
  1. // praca przy okienku
  2. ....

Przed opuszczeniem okienka wątek musi zwrócić swój token, w przeciwnym razie wątki zablokowane w oczekiwaniu na ten token pozostaną zablokowane na czas nieokreślony. Istnieją dwie różne sytuacje:

  • pierwsza sytuacja ma miejsce wtedy, gdy wątek posiadający token jest jednocześnie tym, który sygnalizuje wątkom oczekującym na token, że jest on wolny. Zrobi to w następujący sposób:
1
2
3
4
5
6
7
8
// praca przy okienku
....
// zmiana warunków dostępu do okienka
...
// budzenie wątków oczekujących na token
Monitor.PulseAll(jeton);
// zwolnienie tokenu
Monitor.Exit(jeton);

W wierszu 6 budzi wątki oczekujące na token. To „przebudzenie” oznacza, że stają się one uprawnione do otrzymania tokenu. Nie oznacza to jednak, że otrzymują go natychmiast. W wierszu 8 token zostaje zwolniony. Wszystkie uprawnione wątki otrzymają token po kolei, w sposób nieokreślony. Da im to możliwość ponownego sprawdzenia, czy spełniają warunek dostępu. Wątek, który zwolnił token, zmodyfikował ten warunek w wierszu 4, aby umożliwić wejście nowemu wątku. Pierwszy, który spełni ten warunek, zachowuje token i przechodzi do kolejki.

  • Druga sytuacja ma miejsce wtedy, gdy wątek posiadający token nie jest tym, który powinien powiadomić wątki oczekujące na token, że jest on wolny. Musi jednak go zwolnić, ponieważ wątek odpowiedzialny za wysłanie tego sygnału musi być posiadaczem tokenu. Zrobi to za pomocą operacji:
Monitor.Exit(jeton);

Token jest teraz dostępny, ale wątki, które na niego czekają (wykonali operację Wait(token)), nie są o tym powiadamiane. Zadanie to powierzono innemu wątkowi, który w pewnym momencie wykona kod podobny do poniższego:

1
2
3
4
5
6
7
8
// przejęcie tokenu
Monitor.Enter(jeton);
// zmiana warunku dostępu do okienka
....
// budzenie wątków oczekujących na token
Monitor.PulseAll(jeton);
// zwolnienie tokenu
Monitor.Exit(jeton);

Ostatecznie standardowa konstrukcja zaproponowana w rozdziale Threading książki „C# 3.0” wygląda następująco:

  • zdefiniowanie tokenu dostępu do okienka:
object jeton=new object();
  • poprosić o dostęp do okienka:
lock(jeton){
    while (! jeNeSuisPasCeluiQuiEstAttendu) 
        Monitor.Wait(jeton);
}
// przejście do okienka
...
lock(jeton){...} 

jest równoważne

Monitor.Enter(jeton);
try{...} finally{Monitor.Exit(jeton);}

Należy zauważyć, że w tym schemacie token jest zwalniany natychmiast po przekroczeniu bariery. Inny wątek może wówczas sprawdzić warunek dostępu. Powyższa konstrukcja pozwala zatem na wejście wszystkim wątkom sprawdzającym warunek dostępu. Jeśli nie jest to pożądane, można napisać:

lock(jeton){
    while (! jeNeSuisPasCeluiQuiEstAttendu) 
        Monitor.Wait(jeton);
     // przejście do okienka
    ...
}

gdzie token jest zwalniany dopiero po zakończeniu obsługi w okienku.

  • zmienić warunek dostępu do okienka i powiadomić o tym pozostałe wątki
lock(jeton){
     // zmiana warunku dostępu do okienka
    ...
     // powiadomienie wątków oczekujących na token
    Monitor.PulseAll(jeton);
}

W powyższym przykładzie warunek dostępu może zostać zmieniony wyłącznie przez wątek posiadający token. Można również zapisać to w następujący sposób:

     // zmiana warunku dostępu do okienka
    ...
     // powiadomienie wątków oczekujących na token
    Monitor.PulseAll(jeton);
     // zwolnić token
    Monitor.Exit(jeton);

jeśli wątek posiada już token.

Dysponując tymi informacjami, możemy przepisać aplikację do odczytu i zapisu, ustalając kolejność odczytu i zapisu w celu uzyskania dostępu do odpowiednich okienek. Kod wygląda następująco:


using System;
using System.Threading;

namespace Chap8 {
    class Program2 {
        // wykorzystanie wątków odczytu i zapisu
        // ilustruje wykorzystanie zdarzeń synchronizacyjnych


        // zmienne klasowe
        static int[] data = new int[3];            // zasób współdzielony między wątkami odczytu i zapisu
        static Random objRandom = new Random(DateTime.Now.Second);    // generator liczb losowych
        static object peutLire = new object();        // wskazuje, że można odczytać zawartość zmiennej data
        static object peutEcrire = new object();    // sygnalizuje, że można zapisywać zawartość data
        static bool lectureAutorisée = false;    // w celu zezwolenia na odczyt tablicy
        static bool écritureAutorisée = false;    // w celu zezwolenia na zapis w tablicy
        static string[] ordreLecture;    // ustala kolejność odczytu
        static string[] ordreEcriture;    // ustala kolejność zapisujących
        static int lecteurSuivant = 0;    // wskazuje numer kolejnego odczytującego
        static int écrivainSuivant = 0;    // wskazuje numer następnego zapisu

        //main
        public static void Main(string[] args) {

            // liczba wątków do wygenerowania
            const int nbThreads = 5;

            // tworzenie wątków odczytu
            Thread[] lecteurs = new Thread[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // utworzenie
                lecteurs[i] = new Thread(Lire);
                lecteurs[i].Name = "L" + i.ToString();
                // uruchomienie
                lecteurs[i].Start();
            }

            // tworzenie kolejności odczytu
            ordreLecture = new string[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                ordreLecture[i] = lecteurs[nbThreads - i - 1].Name;
                Console.WriteLine("Le lecteur {0} est en position {1}", ordreLecture[i], i);
            }

            // utworzenie wątków zapisujących
            Thread[] écrivains = new Thread[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // utworzenie
                écrivains[i] = new Thread(Ecrire);
                écrivains[i].Name = "E" + i.ToString();
                // uruchomienie
                écrivains[i].Start();
            }

            // utworzenie kolejności zapisu
            ordreEcriture = new string[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                ordreEcriture[i] = écrivains[i].Name;
                Console.WriteLine("L'écrivain {0} est en position {1}", ordreEcriture[i], i);
            }

             // zezwolenie na zapis
            lock (peutEcrire) {
               écritureAutorisée = true;
                Monitor.Pulse(peutEcrire);
            }


            //zakończenie operacji
            Console.WriteLine("Fin de Main...");
        }

        // odczyt zawartości tablicy
        public static void Lire() {
...
        }

        // zapis do tablicy
        public static void Ecrire() {
...
        }
    }
}

Dostęp do okienka odczytu jest uzależniony od następujących elementów:

  • wiersz 13: token peutLire
  • wiersz 15: wartość logiczna lectureAutorisée
  • wiersz 17: uporządkowana tablica czytelników. Czytelnicy udają się do stanowiska odczytu w kolejności określonej w tej tablicy, która zawiera ich nazwiska.
  • wiersz 19: lecteurSuivant wskazuje numer kolejnego czytelnika uprawnionego do przejścia do stanowiska.

Dostęp do stanowiska zapisu zależy od następujących elementów:

  • wiersz 14: token peutEcrire
  • wiersz 16: wartość logiczna écritureAutorisée
  • wiersz 18: uporządkowana tablica osób uprawnionych do wpisu. Osoby te podchodzą do okienka wpisu w kolejności określonej w tej tablicy, zawierającej ich nazwiska.
  • wiersz 20: écrivainSuivant wskazuje numer kolejnego pisarza uprawnionego do podejścia do okienka.

Pozostałe elementy kodu są następujące:

  • wiersze 29–36: utworzenie i uruchomienie wątków odczytu. Wszystkie zostaną zablokowane, ponieważ odczyt nie jest dozwolony (wiersz 15).
  • wiersze 39–43: kolejność ich przejścia do okienka będzie odwrotna do kolejności ich utworzenia.
  • wiersze 46–53: utworzenie i uruchomienie wątków piszących. Wszystkie zostaną zablokowane, ponieważ zapis nie jest dozwolony (wiersz 16).
  • wiersze 56–60: kolejność ich obsługi w okienku będzie zgodna z kolejnością ich utworzenia.
  • wiersz 64: zezwala się na zapis
  • wiersz 65: powiadamia się piszących, że coś się zmieniło.

Metoda Lire wygląda następująco:


        public static void Lire() {
            // monitorowanie
            Console.WriteLine("Méthode [Lire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
            // należy poczekać na uprawnienie do odczytu
            lock (peutLire) {
                while (!lectureAutorisée || ordreLecture[lecteurSuivant] != Thread.CurrentThread.Name) {
                    Monitor.Wait(peutLire);
                }
                // odczyt tabeli
                for (int i = 0; i < data.Length; i++) {
                    //oczekiwanie 1 s
                    Thread.Sleep(1000);
                    // wyświetlanie
                    Console.WriteLine("{0:hh:mm:ss} : Le lecteur {1} a lu le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
                }
                 // kolejny czytelnik
                lectureAutorisée = false;
                lecteurSuivant++;
                // powiadomienie piszących, że mogą zapisywać
                lock (peutEcrire) {
                    écritureAutorisée = true;
                    Monitor.PulseAll(peutEcrire);
                }

                // monitorowanie
                Console.WriteLine("Méthode [Lire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
            }
}
  • cały proces obsługi przy okienku jest kontrolowany przez procedurę lock z wierszy 5–27. Czytnik, który pobiera token, zachowuje go przez cały czas obsługi przy okienku
  • wiersze 6–8: czytnik, który pobrał token z wiersza 5, zwalnia go, jeśli odczyt nie jest dozwolony lub jeśli nie nadeszła jeszcze jego kolej.
  • wiersze 10–15: obsługa stanowiska (wykorzystanie tablicy)
  • wiersze 17–18: wątek zmienia warunki dostępu do okienka odczytu. Należy zauważyć, że nadal posiada on token odczytu i że zmiany te nie pozwalają jeszcze czytelnikowi na przejście.
  • wiersze 20–23: wątek zmienia warunki dostępu do okienka zapisu i powiadamia wszystkich oczekujących użytkowników zapisujących, że coś się zmieniło.
  • wiersz 27: wątek lock kończy działanie, token peutLire zostaje zwolniony. Wątek odczytu mógłby go wówczas przejąć (wiersz 5), ale nie spełniłby warunku dostępu, ponieważ wartość logiczna lectureAutorisée jest fałszywa. Ponadto wszystkie wątki oczekujące na token peutLire pozostają w stanie oczekiwania, ponieważ operacja PulseAll(peutLire) jeszcze nie miała miejsca.

Metoda Ecrire wygląda następująco:


        public static void Ecrire() {
            // monitorowanie
            Console.WriteLine("Méthode [Ecrire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
            // trzeba poczekać na uprawnienie do zapisu
            lock (peutEcrire) {
                while (!écritureAutorisée || ordreEcriture[écrivainSuivant] != Thread.CurrentThread.Name) {
                    Monitor.Wait(peutEcrire);
                }
                // zapis do tablicy
                for (int i = 0; i < data.Length; i++) {
                    //oczekiwanie 1 s
                    Thread.Sleep(1000);
                    // wyświetlanie
                    data[i] = objRandom.Next(0, 1000);
                    Console.WriteLine("{0:hh:mm:ss} : L'écrivain {1} a écrit le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
                }
                // kolejny zapisujący
                écritureAutorisée = false;
                écrivainSuivant++;
                // budzimy czytniki oczekujące na token peutLire
                lock (peutLire) {
                    lectureAutorisée = true;
                    Monitor.PulseAll(peutLire);
                }
                // monitorowanie
                Console.WriteLine("Méthode [Ecrire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
            }
}
  • cały dostęp do okienka zapisu jest kontrolowany przez operację lock w wierszach 5–27. Użytkownik, który pobiera token, zachowuje go przez cały czas przebywania przy okienku
  • wiersze 6–8: użytkownik, który uzyskał token w wierszu 5, zwalnia go, jeśli zapis nie jest dozwolony lub jeśli nie jest jeszcze jego kolej.
  • wiersze 10–16: obsługa okienka (wykorzystanie tablicy)
  • wiersze 18–19: wątek zmienia warunki dostępu do okienka zapisu. Należy zauważyć, że nadal posiada on token zapisu i że zmiany te nie pozwalają jeszcze żadnemu autorowi na przejście.
  • wiersze 21–24: wątek zmienia warunki dostępu do okienka odczytu i powiadamia wszystkich oczekujących czytelników, że coś się zmieniło.
  • wiersz 27: wątek lock kończy działanie, token peutEcrire zostaje zwolniony. Wątek zapisu mógłby go wówczas przejąć (wiersz 5), ale nie spełniłby warunku dostępu, ponieważ wartość logiczna écritureAutorisée jest fałszywa. Ponadto wszystkie wątki oczekujące na token peutEcrire pozostają w stanie oczekiwania na nową operację PulseAll(peutEcrire).

Przykładowe wykonanie wygląda następująco:

Méthode [Lire] démarrée par le thread n° L0
Méthode [Lire] démarrée par le thread n° L2
Méthode [Lire] démarrée par le thread n° L1
Le lecteur L2 est en position 0
Le lecteur L1 est en position 1
Le lecteur L0 est en position 2
Méthode [Ecrire] démarrée par le thread n° E0
Méthode [Ecrire] démarrée par le thread n° E1
L'écrivain E0 est en position 0
L'écrivain E1 est en position 1
L'écrivain E2 est en position 2
Fin de Main...
Méthode [Ecrire] démarrée par le thread n° E2
12:09:05 : L'écrivain E0 a écrit le nombre 815
12:09:06 : L'écrivain E0 a écrit le nombre 990
12:09:07 : L'écrivain E0 a écrit le nombre 563
Méthode [Ecrire] terminée par le thread n° E0
12:09:08 : Le lecteur L2 a lu le nombre 815
12:09:09 : Le lecteur L2 a lu le nombre 990
12:09:10 : Le lecteur L2 a lu le nombre 563
Méthode [Lire] terminée par le thread n° L2
12:09:11 : L'écrivain E1 a écrit le nombre 411
12:09:12 : L'écrivain E1 a écrit le nombre 11
12:09:13 : L'écrivain E1 a écrit le nombre 54
Méthode [Ecrire] terminée par le thread n° E1
12:09:14 : Le lecteur L1 a lu le nombre 411
12:09:15 : Le lecteur L1 a lu le nombre 11
12:09:16 : Le lecteur L1 a lu le nombre 54
Méthode [Lire] terminée par le thread n° L1
12:09:17 : L'écrivain E2 a écrit le nombre 698
12:09:18 : L'écrivain E2 a écrit le nombre 448
12:09:19 : L'écrivain E2 a écrit le nombre 472
Méthode [Ecrire] terminée par le thread n° E2
12:09:20 : Le lecteur L0 a lu le nombre 698
12:09:21 : Le lecteur L0 a lu le nombre 448
12:09:22 : Le lecteur L0 a lu le nombre 472
Méthode [Lire] terminée par le thread n° L0

10.7. Pule wątków

Do tej pory, aby zarządzać wątkami:

  • tworzyliśmy je za pomocą Thread T = new Thread(...)
  • a następnie uruchamialiśmy je za pomocą T.Start()

W rozdziale „Bazy danych” widzieliśmy, że w przypadku niektórych SGBD możliwe jest korzystanie z pul otwartych połączeń:

  • n połączenia są otwierane przy uruchomieniu puli
  • gdy wątek żąda połączenia, otrzymuje jedno z otwartych połączeń z puli
  • gdy wątek zamyka połączenie, nie jest ono zamykane, lecz zwracane do puli

Korzystanie z puli połączeń jest przezroczyste z punktu widzenia kodu. Korzyścią jest poprawa wydajności: nawiązanie połączenia wiąże się z dużym obciążeniem. W tym przypadku 10 otwartych połączeń może obsłużyć setki żądań.

Podobny system istnieje w przypadku wątków:

  • min wątków jest tworzonych podczas uruchamiania puli. Wartość min jest ustalana za pomocą metody ThreadPool.SetMinThreads(min1,min2). Pula wątków może być wykorzystywana do wykonywania zadań blokujących lub nieblokujących, tzw. asynchronicznych. Pierwszy parametr min1 określa liczbę wątków blokujących, a drugi min2 – liczbę wątków asynchronicznych. Aktualne wartości tych dwóch parametrów można uzyskać za pomocą funkcji ThreadPool.GetMinThreads(out min1,out min2).
  • Jeśli liczba ta jest niewystarczająca, pula utworzy dodatkowe wątki w celu obsługi żądań, aż do osiągnięcia limitu określonego przez max. Wartość max ustala się za pomocą metody ThreadPool.SetMaxThreads(max1,max2). Oba parametry mają takie samo znaczenie jak w metodzie SetMinThreads. Aktualne wartości tych dwóch parametrów można uzyskać za pomocą metody ThreadPool.GetMaxThreads(out max1,out max2). Po osiągnięciu liczby wątków określonej przez metodę max1 żądania wątków dla zadań blokujących będą umieszczane w kolejce oczekiwania na wolny wątek w puli.

Pula wątków oferuje różne korzyści:

  • podobnie jak w przypadku puli połączeń, oszczędza się czas tworzenia wątków: 10 wątków może obsłużyć setki żądań.
  • zapewnia się bezpieczeństwo aplikacji: ustalając maksymalną liczbę wątków, unika się przeciążenia aplikacji zbyt dużą liczbą żądań. Żądania te zostaną umieszczone w kolejce.

Aby przydzielić zadanie wątkom z puli, stosuje się jedną z dwóch metod:

  1. ThreadPool.QueueWorkItem(WaitCallBack)
  2. ThreadPool.QueueWorkItem(WaitCallBack,object)

gdzie WaitCallBack oznacza dowolną metodę o sygnaturze void WaitCallBack(object). Metoda 1 nakazuje wątku wykonanie metody WaitCallBack bez przekazywania jej żadnego parametru. Metoda 2 wykonuje to samo, ale przekazując parametr typu object do metody WaitCallBack.

Oto program ilustrujący te koncepcje:


using System;
using System.Threading;

namespace Chap8 {
    class Program {
        public static void Main() {
            // inicjowanie bieżącego wątku
            Thread main = Thread.CurrentThread;
            // nadajemy nazwę wątku
            main.Name = "Main";

            // korzystamy z puli wątków
            int min1, min2;
            // ustalanie minimalnej liczby wątków blokujących
            ThreadPool.GetMinThreads(out min1, out min2);
            Console.WriteLine("Nombre minimum de tâches bloquantes dans le pool : {0}", min1);
            Console.WriteLine("Nombre minimum de tâches asynchrones dans le pool : {0}", min2);
            ThreadPool.SetMinThreads(3, min2);
            ThreadPool.GetMinThreads(out min1, out min2);
            Console.WriteLine("Nombre minimum de tâches bloquantes dans le pool après changement : {0}", min1);
            // ustawiamy maksymalną liczbę wątków blokujących
            int max1, max2;
            ThreadPool.GetMaxThreads(out max1, out max2);
            Console.WriteLine("Nombre maximum de tâches bloquantes dans le pool : {0}", max1);
            Console.WriteLine("Nombre maximum de tâches asynchrones dans le pool : {0}", max2);
            ThreadPool.SetMaxThreads(5, max2);
            ThreadPool.GetMaxThreads(out max1, out max2);
            Console.WriteLine("Nombre maximum de tâches bloquantes dans le pool après changement : {0}", max1);
            // uruchamia się 7 wątków
            for (int i = 0; i < 7; i++) {
                // uruchamia się wątek i w puli
                ThreadPool.QueueUserWorkItem(Sleep, new Data2 { Numéro = i.ToString(), Début = DateTime.Now, Durée = i + 10 });
            }
            // koniec procedury
            Console.Write("Tapez [entrée] pour terminer le thread {0} à {1:hh:mm:ss:FF}", main.Name, DateTime.Now);
            // oczekiwanie
            Console.ReadLine();
        }

        public static void Sleep(object infos) {
            // odzyskiwanie parametru
            Data2 data = infos as Data2;
            Console.WriteLine("A {2:hh:mm:ss:FF}, le thread n° {0} va dormir pendant {1} seconde(s)", data.Numéro, data.Durée,DateTime.Now);
            // stan puli
            int cpt1, cpt2;
            ThreadPool.GetAvailableThreads(out cpt1, out cpt2);
            Console.WriteLine("Nombre de threads pour tâches bloquantes disponibles dans le pool : {0}", cpt1);
            // przechodzenie w stan uśpienia na czas trwania w sekundach
            Thread.Sleep(data.Durée * 1000);
            // koniec wykonywania
            data.Fin = DateTime.Now;
            Console.WriteLine("A {3:hh:mm:ss:FF}, le thread n° {0} se termine. Il était programmé pour durer {1} seconde(s). Il a duré {2} seconde(s)", data.Numéro, data.Durée, data.Fin - data.Début,DateTime.Now);
        }
    }

    internal class Data2 {
        // różne informacje
        public string Numéro { get; set; }
        public DateTime Début { get; set; }
        public int Durée { get; set; }
        public DateTime Fin { get; set; }
    }
}
  • wiersze 15–17: pobieramy i wyświetlamy aktualną minimalną liczbę obu typów wątków z puli wątków
  • wiersz 18: zmieniamy minimalną liczbę wątków dla zadań blokujących na 2
  • wiersze 19–21: wyświetlane są nowe wartości minimalne
  • wiersze 22–28: wykonuje się to samo, aby ustawić maksymalną liczbę wątków dla zadań blokujących: 5
  • wiersze 30–33: uruchamia się 7 zadań w puli składającej się z 5 wątków. 5 zadań powinno otrzymać po 1 wątku, przy czym pierwsze 2 szybko, ponieważ 2 wątki są nadal dostępne, a pozostałe 3 z opóźnieniem wynoszącym 0,5 sekundy. 2 zadania powinny czekać, aż zwolni się wątek.
  • wiersz 32: zadania wywołują metodę Sleep z wierszy 40–54, przekazując jej parametr typu Data2 zdefiniowany w wierszach 56–62.
  • wiersz 40: metoda Sleep wykonywana przez zadania
  • wiersz 42: pobierany jest parametr przekazany do metody Sleep.
  • wiersz 43: zadanie identyfikuje się na konsoli
  • wiersze 45–47: wyświetlana jest liczba aktualnie dostępnych wątków. Chcemy zobaczyć, jak się zmienia.
  • wiersz 49: zadanie zatrzymuje się na kilka sekund (zadanie blokujące).
  • wiersz 52: po wznowieniu działania wyświetlane są pewne informacje dotyczące jego konta.

Uzyskane wyniki są następujące.

Dla liczb min i max w puli wątków:

1
2
3
4
5
6
Nombre minimum de tâches bloquantes dans le pool : 2
Nombre minimum de tâches asynchrones dans le pool : 2
Nombre minimum de tâches bloquantes dans le pool après changement : 3
Nombre maximum de tâches bloquantes dans le pool : 500
Nombre maximum de tâches asynchrones dans le pool : 1000
Nombre maximum de tâches bloquantes dans le pool après changement : 5

W przypadku wykonania 7 wątków:

A 03:07:37:04, le thread n° 0 va dormir pendant 10 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 3
A 03:07:37:04, le thread n° 2 va dormir pendant 12 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 2
A 03:07:37:04, le thread n° 1 va dormir pendant 11 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 2
A 03:07:38:04, le thread n° 3 va dormir pendant 13 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 1
A 03:07:38:54, le thread n° 4 va dormir pendant 14 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 0
A 03:07:47:04, le thread n° 0 se termine. Il était programmé pour durer 10 seconde(s). Il a duré 00:00:10 seconde(s)
A 03:07:47:04, le thread n° 5 va dormir pendant 15 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 0
A 03:07:48:04, le thread n° 1 se termine. Il était programmé pour durer 11 seconde(s). Il a duré 00:00:11 seconde(s)
A 03:07:48:04, le thread n° 6 va dormir pendant 16 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 0
A 03:07:49:04, le thread n° 2 se termine. Il était programmé pour durer 12 seconde(s). Il a duré 00:00:12 seconde(s)
A 03:07:51:04, le thread n° 3 se termine. Il était programmé pour durer 13 seconde(s). Il a duré 00:00:14 seconde(s)
A 03:07:52:54, le thread n° 4 se termine. Il était programmé pour durer 14 seconde(s). Il a duré 00:00:15.5000000 seconde(s)
A 03:08:02:04, le thread n° 5 se termine. Il était programmé pour durer 15 seconde(s). Il a duré 00:00:25 seconde(s)
A 03:08:04:04, le thread n° 6 se termine. Il était programmé pour durer 16 seconde(s). Il a duré 00:00:27 seconde(s)
  • wiersze 1–6: pierwsze 3 zadania są wykonywane po kolei. Natychmiast znajdują 1 dostępny wątek (MinThreads=3), a następnie przechodzą w stan uśpienia.
  • wiersze 7–9: w przypadku zadań 3 i 4 trwa to nieco dłużej. Dla każdego z nich nie było wolnego wątku. Trzeba było utworzyć nowy. Mechanizm ten działa do 5 (MaxThreads=5).
  • wiersz 10: nie ma już dostępnych wątków: zadania 5 i 6 będą musiały poczekać.
  • wiersze 11–12: zadanie 0 dobiega końca. Zadanie 5 przejmuje swój wątek.
  • wiersze 13–14: zadanie 1 dobiega końca. Zadanie 6 przejmuje swój wątek.
  • wiersze 17–21: zadania kończą się jedno po drugim.

10.8. Klasa BackgroundWorker

10.8.1. Przykład 1

Klasa BackgroundWorker należy do przestrzeni nazw [System.ComponentModel]. Jest używana jak wątek, ale ma pewne cechy szczególne, które w niektórych przypadkach mogą sprawić, że będzie bardziej interesująca niż klasa [Thread]:

  • generuje następujące zdarzenia:
  • DoWork: wątek zażądał wykonania BackgroundWorker
  • ProgressChanged: obiekt BackgroundWorker wykonał metodę ReportProgress. Służy ona do podania procentowego postępu wykonania.
  • RunWorkerCompleted: obiekt BackgroundWorker zakończył swoją pracę. Mogło to nastąpić w wyniku normalnego zakończenia, anulowania lub wystąpienia wyjątku.

Zdarzenia te sprawiają, że obiekt BackgroundWorker jest przydatny w interfejsach graficznych: długotrwałe zadanie zostanie powierzone obiektowi BackgroundWorker, który będzie mógł informować o swoim postępie za pomocą zdarzenia ProgressChanged, a o zakończeniu – za pomocą zdarzenia RunWorkerCompleted. Praca, którą ma wykonać obiekt BackgroundWorker, zostanie zrealizowana przez metodę powiązaną ze zdarzeniem DoWork.

  • Możliwe jest zgłoszenie wniosku o jego anulowanie. W interfejsie graficznym użytkownik może w ten sposób anulować zadanie długotrwałe.
  • Obiekty BackgroundWorker należą do puli i są ponownie wykorzystywane w zależności od potrzeb. Aplikacja, która potrzebuje obiektu BackgroundWorker, pobierze go z puli, która przydzieli jej już istniejący, ale nieużywany wątek. Takie ponowne wykorzystywanie wątków zamiast tworzenia za każdym razem nowego poprawia wydajność.

Korzystamy z tego narzędzia w poprzedniej aplikacji w sytuacji, gdy dostęp do okienka jest niekontrolowany:


using System;
using System.Threading;
using System.ComponentModel;

namespace Chap8 {
    class Program2 {
        // wykorzystanie wątków odczytu i zapisu
        // ilustruje jednoczesne wykorzystanie zasobów współdzielonych i synchronizacji

        // zmienne klasy
        const int nbThreads = 2;                    // łączna liczba wątków
        static int nbLecteursTerminés = 0;        // liczba zakończonych wątków
        static int[] data = new int[5];            // tablica współdzielona między wątkami odczytu i zapisu
        static object appli;                            // synchronizuje dostęp do liczby zakończonych wątków
        static Random objRandom = new Random(DateTime.Now.Second);    // generator liczb losowych
        static AutoResetEvent peutLire;        // sygnalizuje, że można odczytać zawartość tablicy
        static AutoResetEvent peutEcrire;        // sygnalizuje, że można zapisywać w tablicy
        static AutoResetEvent finLecteurs;    // sygnalizuje koniec czytników

        //main
        public static void Main(string[] args) {

            // nadajemy nazwę wątku
            Thread.CurrentThread.Name = "Main";

            // inicjalizacja flag
            peutLire = new AutoResetEvent(false);        // nie można jeszcze odczytywać
            peutEcrire = new AutoResetEvent(true);    // można już zapisywać
            finLecteurs = new AutoResetEvent(false);    // aplikacja nie została zakończona

            // synchronizacja dostępu do licznika zakończonych wątków
            appli = new object();                

            // tworzenie wątków odczytujących
            MyBackgroundWorker[] lecteurs = new MyBackgroundWorker[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // tworzenie
                lecteurs[i] = new MyBackgroundWorker();
                lecteurs[i].Numéro = "L" + i;
                lecteurs[i].DoWork += Lire;
                lecteurs[i].RunWorkerCompleted += EndLecteur;
                // uruchomienie
                lecteurs[i].RunWorkerAsync();
            }

            // tworzenie wątków zapisujących
            MyBackgroundWorker[] écrivains = new MyBackgroundWorker[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // tworzenie
                écrivains[i] = new MyBackgroundWorker();
                écrivains[i].Numéro = "E" + i;
                écrivains[i].DoWork += Ecrire;
                // uruchomienie
                écrivains[i].RunWorkerAsync();
            }

            // oczekiwanie na zakończenie wszystkich wątków
            finLecteurs.WaitOne();
            //zakończenie operacji
            Console.WriteLine("Fin de Main...");
        }

        public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
...
        }

        // odczyt zawartości tablicy
        public static void Lire(object sender, DoWorkEventArgs infos) {
...
        }

        // zapis do tablicy
        public static void Ecrire(object sender, DoWorkEventArgs infos) {
...
        }
    }

    // wątek
    internal class MyBackgroundWorker : BackgroundWorker {
        // różne informacje
        public string Numéro { get; set; }
    }

}

Przedstawiamy jedynie zmiany:

  • klasa Thread została zastąpiona klasą MyBackgroundWorker w wierszach 79–82. Klasa BackgroundWorker została utworzona w celu nadania numeru wątku. Można było postąpić inaczej, przekazując obiekt do metody RunWorkerAsync w wierszach 43 i 54, obiekt zawierający numer wątku.
  • Wiersz 58: metoda Main kończy działanie po tym, jak wszystkie wątki odczytujące zakończą swoją pracę. W tym celu, w wierszu 12, licznik nbLecteursTerminés zlicza liczbę wątków odczytujących, które zakończyły swoją pracę. Licznik ten jest zwiększany przez metodę EndLecteur w wierszach 63–65, która jest wykonywana za każdym razem, gdy kończy się wątek odczytujący. To właśnie ta procedura kontroluje zdarzenie AutoResetEvent finLecteurs w wierszu 18, z którym w wierszu 59 synchronizuje się metoda Main.
  • wiersz 16: ponieważ kilka wątków odczytujących może próbować jednocześnie zwiększyć licznik nbLecteursTerminés, wyłączny dostęp do niego zapewnia obiekt synchronizacyjny appli. Sytuacja ta jest mało prawdopodobna, ale teoretycznie możliwa.
  • wiersze 35–44: tworzenie wątków odczytujących
  • wiersz 38: utworzenie wątku typu MyBackgroundWorker
  • wiersz 39: nadano mu numer
  • wiersz 40: przypisuje mu się metodę Lire do wykonania
  • wiersz 41: metoda EndLecteur zostanie wykonana po zakończeniu działania wątku
  • wiersz 43: wątek zostaje uruchomiony
  • wiersze 47–55: tworzenie wątków zapisujących
  • wiersz 50: utworzenie wątku typu MyBackgroundWorker
  • wiersz 51: przypisano mu numer
  • wiersz 52: przypisano mu metodę Ecrire do wykonania
  • wiersz 54: uruchomiono wątek

Metody Lire i Ecrire pozostają bez zmian. Metoda EndLecteur jest wykonywana na końcu każdego wątku odczytującego. Jej kod wygląda następująco:


        public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
            // przyrost liczby zakończonych odczytów
            lock (appli) {
                nbLecteursTerminés++;
                if (nbLecteursTerminés == nbThreads)
                    finLecteurs.Set();
            }
}

Zadaniem metody EndLecteur jest powiadomienie metody Main, że wszystkie moduły odczytu zakończyły swoją pracę.

  • wiersz 4: licznik nbLecteursTerminés jest zwiększany.
  • wiersze 5–6: jeśli wszyscy czytelnicy wykonali swoje zadania, wówczas zdarzenie finLecteurs jest ustawiane na wartość „prawda”, aby powiadomić metodę Main, która oczekuje na to zdarzenie.
  • Ponieważ procedura EndLecteur jest wykonywana przez wiele wątków, powyższy sekcja krytyczna jest zabezpieczona klauzulą lock w wierszu 3.

Wyniki wykonania są analogiczne do tych z wersji wykorzystującej wątki.

10.8.2. Przykład 2

Poniższy kod ilustruje inne cechy klasy BackgroundWorker:

  • możliwość anulowania zadania
  • przekazanie wyjątku wygenerowanego w zadaniu
  • przekazanie parametru wejścia/wyjścia do zadania

using System;
using System.Threading;
using System.ComponentModel;

namespace Chap8 {
    class Program3 {

        // wątki
        static BackgroundWorker[] tâches = new BackgroundWorker[5];

        public static void Main() {
            // inicjowanie bieżącego wątku
            Thread main = Thread.CurrentThread;
            // nadanie nazwy wątku
            main.Name = "Main";

            // tworzenie wątków
            for (int i = 0; i < tâches.Length; i++) {
                // tworzenie wątku nr i
                tâches[i] = new BackgroundWorker();
                // inicjowanie wątku
                tâches[i].DoWork += Sleep;
                tâches[i].RunWorkerCompleted += End;
                tâches[i].WorkerSupportsCancellation = true;
                // uruchamianie wątku
                tâches[i].RunWorkerAsync(new Data { Numéro = i, Début = DateTime.Now, Durée = i + 1 });
            }
            // anulowanie ostatniego wątku
            tâches[4].CancelAsync();

            // koniec procedury
            Console.WriteLine("Fin du thread {0}, tapez [entrée] pour terminer...", main.Name);
            Console.ReadLine();
            return;
        }

        public static void Sleep(object sender, DoWorkEventArgs infos) {
...
        }

        public static void End(object sender, RunWorkerCompletedEventArgs infos) {
...
        }

        internal class Data {
            // różne informacje
            public int Numéro { get; set; }
            public DateTime Début { get; set; }
            public int Durée { get; set; }
            public DateTime Fin { get; set; }
        }
    }
}
  • wiersz 9: tablica klasy BackgroundWorker
  • wiersze 18–27: tworzenie wątków
  • wiersz 20: utworzenie wątku
  • wiersz 22: wątek wykona metodę Sleep z wierszy 39–41
  • wiersz 23: metoda End z wierszy 43–45 zostanie wykonana po zakończeniu działania wątku
  • wiersz 24: wątek może zostać anulowany
  • wiersz 26: wątek jest uruchamiany z parametrem typu [Data], zdefiniowanym w wierszach 49–52. Obiekt ten posiada następujące pola:
    • Numéro (wejście): numer wątku
    • Début (wejście): godzina rozpoczęcia wykonywania wątku
    • Durée (wejście): czas trwania wykonania Sleep
    • Fin (wyjście): koniec wykonywania wątku
  • wiersz 29: wątek nr 4 zostaje anulowany

Wszystkie wątki wykonują następującą metodę Sleep:


        public static void Sleep(object sender, DoWorkEventArgs infos) {
            // przetwarzamy parametr „infos”
            Data data = (Data)infos.Argument;
            // wyjątek dla zadania nr 3
            if (data.Numéro == 3) {
                throw new Exception("test....");
            }
            // przejście w stan uśpienia na czas trwania w sekundach z zatrzymaniem co sekundę
            for (int i = 1; i <= data.Durée && !tâches[data.Numéro].CancellationPending; i++) {
                // oczekiwanie trwające 1 sekundę
                Thread.Sleep(1000);
            }
            // koniec wykonywania
            data.Fin = DateTime.Now;
            // inicjowanie wyniku
            infos.Result = data;
            infos.Cancel = tâches[data.Numéro].CancellationPending;
}
  • wiersz 1: metoda Sleep ma standardową sygnaturę procedur obsługi zdarzeń. Przyjmuje dwa parametry:
    • sender: źródło zdarzenia, w tym przypadku BackgroundWorker, które wykonuje metodę
    • infos: typu DoWorkEventArgs, który dostarcza informacji o zdarzeniu DoWork. Parametr ten służy zarówno do przekazywania informacji do wątku, jak i do pobierania jego wyników.
  • wiersz 3: parametr przekazany do metody RunWorkerAsync zadania znajduje się we właściwości infos.Argument.
  • wiersze 5–7: generowany jest wyjątek dla zadania nr 3
  • wiersze 9–12: wątek „uśpięty” na Durée sekund w odstępach co jedną sekundę, aby umożliwić test anulowania z wiersza 9. Symuluje to długotrwałą pracę, podczas której wątek regularnie sprawdzałby, czy istnieje żądanie anulowania. Aby zasygnalizować, że zadanie zostało anulowane, wątek musi ustawić właściwość infos.Cancel na wartość true (wiersz 17).
  • wiersz 16: wątek może zwrócić wynik do wątku, który go uruchomił. Umieszcza ten wynik w infos.Result.

Po zakończeniu pracy wątki wykonują następującą metodę End:


public static void End(object sender, RunWorkerCompletedEventArgs infos) {
            // wykorzystuje się parametr „infos” do wyświetlenia wyniku wykonania
            // wyjątek?
            if (infos.Error != null) {
                Console.WriteLine("Le thread {1} a rencontré l'erreur suivante : {0}", infos.Error.Message, sender);
            } else
                if (!infos.Cancelled) {
                    Data data = (Data)infos.Result;
                    Console.WriteLine("Thread {0} terminé : début {1:hh:mm:ss}, durée programmée {2} s, fin {3:hh:mm:ss}, durée effective {4}",
                    data.Numéro, data.Début, data.Durée, data.Fin, (data.Fin - data.Début));
                } else {
                    Console.WriteLine("Thread {0} annulé", sender);
                }
        }
  • wiersz 1: metoda End ma standardową sygnaturę obsługi zdarzeń. Otrzymuje dwa parametry:
    • sender: źródło zdarzenia, w tym przypadku BackgroundWorker, które wykonuje metodę
    • infos: typu RunWorkerCompletedEventArgs, która dostarcza informacji o zdarzeniu RunWorkerCompleted.
  • wiersz 4: pole infos.Error typu Exception jest wypełniane tylko wtedy, gdy wystąpił wyjątek.
  • wiersz 7: pole infos.Cancelled typu boolowskiego przyjmuje wartość true, jeśli wątek został anulowany.
  • wiersz 8: jeśli nie wystąpił wyjątek ani nie doszło do przerwania, wówczas infos.Result jest wynikiem wykonanego wątku. Wykorzystanie tego wyniku w przypadku przerwania wątku lub zgłoszenia przez niego wyjątku powoduje zgłoszenie wyjątku. Dlatego w wierszach 5 i 13 nie jesteśmy w stanie wyświetlić numeru wątku, który został przerwany lub zgłosił wyjątek, ponieważ numer ten znajduje się w infos.Result. Problem ten można obejść poprzez utworzenie klasy pochodnej od klasy BackgroundWorker, w której umieści się informacje do wymiany między wątkiem wywołującym a wątkiem wywoływanym, tak jak zrobiono to w poprzednim przykładzie. W tym przypadku używa się argumentu sender, który reprezentuje BackgroundWorker, zamiast argumentu infos.

Wyniki wykonania są następujące:

1
2
3
4
5
6
Fin du thread Main. Laissez les autres threads se terminer puis tapez [entrée] pour terminer...
Thread 0 terminé : début 05:19:46, durée programmée 1 s, fin 05:19:47, durée effective 00:00:01
Le thread System.ComponentModel.BackgroundWorker a rencontré l'erreur suivante : test....
Thread System.ComponentModel.BackgroundWorker annulé
Thread 1 terminé : début 05:19:46, durée programmée 2 s, fin 05:19:49, durée effective 00:00:03
Thread 2 terminé : début 05:19:46, durée programmée 3 s, fin 05:19:50, durée effective 00:00:04

10.9. Dane lokalne dla wątku

10.9.1. Zasada działania

Rozważmy aplikację trójwarstwową:

Załóżmy, że aplikacja jest przeznaczona dla wielu użytkowników, na przykład aplikacja internetowa. Każdy użytkownik jest obsługiwany przez dedykowany mu wątek. Cykl życia wątku wygląda następująco:

  1. wątek jest tworzony lub pobierany z puli wątków w celu zrealizowania żądania użytkownika
  2. jeśli żądanie to wymaga danych, wątek wykonuje metodę z warstwy [ui], która wywołuje metodę z warstwy [metier], która z kolei wywołuje metodę z warstwy [dao].
  3. Wątek zwraca odpowiedź użytkownikowi. Następnie znika lub jest ponownie wykorzystywany w puli wątków.

W operacji 2 interesujące może być, aby wątek posiadał własne dane, c.a.d, które nie są współdzielone z innymi wątkami. Dane te mogłyby na przykład należeć do konkretnego użytkownika, któremu służy dany wątek. Dane te mogłyby wówczas być wykorzystywane w różnych warstwach [ui, metier, dao].

Klasa Thread umożliwia realizację tego scenariusza dzięki swego rodzaju prywatnemu słownikowi, w którym klucze miałyby typ LocalDataStoreSlot:

tworzy wpis w prywatnym słowniku wątku dla klucza name.
przypisuje wartość data do klucza name w prywatnym słowniku wątku
pobiera wartość powiązaną z kluczem name z prywatnego słownika wątku

Przykładowe zastosowanie mogłoby wyglądać następująco:

  • aby utworzyć parę (clé,valeur) powiązaną z bieżącym wątkiem:
Thread.SetData(Thread.GetNamedDataSlot("clé"),valeur);
  • aby pobrać wartość powiązaną z clé:
Thread.GetData(Thread.GetNamedDataSlot("clé"));

10.9.2. Zastosowanie zasady

Rozważmy następującą aplikację trójwarstwową:

Załóżmy, że warstwa [dao] zarządza bazą artykułów, a jej interfejs początkowo wygląda następująco:


using System.Collections.Generic;

namespace Chap8 {
    public interface IDao {
        int InsertArticle(Article article);
        List<Article> GetAllArticles();
        void DeleteAllArticles();
    }
}
  • wiersz 5: w celu dodania artykułu do bazy
  • wiersz 6: w celu pobrania wszystkich artykułów z bazy
  • wiersz 7: usunięcie wszystkich artykułów z bazy

Później pojawia się potrzeba wprowadzenia metody umożliwiającej dodanie tabeli artykułów za pomocą transakcji, ponieważ chcemy działać na zasadzie „wszystko albo nic”: albo wszystkie artykuły zostaną dodane, albo żaden. Można zatem zmodyfikować interfejs, aby uwzględnić tę nową potrzebę:


using System.Collections.Generic;

namespace Chap8 {
    public interface IDao {
        int InsertArticle(Article article);
        void insertArticles(Article[] articles);
        List<Article> GetAllArticles();
        void DeleteAllArticles();
    }
}
  • wiersz 6: aby dodać tabelę artykułów do bazy

Później, w przypadku innej aplikacji, pojawia się potrzeba usunięcia listy artykułów zapisanej na liście, również w ramach transakcji. Widać, że aby sprostać różnym wymaganiom biznesowym, warstwa [dao] będzie musiała się rozrastać. Można obrać inną drogę:

  • umieścić w warstwie [dao] wyłącznie podstawowe operacje InsertArticle, DeleteArticle, UpdateArticle, SelectArticle, SelectArticles
  • przenieść do warstwy [métier] operacje jednoczesnej aktualizacji wielu pozycji. Operacje te wykorzystywałyby operacje elementarne z warstwy [dao].

Zaletą tego rozwiązania jest to, że ta sama warstwa [dao] mogłaby być wykorzystywana bez zmian z różnymi warstwami [metier]. Powoduje to jednak trudności w zarządzaniu transakcją, która obejmuje aktualizacje, które muszą być wykonywane atomowo w bazie:

  • transakcja musi zostać zainicjowana przez warstwę [metier], zanim wywoła ona metody warstwy [dao]
  • metody warstwy [dao] muszą być świadome istnienia transakcji, aby móc w niej uczestniczyć, jeśli taka istnieje
  • transakcja musi zostać zakończona przez warstwę [métier].

Aby metody warstwy [dao] mogły rozpoznać, czy trwa jakaś transakcja, można by dodać tę transakcję jako parametr każdej metody warstwy [dao]. Parametr ten pojawi się wówczas w sygnaturze metod interfejsu, co powiąże go z konkretnym źródłem danych: bazą danych. Dane lokalne wątku oferują nam bardziej eleganckie rozwiązanie: warstwa [métier] umieści transakcję w danych lokalnych wątku, a warstwa [dao] pobierze ją właśnie stamtąd. Nie ma zatem potrzeby zmiany sygnatury metod warstwy [dao].

Wdrażamy to rozwiązanie w ramach następującego projektu Visual Studio:

  • w [1]: całe rozwiązanie
  • w [2]: używane referencje. Ponieważ baza [4] jest bazą SQL Server Compact, konieczne jest posiadanie referencji [System.Data.SqlServerCe].
  • w [3]: różne warstwy aplikacji.

Baza danych [4] to baza danych SQL Server Compact, z której korzystano już w poprzednim rozdziale, w szczególności w paragrafie 9.3.1.

 

Klasa Article

Wiersz z powyższej tabeli [articles] jest zawarty w obiekcie typu Article:


namespace Chap8 {
    public class Article {
        // właściwości
        public int Id { get; set; }
        public string Nom { get; set; }
        public decimal Prix { get; set; }
        public int StockActuel { get; set; }
        public int StockMinimum { get; set; }

        // konstruktorów
        public Article() { 
        }

        public Article(int id, string nom, decimal prix, int stockActuel, int stockMinimum) {
            Id = id;
            Nom = nom;
            Prix = prix;
            StockActuel = stockActuel;
            StockMinimum = stockMinimum;
        }

        // tożsamość
        public override string ToString() {
            return string.Format("[{0},{1},{2},{3},{4}]", Id, Nom, Prix, StockActuel, StockMinimum);
        }
    }
}

Interfejs warstwy [dao]

Interfejs IDao warstwy [dao] będzie wyglądał następująco:


using System.Collections.Generic;

namespace Chap8 {
    public interface IDao {
        int InsertArticle(Article article);
        List<Article> GetAllArticles();
        void DeleteAllArticles();
    }
}
  • wiersz 5: w celu wstawienia pozycji do tabeli [articles]
  • wiersz 6: w celu umieszczenia wszystkich wierszy tabeli [articles] na liście obiektów Article
  • wiersz 7: w celu usunięcia wszystkich wierszy z tabeli [articles]

Interfejs warstwy [metier]

Interfejs IMetier warstwy [metier] będzie wyglądał następująco:


using System.Collections.Generic;

namespace Chap8 {
    interface IMetier {
        void InsertArticlesInTransaction(Article[] articles);
        void InsertArticlesOutOfTransaction(Article[] articles);
        List<Article> GetAllArticles();
        void DeleteAllArticles();
    }
}
  • wiersz 5: w celu wstawienia zestawu artykułów w ramach transakcji
  • wiersz 6: to samo, ale bez transakcji
  • wiersz 7: w celu uzyskania listy wszystkich artykułów
  • wiersz 8: w celu usunięcia wszystkich artykułów

Implementacja warstwy [metier]

Implementacja biznesowa interfejsu IMetier będzie wyglądać następująco:


using System.Collections.Generic;
using System.Data;
using System.Data.SqlServerCe;
using System.Threading;

namespace Chap8 {
    public class Metier : IMetier {
        // warstwa [dao]
        public IDao Dao { get; set; }
        // łańcuch połączenia
        public string ConnectionString { get; set; }

        // wstawianie tabeli artykułów w ramach transakcji
        public void InsertArticlesInTransaction(Article[] articles) {
            // tworzy się połączenie z bazą danych
            using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
                // otwarcie połączenia
                connexion.Open();
                // transakcja
                SqlCeTransaction transaction = null;
                try {
                    // początek transakcji
                    transaction = connexion.BeginTransaction(IsolationLevel.ReadCommitted);
                    // transakcja jest rejestrowana w wątku
                    Thread.SetData(Thread.GetNamedDataSlot("transaction"), transaction);
                    // wstawianie artykułów
                    foreach (Article article in articles) {
                        Dao.InsertArticle(article);
                    }
                    // zatwierdzanie transakcji
                    transaction.Commit();
                } catch {
                    // cofnięcie transakcji
                    if (transaction != null)
                        transaction.Rollback();
                }
            }
        }

        // dodanie tabeli artykułów bez transakcji
        public void InsertArticlesOutOfTransaction(Article[] articles) {
            // dodawanie artykułów
            foreach (Article article in articles) {
                Dao.InsertArticle(article);
            }
        }

        // lista artykułów
        public List<Article> GetAllArticles() {
            return Dao.GetAllArticles();
        }
        // usunięcie wszystkich artykułów
        public void DeleteAllArticles() {
            Dao.DeleteAllArticles();
        }
    }
}

Klasa posiada następujące właściwości:

  • wiersz 9: odwołanie do warstwy [dao]
  • wiersz 11: ciąg połączenia umożliwiający połączenie z bazą danych artykułów

Omówimy jedynie metodę InsertArticlesInTransaction, która jako jedyna stwarza trudności:

  • wiersz 16: tworzone jest połączenie z bazą danych
  • wiersz 18: połączenie jest otwierane
  • wiersz 23: tworzona jest transakcja
  • wiersz 25: transakcja zostaje zapisana w lokalnych danych wątku, powiązana z kluczem „transaction”
  • wiersze 27–29: dla każdego wstawianego artykułu wywoływana jest metoda pojedynczego wstawiania z warstwy [dao]
  • wiersze 21 i 32: cała operacja wstawiania tablicy jest kontrolowana przez blok try/catch
  • wiersz 31: jeśli dotarliśmy do tego miejsca, oznacza to, że nie wystąpił żaden wyjątek. Wówczas zatwierdzamy transakcję.
  • wiersze 34–35: wystąpił wyjątek, cofamy transakcję
  • wiersz 37: wychodzimy z klauzuli using. Połączenie otwarte w wierszu 18 zostaje automatycznie zamknięte.

Implementacja warstwy [dao]

Implementacja DAO interfejsu IDao będzie wyglądać następująco:


using System.Collections.Generic;
using System.Data;
using System.Data.SqlServerCe;
using System.Threading;

namespace Chap8 {
    public class Dao : IDao {
        // łańcuch połączenia
        public string ConnectionString { get; set; }
        // zapytania
        public string InsertText { get; set; }
        public string DeleteAllText { get; set; }
        public string GetAllText { get; set; }

        // implementacja interfejsu

        // dodawanie artykułu
        public int InsertArticle(Article article) {
            // czy trwa jakaś transakcja?
            SqlCeTransaction transaction = Thread.GetData(Thread.GetNamedDataSlot("transaction")) as SqlCeTransaction;
            // pobierz połączenie lub je utwórz
            SqlCeConnection connexion = null;
            if (transaction != null) {
                // pobierz połączenie
                connexion = transaction.Connection as SqlCeConnection;
            } else {
                // utworzyć połączenie
                connexion = new SqlCeConnection(ConnectionString);
                connexion.Open();
            }
            try {
                // przygotowanie polecenia wstawiania
                SqlCeCommand sqlCommand = new SqlCeCommand();
                sqlCommand.Transaction = transaction;
                sqlCommand.Connection = connexion;
                sqlCommand.CommandText = InsertText;
                sqlCommand.Parameters.Add("@nom", SqlDbType.NVarChar, 30);
                sqlCommand.Parameters.Add("@prix", SqlDbType.Money);
                sqlCommand.Parameters.Add("@sa", SqlDbType.Int);
                sqlCommand.Parameters.Add("@sm", SqlDbType.Int);
                sqlCommand.Parameters["@nom"].Value = article.Nom;
                sqlCommand.Parameters["@prix"].Value = article.Prix;
                sqlCommand.Parameters["@sa"].Value = article.StockActuel;
                sqlCommand.Parameters["@sm"].Value = article.StockMinimum;
                // wykonanie
                return sqlCommand.ExecuteNonQuery();
            } finally {
                // jeśli nie byliśmy w transakcji, zamykamy połączenie
                if (transaction == null) {
                    connexion.Close();
                }
            }
        }

        // lista artykułów
        public List<Article> GetAllArticles() {
...
        }

        // usunięcie pozycji
        public void DeleteAllArticles() {
...
        }
    }
}

Klasa posiada następujące właściwości:

  • wiersz 9: ciąg połączenia umożliwiający połączenie z bazą danych artykułów
  • wiersz 11: polecenie SQL służące do wstawienia artykułu
  • wiersz 12: polecenie SQL służące do usunięcia wszystkich artykułów
  • wiersz 13: polecenie SQL służące do pobrania wszystkich artykułów

Właściwości te zostaną zainicjowane na podstawie następującego pliku konfiguracyjnego [App.config]:


<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <connectionStrings>
        <add name="dbArticlesSqlServerCe" connectionString="Data Source=|DataDirectory|\dbarticles.sdf;Password=dbarticles;" />
    </connectionStrings>
    <appSettings>
        <add key="insertText" value="insert into articles(nom,prix,stockactuel,stockminimum) values(@nom,@prix,@sa,@sm)"/>
        <add key="getAllText" value="select id,nom,prix,stockactuel,stockminimum from articles"/>
        <add key="deleteAllText" value="delete from articles"/>
    </appSettings>
</configuration>

Komentujemy metodę InsertArticle:

  • wiersz 20: pobieramy ewentualną transakcję, którą warstwa [metier] mogła umieścić w wątku
  • wiersze 23–25: jeśli transakcja istnieje, pobieramy połączenie, z którym została powiązana.
  • wiersze 26–30: w przeciwnym razie tworzone i otwierane jest nowe połączenie.
  • wiersze 33–44: przygotowuje się polecenie wstawiania. Jest ono konfigurowane (patrz wiersz g w App.config).
  • wiersz 33: tworzony jest obiekt Command.
  • wiersz 34: jest powiązany z bieżącą transakcją. Jeśli ta nie istnieje (transakcja = null), oznacza to wykonanie polecenia SQL bez jawnej transakcji. Należy pamiętać, że w takim przypadku nadal istnieje transakcja domyślna. W przypadku polecenia SQL (serwer CE) ta domyślna transakcja jest domyślnie w trybie autocommit: po wykonaniu polecenia SQL staje się ono committé.
  • wiersz 35: obiekt Command jest powiązany z bieżącym połączeniem
  • wiersz 36: ustalono tekst SQl do wykonania. Jest to zapytanie z parametrami z wiersza g obiektu App.config.
  • wiersze 37–44: zainicjowano 4 parametry zapytania
  • wiersz 46: zapytanie jest wykonywane.
  • wiersze 49–51: należy pamiętać, że gdyby nie było transakcji, otwarto by nowe połączenie z bazą danych (wiersze 26–30). W takim przypadku należy je zamknąć. Gdyby istniała transakcja, połączenia nie należy zamykać, ponieważ zarządza nim warstwa [metier].

Dwie pozostałe metody nawiązują do treści omówionych w rozdziale „Bazy danych”:


        // lista artykułów
        public List<Article> GetAllArticles() {
            // lista artykułów – początkowo pusta
            List<Article> articles = new List<Article>();
            // obsługa połączenia
            using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
                // nawiązanie połączenia
                connexion.Open();
                // uruchamia sqlCommand z zapytaniem select
                SqlCeCommand sqlCommand = new SqlCeCommand(GetAllText, connexion);
                using (SqlCeDataReader reader = sqlCommand.ExecuteReader()) {
                    // przetwarzanie wyniku
                    while (reader.Read()) {
                        // przetwarzanie bieżącego wiersza
                        articles.Add(new Article(reader.GetInt32(0), reader.GetString(1), reader.GetDecimal(2), reader.GetInt32(3), reader.GetInt32(4)));
                    }
                }
            }
            // zwracamy wynik
            return articles;
        }

        // usuwanie pozycji
        public void DeleteAllArticles() {
            using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
                // nawiązanie połączenia
                connexion.Open();
                // uruchamia sqlCommand z żądaniem aktualizacji
                new SqlCeCommand(DeleteAllText, connexion).ExecuteNonQuery();
            }
}

Aplikacja testowa [console]

Aplikacja testowa [console] wygląda następująco:


using System;
using System.Configuration;

namespace Chap8 {
    class Program {
        static void Main(string[] args) {
            // przetwarzanie pliku konfiguracyjnego
            string connectionString = null;
            string insertText;
            string getAllText;
            string deleteAllText;
            try {
                // łańcuch połączenia
                connectionString = ConfigurationManager.ConnectionStrings["dbArticlesSqlServerCe"].ConnectionString;
                // inne parametry
                insertText = ConfigurationManager.AppSettings["insertText"];
                getAllText = ConfigurationManager.AppSettings["getAllText"];
                deleteAllText = ConfigurationManager.AppSettings["deleteAllText"];
            } catch (Exception e) {
                Console.WriteLine("Erreur de configuration : {0}", e.Message);
                return;
            }
            // tworzenie warstwy [dao]
            Dao dao = new Dao();
            dao.ConnectionString = connectionString;
            dao.DeleteAllText = deleteAllText;
            dao.GetAllText = getAllText;
            dao.InsertText = insertText;
            // tworzenie warstwy [métier]
            Metier metier = new Metier();
            metier.Dao = dao;
            metier.ConnectionString = connectionString;
            // tworzy się tabelę artykułów
            Article[] articles = new Article[2];
            for (int i = 0; i < articles.Length; i++) {
                articles[i] = new Article(0, "article", 100, 10, 1);
            }
            // usuwamy wszystkie artykuły
            Console.WriteLine("Suppression de tous les articles...");
            metier.DeleteAllArticles();
            // wstawia się tabelę poza transakcją
            Console.WriteLine("Insertion des articles hors transaction...");
            try {
                metier.InsertArticlesOutOfTransaction(articles);
            } catch (Exception e){
                Console.WriteLine("Exception : {0}", e.Message);
            }
            // wyświetla się listę artykułów
            Console.WriteLine("Liste des articles");
            AfficheArticles(metier);
            // usuwamy wszystkie artykuły
            Console.WriteLine("Suppression de tous les articles...");
            metier.DeleteAllArticles();
            // wstawia się tabelę do transakcji
            Console.WriteLine("Insertion des articles dans une transaction...");
            metier.InsertArticlesInTransaction(articles);
            // wyświetla pozycje
            Console.WriteLine("Liste des articles");
            AfficheArticles(metier);
        }

        private static void AfficheArticles(IMetier metier) {
            // wyświetla artykuły
            foreach(Article article in metier.GetAllArticles()){
                Console.WriteLine(article);
            }
        }

    }
}
  • wiersze 12–22: wykorzystywany jest plik [App.config].
  • wiersze 24–28: warstwa [dao] jest instancjonowana i inicjowana
  • wiersze 30–32: to samo dotyczy warstwy [metier]
  • wiersze 34–37: tworzona jest tabela zawierająca 2 pozycje o tej samej nazwie. Tabela [articles] w bazie SQL na serwerze [dbarticles.sdf] ma ograniczenie unikalności nazwy. Wstawienie drugiego artykułu zostanie zatem odrzucone. Jeśli wstawienie tabeli odbywa się poza transakcją, pierwszy artykuł zostanie najpierw wstawiony i pozostanie w bazie. Jeśli wstawienie tabeli odbywa się w ramach transakcji, pierwszy artykuł zostanie najpierw wstawiony, a następnie usunięty podczas zakończenia transakcji.
  • wiersze 39–50: wstawienie tabeli zawierającej 2 pozycje poza transakcją i weryfikacja.
  • wiersze 52–59: to samo, ale w ramach transakcji

Wyniki wykonania są następujące:

1
2
3
4
5
6
7
8
9
Suppression de tous les articles...
Insertion des articles hors transaction...
Exception : A duplicate value cannot be inserted into a unique index. [ Table na
me = ARTICLES,Constraint name = UQ__ARTICLES__0000000000000010 ]
Liste des articles
[7,article,100,10,1]
Suppression de tous les articles...
Insertion des articles dans une transaction...
Liste des articles
  • wiersze 5–6: wstawienie poza transakcją spowodowało, że pierwszy artykuł pozostał w bazie
  • wiersz 9: wstawienie wykonane w transakcji nie pozostawiło żadnego artykułu w bazie

10.9.3. Wniosek

Powyższy przykład pokazał zalety danych lokalnych dla danego wątku w zarządzaniu transakcjami. Nie należy go powielać w tej postaci. Frameworki takie jak Spring, NHibernate itp. wykorzystują tę technikę, ale sprawiają, że jest ona jeszcze bardziej przezroczysta: warstwa [metier] może korzystać z transakcji, a warstwa [dao] nie musi o tym wiedzieć. W kodzie warstwy [dao] nie ma zatem żadnego obiektu Transaction. Osiąga się to za pomocą techniki proxy zwanej AOP (programowanie zorientowane na aspekty). Ponownie możemy jedynie zachęcić czytelnika do korzystania z tych frameworków.

10.10. Aby pogłębić wiedzę...

Aby zgłębić trudną dziedzinę synchronizacji wątków, warto zapoznać się z rozdziałem Threading książki „C# 3.0”, o której mowa we wstępie do niniejszego dokumentu. Przedstawiono w nim liczne techniki synchronizacji dostosowane do różnych sytuacji.