.NET et les threads

DrQ

Ce document a pour but de présenter les threads avec .NET. Il parle des traitements multithreadés ainsi que de l'interaction entre un thread et l'interface utilisateurs.

N'hésitez pas à commenter cet article ! Commentez ♪

- Titre : .NET et les threads

- Auteur : DrQ

- Parution : 14 juillet 2003

- Licence : Ce document est issu de http://www.developpez.com et reste la propriété exclusive de son auteur. La copie, modification et/ou distribution par quelque moyen que ce soit est soumise à l'obtention préalable de l'autorisation de l'auteur.

Sommaire

- I. Introduction

- II. Création d'un thread

- III. Synchronisation de deux threads

- IV. Utilisation partagée des ressources

- V. Mettre à jour l'IHM pendant un traitement

- V-A. Méthode Invoke

- V-B. Méthode BeginInvoke

- VI. Les exclusions mutuelles

- VI-A. La classe Monitor

- VI-B. La classe Mutex

I - Introduction

Le multithreading est utilisé un peu partout. Vous voulez faire une application réseau et vous avez besoin de traiter les données de façon parallèle, ou bien vous voulez paralléliser des traitements de calcul, mettre à jour une interface graphique pendant qu'un traitement se déroule… Lorsque vous faites du multithreading, des « problèmes » apparaissent comme le partage de ressources ou l'interbloquage. Vous trouverez dans ce document comment manipuler les threads et comment gérer les problèmes liés au multithreading.

II - Création d'un thread

Afin de pouvoir travailler avec les threads il faut utiliser l'espace de nom « System.Threading ». Dans toutes vos classes utilisant les threads vous devez mettre :

Utilisation de l'espace de nom System.Threading

using System.Threading;

Pour un premier exemple nous allons créer un thread qui rajoute 1 à une variable. Nous y verrons comment « endormir » un thread pour une durée déterminée, comment mettre un thread en pause, comment le relancer et comment l'interrompre.

Exemple

using System;
using System.Threading;

namespace ThreadTest
{
class Class1
{
int cmpt;

[STAThread]
static void Main(string[] args)
{
Class1 c = new Class1();

c.LaunchTest();
}

public void LaunchTest(){
Thread t = new Thread(new ThreadStart(NextStep));
t.Start();
for(cmpt=0;cmpt<10;) {
Console.Out.WriteLine(cmpt.ToString());
// Suspend le thread d'incrémentation
t.Suspend();
// Suspend le thread principal pendant 1/2 seconde
Thread.Sleep(500);
// Relance le thread de comptage
t.Resume();
}
// Arrête le thread de comptage
t.Abort();
Console.In.ReadLine();
}

public void NextStep(){
while(true) {
cmpt++;
// Suspend le thread de comptage pendant 1s
Thread.Sleep(500);
}
}
}
}

Si vous lancez l'application plusieurs fois vous verrez que l'affichage varie. Donc aucune synchronisation entre les threads.

La méthode statique Thread.Sleep permet de bloquer le thread dans lequel le code s'exécute pendant une durée donnée en paramètre (durée en millisecondes). Lorsque vous faites un Threads.Sleep(500) dans le for cela n'arrête pas la boucle de la méthode NextStep. Vous vous en rendez plus compte avec ce code :

Exemple

using System;
using System.Threading;
using System.Reflection;

namespace ThreadTest {
class Class1 {
int cmpt;

[STAThread]
static void Main(string[] args) {
Console.Out.WriteLine();

Class1 c = new Class1();
c.LaunchTest();
}

public void LaunchTest(){
Thread t = new Thread(new ThreadStart(NextStep));
cmpt=0;
t.Start();
for(int i=0;i<10;i++) {
Console.Out.WriteLine(cmpt.ToString());
// Suspend le thread d'incrémentation
t.Suspend();
// Suspend le thread principal pendant 1/2 seconde
Thread.Sleep(500);
// Relance le thread de comptage
t.Resume();
}
// Arrête le thread de comptage
t.Abort();
Console.In.ReadLine();
}

public void NextStep(){
while(true) {
cmpt++;
}
}
}
}

