Skip to content

8. Wątki wykonawcze

8.1. Wprowadzenie

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

Image

Będziemy korzystać tylko z niektórych właściwości i metod tej klasy:

CurrentThread - propriété statique
zwraca wątek aktualnie wykonywany
Name - propriété d'objet
nazwa wątku
isAlive - propriété d'objet
wskazuje, czy wątek jest aktywny (true), czy nie (false)
Start - méthode d'objet
uruchamia wątek
Abort - méthode d'objet
ostatecznie zatrzymuje wykonywanie wątku
Sleep(n) - méthode statique
wstrzymuje wykonywanie wątku na n milisekund
Suspend() - méthode d'objet
tymczasowo zawiesza wykonywanie wątku
Resume() - méthode d'objet
wznowi wykonywanie zawieszonego wątku
Join() - méthode d'objet
operacja blokująca – czeka na zakończenie wątku, aby przejść do następnej instrukcji

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


' wykorzystanie wątków
Imports System
Imports System.Threading

Public Module thread1
    Public Sub Main()
        ' inicjowanie bieżącego wątku
        Dim main As Thread = Thread.CurrentThread
        ' wyświetlanie
        Console.Out.WriteLine(("Thread courant : " + main.Name))
        ' zmiana nazwy
        main.Name = "main"
        ' weryfikacja
        Console.Out.WriteLine(("Thread courant : " + main.Name))
        ' pętla nieskończona
        While True
            ' wyświetlanie
            Console.Out.WriteLine((main.Name + " : " + DateTime.Now.ToString("hh:mm:ss")))
            ' tymczasowe zatrzymanie
            Thread.Sleep(1000)
        End While
    End Sub
End Module

Wyniki wyświetlane na ekranie:

dos>thread1
Thread courant :
Thread courant : main
main : 06:13:55
main : 06:13:56
main : 06:13:57
main : 06:13:58
main : 06:13:59

Powyższy przykład ilustruje następujące kwestie:

  • funkcja Main działa poprawnie w wątku
  • dostęp do właściwości tego wątku uzyskuje się za pomocą funkcji Thread.CurrentThread
  • rolę metody Sleep. W tym przypadku wątek wykonujący funkcję Main regularnie przechodzi w stan uśpienia na 1 sekundę pomiędzy kolejnymi wyświetleniami.

8.2. Tworzenie wątków wykonawczych

Możliwe jest tworzenie aplikacji, 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, suspend)
  • przechodząc w stan uśpienia na określony czas (sleep)
  1. Wątek T jest najpierw tworzony przez jego konstruktor
Public Sub New(ByVal start As ThreadStart)

ThreadStart jest typu delegate i definiuje prototyp funkcji bez parametrów:

Public Delegate Sub ThreadStart()

Klasyczna konstrukcja wygląda następująco:

dim T as Thread=new Thread(new ThreadStart(run));

Funkcja run przekazana jako parametr zostanie wykonana przy uruchomieniu wątku.

  1. Wykonanie wątku T jest uruchamiane przez funkcję T.Start(): funkcja [run] 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.
  1. Po uruchomieniu wątek thread działa autonomicznie. Zakończy działanie, gdy funkcja start, którą wykonuje, zakończy swoją pracę.
  1. Do zadania T można wysyłać określone sygnały:
    1. T.Suspend() nakazuje mu chwilowe zatrzymanie się
    2. T.Resume() nakazuje mu wznowienie pracy
    3. T.Abort() nakazuje jej ostateczne zatrzymanie
  1. Można również poczekać na zakończenie jego wykonywania za pomocą T.join(). Jest to instrukcja blokująca: program, który ją wykonuje, pozostaje zablokowany do momentu, aż zadanie T zakończy swoją pracę. Jest to sposób na synchronizację.

Przyjrzyjmy się następującemu programowi:


' opcje
Option Strict On
Option Explicit On 

' przestrzenie nazw
Imports System
Imports System.Threading

