Skip to content

8. Les threads d'exécution

8.1. Introduction

Lorsqu'on lance une application, elle s'exécute dans un flux d'exécution appelé un thread. La classe .NET modélisant un thread est la classe System.Threading.Thread et a la définition suivante :

Image

Nous n'utiliserons que certaines des propriétés et méthodes de cette classe :

CurrentThread - propriété statique
donne le thread actuellement en cours d'exécution
Name - propriété d'objet
nom du thread
isAlive - propriété d'objet
indique si le thread est actif(true) ou non (false)
Start - méthode d'objet
lance l'exécution d'un thread
Abort - méthode d'objet
arrête définitivement l'exécution d'un thread
Sleep(n) - méthode statique
arrête l'exécution d'un thread pendant n millisecondes
Suspend() - méthode d'objet
suspend temporairement l'exécution d'un thread
Resume() - méthode d'objet
reprend l'excéution d'un thread suspendu
Join() - méthode d'objet
opération bloquante - attend la fin du thread pour passer à l'instruction suivante

Regardons une première application mettant en évidence l'existence d'un thread principal d'exécution, celui dans lequel s'exécute la fonction Main d'une classe :


' utilisation de threads
Imports System
Imports System.Threading

Public Module thread1
    Public Sub Main()
        ' init thread courant
        Dim main As Thread = Thread.CurrentThread
        ' affichage
        Console.Out.WriteLine(("Thread courant : " + main.Name))
        ' on change le nom
        main.Name = "main"
        ' vérification
        Console.Out.WriteLine(("Thread courant : " + main.Name))
        ' boucle infinie
        While True
            ' affichage
            Console.Out.WriteLine((main.Name + " : " + DateTime.Now.ToString("hh:mm:ss")))
            ' arrêt temporaire
            Thread.Sleep(1000)
        End While
    End Sub
End Module

Les résultats écran :

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

L'exemple précédent illustre les points suivants :

  • la fonction Main s'exécute bien dans un thread
  • on a accès aux caractéristiques de ce thread par Thread.CurrentThread
  • le rôle de la méthode Sleep. Ici le thread exécutant Main se met en sommeil régulièrement pendant 1 seconde entre deux affichages.

8.2. Création de threads d'exécution

Il est possible d'avoir des applications où des morceaux de code s'exécutent de façon "simultanée" dans différents threads d'exécution. Lorsqu'on dit que des threads s'exécutent de façon simultanée, on commet souvent un abus de langage. Si la machine n'a qu'un processeur comme c'est encore souvent le cas, les threads se partagent ce processeur : ils en disposent, chacun leur tour, pendant un court instant (quelques millisecondes). C'est ce qui donne l'illusion du parallélisme d'exécution. La portion de temps accordée à un thread dépend de divers facteurs dont sa priorité qui a une valeur par défaut mais qui peut être fixée également par programmation. Lorsqu'un thread dispose du processeur, il l'utilise normalement pendant tout le temps qui lui a été accordé. Cependant, il peut le libérer avant terme :

  • en se mettant en attente d'un événement (wait, join, suspend)
  • en se mettant en sommeil pendant un temps déterminé (sleep)
  1. Un thread T est d'abord créé par son constructeur
Public Sub New(ByVal start As ThreadStart)

ThreadStart est de type delegate et définit le prototype d'une fonction sans paramètres :

Public Delegate Sub ThreadStart()

Une construction classique est la suivante :

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

La fonction run passée en paramètres sera exécutée au lancement du Thread.

  1. L'exécution du thread T est lancé par T.Start() : la fonction [run] passée au constructeur de T va alors être exécutée par le thread T. Le programme qui exécute l'instruction T.start() n'attend pas la fin de la tâche T : il passe aussitôt à l'instruction qui suit. On a alors deux tâches qui s'exécutent en parallèle. Elles doivent souvent pouvoir communiquer entre elles pour savoir où en est le travail commun à réaliser. C'est le problème de synchronisation des threads.
  1. Une fois lancé, le thread s'exécute de façon autonome. Il s'arrêtera lorsque la fonction start qu'il exécute aura fini son travail.
  1. On peut envoyer certains signaux à la tâche T :
    1. T.Suspend() lui dit de s'arrêter momentanément
    2. T.Resume() lui dit de reprendre son travail
    3. T.Abort() lui dit de s'arrêter définitivement
  1. On peut aussi attendre la fin de son exécution par T.join(). On a là une instruction bloquante : le programme qui l'exécute est bloqué jusqu'à ce que la tâche T ait terminé son travail. C'est un moyen de synchronisation.

