Programmation C sharp/Threads et synchronisation
Un thread est un contexte d'exécution ayant sa propre pile de paramètres et de variables locales, mais partageant les mêmes variables globales (variables statiques des classes) que les autres threads du même processus (la même instance d'une application créée au moment de son lancement).
Initialement, un processus ne possède qu'un seul thread. En général, celui-ci crée d'autres threads pour le traitement asynchrone de la file de messages provenant du système d'exploitation (gestion des évènements), du matériel géré, ...
Les classes utilisées dans ce chapitre sont définies dans l'espace de nom System.Threading
:
using System.Threading;
Créer et démarrer un thread
modifierUn thread est créé pour effectuer une tâche en parallèle d'une autre. Si l'application possède une interface graphique et doit effectuer une tâche qui prend du temps (calcul, téléchargement, ...), si un seul thread est utilisé, durant la longue tâche l'interface graphique est inutilisable. Il vaut mieux effectuer la tâche longue dans un nouveau thread afin que l'utilisateur puisse continuer à utiliser l'interface graphique (pour annuler cette tâche par exemple).
Les threads sont également nécessaires pour gérer plusieurs tâches indépendantes les unes des autres, comme par exemple pour un serveur, la gestion de connexions simultanées avec plusieurs clients. Dans ce cas, chaque thread effectue les mêmes tâches. Ils sont en général gérés par un ensemble de threads (Thread pool en anglais).
Les threads et les outils associés sont gérés par les classes de l'espace de nom System.Threading
.
La classe Thread
gère un thread. Son constructeur accepte comme premier paramètre :
- soit un délégué de type
ThreadStart
:
delegate void ThreadStart()
- soit un délégué de type
ParameterizedThreadStart
:
delegate void ParameterizedThreadStart(object parameter)
Le second paramètre est optionnel : int maxStackSize
.
Il indique la taille maximale de la pile à allouer au nouveau thread.
Une fois le thread créé, il faut appeler la méthode Start
pour démarrer le thread :
- soit
Start()
si ledelegate
n'a aucun paramètre (ThreadStart
), - soit
Start(object parameter)
si ledelegate
accepte un paramètre de typeobject
(ParameterizedThreadStart
).
Exemple 1 :
// La tâche qui prend du temps ...
private void longTask()
{
Console.WriteLine("Début de la longue tâche dans le thread "
+ Thread.CurrentThread.GetHashCode());
//...
}
// Démarre un nouveau thread pour la méthode longTask()
public void StartLongTask()
{
Console.WriteLine("Création d'un thread à partir du thread "
+ Thread.CurrentThread.GetHashCode());
Thread th = new Thread(
new ThreadStart( this.longTask )
);
th.Start();
}
Exemple 2 : Une méthode acceptant un paramètre.
// La tâche qui prend du temps ...
private void telecharger(string url)
{
Console.WriteLine("Début du téléchargement de " + url +
" dans le thread " + Thread.CurrentThread.GetHashCode());
//...
}
// Démarre un nouveau thread pour la méthode telecharger()
public void CommencerATelecharger(string url)
{
Console.WriteLine("Création d'un thread à partir du thread "
+ Thread.CurrentThread.GetHashCode());
Thread th = new Thread(
new ParameterizedThreadStart( this.telecharger )
);
th.Start( url );
}
Exemple 3 : Si la méthode doit accepter plusieurs paramètres, il faut passer un tableau de paramètres. Puisqu'un tableau est egalement un objet, le tableau peut être passé sous la forme d'une référence d'objet, qui sera reconverti en tableau dans la méthode du delegate.
// La tâche qui prend du temps ...
private void telecharger(object object_parameters)
{
object[] parameters = (object[]) object_parameters;
string url = (string) parameters[0];
int tentatives = (int) parameters[1];
Console.WriteLine("Début du téléchargement de " + url +
" ("+tentatives+" tentatives) dans le thread " +
Thread.CurrentThread.GetHashCode());
//...
}
// Démarre un nouveau thread pour la méthode telecharger()
public void CommencerATelecharger(string url, int tentatives)
{
Console.WriteLine("Création d'un thread à partir du thread "
+ Thread.CurrentThread.GetHashCode());
Thread th = new Thread(
new ParameterizedThreadStart( this.telecharger )
);
th.Start( new object[] { url, tentatives } );
}
Attendre la fin d'un thread
modifierUne fois qu'un thread a démarré, la méthode join peut être appelée pour attendre que la méthode du thread se termine. Cette méthode est surchargée :
void Join()
: attend indéfiniment que le thread se termine,bool Join(int millisecondsTimeout)
: attend que le thread se termine dans le temps imparti spécifié en millisecondes. Cette méthode retournetrue
si le thread s'est terminé dans le temps imparti, etfalse
sinon,bool Join(TimeSpan timeout)
: cette méthode fonctionne de la même manière que la précédente, excepté que le temps imparti est spécifié par une structure de typeSystem.TimeSpan
.
Exemple :
// Démarre un nouveau thread pour la méthode longTask()
public void startLongTask()
{
Console.Writeline("Création d'un thread à partir du thread "
+ Thread.CurrentThread.GetHashCode());
Thread th = new Thread(
new ThreadStart( this.longTask )
);
Console.Writeline("Démarrage du thread " + th.GetHashCode() );
th.Start();
Console.Writeline("Attente de la fin du thread " + th.GetHashCode()
+ " pendant 5 secondes ...");
bool fini = th.Join(5000); // 5000 ms
if (!fini) // si pas fini
{
Console.Writeline("Le thread " + th.GetHashCode()
+ " n'a pas terminé au bout de 5 secondes, attente indéfinie ...");
th.Join();
}
Console.Writeline("Le thread " + th.GetHashCode() + " a terminé sa tâche.");
}
Suspendre et Arrêter un thread
modifierSuspendre un thread
modifierPour suspendre un thread, il faut appeler la méthode Suspend()
. Le thread est alors suspendu jusqu'à l'appel à la méthode Resume()
. Cependant ces deux méthodes sont obsolètes, car un thread suspendu détient toujours les ressources qu'il avait acquis avant sa suspension. Ce problème risque de provoquer le blocage d'autres threads tant que celui-là est suspendu.
Arrêter un thread
modifierL'arrêt d'un thread peut être demandé en appelant la méthode Interrupt()
. Cette méthode provoque le lancement d'une exception lorsque le thread appelera une méthode d'entrée-sortie. Donc l'arrêt du thread n'est pas instantané.
Cependant l'appel à cette méthode peut interrompre le thread à n'importe quel moment de son exécution. Il faut donc prévoir que ce genre d'exception soit lancé pour pouvoir libérer les ressources dans un bloc try..finally
, voire utiliser using
).
Une solution alternative est de tester une condition de terminaison du thread. Ceci permet de spécifier où le thread peut se terminer, et libérer les ressources correctement.
Exemple :
public void UnThread()
{
// ... initialisation
while ( continuer_thread )
{
// ... tâches du thread
}
// ... libération des ressources
}
Cependant, l'utilisation d'un bloc try..finally
(ou du mot clé using
) est tout de même nécessaire au cas où le système interromperait le thread (fin brutale de l'application par exemple).
Propriétés d'un thread
modifierVoici une liste des principales propriétés de la classe Thread
:
bool IsAlive
- (lecture seule) Indique si le thread est toujours actif.
bool IsBackground
- Indique si le thread est un thread d'arrière plan. Un thread d'arrière plan ne permet pas de maintenir l'exécution d'une application. C'est à dire qu'une application est terminée dès qu'il n'y a plus aucun thread de premier plan en cours d'exécution.
bool IsThreadPoolThread
- (lecture seule) Indique si le thread appartient au pool de threads standard.
int ManagedThreadId
- (lecture seule) Retourne l'identifiant attribué au thread par le framework .Net.
string Name
- Nom attribué au thread.
ThreadPriority Priority
- Priorité du thread :
Lowest
: priorité la plus basse,BelowNormal
: priorité inférieure à normale,Normal
: priorité normale,AboveNormal
: priorité supérieure à normale,Highest
: priorité la plus haute.
ThreadState ThreadState
- (lecture seule) État actuel du thread.
- Cette propriété est une combinaison de valeurs de l'énumération
ThreadState
:Running = 0x00000000
: en cours d'exécution,StopRequested = 0x00000001
: arrêt demandé,SuspendRequested = 0x00000002
: suspension demandée,Background = 0x00000004
: thread d'arrière plan,Unstarted = 0x00000008
: thread non démarré,Stopped = 0x00000010
: thread arrêté,WaitSleepJoin = 0x00000020
: thread en attente (wait, sleep, ou join),Suspended = 0x00000040
: thread suspendu,AbortRequested = 0x00000080
: abandon demandé,Aborted = 0x00000100
: thread abandonné.
Le pool de threads
modifierUn pool de threads est associé à chaque processus. Il est composé de plusieurs threads réutilisables effectuant les tâches qu'on lui assigne dans une file d'attente. Une tâche est placée dans la file d'attente, puis on lui affecte un thread inocupé qui l'effectuera, puis le thread se remet en attente d'une autre tâche. Une partie de ces threads est consacrée aux opérations d'entrées-sorties asynchrones.
Le pool de threads est géré par les méthodes statiques de la classe ThreadPool
. Par défaut, le pool contient 25 threads par processeur.
La méthode QueueUserWorkItem permet d'ajouter une nouvelle tâche :
bool QueueUserWorkItem ( WaitCallback callback, object state )
Le premier paramètre est un délégué dont la signature est la suivante :
delegate void WaitCallback(object state)
Le second paramètre est optionnel et contient l'argument passé au délégué.
Le délégué passé en paramètre effectuera la tâche, dans le thread qui lui sera attribué.
Synchronisation entre les threads
modifierLe fait que tous les threads d'un même processus partagent les mêmes données signifie que les threads peuvent accéder simultanément à un même objet. Si un thread modifie un objet (écriture, en utilisant une méthode de l'objet par exemple) pendant qu'un autre thread récupère des informations sur cet objet (lecture), ce dernier peut obtenir des données incohérentes résultant d'un état intermédiaire temporaire de l'objet accédé.
Pour résoudre ce genre de problème, des outils de synchronisation permettent de suspendre les threads essayant d'accéder à un objet en cours de modification par un autre thread.
Moniteur
modifierUn moniteur (monitor en anglais) ne permet l'accès qu'à un seul thread à la fois. C'est à dire que si plusieurs threads essaye d'accéder au même moniteur, un seul obtiendra l'accès, les autres étant suspendus jusqu'à ce qu'ils puissent à leur tour détenir l'accès exclusif.
La classe Monitor gère ce type d'objet de synchronisation. Toutes les méthodes de cette classe sont statiques. Les principales méthodes sont :
void Enter(object obj)
- Cette méthode suspend le thread appelant si un autre thread possède déjà l'accès exclusif, ou retourne immédiatement sinon.
void Exit(object obj)
- Cette méthode met fin à l'accès exclusif par le thread appelant, et permet à un thread suspendu d'obtenir l'accès exclusif à son tour.
bool TryEnter(object obj)
- Cette méthode permet de tenter d'obtenir l'accès exclusif. Elle retourne true si l'accès exclusif est obtenu, false sinon.
bool TryEnter(object obj,int milliseconds)
- Cette méthode permet de tenter d'obtenir l'accès exclusif, dans le temps imparti spécifié en millisecondes. Elle retourne true si l'accès exclusif est obtenu, false sinon.
L'objet passé en paramètre identifie le moniteur accédé. C'est à dire que tout objet peut être utilisé comme moniteur. En C#, tout est objet, même les chaînes de caractères, et les valeurs numériques et booléennes. Cependant, il n'est pas recommandé d'utiliser de telles valeurs ou des références publiques, car ce sont des références globales. Il est préférable d'utiliser des membres privés, voire des variables locales.
Exemple :
using System;
using System.Threading;
public class TestMonitor
{
private object synchro = new object();
public void MethodeThread()
{
int id = Thread.CurrentThread.ManagedThreadId;
Console.WriteLine("Début du thread " + id );
Monitor.Enter( synchro );
Console.WriteLine("Le thread " + id + " entre exclusivement ..." );
Thread.Sleep(1000); // attend 1 seconde
Console.WriteLine("Le thread " + id + " sort ..." );
Monitor.Exit( synchro );
Console.WriteLine("Fin du thread " + id );
}
public void Test()
{
// 2 threads
Thread
thread1 = new Thread( new ThreadStart( MethodeThread ) ),
thread2 = new Thread( new ThreadStart( MethodeThread ) );
thread1.Start();
thread2.Start();
}
public static void Main()
{
new TestMonitor().Test();
}
}
Ce programme affiche :
Début du thread 3 Le thread 3 entre exclusivement ... Début du thread 4 Le thread 3 sort ... Le thread 4 entre exclusivement ... Fin du thread 3 Le thread 4 sort ... Fin du thread 4
Ce programme ne tient pas compte des exceptions. Il faut cependant les prévoir pour s'assurer de libérer le moniteur, en utilisant un bloc try..finally
:
...
public void MethodeThread()
{
int id = Thread.CurrentThread.ManagedThreadId;
Console.WriteLine("Début du thread " + id );
Monitor.Enter( synchro );
try
{
Console.WriteLine("Le thread " + id + " entre exclusivement ..." );
Thread.Sleep(1000); // attend 1 seconde
Console.WriteLine("Le thread " + id + " sort ..." );
}
finally
{
Monitor.Exit( synchro );
}
Console.WriteLine("Fin du thread " + id );
}
...
Le mot clé lock
modifier
Le mot clé lock
est utilisable pour produire un code équivalent au précédent :
...
public void MethodeThread()
{
int id = Thread.CurrentThread.ManagedThreadId;
Console.WriteLine("Début du thread " + id );
lock( synchro ) // <- enter ( synchro )
{
Console.WriteLine("Le thread " + id + " entre exclusivement ..." );
Thread.Sleep(1000); // attend 1 seconde
Console.WriteLine("Le thread " + id + " sort ..." );
} // <- exit ( synchro )
Console.WriteLine("Fin du thread " + id );
}
...
Le mot clé lock
est suivi de la référence de l'objet dont l'accès doit être exclusif durant le bloc de code qui suit.
Autres outils de synchronisation
modifierLes autres outils de synchronisation dérivent de la classe abstraite WaitHandle
.
Les objets WaitHandle
possèdent deux états :
- l'état signalé (set) dans lequel l'objet permet aux threads d'accéder à la ressource,
- l'état non signalé (reset) ne permet pas de nouvel accès. Toute tentative d'accès suspendra le thread, jusqu'au retour de l'état signalé.
Attendre l'état signalé d'un objet WaitHandle
modifier
Les méthodes suivantes de la classe WaitHandle
permettent d'attendre l'état signalé d'un objet WaitHandle
:
// Attendre que l'objet WaitHandle soit dans l'état signalé :
public virtual bool WaitOne();
public virtual bool WaitOne(int millisecondsTimeout, bool exitContext);
public virtual bool WaitOne(TimeSpan timeout, bool exitContext);
Il est possible d'appeler ces méthodes en spécifiant un temps limite (int
: nombre de millisecondes ou TimeSpan
) et un indicateur booléen exitContext
. Ce paramètre vaut true
si le contexte de synchronisation (l'ensemble des verrous) doit être libéré avant l'attente puis récupéré ensuite. Il vaut false
sinon, c'est à dire que les verrous seront toujours détenus par le thread durant son attente.
Ces méthodes retournent true
si l'état est signalé, et false
sinon (temps limite expiré).
Attendre l'état signalé de plusieurs objets WaitHandle
modifier
Les méthodes statiques suivantes de la classe WaitHandle
permettent d'attendre l'état signalé pour un tableau d'objets WaitHandle
:
// Attendre que tous les objets WaitHandle soit dans l'état signalé :
public static bool WaitAll(WaitHandle[] waitHandles);
public static bool WaitAll(WaitHandle[] waitHandles,
int millisecondsTimeout, bool exitContext);
public static bool WaitAll(WaitHandle[] waitHandles,
TimeSpan timeout, bool exitContext);
// Attendre qu'au moins l'un des objets WaitHandle soit dans l'état signalé :
public static int WaitAny(WaitHandle[] waitHandles);
public static int WaitAny(WaitHandle[] waitHandles,
int millisecondsTimeout, bool exitContext);
public static int WaitAny(WaitHandle[] waitHandles,
TimeSpan timeout, bool exitContext);
Le tableau d'objets WaitHandle
ne doit pas comporter de doublon, sinon une exception DuplicateWaitObjectException
est levée.
Il est possible d'appeler ces méthodes en spécifiant un temps limite (int
: nombre de millisecondes ou TimeSpan
) et un indicateur booléen exitContext
(voir section précédente).
Les méthodes WaitAll
retournent true
si l'état de tous les objets est signalé, ou false
sinon (temps limite expiré).
Les méthodes WaitAny
retournent l'indice dans le tableau de l'objet dont l'état est signalé, ou la constante WaitHandle.WaitTimeout
sinon (temps limite expiré).
Signaler et attendre
modifierLes méthodes statiques suivantes permettent de mettre l'état signalé sur un objet WaitHandle
et d'attendre un autre objet WaitHandle
:
// Mettre l'état signalé sur l'objet toSignal
// Ensuite, attendre que l'objet toWaitOn soit dans l'état signalé :
public static bool SignalAndWait(WaitHandle toSignal, WaitHandle toWaitOn);
public static bool SignalAndWait(WaitHandle toSignal, WaitHandle toWaitOn,
int millisecondsTimeout, bool exitContext);
public static bool SignalAndWait(WaitHandle toSignal, WaitHandle toWaitOn,
TimeSpan timeout, bool exitContext);
Évènements
modifierUn évènement est une instance de la classe EventWaitHandle
(sous-classe de la classe WaitHandle
vue précédemment).
Il permet de modifier son état signalé / non-signalé grâce aux deux méthodes suivantes :
public bool Reset(); // -> état non-signalé
public bool Set(); // -> état signalé
Cette classe possède le constructeur suivant :
public EventWaitHandle(bool initialState, EventResetMode mode);
Les paramètres sont les suivants :
- initialState
- État initial : signalé (
true
) ou non-signalé (false
). - mode
- Mode pour le retour à l'état non-signalé : AutoReset ou ManualReset :
- ManualReset : le retour à l'état non-signalé (reset) se fait explicitement en appelant la méthode
Reset()
. - AutoReset : le retour à l'état non-signalé est automatiquement effectué quand un thread est activé, c'est à dire quand il a terminé d'attendre l'état signalé avec une méthode wait (WaitOne, WaitAny ou WaitAll).
- ManualReset : le retour à l'état non-signalé (reset) se fait explicitement en appelant la méthode
- Il y a une sous-classe pour chacun des deux modes (voir ci-dessous pour une description).
Synchronisation inter-processus
modifierCette classe possède également le constructeur suivant :
public EventWaitHandle(bool initialState, EventResetMode mode,
string name, ref Boolean createdNew,
System.Security.AccessControl.EventWaitHandleSecurity eventSecurity);
Les trois paramètres supplémentaires sont tous optionnels et sont utilisés pour le partage au niveau système et donc pour la synchronisation entre processus :
- name
- Nom unique identifiant cette instance de la classe
EventWaitHandle
. - createdNew
- Référence à une variable booléenne que la fonction va utiliser pour indiquer si un nouvel objet
EventWaitHandle
a été créé (true
) ou s'il existe déjà (false
). - eventSecurity
- Définit les conditions d'accès à l'objet partagé.
Elle possède également une méthode statique permettant de retrouver une instance de la classe EventWaitHandle
existante partagée au niveau système :
public static EventWaitHandle OpenExisting(string name, System.Security.AccessControl.EventRights rights);
Le paramètre rights
est optionnel et permet d'accéder à l'objet partagé.
Sous-classes
modifierCette classe a deux sous-classes :
AutoResetEvent
: Le retour à l'état non-signalé est automatique. Une fois que l'état signalé est obtenu par un thread (fin de l'attente par une méthode wait), l'objetAutoResetEvent
revient à l'état non-signalé, empêchant un autre thread d'obtenir l'état signalé.ManualResetEvent
: Le retour à l'état non-signalé se fait explicitement.
Ces deux sous-classes ont un constructeur qui accepte comme paramètre un booléen initialState
indiquant son état initial : true
pour l'état signalé, false
pour l'état non-signalé.
Exemple :
using System;
using System.Threading;
class Exemple
{
static AutoResetEvent evenementTermine;
static void AutreThread()
{
Console.WriteLine(" Autre thread : 0% accompli, attente...");
evenementTermine.WaitOne(); // Attend état signalé + Reset (auto)
// l'état a été signalé, mais retour à l'état non-signalé
// maintenant que l'appel à WaitOne est terminé.
Console.WriteLine(" Autre thread : 50% accompli, attente...");
evenementTermine.WaitOne(); // Attend état signalé + Reset (auto)
// l'état a été signalé, mais retour à l'état non-signalé
// maintenant que l'appel à WaitOne est terminé.
Console.WriteLine(" Autre thread : 100% accompli, terminé.");
}
static void Main()
{
evenementTermine = new AutoResetEvent(false);
Console.WriteLine("Main: démarrage de l'autre thread...");
Thread t = new Thread(AutreThread);
t.Start();
Console.WriteLine("Main: tâche 1/2 : 1 seconde ...");
Thread.Sleep(1000);
evenementTermine.Set(); // -> état signalé
Console.WriteLine("Main: tâche 2/2 : 2 secondes ...");
Thread.Sleep(2000);
evenementTermine.Set(); // -> état signalé
Console.WriteLine("Main: fin des tâches.");
}
}
Verrou d'exclusion mutuelle
modifierUn verrou d'exclusion mutuelle (mutex en abrégé) permet de donner un accès exclusif pour une ressource à un seul thread à la fois.
Cette classe dérive de la classe WaitHandle
décrite avant.
Pour obtenir l'accès, il faut appeler une méthode wait (WaitOne par exemple), c'est à dire attendre l'état signalé. La méthode suivante permet de libérer le verrou en positionnant l'état signalé :
public void ReleaseMutex(); // -> État signalé
Comme pour une instance de la classe AutoResetEvent
, le retour à l'état non-signalé est automatique, quand un thread sort de la fonction d'attente.
Cette classe possède le constructeur suivant :
public Mutex(bool initiallyOwned,
string name, ref Boolean createdNew,
System.Security.AccessControl.MutexSecurity mutexSecurity);
Tous les paramètres sont optionnels :
initiallyOwned
:true
si le mutex est dans l'état non-signalé et appartient au thread appelant,false
sinon (état signalé).- Les autres paramètres servent à la synchronisation inter-processus et sont décrits dans la section "Synchronisation inter-processus" précédente.
Cet objet est dédié à la synchronisation inter-processus. Pour de l'exclusion mutuelle entre threads du même processus, du point de vue des performances, il est préférable d'utiliser un moniteur, ou l'instruction lock.
Attribut volatile
modifierPour des raisons de performance, le système d'exécution peut décider de changer l'ordre des lectures et écritures en mémoire.
Cela peut gêner le fonctionnement quand un attribut de classe est accédé par différents threads en même temps.
Le mot-clé volatile
résoud le problème car les champs déclarés comme volatiles ne sont pas soumis à ce changement, ce qui permet d'assurer que l'écriture par un thread et la lecture par un autre se fasse dans le bon ordre.
Le mot-clé peut être appliqué aux attributs d'une classe ou d'une structure des types suivants :
- Référence d'objet ;
- Pointeur (Code non vérifié), toutefois l'objet pointé n'est pas volatile, seulement le pointeur lui-même, et il n'est pas possible de déclarer un pointeur vers volatile ;
- Types primitifs : sbyte, byte, short, ushort, int, uint, char, float, et bool ;
- Énumération avec un type primitif de base : byte, sbyte, short, ushort, int, ou uint ;
- Paramètres de types générique définis comme de type référence ;
- IntPtr et UIntPtr.
Les autres types (incluant double et long) ne peuvent être déclarés comme volatiles car les lectures et écritures ne sont pas faites de manière atomique, c'est à dire que l'accès (lecture/écriture) ne se fait pas en une fois, autorisant un autre thread à s'exécuter au milieu de l'opération.
Pour ces types, il faut utiliser les outils de synchronisation tels que lock
par exemple.
Le mot-clé ne s'applique pas aux variables locales car elles ne sont pas partagées avec d'autres threads, mais allouées sur la pile dédiée au thread.