Module thread2
    Public Sub Main()
        ' inicjowanie bieżącego wątku
        Dim main As Thread = Thread.CurrentThread
        ' nadanie nazwy wątku
        main.Name = "main"

        ' tworzenie wątków wykonawczych
        Dim tâches(4) As Thread
        Dim i As Integer
        For i = 0 To tâches.Length - 1
            ' tworzenie wątku i
            tâches(i) = New Thread(New ThreadStart(AddressOf affiche))
            ' ustalanie nazwy wątku
            tâches(i).Name = "tache_" & i
            ' uruchamianie wątku i
            tâches(i).Start()
        Next i
        ' koniec procedury głównej
        Console.Out.WriteLine(("fin du thread " + main.Name))
    End Sub

    Public Sub affiche()
        ' wyświetlenie informacji o rozpoczęciu wykonywania
        Console.Out.WriteLine(("Début d'exécution de la méthode affiche dans le Thread " + Thread.CurrentThread.Name + " : " + DateTime.Now.ToString("hh:mm:ss")))
        ' przejście w stan uśpienia na 1 s
        Thread.Sleep(1000)
        ' wyświetlenie zakończenia wykonywania
        Console.Out.WriteLine(("Fin d'exécution de la méthode affiche dans le Thread " + Thread.CurrentThread.Name + " : " + DateTime.Now.ToString("hh:mm:ss")))
    End Sub
End Module

Wątek główny, który wykonuje funkcję Main, tworzy 5 innych wątków odpowiedzialnych za wykonanie metody statycznej affiche. Wyniki są następujące:

dos>thread2
fin du thread main
Début d'exécution de la méthode affiche dans le Thread tache_0 : 05:27:53
Début d'exécution de la méthode affiche dans le Thread tache_1 : 05:27:53
Début d'exécution de la méthode affiche dans le Thread tache_2 : 05:27:53
Début d'exécution de la méthode affiche dans le Thread tache_3 : 05:27:53
Début d'exécution de la méthode affiche dans le Thread tache_4 : 05:27:53
Fin d'exécution de la méthode affiche dans le Thread tache_0 : 05:27:54
Fin d'exécution de la méthode affiche dans le Thread tache_1 : 05:27:54
Fin d'exécution de la méthode affiche dans le Thread tache_2 : 05:27:54
Fin d'exécution de la méthode affiche dans le Thread tache_3 : 05:27:54
Fin d'exécution de la méthode affiche dans le Thread tache_4 : 05:27:54

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 przechodzi natychmiast do kolejnej instrukcji, nie czekając 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 będzie podążał 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, aby zakończyć metodę Main za pomocą instrukcji:

         ' koniec obsługi
        Console.Out.WriteLine(("fin du thread " + main.Name))
        Environment.Exit(0)

Wynik wykonania nowego programu wygląda następująco:

fin du thread main

Wątki utworzone przez funkcję Main nie są wykonywane. Jest to instrukcja

        Environment.Exit(0)

powoduje to: usuwa ona wszystkie wątki aplikacji, a nie tylko wątek Main. Rozwiązaniem tego problemu jest to, aby metoda Main czekała na zakończenie działania wątków, które utworzyła, zanim sama się zakończy. Można to osiągnąć za pomocą metody Join klasy Thread:


         ' oczekiwanie na zakończenie wykonywania wszystkich wątków
        For i = 0 To tâches.Length - 1
            ' oczekiwanie na zakończenie wykonywania wątku i
            tâches(i).Join()
        Next i        'for
         ' koniec procedury głównej
        Console.Out.WriteLine(("fin du thread " + main.Name))
        Environment.Exit(0)

Otrzymujemy wówczas następujące wyniki:

Début d'exécution de la méthode affiche dans le Thread tache_1 : 05:34:48
Début d'exécution de la méthode affiche dans le Thread tache_2 : 05:34:48
Début d'exécution de la méthode affiche dans le Thread tache_3 : 05:34:48
Début d'exécution de la méthode affiche dans le Thread tache_4 : 05:34:48
Début d'exécution de la méthode affiche dans le Thread tache_0 : 05:34:48
Fin d'exécution de la méthode affiche dans le Thread tache_2 : 05:34:50
Fin d'exécution de la méthode affiche dans le Thread tache_1 : 05:34:50
Fin d'exécution de la méthode affiche dans le Thread tache_3 : 05:34:50
Fin d'exécution de la méthode affiche dans le Thread tache_0 : 05:34:50
Fin d'exécution de la méthode affiche dans le Thread tache_4 : 05:34:50
fin du thread main

8.3. Znaczenie wątków

Skoro już wykazaliśmy istnienie domyślnego wątku, który wykonuje metodę Main, i wiemy, jak tworzyć kolejne, zastanówmy się, jakie korzyści płyną z wątków i dlaczego je tutaj omawiamy. Istnieje pewien rodzaj aplikacji, które dobrze nadają się do wykorzystania wątków – są to aplikacje typu klient-serwer w Internecie. W takiej aplikacji 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 zgodnych z tym schematem: serwisy internetowe, poczta elektroniczna, przeglądanie forów, 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 godzin. 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.

8.4. Dostęp do zasobów współdzielonych

W przytoczonym powyżej przykładzie relacji klient-serwer każdy wątek obsługuje klienta w sposób w dużej mierze niezależny. Niemniej jednak wątki mogą być zmuszone do współpracy w celu zrealizowania usługi żądanej przez ich klienta, zwłaszcza w przypadku dostępu do zasobów współdzielonych. Powyższy schemat przypomina okienka w dużej instytucji administracyjnej, na przykład na poczcie, gdzie przy każdym okienku urzędnik obsługuje klienta. Załóżmy, że od czasu do czasu urzędnicy ci muszą wykonać kserokopie dokumentów przyniesionych przez klientów, a do dyspozycji jest tylko jedna kserokopiarka. Dwóch pracowników nie może korzystać z kserokopiarki jednocześnie. Jeśli pracownik i zastał kserokopiarkę zajętą przez pracownika j, będzie musiał poczekać. Sytuację tę nazywamy dostępem do zasobu współdzielonego i w informatyce jest ona dość trudna do zarządzania. Weźmy następujący przykład:

  • aplikacja wygeneruje n wątków, gdzie n jest przekazywane jako parametr
  • zasób współdzielony to licznik, który musi być zwiększany przez każdy wygenerowany wątek
  • Po zakończeniu działania aplikacji wyświetlana jest wartość licznika. Powinno się więc uzyskać n.

Program wygląda następująco:


' opcje
Option Explicit On 
Option Strict On

' wykorzystanie wątków
Imports System
Imports System.Threading