Examinons le programme suivant :


' options
Option Strict On
Option Explicit On 

' espaces de noms
Imports System
Imports System.Threading

Module thread2
    Public Sub Main()
        ' init Thread courant
        Dim main As Thread = Thread.CurrentThread
        ' on fixe un nom au Thread
        main.Name = "main"

        ' création de threads d'exécution
        Dim tâches(4) As Thread
        Dim i As Integer
        For i = 0 To tâches.Length - 1
            ' on crée le thread i
            tâches(i) = New Thread(New ThreadStart(AddressOf affiche))
            ' on fixe le nom du thread
            tâches(i).Name = "tache_" & i
            ' on lance l'exécution du thread i
            tâches(i).Start()
        Next i
        ' fin de main
        Console.Out.WriteLine(("fin du thread " + main.Name))
    End Sub

    Public Sub affiche()
        ' affichage début d'exécution
        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")))
        ' mise en sommeil pendant 1 s
        Thread.Sleep(1000)
        ' affichage fin d'exécution
        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

Le thread principal, celui qui exécute la fonction Main, crée 5 autres threads chargés d'exécuter la méthode statique affiche. Les résultats sont les suivants :

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

Ces résultats sont très instructifs :

  • on voit tout d'abord que le lancement de l'exécution d'un thread n'est pas bloquante. La méthode Main a lancé l'exécution de 5 threads en parallèle et a terminé son exécution avant eux. L'opération
            ' on lance l'exécution du thread i
            tâches(i).Start()

lance l'exécution du thread tâches[i] mais ceci fait, l'exécution se poursuit immédiatement avec l'instruction qui suit sans attendre la fin d'exécution du thread.

  • tous les threads créés doivent exécuter la méthode affiche. L'ordre d'exécution est imprévisible. Même si dans l'exemple, l'ordre d'exécution semble suivre l'ordre des demandes d'exécution, on ne peut en conclure de généralités. Le système d'exploitation a ici 6 threads et un processeur. Il va distribuer le processeur à ces 6 threads selon des règles qui lui sont propres.
  • on voit dans les résultats une conséquence de la méthode Sleep. dans l'exemple, c'est le thread 0 qui exécute le premier la méthode affiche. Le message de début d'exécution est affiché puis il exécute la méthode Sleep qui le suspend pendant 1 seconde. Il perd alors le processeur qui devient ainsi disponible pour un autre thread. L'exemple montre que c'est le thread 1 qui va l'obtenir. Le thread 1 va suivre le même parcours ainsi que les autres threads. Lorsque la seconde de sommeil du thread 0 va être terminée, son exécution peut reprendre. Le système lui donne le processeur et il peut terminer l'exécution de la méthode affiche.

Modifions notre programme pour le terminer la méthode Main par les instructions :

        ' fin de main
        Console.Out.WriteLine(("fin du thread " + main.Name))
        Environment.Exit(0)

L'exécution du nouveau programme donne :

fin du thread main

Les threads créés par la fonction Main ne sont pas exécutés. C'est l'instruction

        Environment.Exit(0)

qui fait cela : elle supprime tous les threads de l'application et non simplement le thread Main. La solution à ce problème est que la méthode Main attende la fin d'exécution des threads qu'elle a créés avant de se terminer elle-même. Cela peut se faire avec la méthode Join de la classe Thread :


        ' on attend la fin d'exécution de tous les threads
        For i = 0 To tâches.Length - 1
            ' attente de la fin d'exécution du thread i
            tâches(i).Join()
        Next i        'for
        ' fin de main
        Console.Out.WriteLine(("fin du thread " + main.Name))
        Environment.Exit(0)

On obtient alors les résultats suivants :

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. Intérêt des threads

Maintenant que nous avons mis en évidence l'existence d'un thread par défaut, celui qui exécute la méthode Main, et que nous savons comment en créer d'autres, arrêtons-nous sur l'intérêt pour nous des threads et sur la raison pour laquelle nous les présentons ici. Il y a un type d'applications qui se prêtent bien à l'utilisation des threads, ce sont les applications client-serveur de l'internet. Dans une telle application, un serveur situé sur une machine S1 répond aux demandes de clients situés sur des machines distantes C1, C2, ..., Cn.