La méthode Suspend() permet de suspendre le thread.

La méthode Resume() permet de relancer le thread.

La méthode Abrort() permet d'arrêter le thread.

III - Synchronisation de deux threads

Dans la partie précédente, les threads ne sont pas du tout synchronisés. Le but de cette partie est de montrer une façon de synchroniser deux threads. Pour cela il faut utiliser les délégués ou plus précisément les évènements.

Afin de bien comprendre, nous reprendrons l'exemple du chapitre 2 et le résultat ne sera affiché que lorsque le compteur sera incrémenté.

Synchronisation

using System;
using System.Threading;

namespace ThreadTest {
class Class1 {
int cmpt;
bool ok1;
bool ok2;

delegate void CmptIncEventHandler();
private event CmptIncEventHandler Thread1Event;
private event CmptIncEventHandler Thread2Event;
Thread t1;
Thread t2;

public Class1() {
// Évènement permettant la synchronisation inter threads
this.Thread1Event += new CmptIncEventHandler(Th1OK);
this.Thread2Event += new CmptIncEventHandler(Th2OK);
}

[STAThread]
static void Main(string[] args) {
Console.Out.WriteLine();

Class1 c = new Class1();
c.LaunchTest();
}

public void LaunchTest(){
t1 = new Thread(new ThreadStart(Th1));
t2 = new Thread(new ThreadStart(Th2));
cmpt=0;
t1.Start(); // Lance le thread1
t2.Start(); // Lance le thread2
ok1=true; // Commence par le thread1
while(cmpt<10){
Thread.Sleep(0);
}
t1.Abort();
t2.Abort();
Console.In.ReadLine();
}

public void Th1OK() {
ok2=true;
}

public void Th2OK() {
ok1=true;
}

public void Th1(){
while(true) {
if(ok1) {
ok1=false;
cmpt++;
Console.Out.WriteLine("[1]cmpt=" + cmpt.ToString());
Thread1Event();
}
Thread.Sleep(10);
}
}

public void Th2(){
while(true) {
if(ok2) {
ok2=false;
cmpt++;
Console.Out.WriteLine("[2]cmpt=" + cmpt.ToString());
Thread2Event();
}
Thread.Sleep(10);
}
}
}
}

Vous obtiendrez un résultat du style :

Résultat

[1]cmpt=1
[2]cmpt=2
[1]cmpt=3
[2]cmpt=4
[1]cmpt=5
[2]cmpt=6
[1]cmpt=7
[2]cmpt=8
[1]cmpt=9
[2]cmpt=10

Vous constatez que les threads traitent la même donnée l'un après l'autre.

Le fait de travailler sur la même donnée ou même ressource peut poser problème. Dans l'exemple précédent, le problème ne se pose pas, car les deux threads sont synchronisés et donc ne peuvent pas accéder à la même ressource en même temps. Qu'en est-il de deux processus non synchronisés ? Nous verrons cela dans le chapitre suivant « Partage de ressources ».

IV - Utilisation partagée des ressources

Cette partie montre le cas de partage de ressources. Comment éviter que deux threads altèrent la ressource en même temps ? Ceci est faisable par le mot clé lock qui permet de bloquer une ressource. Son utilisation est relativement simple, vous mettez le code utilisant la ressource dans lebloc du lock.

Utilisation du lock

using System;
using System.Threading;