Public Class thread3
    ' zmienne klasy
    Private Shared cptrThreads As Integer = 0

    Public Overloads Shared Sub Main(ByVal args() As [String])
        ' instrukcja obsługi
        Const syntaxe As String = "pg nbThreads"
        Const nbMaxThreads As Integer = 100

        ' sprawdzanie liczby argumentów
        If args.Length <> 1 Then
            ' błąd
            Console.Error.WriteLine(syntaxe)
            ' zatrzymanie
            Environment.Exit(1)
        End If
        ' sprawdzanie jakości argumentu
        Dim nbThreads As Integer = 0
        Try
            nbThreads = Integer.Parse(args(0))
            If nbThreads < 1 Or nbThreads > nbMaxThreads Then
                Throw New Exception
            End If
        Catch
            ' błąd
            Console.Error.WriteLine("Nombre de threads incorrect (entre 1 et " & nbMaxThreads & ")")
            ' koniec
            Environment.Exit(2)
        End Try
        ' tworzenie i generowanie wątków
        Dim threads(nbThreads - 1) As Thread
        Dim i As Integer
        For i = 0 To nbThreads - 1
            ' tworzenie
            threads(i) = New Thread(New ThreadStart(AddressOf incrémente))
            ' nazewnictwo
            threads(i).Name = "tache_" & i
            ' uruchomienie
            threads(i).Start()
        Next i
        ' oczekiwanie na zakończenie wątków
        For i = 0 To nbThreads - 1
            threads(i).Join()
        Next i        ' affichage compteur
        Console.Out.WriteLine(("Nombre de threads générés : " & cptrThreads))
    End Sub

    Public Shared Sub incrémente()
        ' zwiększa licznik wątków
        ' odczyt licznika
        Dim valeur As Integer = cptrThreads
        ' monitorowanie
        Console.Out.WriteLine(("A " + DateTime.Now.ToString("hh:mm:ss") & ", le thread " & Thread.CurrentThread.Name & " a lu la valeur du compteur : " & cptrThreads))
        ' oczekiwanie
        Thread.Sleep(1000)
        ' zwiększenie licznika
        cptrThreads = valeur + 1
        ' monitorowanie
        Console.Out.WriteLine(("A " & DateTime.Now.ToString("hh:mm:ss") & ", le thread " & Thread.CurrentThread.Name & " a écrit la valeur du compteur : " & cptrThreads))
    End Sub
End Class

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, wykorzystywanej przez każdy wątek do zwiększania wartości licznika statycznego cptrThreads.

  1. licznik jest odczytywany
  2. wątek zatrzymuje się na 1 s. W ten sposób traci dostęp do procesora
  3. licznik jest zwiększany

Krok 2 służy wyłącznie do wymuszenia utraty kontroli nad procesorem przez wątek. Kontrola ta zostanie przekazana innemu wątku. W praktyce nic nie gwarantuje, że wątek nie zostanie przerwany w okresie między odczytaniem wartości licznika a jego zwiększeniem o 1. Istnieje ryzyko utraty dostępu do procesora między odczytaniem wartości licznika a zapisaniem jego wartości zwiększonej o 1. Operacja zwiększania wartości licznika składa się bowiem z kilku instrukcji elementarnych na poziomie procesora, które mogą zostać przerwane. Krok 2, polegający na zawieszeniu na jedną sekundę, służy zatem wyłącznie usystematyzowaniu tego ryzyka. Uzyskane wyniki są następujące:

dos>thread3 5
A 05:44:34, le thread tache_0 a lu la valeur du compteur : 0
A 05:44:34, le thread tache_1 a lu la valeur du compteur : 0
A 05:44:34, le thread tache_2 a lu la valeur du compteur : 0
A 05:44:34, le thread tache_3 a lu la valeur du compteur : 0
A 05:44:34, le thread tache_4 a lu la valeur du compteur : 0
A 05:44:35, le thread tache_0 a écrit la valeur du compteur : 1
A 05:44:35, le thread tache_1 a écrit la valeur du compteur : 1
A 05:44:35, le thread tache_2 a écrit la valeur du compteur : 1
A 05:44:35, le thread tache_3 a écrit la valeur du compteur : 1
A 05:44:35, le thread tache_4 a écrit la valeur du compteur : 1
Nombre de threads générés : 1

Analizując te wyniki, łatwo zrozumieć, co się dzieje:

  • pierwszy wątek odczytuje licznik. Znajduje wartość 0.
  • zatrzymuje się na 1 s, tracąc w ten sposób dostęp do procesora
  • następnie drugi wątek przejmuje 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.
  • W ciągu 1 s wszystkie 5 wątków zdąży się uruchomić i odczytać wartość 0.
  • Kiedy będą się budzić jeden po drugim, zwiększą odczytaną wartość 0 i zapiszą wartość 1 w liczniku, co potwierdza program główny (Main).

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.