Nous utilisons tous les jours des applications de l'internet correspondant à ce schéma : services Web, messagerie électronique, consultation de forums, transfert de fichiers... Dans le schéma ci-dessus, le serveur S1 doit servir les clients Ci de façon simultanée. Si nous prenons l'exemple d'un serveur FTP (File Transfer Protocol) qui délivre des fichiers à ses clients, nous savons qu'un transfert de fichier peut prendre parfois plusieurs heures. Il est bien sûr hors de question qu'un client monopolise tout seul le serveur une telle durée. Ce qui est fait habituellement, c'est que le serveur crée autant de threads d'exécution qu'il y a de clients. Chaque thread est alors chargé de s'occuper d'un client particulier. Le processeur étant partagé cycliquement entre tous les threads actifs de la machine, le serveur passe alors un peu de temps avec chaque client assurant ainsi la simultanéité du service.

8.4. Accès à des ressources partagées

Dans l'exemple client-serveur évoqué ci-dessus, chaque thread sert un client de façon largement indépendante. Néanmoins, les threads peuvent être amenés à coopérer pour rendre le service demandé à leur client notamment pour l'accès à des ressources partagées. Le schéma ci-dessus fait penser aux guichets d'une grande administration, une poste par exemple où à chaque guichet un agent sert un client. Supposons que de temps en temps ces agents soient amenés à faire des photocopies de documents amenés par leurs clients et qu'il n'y ait qu'une photocopieuse. Deux agents ne peuvent utiliser la photocopieuse en même temps. Si l'agent i trouve la photocopieuse utilisée par l'agent j, il devra attendre. On appelle cette situation, l'accès à une ressource partagée et en informatique elle est assez délicate à gérer. Prenons l'exemple suivant :

  • une application va générer n threads, n étant passé en paramètre
  • la ressource partagée est un compteur qui devra être incrémenté par chaque thread généré
  • à la fin de l'application, la valeur du compteur est affiché. On devrait donc trouver n.

Le programme est le suivant :


' options
Option Explicit On 
Option Strict On

' utilisation de threads
Imports System
Imports System.Threading

Public Class thread3
    ' variables de classe
    Private Shared cptrThreads As Integer = 0

    Public Overloads Shared Sub Main(ByVal args() As [String])
        ' mode d'emploi
        Const syntaxe As String = "pg nbThreads"
        Const nbMaxThreads As Integer = 100

        ' vérification nbre d'arguments
        If args.Length <> 1 Then
            ' erreur
            Console.Error.WriteLine(syntaxe)
            ' arrêt
            Environment.Exit(1)
        End If
        ' vérification qualité de l'argument
        Dim nbThreads As Integer = 0
        Try
            nbThreads = Integer.Parse(args(0))
            If nbThreads < 1 Or nbThreads > nbMaxThreads Then
                Throw New Exception
            End If
        Catch
            ' erreur
            Console.Error.WriteLine("Nombre de threads incorrect (entre 1 et " & nbMaxThreads & ")")
            ' fin
            Environment.Exit(2)
        End Try
        ' création et génération des threads
        Dim threads(nbThreads - 1) As Thread
        Dim i As Integer
        For i = 0 To nbThreads - 1
            ' création
            threads(i) = New Thread(New ThreadStart(AddressOf incrémente))
            ' nommage
            threads(i).Name = "tache_" & i
            ' lancement
            threads(i).Start()
        Next i
        ' attente de la fin des threads
        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()
        ' augmente le compteur de threads
        ' lecture compteur
        Dim valeur As Integer = cptrThreads
        ' suivi
        Console.Out.WriteLine(("A " + DateTime.Now.ToString("hh:mm:ss") & ", le thread " & Thread.CurrentThread.Name & " a lu la valeur du compteur : " & cptrThreads))
        ' attente
        Thread.Sleep(1000)
        ' incrémentation compteur
        cptrThreads = valeur + 1
        ' suivi
        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

Nous ne nous attarderons pas sur la partie génération de threads déjà étudiée. Intéressons-nous plutôt à la méthode incrémente, utilisée par chaque thread pour incrémenter le compteur statique cptrThreads.

  1. le compteur est lu
  2. le thread s'arrête 1 s. Il perd donc le processeur
  3. le compteur est incrémenté