namespace ThreadTest {
class MaRessource {
int m_mavar;

public MaRessource(){
m_mavar = 0;
}

public int MaVar {
get{return m_mavar;}
set{m_mavar = value;}
}
}

class Class1 {
MaRessource res;

public Class1() {
res = new MaRessource();
}

[STAThread]
static void Main(string[] args) {
Console.Out.WriteLine();

Class1 c = new Class1();
c.LaunchTest();
}

public void LaunchTest(){
Thread t1 = new Thread(new ThreadStart(Th1));
t1.Start(); // Lance le thread1
lock(res){
Console.Out.WriteLine(DateTime.Now.ToString("[1]hh:mm:ss > ") + res.MaVar);
res.MaVar = 10;
Thread.Sleep(2000);
}
lock(res){
Console.Out.WriteLine(DateTime.Now.ToString("[3]hh:mm:ss > ") + res.MaVar);
}
Console.In.ReadLine();
}

public void Th1(){
Thread.Sleep(10);
lock(res){
res.MaVar=50;
Console.Out.WriteLine(DateTime.Now.ToString("[2]hh:mm:ss > ") + res.MaVar);
}
}
}
}

En résultat nous obtenons :

Résultat

[1]07:31:58 > 0
[2]07:32:00 > 50
[3]07:32:00 > 50

Comment .NET a-t-il réagi ? Le sleep dans la méthode Th1 permet de laisser le temps au thread principal de rentrer dans le lock. Lorsque le lock de la méthode Th1 est effectué, il s'aperçoit que la ressource res est déjà bloquée. Il met donc le lock sur la liste d'attente jusqu'à ce que le lock actuel ne soit plus. Même réaction lorsque le deuxième lock de LaunchTest est appelé, il est mis en attente jusqu'à ce que le lock de Th1 soit terminé. Ainsi une même ressource ne peut être utilisée en même temps dans différents threads.

V - Mettre à jour l'IHM pendant un traitement

Pour la mise à jour d'une interface graphique, il faut aussi passer par les délégués, mais cela ne suffit pas. Pour fonctionner correctement, vous devez utiliser soit la méthode Invoke soit BeginInvoke.
Pour exemple, un thread incrémentera une barre de progression.

V-A - Méthode Invoke

La méthode Invoke est « bloquée », c'est-à-dire rend la main qu'après le retour de la méthode appelée par le délégué. Attention cette ne peut être qu'à partir du moment que le contrôle appelant n'a pas été créé dans le thread dans lequel le Invoke est fait : ce cas peut se produire lors de la création dynamique de contrôles. Pour résoudre ce problème, voir le point suivant.

La première chose à faire est de déclarer le délégué :

Déclaration du délégué

private delegate void ProgressBarDelegateHandler(int step);
private ProgressBarDelegateHandler ProgressBarDelegate;

Ensuite il faut une méthode de même prototype que le délégué :

Méthode de mise à jour

private void UpdateProgressBar(int step)
{
// Mise à jour de la barre de progression
progressBar1.Value = step;
}

Le lien entre le délégué et la méthode de mise à jour se fait de la façon suivante :

ProgressBarDelegate = new ProgressBarDelegateHandler(UpdateProgressBar);

Dans la méthode tournant dans le thread séparé, l'appel de la mise à jour se fait avec la méthode Invoke de la façon suivante :

this.Invoke(this.ProgressBarDelegate,new object[] {i});

Voici le code complet :

Exemple

using System;
using System.Drawing;
using System.ComponentModel;
using System.Windows.Forms;
using System.Threading;