8.5. Wyłączny dostęp do zasobu współdzielonego

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


         ' odczyt licznika
        Dim valeur As Integer = cptrThreads
        ' oczekiwanie
        Thread.Sleep(1000)
        ' przyrost licznika
        cptrThreads = valeur + 1

Aby wykonać ten kod, należy zapewnić, że wątek działa w izolacji. Może on zostać przerwany, ale podczas tej przerwy żaden inny wątek nie może wykonywać tego samego kodu. Platforma .NET oferuje kilka narzędzi zapewniających wyłączność dostępu do krytycznych sekcji kodu. Wykorzystamy klasę Mutex:

Image

W tym przypadku wykorzystamy wyłącznie następujące konstruktory i metody:

public Mutex()
tworzy obiekt synchronizacji M
public bool WaitOne()
Wątek T1, który wykonuje operację M.WaitOne(), żąda własności obiektu synchronizacji M. Jeśli muteks M nie jest posiadany przez żaden wątek (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 muteks może należeć tylko do jednego wątku. Zostanie odblokowany, gdy wątek T1 zwolni muteks M, który posiada. W ten sposób wiele wątków może zostać zablokowanych w oczekiwaniu na muteks M.
public void
ReleaseMutex()
Wątek T1, który wykonuje operację M.ReleaseMutex(), rezygnuje z posiadania muteksu M.Lorsque; wątek T1 straci dostęp do procesora, a 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

Mutex M zarządza dostępem do współdzielonego zasobu R. Wątek żąda zasobu R za pomocą funkcji M.WaitOne() i zwraca go za pomocą funkcji M.ReleaseMutex(). Sekcja krytyczna kodu, która może być wykonywana tylko przez jeden wątek naraz, stanowi współdzielony zasób. Synchronizacja wykonywania sekcji krytycznej 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. Oczywiście należy zawsze 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 muteks, który nigdy nie został zwolniony, nigdy nie uzyskają dostępu do procesora. Ponadto należy unikać sytuacji wzajemnego blokowania (deadlock), w której 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ć dostęp do muteksu M1. Jest zablokowany.

W tym przypadku wątki T1 i T2 oczekują na siebie nawzajem. Sytuacja ta ma miejsce, 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ą jednego 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. Jeśli zastosujemy to, co właśnie omówiliśmy, w poprzednim przykładzie, nasza aplikacja będzie wyglądać następująco:


' opcje
Option Explicit On 
Option Strict On

' wykorzystanie wątków
Imports System
Imports System.Threading

Public Class thread4
    ' zmienne klasy
    Private Shared cptrThreads As Integer = 0    ' compteur de threads
    Private Shared autorisation As Mutex

    Public Overloads Shared Sub Main(ByVal args() As [String])
        ' instrukcja obsługi
        Const syntaxe As String = "pg nbThreads"
        Const nbMaxThreads As Integer = 100

        ' sprawdzanie liczby argumentów
        If args.Length <> 1 Then
            ' błąd
            Console.Error.WriteLine(syntaxe)
            ' zatrzymanie
            Environment.Exit(1)
        End If
        ' sprawdzanie jakości argumentu
        Dim nbThreads As Integer = 0
        Try
            nbThreads = Integer.Parse(args(0))
            If nbThreads < 1 Or nbThreads > nbMaxThreads Then
                Throw New Exception
            End If
        Catch
        End Try

        ' inicjalizacja uprawnień dostępu do sekcji krytycznej
        autorisation = New Mutex

        ' tworzenie i generowanie wątków
        Dim threads(nbThreads) As Thread
        Dim i As Integer
        For i = 0 To nbThreads - 1
            ' tworzenie
            threads(i) = New Thread(New ThreadStart(AddressOf incrémente))
            ' nazywanie
            threads(i).Name = "tache_" & i
            ' uruchomienie
            threads(i).Start()
        Next i
        ' oczekiwanie na zakończenie wątków
        For i = 0 To nbThreads - 1
            threads(i).Join()
        Next i
        ' wyświetlanie licznika
        Console.Out.WriteLine(("Nombre de threads générés : " & cptrThreads))
    End Sub

    Public Shared Sub incrémente()
        ' zwiększa licznik wątków
        ' wnosimy o zezwolenie na wejście do sekcji krytycznej
        autorisation.WaitOne()
        ' odczyt licznika
        Dim valeur As Integer = cptrThreads
        ' monitorowanie
        Console.Out.WriteLine(("A " & DateTime.Now.ToString("hh:mm:ss") & ", le thread " & Thread.CurrentThread.Name & " a lu la valeur du compteur : " & cptrThreads))
        ' oczekiwanie
        Thread.Sleep(1000)
        ' inkrementacja licznika
        cptrThreads = valeur + 1
        ' monitorowanie
        Console.Out.WriteLine(("A " & DateTime.Now.ToString("hh:mm:ss") & ", le thread " & Thread.CurrentThread.Name & " a écrit la valeur du compteur : " & cptrThreads))
        ' przywracanie uprawnień dostępu
        autorisation.ReleaseMutex()
    End Sub
End Class

Uzyskane wyniki są zgodne z oczekiwaniami:

dos>thread4 5
A 05:51:10, le thread tache_0 a lu la valeur du compteur : 0
A 05:51:11, le thread tache_0 a écrit la valeur du compteur : 1
A 05:51:11, le thread tache_1 a lu la valeur du compteur : 1
A 05:51:12, le thread tache_1 a écrit la valeur du compteur : 2
A 05:51:12, le thread tache_2 a lu la valeur du compteur : 2
A 05:51:13, le thread tache_2 a écrit la valeur du compteur : 3
A 05:51:13, le thread tache_3 a lu la valeur du compteur : 3
A 05:51:14, le thread tache_3 a écrit la valeur du compteur : 4
A 05:51:14, le thread tache_4 a lu la valeur du compteur : 4
A 05:51:15, le thread tache_4 a écrit la valeur du compteur : 5
Nombre de threads générés : 5

8.6. Synchronizacja oparta na zdarzeniach

Rozważmy następującą sytuację, nazywaną czasem sytuacją producentów i konsumentów.

  1. Mamy tablicę, do której niektóre procesy zapisują dane (producenci), a inne je odczytują (konsumenci).
  2. Producenci są sobie równi, ale działają na zasadzie wyłączności: w danym momencie tylko jeden producent może umieszczać swoje dane w tablicy.
  3. Konsumenci są sobie równi, ale wykluczają się wzajemnie: w danym momencie tylko jeden konsument może odczytywać dane zapisane w tablicy.
  4. Konsument może odczytać dane z tablicy tylko wtedy, gdy producent je tam umieścił, a producent może umieścić nowe dane w tablicy tylko wtedy, gdy te, które się w niej znajdują, zostały już wykorzystane.

W niniejszym omówieniu można wyróżnić dwa wspólne zasoby:

    1. tabela do zapisu
    2. tabela do odczytu

Dostęp do tych dwóch współdzielonych zasobów można kontrolować za pomocą muteksów, jak pokazano wcześniej, po jednym dla każdego zasobu. Gdy konsument uzyska dostęp do tabeli do odczytu, musi sprawdzić, czy rzeczywiście znajdują się w niej dane. W tym celu wykorzystamy zdarzenie, które go o tym powiadomi. Podobnie producent, który uzyskał dostęp do tabeli do zapisu, będzie musiał poczekać, aż konsument ją opróżni. Również w tym przypadku wykorzystamy zdarzenie.

Wykorzystywane zdarzenia będą częścią klasy AutoResetEvent:

Image

Ten typ zdarzenia jest analogiczny do wartości logicznej, ale pozwala uniknąć oczekiwania aktywnego lub półaktywnego. Jeśli więc prawo do zapisu jest kontrolowane przez wartość logiczną peutEcrire, producent przed zapisaniem wykona kod w następującym stylu:

while(peutEcrire==false)        ' attente active

lub

while(peutEcrire==false) ' attente semi-active
    Thread.Sleep(100)                ' attente de 100ms
end while

W pierwszej metodzie wątek niepotrzebnie obciąża procesor. W drugiej co 100 ms sprawdza stan zmiennej logicznej peutEcrire. Klasa AutoResetEvent pozwala jeszcze bardziej usprawnić działanie: wątek poprosi o wybudzenie, gdy wystąpi oczekiwane zdarzenie:

AutoEvent peutEcrire=new AutoResetEvent(false)        ' peutEcrire=false;
....
peutEcrire.WaitOne() ' le thread attend que l'évt peutEcrire passe à vrai

Operacja

AutoEvent peutEcrire=new AutoResetEvent(false)        ' peutEcrire=false;

inicjuje zmienną logiczną peutEcrire na wartość false. Operacja

peutEcrire.WaitOne() ' le thread attend que l'évt peutEcrire passe à vrai

wykonana przez wątek powoduje, że wątek ten przechodzi dalej, jeśli zmienna logiczna peutEcrire ma wartość prawdziwą, w przeciwnym razie zostaje zablokowany do momentu, aż zmienna ta przyjmie wartość prawdziwą. Inny wątek ustawi tę wartość na „prawda” za pomocą operacji peutEcrire.Set() lub na „fałsz” za pomocą operacji peutEcrire.Reset().

Program typu „producent-konsument” wygląda następująco:


' korzystanie z wątków odczytu i zapisu
' ilustruje jednoczesne korzystanie z zasobów współdzielonych i synchronizacji

' opcje
Option Explicit On 
Option Strict On

' korzystanie z wątków
Imports System
Imports System.Threading

Public Class lececr

    ' zmienne klasowe
    Private Shared data(5) As Integer    ' ressource partagée entre threads lecteur et threads écrivain
    Private Shared lecteur As Mutex    ' variable de synchronisation pour lire le tableau
    Private Shared écrivain As Mutex    ' variable de synchronisation pour écrire dans le tableau
    Private Shared objRandom As New Random(DateTime.Now.Second)    ' un générateur de nombres aléatoires
    Private Shared peutLire As AutoResetEvent    ' signale qu'on peut lire le contenu de data
    Private Shared peutEcrire As AutoResetEvent

    Public Shared Sub Main(ByVal args() As [String])

        ' liczba wątków do wygenerowania
        Const nbThreads As Integer = 3

        ' inicjalizacja flag
        peutLire = New AutoResetEvent(False)        ' on ne peut pas encore lire
        peutEcrire = New AutoResetEvent(True)        ' on peut déjà écrire

        ' inicjalizacja zmiennych synchronizacyjnych
        lecteur = New Mutex         ' synchronise les lecteurs
        écrivain = New Mutex         ' synchronise les écrivains

        ' tworzenie wątków odczytujących
        Dim lecteurs(nbThreads) As Thread
        Dim i As Integer
        For i = 0 To nbThreads - 1
            ' tworzenie
            lecteurs(i) = New Thread(New ThreadStart(AddressOf lire))
            lecteurs(i).Name = "lecteur_" & i
            ' uruchomienie
            lecteurs(i).Start()
        Next i

        ' tworzenie wątków zapisujących
        Dim écrivains(nbThreads) As Thread
        For i = 0 To nbThreads - 1
            ' tworzenie
            écrivains(i) = New Thread(New ThreadStart(AddressOf écrire))
            écrivains(i).Name = "écrivain_" & i
            ' uruchomienie
            écrivains(i).Start()
        Next i

        'zakończenie operacji
        Console.Out.WriteLine("fin de Main...")
    End Sub

    ' odczyt zawartości tablicy
    Public Shared Sub lire()
        ' sekcja krytyczna
        lecteur.WaitOne()        ' un seul lecteur peut passer
        peutLire.WaitOne()        ' on doit pouvoir lire

        ' odczyt tabeli
        Dim i As Integer
        For i = 0 To data.Length - 1
            'oczekiwanie 1 s
            Thread.Sleep(1000)
            ' wyświetlanie
            Console.Out.WriteLine((DateTime.Now.ToString("hh:mm:ss") & " : Le lecteur " & Thread.CurrentThread.Name & " a lu le nombre " & data(i)))
        Next i

        ' nie można już odczytać
        peutLire.Reset()
        ' można zapisywać
        peutEcrire.Set()
        ' koniec sekcji krytycznej
        lecteur.ReleaseMutex()
    End Sub

    ' zapis w tablicy
    Public Shared Sub écrire()
        ' sekcja krytyczna
        ' może przejść tylko jeden pisarz
        écrivain.WaitOne()
        ' należy poczekać na zezwolenie na zapis
        peutEcrire.WaitOne()

        ' zapis do tablicy
        Dim i As Integer
        For i = 0 To data.Length - 1
            'oczekiwanie 1 s
            Thread.Sleep(1000)
            ' wyświetlanie
            data(i) = objRandom.Next(0, 1000)
            Console.Out.WriteLine((DateTime.Now.ToString("hh:mm:ss") & " : L'écrivain " & Thread.CurrentThread.Name & " a écrit le nombre " & data(i)))
        Next i

        ' nie można już zapisywać
        peutEcrire.Reset()
        ' można odczytywać
        peutLire.Set()
        'koniec sekcji krytycznej
        écrivain.ReleaseMutex()
    End Sub
End Class

Wykonanie daje następujące wyniki:

dos>lececr
fin de Main...
05:56:56 : L'écrivain écrivain_0 a écrit le nombre 459
05:56:57 : L'écrivain écrivain_0 a écrit le nombre 955
05:56:58 : L'écrivain écrivain_0 a écrit le nombre 212
05:56:59 : L'écrivain écrivain_0 a écrit le nombre 297
05:57:00 : L'écrivain écrivain_0 a écrit le nombre 37
05:57:01 : L'écrivain écrivain_0 a écrit le nombre 623
05:57:02 : Le lecteur lecteur_0 a lu le nombre 459
05:57:03 : Le lecteur lecteur_0 a lu le nombre 955
05:57:04 : Le lecteur lecteur_0 a lu le nombre 212
05:57:05 : Le lecteur lecteur_0 a lu le nombre 297
05:57:06 : Le lecteur lecteur_0 a lu le nombre 37
05:57:07 : Le lecteur lecteur_0 a lu le nombre 623
05:57:08 : L'écrivain écrivain_1 a écrit le nombre 549
05:57:09 : L'écrivain écrivain_1 a écrit le nombre 34
05:57:10 : L'écrivain écrivain_1 a écrit le nombre 781
05:57:11 : L'écrivain écrivain_1 a écrit le nombre 555
05:57:12 : L'écrivain écrivain_1 a écrit le nombre 812
05:57:13 : L'écrivain écrivain_1 a écrit le nombre 406
05:57:14 : Le lecteur lecteur_1 a lu le nombre 549
05:57:15 : Le lecteur lecteur_1 a lu le nombre 34
05:57:16 : Le lecteur lecteur_1 a lu le nombre 781
05:57:17 : Le lecteur lecteur_1 a lu le nombre 555
05:57:18 : Le lecteur lecteur_1 a lu le nombre 812
05:57:19 : Le lecteur lecteur_1 a lu le nombre 406
05:57:20 : L'écrivain écrivain_2 a écrit le nombre 442
05:57:21 : L'écrivain écrivain_2 a écrit le nombre 83
^C

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

  • w danym momencie działa tylko jeden czytnik, mimo że traci on dostęp do procesora w sekcji krytycznej lire
  • w danym momencie działa tylko jeden zapisujący, mimo że traci on dostęp do procesora w sekcji krytycznej écrire
  • 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