L'étape 2 n'est là que pour forcer le thread à perdre le processeur. Celui-ci va être donné à un autre thread. Dans la pratique, rien n'assure qu'un thread ne sera pas interrompu entre le moment où il va lire le compteur et le moment où il va l'incrémenter. Le risque existe de perdre le processeur entre le moment où on lit la valeur du compteur et celui on écrit sa valeur incrémentée de 1. En effet, l'opération d'incrémentation va faire l'objet de plusieurs instructions élémentaires au niveau du processeur qui peuvent être interrompues. L'étape 2 de sommeil d'une seconde n'est donc là que pour systématiser ce risque. Les résultats obtenus sont les suivants :

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

A la lecture de ces résultats, on voit bien ce qui se passe :

  • un premier thread lit le compteur. Il trouve 0.
  • il s'arrête 1 s donc perd le processeur
  • un second thread prend alors le processeur et lit lui aussi la valeur du compteur. Elle est toujours à 0 puisque le thread précédent ne l'a pas encore incrémenté. Il s'arrête lui aussi 1 s.
  • en 1 s, les 5 threads ont le temps de passer tous et de lire tous la valeur 0.
  • lorsqu'ils vont se réveiller les uns après les autres, ils vont incrémenter la valeur 0 qu'ils ont lue et écrire la valeur 1 dans le compteur, ce que confirme le programme principal (Main).

D'où vient le problème ? Le second thread a lu une mauvaise valeur du fait que le premier avait été interrompu avant d'avoir terminé son travail qui était de mettre à jour le compteur dans la fenêtre. Cela nous amène à la notion de ressource critique et de section critique d'un programme:

  • une ressource critique est une ressource qui ne peut être détenue que par un thread à la fois. Ici la ressource critique est le compteur.
  • une section critique d'un programme est une séquence d'instructions dans le flux d'exécution d'un thread au cours de laquelle il accède à une ressource critique. On doit assurer qu'au cours de cette section critique, il est le seul à avoir accès à la ressource.

8.5. Accès exclusif à une ressource partagée

Dans notre exemple, la section critique est le code situé entre la lecture du compteur et l'écriture de sa nouvelle valeur :


        ' lecture compteur
        Dim valeur As Integer = cptrThreads
        ' attente
        Thread.Sleep(1000)
        ' incrémentation compteur
        cptrThreads = valeur + 1

Pour exécuter ce code, un thread doit être assuré d'être tout seul. Il peut être interrompu mais pendant cette interruption, un autre thread ne doit pas pouvoir exécuter ce même code. La plate-forme .NET offre plusieurs outils pour assurer l'entrée unitaire dans les sections critiques de code. Nous utiliserons la classe Mutex :

Image

Nous n'utiliserons ici que les constructeurs et méthodes suivants :

public Mutex()
crée un objet de synchronisation M
public bool WaitOne()
Le thread T1 qui exécute l'opération M.WaitOne() demande la propriété de l'objet de synchronisation M. Si le Mutex M n'est détenu par aucun thread (le cas au départ), il est "donné" au thread T1 qui l'a demandé. Si un peu plus tard, un thread T2 fait la même opération, il sera bloqué. En effet, un Mutex ne peut appartenir qu'à un thread. Il sera débloqué lorsque le thread T1 libèrera le mutex M qu'il détient. Plusieurs threads peuvent ainsi être bloqués en attente du Mutex M.
public void
ReleaseMutex()
Le thread T1 qui effectue l'opération M.ReleaseMutex() abandonne la propriété du Mutex M.Lorsque le thread T1 perdra le processeur, le système pourra le donner à l'un des threads en attente du Mutex M. Un seul l'obtiendra à son tour, les autres en attente de M restant bloqués

Un Mutex M gère l'accès à une ressource partagée R. Un thread demande la ressource R par M.WaitOne() et la rend par M.ReleaseMutex(). Une section critique de code qui ne doit être exécutée que par un seul thread à la fois est une ressource partagée. La synchronisation d'exécution de la section critique peut se faire ainsi :

M.WaitOne()
' le thread est seul à entrer ici
' section critique
....
M.ReleaseMutex()