namespace ThreadTest
{
public class Form1 : System.Windows.Forms.Form
{
// Déclaration du délégué nécessaire à la mise à jour
private delegate void ProgressBarDelegateHandler(int step);
private ProgressBarDelegateHandler ProgressBarDelegate;

private System.Windows.Forms.Button button1;
private System.Windows.Forms.ProgressBar progressBar1;
private System.ComponentModel.Container components = null;

public Form1()
{
InitializeComponent();
// Sur appel du délégué, la méthode UpdateProgressBar est donc appelé
ProgressBarDelegate = new ProgressBarDelegateHandler(UpdateProgressBar);
}

protected override void Dispose( bool disposing )
{
if( disposing )
{
if(components != null)
{
components.Dispose();
}
}
base.Dispose( disposing );
}

private void InitializeComponent()
{
this.button1 = new System.Windows.Forms.Button();
this.progressBar1 = new System.Windows.Forms.ProgressBar();
this.SuspendLayout();
this.button1.Location = new System.Drawing.Point(432, 96);
this.button1.Name = "button1";
this.button1.Size = new System.Drawing.Size(96, 40);
this.button1.TabIndex = 0;
this.button1.Text = "GO";
this.button1.Click += new System.EventHandler(this.button1_Click);
this.progressBar1.Location = new System.Drawing.Point(16, 104);
this.progressBar1.Name = "progressBar1";
this.progressBar1.Size = new System.Drawing.Size(344, 24);
this.progressBar1.TabIndex = 1;
this.AutoScaleBaseSize = new System.Drawing.Size(5, 13);
this.ClientSize = new System.Drawing.Size(568, 301);
this.Controls.Add(this.progressBar1);
this.Controls.Add(this.button1);
this.Name = "Form1";
this.Text = "Form1";
this.ResumeLayout(false);
}

private void button1_Click(object sender, System.EventArgs e) {
progressBar1.Minimum = 0;
progressBar1.Maximum = 10;
// Lancement du thread
Thread t = new Thread(new ThreadStart(ThreadProcess));
t.Start();
}

private void ThreadProcess() {
for(int i = 0 ; i < 11 ; i++)
{
// Déclenche la mise à jour de l'interface
this.Invoke(this.ProgressBarDelegate,new object[] {i});
Thread.Sleep(1000);
}
}

private void UpdateProgressBar(int step)
{
// Mise à jour de la barre de progression
progressBar1.Value = step;
}
}
}

V-B - Méthode BeginInvoke

Contrairement à la méthode Invoke, la méthode BeginInvoke n'est pas « bloquée ». Par contre une seconde méthode permet d'attendre la fin de l'exécution de la méthode lancée : EndInvoke. De plus BeginInvoke peut être appelée de n'importe quel thread.

Tout comme pour la méthode Invoke, il faut déclarer le délégué :

private delegate void InvokeMethod(int step);

Ensuite vous pouvez utiliser les deux méthodes BeginInvoke et EndInvoke :

IAsyncResult res = progressBar1.BeginInvoke(new InvokeMethod(UpdateProgressBar),new object[] {i});
progressBar1.EndInvoke(res);

Afin que vous puissiez tester, voici le code complet :

using System;
using System.Drawing;
using System.Collections;
using System.ComponentModel;
using System.Windows.Forms;
using System.Threading;

namespace ThreadTest
{
public class Form2 : System.Windows.Forms.Form
{

private delegate void InvokeMethod(int step);

private System.Windows.Forms.ProgressBar progressBar1;
private System.Windows.Forms.Button button1;
private System.ComponentModel.Container components = null;

public Form2()
{
InitializeComponent();
}

protected override void Dispose( bool disposing )
{
if( disposing )
{
if(components != null)
{
components.Dispose();
}
}
base.Dispose( disposing );
}

private void InitializeComponent()
{
this.progressBar1 = new System.Windows.Forms.ProgressBar();
this.button1 = new System.Windows.Forms.Button();
this.SuspendLayout();
this.progressBar1.Location = new System.Drawing.Point(8, 16);
this.progressBar1.Name = "progressBar1";
this.progressBar1.Size = new System.Drawing.Size(344, 24);
this.progressBar1.TabIndex = 3;
this.button1.Location = new System.Drawing.Point(424, 8);
this.button1.Name = "button1";
this.button1.Size = new System.Drawing.Size(96, 40);
this.button1.TabIndex = 2;
this.button1.Text = "GO";
this.button1.Click += new System.EventHandler(this.button1_Click);
this.AutoScaleBaseSize = new System.Drawing.Size(5, 13);
this.ClientSize = new System.Drawing.Size(544, 285);
this.Controls.Add(this.progressBar1);
this.Controls.Add(this.button1);
this.Name = "Form2";
this.Text = "Form2";
this.ResumeLayout(false);

}

private void button1_Click(object sender, System.EventArgs e) {
this.progressBar1.Minimum=0;
this.progressBar1.Maximum=10;
Thread t = new Thread(new ThreadStart(ThreadProcess));
t.Start();
}

private void ThreadProcess() {
IAsyncResult res;
for(int i = 0 ; i < 11 ; i++) {
res = progressBar1.BeginInvoke(new InvokeMethod(UpdateProgressBar),new object[] {i});
progressBar1.EndInvoke(res);
Thread.Sleep(1000);
}
}

private void UpdateProgressBar(int step) {
this.progressBar1.Value = step;
}
}
}

