Les threads
Un article de Mangue.org, l'encyclopéde libre.
Dans ce cours nous allons apprendre à manipuler les threads, en créer, les démarrer, les stopper, les contrôler ; tout cela grâce au namespace System.Thread.
| Sommaire |
Description
Un thread est l'unité de base à laquelle le système d'exploitation alloue du temps processeur. Dans un système multitâche, le processeur n'exécute pas les tâches demandées les unes après les autres. Il bascule constamment de tâche en tâche, (de thread en thread) ce qui créé l'illusion qu'il exécute plusieurs opérations simultanément, car il passe un temps très court sur chaque tâche. Ce laps de temps est appelé quantum. A chaque basculement d'un thread à l'autre, il convient bien sûr de restaurer l'état dans lequel était le processeur lorsqu'il est passé de la tâche qu'il vient de reprendre à une autre. C'est ce qu'on appelle le contexte d'exécution. Ce contexte est composé des différents registres et piles de la machine sur laquelle il tourne.
Un processus peut créer un ou plusieurs threads pour exécuter des portions de code de programmes associées avec le processus. Les threads s'exécutent dans un domaine d'application, cependant un même thread peut fonctionner indifféremment dans un domaine ou un autre. Chaque domaine est démmaré avec un seul et unique thread, mais il est possible de créer de nouveaux threads à partir de n'importe lequel de ces threads.
Chaque thread gère des exceptions, une priorité, et ensemble de structures que le système sauvegarde pour restaurer son contexte d'exécution quand le thread sera à nouveau au premier plan.
La gestion des threads peut se faire de deux façons: un technique consiste à utiliser la classe ThreadPool, qui offre une gestion simplifiée et efficace des threads, et l'autre laisse le programmeur plus libre, cela se faisant au prix d'une utilisation plus complexe.
Commençons avec la gestion simplifiée qu'offre la classe ThreadPool.
ThreadPool
La méthode du "thread pooling" permet, en contrepartie de la perte du contrôle total de la vie des threads, de faire un usage très efficace du multithreading. Il n'est pas rare de rencontrer des applications utilisant plusieurs threads, mais souvent ces threads passent la majeure partie du temps en veille, à attendre qu'un évènement se produise pour se mettre en route, parfois même pour retourner à l'état de veille quelques cycles plus tard.
L'utilisation du thread pooling permet de fournir à votre application un ensemble de threads gérés par le système, de façon à ce que vous puissiez concentrer vos efforts sur la partie applicative de votre programme et non la gestion des threads. Cette méthode est particulièrement recommandée si votre application dispose de plusieurs tâches demandant plus d'un thread. Dans ce cas, le thread pooling permet au système d'optimiser l'utilisation des threads afin d'améliorer les performances de votre application, mais aussi celles des autres processus. (ce que vous ne pourriez faire à la main, votre programme n'ayant aucune connaissance des autres applications) Le thread pooling optimise en fait le temps alloué à chaque thread, non en se référant uniquement aux threads de votre application, mais en le faisant par rapport à tous les processus du système, ce qui bénéficie donc à tous.
En général, on met dans la thread pool des opérations qui sont liées à des évènements précis. Un thread est donc créé avec pour mission de surveiller le statut de ces évènements. Quand un évènement attendu arrive, le thread correspondant de la thread pool se met en marche. On peut également mettre dans la thread pool des opérations qui ne sont pas assujetties à une attente particulière, afin qu'elle soient exécutées par un thread dédié à elles. Une fois qu'un travail a été mis en queue dans la thread pool, il n'y a aucun moyen de l'annuler.
En théorie, le nombre d'opération possible dans une thread pool n'est limité que par la mémoire de la machine sur laquelle elle réside, mais dans les faits, une thread pool a une contenance par défaut de 25 threads par processeur. Chaque thread est démarré avec la priorité par défaut, et chaque processus ne peut avoir qu'une thread pool. Il en découle qu'il n'y a qu'une thread pool par domaine d'application. Elle est créé au premier appel de QueueUserWorkItem, ou bien quand un timer ou un opération qui était en attente ajoute une opération à la queue.
Bien sûr, la thread pool s'occupe de choisir le bon domaine d'application quand elle démarre une opération.
Intéressons-nous maintenant aux méthodes de la classe ThreadPool. Elles sont toutes statiques, et nous ne sommes donc jamais amenés à instancier cette classe. (elle n'a de toutes façons pas de constructeur)
GetMaxThreads
Cette méthode récupère le nombre de requêtes que la thread pool peut mettre en queue en même temps. Une fois ce nombre dépassé, tout les requêtes arrivant dans la thread pool seront bloquées tant que les autres requêtes n'auront pas fini leur travail.
void GetMaxThreads( out int nbThreads, out int nbAsyncIOThreads )
Après l'appel de cette méthode, nbThreads contiendra le nombre maximum de threads pouvant travailler en même temps et nbAsyncIOThreads le nombre maximum de threads pratiquant des entrées/sorties asynchrones pouvant travailler au même moment.
GetAvailableThreads
Cett méthode récupère le nombre de requêtes qu'il est possible d'ajouter avant d'atteindre la limite définie par GetMaxThreads.
void GetAvailableThreads( out int nbThreads, out int nbAsyncOIThreads )
Après l'appel de cette méthode, nbThreads contiendra le nombre maximum de threads pouvant être rajoutés dans la threadpool avant d'atteindre la limite et nbAsyncIOThreads le nombre maximum de threads pratiquant des entrées/sorties asynchrones pouvant être rajoutés dans la threadpool avant d'atteindre la limite.
QueueUserWorkItem
Cette méthode ajoute une opération dans le queue. Elle sera démarrée dès que le nombre de threads actifs le permettra. L'opération à ajouter est spécifiée en paramètre par objet de type WaitCallback qui représente un délégué sur la méthode à appeler.
bool QueueUserWorkItem( WaitCallback fonction )
La méthode retourne true si elle s'est exécutée correctement, false sinon.
Pour référencer une méthode avec l'objet WaitCallback, nous devons écrire:
WaitCallback fonction = new WaitCallback( maMéthode );
Attention, la méthode référencée doit obligatoirement accepter un object en paramètre, et uniquement lui. Ce qui donne un prototype de la forme:
void maMéthode( object o )
Pour rajouter un travail dans la thread pool, nous ferons:
ThreadPool.QueueUserWorkItem( new WaitCallback( maMéthode ) );
Cette façon de procéder ne permet malheureusement pas de passer des paramètres à la méthode appelée. Pour cela, il existe une autre version de QueueUserWorkItem, qui prend, en plus du WaitCallback, un object en paramètre. Cet objet sera passé en paramètre à la fonction référencée par le WaitCallback. Si la méthode appelée prend un entier en paramètre, nous pourrons tout à fait écrire:
ThreadPool.QueueUserWorkItem( new WaitCallback( maMéthode ), 5 );
Attention cependant, le paramètre étant passé en tant qu'object, la méthode devra accepter un object en paramètre, et non pas explicitement un entier.
public maMéthode( Object o ) { }
RegisterWaitForSingleObjet
Cette méthode introduit la notion de synchronisation. Tout comme QueueUserWorkItem, elle ajoute une opération dans le queue. Elle sera démarrée suivant le WaitHandle passé en paramètre, qui est assorti d'un temps limite lui aussi passé en paramètre. L'opération à ajouter est spécifiée en paramètre par une objet de type WaitCallback qui représente le délégué à invoquer.
Suivant les versions de la méthode, la limite de temps peut être exprimée sous forme d'int, de long, de TimeSpan ou de uint. Attention si vous souhaiter profiter des possibilités d'interopérabilité de .NET, cette dernière version n'est pas conforme avec la CLS.
Nous nous intéresserons à la version utilisant un int pour représenter la limite de temps. Nous reviendrons sur les spécificités de chaque version dans quelques lignes.
RegisteredWaitHandle RegisterWaitForSingleObjet( WaitHandle condition,
WaitOrTimerCallback méthode, object paramètres, int tempsLimite, bool exécuterUneFois )
Le premier paramètre représente est une instance de WaitHandle. Nous reviendrons sur cet objet dans quelques lignes. Le deuxième argument représente la méthode à appeler. L'objet passé en troisième paramètre est le paramètre à passer à la méthode référencée par l'objet WaitOrTimerCallback. Ce dernier fonctionne comme un objet WaitCallback, à une différence près: la méthode référencée doit maintenant, en plus de l'object, accepter un booléen en paramètre, comme par exemple:
void maMéthode( object o, bool signaled )
Le quatrième paramètre réprésente le temps limite avant lequel doit démarrer la méthode. Si elle n'a pas démarré dans le temps imparti, la méthode ne sera pas appelée. Le booléen indique, s'il est à true, que la méthode ne sera appelée qu'une seule fois. S'il est à false, la méthode sera exécutée à chaque fois que l'évènement passé en premier paramètre sera signalé.
La méthode retourne un objet RegisteredWaitHandle. Cet objet contient une méthode Unregister, qui permet de retirer le wait handle de la liste d'évènements à surveiller.
Examinons à présent l'objet WaitHandle. Cette classe abstraite est utilisée par les objets qui assurent la gestion de la synchronisation. Les classes qui en sont dérivées utilisent un mécanisme de signaux pour indiquer qu'elle prennent ou libèrent l'accès exclusif à une ressource partagée. Par exemple, on peut imaginer que plusieurs threads vont modifier la même variable globale. Dans ce cas, il serait bien sûr préférable d'éviter que plusieurs threads accèdent en même temps à cette variable, ce qui peut poser des problèmes si un thread modifie la valeur qu'un autre thread est en train de lire. C'est ce genre de situation que permet d'éviter la synchronisation.
Lorsqu'on parle de synchronisation des threads, un évènement désigne un objet qui peut avoir deux états: signalé et non-signalé. C'est ce à quoi nous ferons référence quand nous parlerons ici d'évènement.
Il existe trois classes dérivées de WaitHandle:
AutoResetEvent: cet évènement, une fois signalé, le reste jusqu'à ce que le thread concerné par cet évènement en prenne connaissance. Il est ensuite immédiatement basculé à l'état non-signalé.
ManualResetEvent: cet évènement fonctionne comme AutoResetEvent, à la différence qu'il ne repasse pas automatiquement à l'état non-signalé. Cela doit être fait manuellement.
Ces deux évènements fonctionnant de la même façon, nous allons les détailler tous les deux ensemble.
Le constructeur de ces deux classes prend un booléen en paramètre. Il indique, s'il est à true, que la classe doit être initialisée à l'état signalé. S'il est à false, la classe sera initialisée avec l'état non-signalé.
Pour passer à l'état signalé, nous utilisons la méthode Set. Elle retourne un booléen qui indique le succès ou non de l'opération.
Pour passer à l'état non-signalé, nous utilisons la méthode Reset. Elle retourne un booléen qui indique le succès ou non de l'opération.
Enfin, une méthode permet de bloquer le thread associé en attendant un signal: c'est la méthode WaitOne. Elle retourne un booléen qui indique si le signal a été ou non reçu. Il existe une version sans paramètre, et deux qui en acceptent. Toutes les deux prennent en premier paramètre une donnée représentant le temps maximum à attendre, exprimé soit par un int, soit par un TimeSpan. Pour les deux méthodes, le second paramètre est un booléen qui indique si, à l'issue de l'attente, le thread doit reprendre le contexte dans lequel il était avant le début de l'attente.
Voici un exemple pour illustrer l'utilisation de ces deux évènements:
public static void Main(string[] args)
{
AutoResetEvent arev = new AutoResetEvent(false);
for(int i=0; i<30;i++)
{
ThreadPool.RegisterWaitForSingleObject(
arev,
new WaitOrTimerCallback(FonctionDuThread),
i,
-1,
true
);
}
arev.Set();
Console.ReadLine();
}
public static void FonctionDuThread( object O, bool signaled )
{
Thread.Sleep(2000);
Console.WriteLine("sleep2000 fini {0}", (int) O);
}
Le programme créé un évènement AutoResetEvent à l'état non-signalé, et ajoute 30 threads dans la thread pool. Ces threads exécutent une méthode qui reste en veille deux secondes, puis affiche un message dans la console. Le démarrage des threads est lié à l'AutoResetEvent, et n'a pas de temps limite. De plus, il sera passé un argument à la méthode appelée dans chaque thread: cet argument est le compteur de la boucle for dans laquelle se fait la création des threads, ce qui permet d'assigner un numéro différent à chaque thread. Pour finir, chaque thread ne s'exécutera qu'une fois.
Le lancement de ce programme produira la sortie suivante:
sleep2000 fini 0
On pourrait s'attendre à ce que tous les threads soient démarrés lorsque nous signalons l'AutoResetEvent, avec:
arev.Set();
Mais ce serait oublier que nous avons un évènement qui se remet automatiquement à l'état non-signalé. En effet, dès que le premier thread aura reçu le signal de l'évènement, ce dernier se remettra à l'état non-signalé, et les autres threads ne recevront aucun signal.
Si nous souhaitons démarrer tous les threads, il est préférable d'utiliser un ManualResetEvent, qui restera signalé après qu'on aie appelé sa méthode Set.
Le programme principal devient:
public static void Main(string[] args)
{
ManualResetEvent arev = new ManualResetEvent(false);
for(int i=0; i<30;i++)
{
ThreadPool.RegisterWaitForSingleObject(
arev,
new WaitOrTimerCallback(FonctionDuThread),
i,
-1,
true
);
}
arev.Set();
Console.ReadLine();
}
Le programme donnera une sortie qui ressemblera à la suivante:
sleep2000 fini 0 sleep2000 fini 1 sleep2000 fini 2 sleep2000 fini 3 sleep2000 fini 4 sleep2000 fini 5 sleep2000 fini 7 sleep2000 fini 6 sleep2000 fini 9 sleep2000 fini 8 sleep2000 fini 11 sleep2000 fini 10 sleep2000 fini 13 sleep2000 fini 12 sleep2000 fini 14 sleep2000 fini 17 sleep2000 fini 15
etc jusqu'à arriver au thread 29...
Mutex: Si un thread acquiert le mutex, un second thread qui voudrait en faire autant sera suspendu le temps que le premier le libère. Autrement dit, acquérir le mutex "donne le droit" d'accéder à la ressource partagée qu'il concerne et assure que seul le possesseur du mutex sera en mesure de le faire. (MutEx correspond à "MUTual EXclusion", soit exclusion mutuelle)
L'objet Mutex dispose de quatre constructeurs. Un constructeur sans paramètre l'initialise avec les valeurs par défaut. Un autre, qui prend un booléen en paramètre, l'initialise avec la possession du mutex si le booléen est à true, sans s'il est à false. La troisième version ajoute à ce paramètre une string qui sert à donner un nom au mutex. La dernière version ajoute à cela un booléen qui contiendra, une fois que le constructeur aura terminé son travail, true si l'objet a effectivement eu possession du mutex à son initialisation, false sinon.
Deux méthodes sont à notre disposition: WaitOne, qui permet de stopper l'exécution du thread en attendant de pouvoir acquérir le mutex, (elle fonctionne de la même façon qu'avec ManuelResetEvent et AutoResetEvent, et possède les mêmes surcharges) et ReleaseMutex, qui permet de libérer le mutex. Il est possible d'appeler WaitOne plusieurs fois à la suite, auquel cas il devra être libéré autant de fois avec ReleaseMutex. Cette méthode peut lever une ApplicationException si elle est appelée alors que l'objet n'a pas possession du mutex.
Il existe en sus deux méthodes statiques contenues dans la classe WaitHandle:(elles peuvent donc être utilisées par les classes dérivées) WaitAny et WaitAll. Elles permettent respectivement d'attendre qu'un des évènements spécifiés soit signalé, ou bien que tous le soient.
int WaitAny( WaitHandle[] évènements ) int WaitAny( WaitHandle[] évènements, int tempsLimite, bool restaurerContexte ) int WaitAny( WaitHandle[] évènements, TimeSpan tempsLimite, bool restaurerContexte ) bool WaitAll( WaitHandle[] évènements ) bool WaitAll( WaitHandle[] évènements, int tempsLimite, bool restaurerContexte ) bool WaitAll( WaitHandle[] évènements, TimeSpan tempsLimite, bool restaurerContexte )
Comme vous pouvez le voir, leurs prototypes sont très semblables. Les paramètres ont d'ailleurs le même rôle. Le premier paramètre est le tableau de WaitHandle à surveiller. Il ne doit pas comporter de doublons, sans quoi la méthode lèvera une DuplicateWaitObjectException. Le second paramètre sert à indiquer un temps limite dans lequel un ou tous les évènements (suivant la méthode) doivent être signalés. Enfin le dernier paramètre indique si oui ou non le contexte d'exécution du thread qui attend doit être restauré. (dans ce cas il sera remplacé par le contexte dans lequel il était avant l'attente) WaitAll retourne true si tous les évènements sont signalés, et WaitAny retourne l'indice dans le tableau passé en paramètre de l'évènement qui a provoqué sa sortie. Elle retourne la constante WaitHandle.WaitTimeout si le temps limite a été dépassé sans qu'aucun évènement ne soit signalé.
Voyons un exemple qui utilise toutes les notions que nous venons de voir. Attention, certaines méthodes vous seront peut-être encore inconnues: elles seront expliquées dans la partie suivante.
using System;
using System.Threading;
public class TestMutex
{
static Mutex mutex1;
static Mutex mutex2;
static AutoResetEvent évènement1 = new AutoResetEvent(false);
static AutoResetEvent évènement2 = new AutoResetEvent(false);
static AutoResetEvent évènement3 = new AutoResetEvent(false);
static AutoResetEvent évènement4 = new AutoResetEvent(false);
public static void Main(String[] args)
{
Console.WriteLine("Exemple d'utilisation de Mutex,
WaitAny et WaitAll...");
// création d'un mutex qu'on possède, avec le nom "monMutex"
mutex1 = new Mutex(true,"monMutex");
// création d'un mutex qu'on possède, sans nom
mutex2 = new Mutex(true);
Console.WriteLine(" - Main possède mutex1 et mutex2");
AutoResetEvent[] evs = new AutoResetEvent[4];
evs[0] = évènement1; // évènement pour t1
evs[1] = évènement2; // évènement pour t2
evs[2] = évènement3; // évènement pour t3
evs[3] = évènement4; // évènement pour t4
// création des quatre threads
TestMutex tm = new TestMutex( );
Thread t1 = new Thread(new ThreadStart(tm.t1Start));
Thread t2 = new Thread(new ThreadStart(tm.t2Start));
Thread t3 = new Thread(new ThreadStart(tm.t3Start));
Thread t4 = new Thread(new ThreadStart(tm.t4Start));
// lancement des quatre threads
t1.Start( ); // fait un Mutex.WaitAll(Mutex[]
// de mutex1 and mutex2)
t2.Start( ); // fait un Mutex.WaitOne(Mutex mutex1)
t3.Start( ); // fait un Mutex.WaitAny(Mutex[]
// de mutex1 and mutex2)
t4.Start( ); // fait un Mutex.WaitOne(Mutex mutex2)
Thread.Sleep(2000);
Console.WriteLine(" - Main libère mutex1");
mutex1.ReleaseMutex( ); // t2 et t3 vont se terminer et
// envoyer un signal
Thread.Sleep(1000);
Console.WriteLine(" - Main libère mutex2");
mutex2.ReleaseMutex( ); // t1 et t4 vont se terminer et
// envoyer un signal
// on attend que les 4 threads signalent qu'ils ont terminé
WaitHandle.WaitAll(evs);
Console.WriteLine("... fin de l'exemple (appuyez sur entrée
pour sortir)");
Console.ReadLine();
}
public void t1Start( )
{
Console.WriteLine("Thread 1 démarré - Mutex.WaitAll(Mutex[])");
Mutex[] mutexs = new Mutex[2];
mutexs[0] = mutex1; // créer une tableau de Mutex pour WaitAll
mutexs[1] = mutex2;
Mutex.WaitAll(mutexs);// on attend que mutex1 et mutex2
// soient libérés
Thread.Sleep(2000);
Console.WriteLine("Thread 1 terminé -
Tous les mutex du tableau sont libres");
évènement1.Set( ); // AutoResetEvent.Set()
// signale que cest fini
}
public void t2Start( )
{
Console.WriteLine("Thread 2 démarré - mutex1.WaitOne( )");
mutex1.WaitOne( ); // on attend que mutex1 soit libéré
Console.WriteLine("Thread 2 terminé - mutex1 est libre");
évènement2.Set( ); // AutoResetEvent.Set()
// signale que c'est fini
}
public void t3Start( )
{
Console.WriteLine("Thread 3 démarré - Mutex.WaitAny(Mutex[])");
Mutex[] mutexs = new Mutex[2];
mutexs[0] = mutex1; // créer un tableau de Mutex pour WaitAny
mutexs[1] = mutex2;
Mutex.WaitAny(mutexs); // attendre qu'un des mutexs soit libéré
Console.WriteLine("Thread 3 terminé -
Un des mutex du tableau est libre");
évènement3.Set( ); // AutoResetEvent.Set()
// signale que c'est fini
}
public void t4Start( )
{
Console.WriteLine("Thread 4 démarré - mutex2.WaitOne( )");
mutex2.WaitOne( ); // on attend que mutex2 soit libéré
Console.WriteLine("Thread 4 terminé - mutex2 est libre");
évènement4.Set( ); // AutoResetEvent.Set()
// signale que c'est fini
}
}
Ce programme créé deux Mutex, quatre AutoResetEvent et quatre Thread. Chacun des quatre évènements est utilisé par un des threads pour signaler qu'il a terminé son exécution, ce qui nous permet d'afficher une phrase disant que le programme est terminé en étant certain qu'aucun thread n'est encore en cours d'exécution. Les quatre threads sont ensuite lancés. Chacun d'entre eux réagit différemment par rapport aux deux Mutex. L'un attend que le premier soit libéré (méthode WaitOne), un autre attend que le second soit libéré (méthode WaitOne), un troisième attend qu'un des deux Mutex le soit (méthode WaitAny), et un dernier attend lui que les deux Mutex soit libres pour se terminer. (méthode WaitAll)
Voici la sortie produite par ce programme:
Exemple d'utilisation de Mutex, WaitAny et WaitAll... - Main possède mutex1 et mutex2 Thread 1 démarré - Mutex.WaitAll(Mutex[]) Thread 2 démarré - mutex1.WaitOne( ) Thread 3 démarré - Mutex.WaitAny(Mutex[]) Thread 4 démarré - mutex2.WaitOne( ) - Main libère mutex1 Thread 2 terminé - mutex1 est libre Thread 3 terminé - Un des mutex du tableau est libre - Main libère mutex2 Thread 4 terminé - mutex2 est libre Thread 1 terminé - Tous les mutex du tableau sont libres ... fin de l'exemple (appuyez sur entrée pour sortir)
Détaillons maintenant les différentes surcharges de RegisterWaitForSingleObject. La version qui représente le temps limite par un entier accepte que la valeur -1 lui soit passée. dans ce cas, il n'y a pas de temps limite. Attention, une valeur inférieure à -1 provoquera une ArgumentOutOfRangeException. Si la valeur spécifiée est 0, la méthode teste l'état de l'objet et se termine immédiatement. Ceci est également valable pour les versions qui utilisent un long, un int, et un TimeSpan pour représenter le temps limite La version qui accepte un TimeSpan est en plus susceptible de lever une NotSupportedException si la valeur passée est supérieure à la valeur maximum que peut contenir un int. Cette valeur (pour information, 2 147 483 647) est disponible via la constante Int32.MaxValue.
Il existe une version de cette méthode, nommée UnsafeRegisterWaitForSingleObject, qui permet d'obtenir, dans le thread, un code avec des privilèges plus élevés au niveau de la sécurité que ce qu'on obtient avec un appel à RegisterWaitForSingleObject.
Gérer les threads soi-même
Dans certaines situations, il est recommandé de gérer soi-même ses threads. Par exemple, si vous voulez leur assigner une priorité particulière, ou si une tâche doit s'exécuter pendant un temps assez long (et risque donc de bloquer les autres tâches.) Cela est également approprié quand vous avez besoin d'une identité persistante pour un thread, dans le sens où vous voulez utilisez ce thread en particulier.
La méthode de manipulation de threads font partie de la classe Thread, du namespace System.threading.
Créer et démarrer un thread
Pour créer un thread, commençons par instancier un objet Thread. Celle classe dispose d'un unique constructeur, qui prend un ThreadStart en paramètre. Cet objet est un délégué vers la méthode que va exécuter le thread créé. La méthode référencée par le délégué ne doit accepter aucun paramètre et ne rien renvoyer.
ThreadSart monThreadStart = new ThreadStart( maMéthode );
Thread monThread = new Thread( monThreadStart );
void maMéthode() { //corps de la méthode
}
Notez que, une fois ce constructeur appelé, le thread est créé mais n'a pas encore commencé son exécution. Pour cela, il faut utiliser la méthode Start.
La méthode Start ne prend aucun paramètre et ne renvoie rien. Elle notifie au système que le thread est prêt à être exécuté.
monThread.Start();
Attention, un thread dont l'exécution s'est terminée ne peut être redémarré en appelant Start une seconde fois.
Il n'est pas nécessaire de libérer la mémoire occupée par un thread quand il a terminé son exécution. cela est fait automatiquement par le CLR.
Etat et priorité d'un thread
L'état d'un thread est rapporté dans sa propriété ThreadState. Celle-ci peut prendre différents valeurs:- Aborted: le thread est stoppé
- AbortRequested: la méthode Abort a été appelée sur le thread
- Background: le thread s'exécute en arrière-plan. Cela peut être contrôlé par la propriété IsBackground.
- Running: le thread a été démarré avec Start, il n'est pas bloqué, et aucune requête de terminaison ne lui a été envoyée.
- Stopped: le thread a été arrêté.
- StopRequested: le thread a reçu une requête de terminaison (cette valeur n'est normalement pas utilisée par le programmeur)
- Suspended: le thread a été suspendu
- SuspendRequested: le thread a reçu une requête de suspension
- Unstarted: le thread a été créé mais n'est pas en cours d'exécution. La méthode Start n'a donc pas encore été invoquée sur ce thread.
- WaitSleepJoin: le thread est bloqué suite à un appel à Wait, Sleep ou Join
Un thread peut être dans plusieurs de ces états au même moment. Par exemple, si un thread est bloqué sur un appel à Wait, et qu'un autre thread appelle Abort sur lui, le thread bloqué sera à la fois dans l'état WaitSleepJoin et AbortRequested.
Vous devez utilisez un masque binaire pour tester la valeur de ThreadState. Par exemple
if( (monThread.ThreadState & (Stopped | Unstarted) ) == 0 )
teste si le thread monThread est dans l'état Running. (c'est à dire, ni Stopped, ni Unstarted)
Pour éviter l'utilisation du masque, vous pouvez recourir à la propriété IsAlive, qui est un booléen indiquant si le thread est en cours d'exécution ou non. De même, la propriété IsThreadPoolThread est un booléen qui indique si le thread fait partie d'une thread pool.
Chaque thread possède une priorité qui permet au système de planifier son accès au processeur. Un thread démarre avec la valeur par défaut, c'est à dire ThreadPriority.Normal. Cette valeur peut être changée en modifiant la propriété Priority. Elle peut prendre les valeurs suivantes: (par ordre croissant de priorité) ThreadPriority.Lowest, ThreadPriority.BelowNormal, ThreadPriority.Normal, ThreadPriority.AboveNormal, ThreadPriority.Highest.
L'algorithme utilisé pour planifier l'accès des threads au processeur peut varier d'un système à l'autre. Le système peut faire varier lui-même la priorité des threads suivant qu'ils s'exécutent en arrière-plan ou non.
Stopper l'exécution d'un thread
Il existe plusieurs manières de stopper l'exécution d'un thread. On peut le terminer purement et simplement, le suspendre puis reprendre son exécution, le mettre en veille pour un temps défini, etc.
La méthode Sleep permet de suspendre l'exécution d'un thread pour un temps défini. Elle accepte en paramètre soit un int, qui définira le nombre de millisecondes à passer en état de veille, ou bien un TimeSpan. Il est possible de passer la valeur Timeout.Infinite, afin de suspendre le thread indéfiniment. Cette méthode peut être appelée à partir d'un thread sur un autre, avec une valeur de zéro. Dans ce cas, le thread appelé cèdera la fin de son quantum à l'appelant.
// stopper l'exécution du thread pour une seconde monThread.Sleep( 1000 );
Suspend permet de suspendre l'exécution d'un thread. Elle ne prend rien en paramètre, et ne retourne rien. Elle peut lever une ThreadStateException si le thread n'est pas encore démarré ou bien est terminé. Si cette méthode est appelée sur un thread déjà suspendu, elle n'aura aucun effet.
Cette suspension peut être levée par un appel à Resume. Cette méthode n'accepte aucun paramètre et ne retourne rien. Elle peut lever une ThreadStateException si le thread n'est pas encore démarré ou bien est terminé, ou encore si le thread n'était pas suspendu.
monThread.Suspend(); // traitements... monThread.Resume();
La méthode Join permet d'attendre qu'un thread se termine. La méthode Abort, par exemple, permet de terminer un thread, mais cela n'est pas immédiat. Appeler Join sur ce même thread permettra d'attendre qu'il se finisse. Celle-ci peut s'appeler sans paramètres, auquel cas elle ne retourne rien, ou bien avec un int ou un TimeSpan qui représentera le temps limite dans lequel le thread doit se terminer. Dans ce cas, la méthode retournera un booléen qui indiquera, s'il est à true, que le thread s'est terminé dans le temps imparti, et false dans le cas contraire.
monThread.Abort(); // attendre que le thread se termine monThread.Join();
Pour sortir d'un état de veille (provoqué par Sleep, Suspend ou Join) on peut également utiliser la méthode Interrupt. Cette méthode s'utilise sans argument et ne retourne rien.
// avec cette instruction, le thread est normalement // bloqué indéfiniment monThread.Sleep(); // ... mais on peut le réveiller avec Interrupt monThread.Interrupt();
La méthode Abort permet de terminer un thread, ou plus précisément de lancer la procédure de fermeture. Le thread ne se termine pas instantanément.
Quand cette méthode est appelée, elle lève une ThreadAbortException dans le thread concerné. Cette exception spéciale peut être gérée par le code de l'application, mais sera levée une nouvelle fois en fin du bloc catch, à moins que ResetAbort ne soit appelée. Le bloc finally sera exécuté avant que le thread ne se termine. Comme nous l'avons dit, la fin du thread n'est pas immédiate, et l'est encore moins si des traitements importants prennent place dans le bloc finally, ce qui aura pour effet de retarder la terminaison. Appelez Join pour vous assurer qu'un thread est bien arrêté.
Si Abort est appelée sur un thread qui n'est pas démarré, il se terminera dès que Start sera appelée. Si elle est appelée sur un thread suspendu, son exécution sera reprise puis terminée. Si Abort est appelée sur un thread bloqué ou en veille, il sera réveillé puis arrêté.
Comme nous l'avons vu, l'appel à Abort ne provoque pas la fin immédiate du thread concerné. Il est donc possible, grâce à ResetAbort, d'annuler la demande de terminaison du thread. Cette méthode ne prend aucun paramètre et ne retourne rien.
monThread.Abort();
// attendre une seconde que le thread se termine
// s'il ne s'est pas terminé, on annule la requête de terminaison
if( monThread.Join( 1000 ) == false )
{
monThread.ResetAbort();
}
Synchroniser deux threads
Lorsque deux threads doivent accéder à la même donnée se pose le problème d'exclusion mutuelle, dont nous avons parlé lors de notre étude du thread pooling. Nous avions alors évoqué la possiblilité de solutionner ce problème grâce à l'évènement Mutex. On peut également le gérer grâce au mot-clé lock.
Ce mot-clé obtient l'accés exclusif à une données le temps que l'on y accède.
lock( valeur )
{
// traitement
}
Ceci va obtenir l'accès exclusif à la donnée "valeur" le temps que durera le traitement inscrit entre les accolades. Une fois ce traitement terminée, la variable sera à nouveau accessible par d'autres threads.
Lock peut s'appliquer à des variables, mais aussi à des classes, pour restreindre l'accès à des méthodes statiques par exemple.
class uneClasse
{
public static void ajouter( object o )
{
lock(typeof(uneClasse)
{
// traitement
}
}
}
La classe Monitor (namespace System.Threading) permet également de gérer le problème de l'exclusion mutuelle.
Cette classe ne contient que des méthodes statiques, il est donc inutile (et impossible) de l'instancier.
La méthode Enter permet de poser un verrou sur un objet passé en paramètre. Si un verrou a déjà été posé sur cet objet par un autre thread, l'exécution du thread sera bloquée le temps que l'objet soit disponible. Cette méthode peut être appelée plusieurs fois consécutives sur le même objet, et dans ce cas la méthode qui permet de lever le verrou (méthode Exit) devra être appelée le même nombre de fois pour que l'objet soit libre à nouveau. L'utilisation de cette méthode a un résultat identique à l'utilisation de lock.
Monitor.Enter( monObjet ); //traitement Monitor.Exit( monObjet );
La méthode TryEnter a un effet semblable à Enter, mais ne bloquera pas le thread si le verrou ne peut être posé. Elle retournera false dans ce cas, et true si le verrrou a pu être posé. Elle peut cependant être assortie d'un temps maximum pendant lequel le thread sera bloqué dans l'attente de l'hypotétique libération de l'objet.
if( Monitor.TryEnter( monObjet ) // traitement If( Monitor.TryEnter( monObjet, 1000 ) ) //traitement
La méthode Wait lève le verrou posé sur un objet, puis bloque l'exécution du thread courant jusqu'à ce qu'un verrou puisse à nouveau être posé sur l'objet. Si Enter a été invoquée plusieurs fois sur le même objet, un seul appel à Wait suffit, il n'est pas nécessaire de l'appeler autant de fois que Enter pour lever le verrou. Quand la méthode Wait reprendra le verrou, elle en reposera le nombre qu'il y en avait précédemment. Cette méthode est utilisée quand un thread qui opère des traitements sur un objet doit attendre qu'un autre thread modifie cet objet avant de pouvoir continuer.
Quand un thread appelle Wait, cela lève le verrou posé sur l'objet spécifié. Le thread appelant entre alors dans la file d'attente de l'objet, et le prochain thread prêt à prendre le verrou sur cet objet le fait. Tous les threads qui appellent Wait restent dans la file d'attente jusqu'à ce qu'ils recoivent un signal de Pulse ou PulseAll, envoyé par le possesseur du verrou. Si Pulse est envoyé, seul le thread en première position dans la file d'attente est affecté. S'il s'agit de PulseAll, tous les threads de la file d'attente sont affectés. Quand le signal est reçu, un ou plusieurs threads quittent la file d'attente pour entrer la file d'attente des threads prêts. Seul un thread faisant partie de cette file est susceptible d'acquérir le verrou.
Cette méthode se termine quand l'appelant reprend le verrou. Elle peut donc bloquer un thread indéfiniment si le possesseur du verrou n'appelle jamais Pulse ou PulseAll.
Pulse, tout comme PulseAll, prend en paramètre l'object sur lequel le verrou est enlevé.
L'utilisation de Pulse, PulseAll et Wait est indépendante de celle de Enter et Exit, puisque Wait permet de poser des verrous. On peut donc écrire un programme utilisant les verrous sans pour autant se servir de ces deux méthodes.
Voici un exemple d'utilisation des trois méhodes Wait, Pulse et PulseAll:
using System;
using System.Threading;
public class ExempleMonitor
{
public static void Main(String[] args)
{
Console.WriteLine("Exemple d'utilisation de la classe Monitor");
// création du produit
Produit produit = new Produit( );
// nous faisons une livraison et une commande
Livraison livraison = new Livraison( produit, 20 );
Commande commande = new Commande( produit, 20 );
// création des threads
Thread Tlivraison = new Thread(new ThreadStart(livraison.ThreadRun));
Thread Tcommande = new Thread(new ThreadStart(commande.ThreadRun));
try
{
// démarrage des threads
Tlivraison.Start( );
Tcommande.Start( );
// on attend qu'ils aient fini de s'exécuter
Tlivraison.Join( );
Tcommande.Join( );
Console.WriteLine("\nTerminé! (appuyez sur entrée pour sortir)");
Console.ReadLine();
}
catch (ThreadStateException e)
{
Console.WriteLine(e); // afficher le texte de l'exception
}
catch (ThreadInterruptedException e)
{
Console.WriteLine(e); // un thread a été interrompu pendant un Wait
}
}
}
public class Livraison
{
Produit produit; // le produit livré
int quantité = 1; // la quantité livrée
public Livraison(Produit produit, int quantité)
{
this.produit = produit; // initialisation de variables
this.quantité = quantité; // membres de la classe
}
public void ThreadRun( )
{
for(int i=0; i<quantité; i++)
produit.Stocke(); // "stockage" du produit
}
}
public class Commande
{
Produit produit; // le produit commandé
int quantité = 1; // laquantité commandée
public Commande(Produit produit, int quantité)
{
this.produit = produit; // initialisation des variables
this.quantité = quantité; // membres de la classe
}
public void ThreadRun( )
{
for(int i=0; i<quantité; i++)
produit.DeStocke( ); // "déstockage" du produit
}
}
public class Produit
{
// stock initial
int nbEnStock = 10;
// indique la fin d'un opération de déstockage
bool drapeau = false;
public void DeStocke( )
{
lock(this)
{
if (!drapeau) // si ce drapeau est à false, un stockage est en cours
{ // on en attend donc la fin
try
{
// attendre le Monitor.Pulse de Stocke()
Monitor.Wait(this);
}
catch (SynchronizationLockException e)
{
Console.WriteLine(e);
}
catch (ThreadInterruptedException e)
{
Console.WriteLine(e);
}
}
Thread.Sleep(100);
// déstockage d'un produit
nbEnStock--;
Console.WriteLine("Un produit déstocké");
// on signale la fin de l'opération
drapeau = true;
Monitor.Pulse(this);
}
}
public void Stocke( )
{
lock(this)
{
if (drapeau) // si ce drapeau est à true, un déstockage est en cours
{ // on doit donc en attendre la fin
try
{
Monitor.Wait(this); // on attend le Monitor.Pulse de DeStocke()
}
catch (SynchronizationLockException e)
{
Console.WriteLine(e);
}
catch (ThreadInterruptedException e)
{
Console.WriteLine(e);
}
}
Thread.Sleep(100);
// stockage d'un produit
nbEnStock++;
// on signale la fin de l'opération
drapeau = false;
Console.WriteLine("Un produit stocké");
Monitor.Pulse(this);
}
}
}
Ce programme gère le stock de produits. Chaque objet produit dispose d'un variable qui contient le nombre de produits de ce type en stock. Cet objet possède également une méthode Stocke et DéStocke, destinées à ajouter ou enlever un produit au stock. Les classes Commande et Livraison utilisent ces méthodes. Une classe Commande enlève du stock la quantité de produits passés en paramètre, tandis que la classe Livraison les stocke.
Le programme principal démarre un thread qui va effectuer une commande de 20 produits, et un autre qui va effectuer une livraison de 20 produits. Comme ces deux méthodes accèdent au même objet Produit, nous devons gérer l'accès concurrentiel à la variable qui contient l'état du stock. Cela se fait par l'utilisation des méthodes Monitor.Wait et Monitor.Pulse.
Annexe
Plusieurs programmes sont disponibles pour illustrer les divers notions abordées dans ce cours:- AutoResetEvent: illustre l'utilisation d'un AutoResetEvent
- ManualResetEvent: illustre l'utilisation d'un ManualResetEvent
- Monitor: illustre l'utilisation de l'objet Monitor
- MutexWaitAllWaitAny: illustre l'utilisation d'un Mutex et des méthodes WaitAll et WaitAny
Tous ces programmes sont disponibles dans l'archive à télécharger ici (http://www.mangue.org/cours/csharp/download/processusprogs.zip).