où M est un objet Mutex. Il faut bien sûr ne jamais oublier de libérer un Mutex devenu inutile pour qu'un autre thread puisse entrer dans la section critique, sinon les threads en attente d'un Mutex jamais libéré n'auront jamais accès au processeur. Par ailleurs, il faut éviter la situation d'interblocage (deadlock) dans laquelle deux threads s'attendent mutuellement. Considérons les actions suivantes qui se suivent dans le temps :

  • un thread T1 obtient la propriété d'un Mutex M1 pour avoir accès à une ressource partagée R1
  • un thread T2 obtient la propriété d'un Mutex M2 pour avoir accès à une ressource partagée R2
  • le thread T1 demande le Mutex M2. Il est bloqué.
  • le thread T2 demande le Mutex M1. Il est bloqué.

Ici, les threads T1 et T2 s'attendent mutuellement. Ce cas apparaît lorsque des threads ont besoin de deux ressources partagées, la ressource R1 contrôlée par le Mutex M1 et la ressource R2 contrôlée par le Mutex M2. Une solution possible est de demander les deux ressources en même temps à l'aide d'un Mutex unique M. Mais ce n'est pas toujours possible si par exemple cela entraîne une mobilisation longue d'une ressource coûteuse. Une autre solution est qu'un thread ayant M1 et ne pouvant obtenir M2, relâche alors M1 pour éviter l'interblocage. Si nous mettons en pratique ce que nous venons de voir sur l'exemple précédent, notre application devient la suivante :


' options
Option Explicit On 
Option Strict On

' utilisation de threads
Imports System
Imports System.Threading

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

    Public Overloads Shared Sub Main(ByVal args() As [String])
        ' mode d'emploi
        Const syntaxe As String = "pg nbThreads"
        Const nbMaxThreads As Integer = 100

        ' vérification nbre d'arguments
        If args.Length <> 1 Then
            ' erreur
            Console.Error.WriteLine(syntaxe)
            ' arrêt
            Environment.Exit(1)
        End If
        ' vérification qualité de l'argument
        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

        ' initialisation de l'autorisation d'accès à une section critique
        autorisation = New Mutex

        ' création et génération des threads
        Dim threads(nbThreads) As Thread
        Dim i As Integer
        For i = 0 To nbThreads - 1
            ' création
            threads(i) = New Thread(New ThreadStart(AddressOf incrémente))
            ' nommage
            threads(i).Name = "tache_" & i
            ' lancement
            threads(i).Start()
        Next i
        ' attente de la fin des threads
        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()
        ' augmente le compteur de threads
        ' on demande l'autorisation d'entrer dans la secton critique
        autorisation.WaitOne()
        ' lecture compteur
        Dim valeur As Integer = cptrThreads
        ' suivi
        Console.Out.WriteLine(("A " & DateTime.Now.ToString("hh:mm:ss") & ", le thread " & Thread.CurrentThread.Name & " a lu la valeur du compteur : " & cptrThreads))
        ' attente
        Thread.Sleep(1000)
        ' incrémentation compteur
        cptrThreads = valeur + 1
        ' suivi
        Console.Out.WriteLine(("A " & DateTime.Now.ToString("hh:mm:ss") & ", le thread " & Thread.CurrentThread.Name & " a écrit la valeur du compteur : " & cptrThreads))
        ' on rend l'autorisation d'accès
        autorisation.ReleaseMutex()
    End Sub
End Class

Les résultats obtenus sont conformes à ce qui était attendu :

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. Synchronisation par événements

Considérons la situation suivante, appelée parfois situation de producteurs-consommateurs.

  1. On a un tableau dans lequel des processus viennent déposer des données (les producteurs) et d'autres viennent les lire (les consommateurs).
  2. Les producteurs sont égaux entre-eux mais exclusifs : un seul producteur à la fois peut déposer ses données dans le tableau.
  3. Les consommateurs sont égaux entre-eux mais exclusifs : un seul lecteur à la fois peut lire les données déposées dans le tableau.
  4. Un consommateur ne peut lire les données du tableau que lorsqu'un producteur en a déposé dedans et un producteur ne peut déposer de nouvelles données dans le tableau que lorsque celles qui y sont ont été consommées.

On peut dans cet exposé distinguer deux ressources partagées :

    1. le tableau en écriture
    2. le tableau en lecture