Déclaration du délégué

private delegate void ProgressBarDelegateHandler(int step);

VI - Les exclusions mutuelles

En complément du chapitre 4, il existe deux classes permettant de gérer le partage de ressources. Ces deux classes sont la classe Monitor et la classe Mutex.

VI-A - La classe Monitor

La classe Monitor est très simple d'utilisation. Jusqu'à présent on a vu comment faire pour paralléliser les traitements, maintenant nous allons forcer un traitement à être synchrone. À quoi ça peut-il servir ? Prenez le cas où votre application déclenche un traitement et vous devez attendre la fin de ce traitement pour pouvoir continuer. Mais voilà, le traitement que vous déclenchez se fait en arrière-plan et vous n'avez pas de contrôle dessus si ce n'est un évènement vous disant qu'il est fini.

Prenons pour exemple un composant que l'on appellera ComponentUsed qui possède une méthode Start() qui déclenche le traitement dans un thread séparé : le code continue après l'appel de Start même si le traitement n'est pas fini. Un évènement EventFinish vous indique que le traitement est terminé.

La classe Monitor ne s'instancie pas. Les méthodes disponibles sont Enter, TryEnter, Exit, Pulse, PulseAll, Wait. Les méthodes Enter/TryEnter et Exit vont ensemble ainsi que Pulse/PulseAll et Wait vont ensemble.

Monitor.Enter/Monitor.Exit représente la même chose que le mot clé lock. Cela permet de dire qu'une zone est critique et empêche deux threads d'entrer dans la zone en même temps.

Monitor.Enter(this);
try
{
// ...
}
catch(Exception){/*empty catch*/}
finally
{
Monitor.Exit(this);
}

// est identique à

lock(this)
{
// ...
}

Monitor.Wait permet d'attendre un signal indiquant que le processus peut repartir. Pour reprendre notre exemple, vous effectuez un Monitor.Wait() juste après le ComponentUsed.Start(). L'exécution sera bloquée. Dans l'évènement EventFinish vous faites un Monitor.Pulse() ainsi vous débloquez le thread bloqué par Monitor.Wait().

Si vous faites plusieurs Monitor.Wait(), ces derniers sont empilés. À chaque appel de Monitor.Pulse() vous libérez le plus ancien. Si vous voulez les débloquer tous, vous pouvez faire un Monitor.PulseAll();

Dans le cas où vous ne voulez pas attendre indéfiniment un Monitor.Pulse() vous pouvez indiquer une durée en millisecondes d'attente maximale à la méthode Monitor.Wait(object obj, int millisecond).

VI-B - La classe Mutex

La classe Mutex est similaire aux méthodes Enter et Exit de la classe Monitor, mais, mais de façon bien plus large : grâce à la classe Mutex les exclusions peuvent se faire interapplications.

Pour cela vous devez utiliser les mutex nommés.

Mutex m = new Mutex("MonAppli de test");

Afin de prendre possession du mutex vous devez utiliser la méthode WaitOne(). Cette méthode attend que le mutex soit disponible pour en prendre possession. Afin de ne pas être bloquée indéfiniment, elle peut prendre en paramètre une durée maximale d'attente. Si l'objet reçoit un signal avant le temps écoulé alors la méthode retourne true sinon false.

Pour libérer le mutex vous devez utiliser la méthode ReleaseMutex();

Mutex m = new Mutex("MonAppli de test");
m.ReleaseMutex();