L'accès à ces deux ressources partagées peut être contrôlée par des Mutex comme vu précédemment, un pour chaque ressource. Une fois qu'un consommateur a obtenu le tableau en lecture, il doit vérifier qu'il y a bien des données dedans. On utilisera un événement pour l'en avertir. De même un producteur ayant obtenu le tableau en écriture devra attendre qu'un consommateur l'ait vidé. On utilisera là encore un événement.

Les événements utilisés feront partie de la classe AutoResetEvent :

Image

Ce type d'événement est analogue à un booléen mais évite des attentes actives ou semi-actives. Ainsi si le droit d'écriture est contrôlé par un booléen peutEcrire, un producteur avant d'écrire exécutera un code du genre :

while(peutEcrire==false)        ' attente active

ou

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

Dans la première méthode, le thread mobilise inutilement le processeur. Dans la seconde, il vérifie l'état du booléen peutEcrire toutes les 100 ms. La classe AutoResetEvent permet encore d'améliorer les choses : le thread va demander à être réveillé lorsque l'événement qu'il attend se sera produit :

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

L'opération

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

initialise le booléen peutEcrire à false. L'opération

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

exécutée par un thread fait que celui-ci passe si le booléen peutEcrire est vrai ou sinon est bloqué jusqu'à ce qu'il devienne vrai. Un autre thread le passera à vrai par l'opération peutEcrire.Set() ou à faux par l'opération peutEcrire.Reset().

Le programme de producteurs-consommateurs est le suivant :


' utilisation de threads lecteurs et écrivains
' illustre l'utilisation simultanée de ressources partagées et de synchronisation

' options
Option Explicit On 
Option Strict On

' utilisation de threads
Imports System
Imports System.Threading

Public Class lececr

    ' variables de classe
    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])

        ' le nbre de threads à générer
        Const nbThreads As Integer = 3

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

        ' initialisation des variables de synchronisation
        lecteur = New Mutex         ' synchronise les lecteurs
        écrivain = New Mutex         ' synchronise les écrivains

        ' création des threads lecteurs
        Dim lecteurs(nbThreads) As Thread
        Dim i As Integer
        For i = 0 To nbThreads - 1
            ' création
            lecteurs(i) = New Thread(New ThreadStart(AddressOf lire))
            lecteurs(i).Name = "lecteur_" & i
            ' lancement
            lecteurs(i).Start()
        Next i

        ' création des threads écrivains
        Dim écrivains(nbThreads) As Thread
        For i = 0 To nbThreads - 1
            ' création
            écrivains(i) = New Thread(New ThreadStart(AddressOf écrire))
            écrivains(i).Name = "écrivain_" & i
            ' lancement
            écrivains(i).Start()
        Next i

        'fin de main
        Console.Out.WriteLine("fin de Main...")
    End Sub

    ' lire le contenu du tableau
    Public Shared Sub lire()
        ' section critique
        lecteur.WaitOne()        ' un seul lecteur peut passer
        peutLire.WaitOne()        ' on doit pouvoir lire

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

        ' on ne peut plus lire
        peutLire.Reset()
        ' on peut écrire
        peutEcrire.Set()
        ' fin de section critique
        lecteur.ReleaseMutex()
    End Sub

    ' écrire dans le tableau
    Public Shared Sub écrire()
        ' section critique
        ' un seul écrivain peut passer
        écrivain.WaitOne()
        ' on doit attendre l'autorisation d'écriture
        peutEcrire.WaitOne()

        ' écriture tableau
        Dim i As Integer
        For i = 0 To data.Length - 1
            'attente 1 s
            Thread.Sleep(1000)
            ' affichage
            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

        ' on ne peut plus écrire
        peutEcrire.Reset()
        ' on peut lire
        peutLire.Set()
        'fin de section critique
        écrivain.ReleaseMutex()
    End Sub
End Class

L'exécution donne les résultats suivants :

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

On peut remarquer les points suivants :

  • on a bien 1 seul lecteur à la fois bien que celui-ci perde le processeur dans la section critique lire
  • on a bien 1 seul écrivain à la fois bien que celui-ci perde le processeur dans la section critique écrire
  • un lecteur ne lit que lorsqu'il y a quelque chose à lire dans le tableau
  • un écrivain n'écrit que lorsque le tableau a été entièrement lu