WaitHandles - Auto/ManualResetEvent et Mutex

Monitor.Wait/Pulse n'est pas le seul moyen de faire attendre un thread sur un événement se produisant dans un autre thread. Les programmeurs Win32 sont habitués à utiliser divers autres mécanismes depuis longtemps. Ces mécanismes sont proposés par les classes AutoResetEvent, ManualResetEvent et Mutex qui dérivent toutes de WaitHandle. Toutes ces classes se trouvent dans le namespace System.Threading. Le mécanisme de Semaphore de Win32 est intégré dans .NET 2.0 mais par contre il n'a pas été intégré dans .NET 1.1. Si malgré tout vous en avez besoin, vous pouvez l'intégrer vous même en utilisant P/Invoke, ou écrire votre propre classe de comptage de sémaphores.

Même si cela peut surprendre, l'utilisation de ces classes peut être sensiblement plus lente que l'utilisation des différentes méthodes de la classe Monitor. Je suppose que cela vient du fait que "sortir" du code managé pour faire un appel Win32 natif puis ensuite revenir dans le code managé est plus couteux, comparé à la technique entièrement managée que la classe Monitor fournit.

La classe WaitHandle ne propose que peu de méthodes/propriétés utiles :

De plus, cette classe possède deux méthodes statiques permettant de manipuler des ensembles de WaitHandle :

Toutes les méthodes WaitXXX() sont surchargées pour vous permettre de spécifier un timeout et demander de quitter ou pas le domaine de synchronisation (la valeur par défaut pour ce dernier argument est "false"). La plupart des développeurs .NET n'auront pas besoin d'utiliser les domaines de synchronisation. Malgré tout, si vous souhaitez en savoir plus, Richard Grimes a écrit un article dessus pour le site Dr. Dobb's.

Auto/ManualResetEvent

Ces deux classes de gestion d'événement (à ne pas confondre avec les événements .NET qui n'ont rien à voir) sont très similaires. On peut se les représenter comme des portes. Lorsqu'elles sont dans l'état "signalé" ou "set" elles sont ouvertes. Lorsqu'elles sont dans l'état "non-signalé" ou "reset" elles sont fermées. Un appel à WaitOne() attend que la porte s'ouvre afin que le thread puisse continuer. La différence entre les deux classes est qu'une instance de AutoResetEvent va se replacer elle-même dans l'état "non-signalé" immédiatement après un appel à WaitOne() (comme si chaque personne passant la porte la refermait derrière elle). Avec une instance de ManualResetEvent vous devez demander au thread de se "reseter" (fermer la porte) lorsque vous voulez que les appels à WaitOne() redeviennent bloquant. L'état signalé/non-signalé des deux classes peut être manuellement modifié à tout moment et par n'importe quel thread en utilisant les méthodes Set et Reset (ces deux méthodes retournent un booleen indiquant si l'opération a réussi, mais la documentation ne donne pas plus d'information sur les causes possibles d'un échec).

Voici un exemple qui simule une course avec 10 participants. Chaque thread participant possède une instance de ManualResetEvent qui, au départ, est à l'état non-signalé. Quand un thread fini la course il passe en état signalé. Le thread principal utilise la méthode WaitHandle.WaitAny pour attendre le premier participant finissant la course et utilise la valeur retournée par la méthode pour déterminer qui est le gagnant. Ensuite le thread principal utilise la méthode WaitHandle.WaitAll pour attendre l'arrivée des autres participants. Il est important de noter que si nous avions utilisé des instances de AutoResetEvent nous aurions du appeler Set sur le gagnant car il aurait été automatiquement repassé à l'état non-signalé lors de l'appel à WaitAny et l'appel à WaitHandle.WaitAll ne se serait jamais terminé.

    
using System;
using System.Threading;

class Test
{
    static void Main()
    {
        ManualResetEvent[] events = new ManualResetEvent[10];
        for (int i=0; i < events.Length; i++)
        {
            events[i] = new ManualResetEvent(false);
            Runner r = new Runner(events[i], i);
            new Thread(new ThreadStart(r.Run)).Start();
        }
        
        int index = WaitHandle.WaitAny(events);
        
        Console.WriteLine ("***** The winner is {0} *****", 
                           index);
        
        WaitHandle.WaitAll(events);
        Console.WriteLine ("All finished!");
    }
}

class Runner
{
    static readonly object rngLock = new object();
    static Random rng = new Random();
    
    ManualResetEvent ev;
    int id;
    
    internal Runner (ManualResetEvent ev, int id)
    {
        this.ev = ev;
        this.id = id;
    }
    
    internal void Run()
    {
        for (int i=0; i < 10; i++)
        {
            int sleepTime;
            // La classe Random n'est pas garantie thread-safe...
            lock (rngLock)
            {
                sleepTime = rng.Next(2000);
            }
            Thread.Sleep(sleepTime);
            Console.WriteLine ("Runner {0} at stage {1}",
                               id, i);
        }
        ev.Set();
    }
}

Mutex

Alors que les classes Auto/ManualResetEvent ressemblent beaucoup aux Monitor.Wait/Pulse, la classe Mutex s'apparente plus à Monitor.Enter/Exit. Un mutex possède un compteur avec le nombre de fois qu'il a été acquis, et le thread propriétaire courant. Si le compteur vaut zéro, le mutex n'a pas de propriétaire et il peut être acquis par n'importe qui. Si le compteur ne vaut pas zéro, le propriétaire courant peut encore l'acquérir autant de fois qu'il veut sans se bloquer, mais un tous les autres thread doivent attendre que le compteur soit redescendu à zéro pour pouvoir acquérir le mutex. Les méthodes WaitXXX() sont utilisées pour acquérir le mutex, et la méthode ReleaseMutex() est utilisée par le propriétaire pour décrémenter le compteur. Seul le propriétaire du mutex peut faire diminuer le compteur.

Jusqu'à présent le Mutex ressemble beaucoup au Monitor. La différence est qu'un Mutex est un objet cross-process (un même mutex peut être utilisé dans plusieurs processus) si vous lui donnez un nom. Par exemple, un thread dans un processus peut attendre qu'un autre thread dans un autre processus relâche le mutex. Quand vous construisez un mutex nommé vous devez faire attention si oui ou non vous voulez/pouvez acquérir sa propriété originelle. Heureusement, il y a un constructeur qui permet de détecter si le systeme a créé un mutex complètement nouveau ou s'il en a utilisé un existant. Si le constructeur demande la propriété originelle du mutex, elle ne lui sera accordée que s'il crée un nouveau mutex.

Il est recommandé de faire commencer les noms des mutex par "Local\" ou "Global\" afin d'indiquer s'ils doivent être créés dans l'espace de nom local ou global. Il me semble que l'espace de nom local est l'espace de nom par défaut, mais afin d'éviter tout risque il est préférable de le spécifier explicitement. Si vous créez un mutex dans l'espace de nom global, il sera partagé avec les autres utilisateurs loggés sur la machine. Au contraire, si vous créez un mutex dans l'espace de nom local, il est propre à l'utilisateur courant. Faites attention de choisir un nom plutôt original et rare afin de ne pas rentrer en collision avec d'autres programmes.

Pour être sincère, je pense que la principale utilisation des mutex est la détection des autres instances de l'application déjà ouvertes. La communication inter-processus à ce niveau là n'est pas souvent utilisée. Le mutex vous permet aussi d'attendre la libération de soit un, soit toute une liste de WaitHandles. Pour les autres utilisations où la classe Monitor suffit, je conseille d'utiliser cette dernière d'autant plus qu'en C# l'instruction lock simplifie son utilisation. Voici un exemple détectant si une autre instance de l'application est déjà ouverte :

using System;
using System.Threading;

class Test
{
    static void Main()
    {
        bool firstInstance;
        
        using (Mutex mutex = new Mutex(true, 
                                       @"Global\Jon.Skeet.MutexTestApp",
                                       out firstInstance))
        {
            if (!firstInstance)
            {
                Console.WriteLine ("Other instance detected; aborting.");
                return;
            }
            
            Console.WriteLine ("We're the only instance running - yay!");
            for (int i=0; i < 10; i++)
            {
                Console.WriteLine (i);
                Thread.Sleep(1000);
            }
        }
    }
}

Si vous faites tourner cet exemple dans deux consoles différentes, l'une va compter jusqu'à 10 doucement pendant que l'autre s'arrêtera après avoir détecté qu'une autre instance de l'application est en train de tourner. L'instruction using autour du mutex doit être étendue autour de l'ensemble du code exécuté, sinon, une autre instance pourrait créer un nouveau mutex avec le même nom une fois que le précédent mutex a été détruit. Par exemple, supposons que vous utilisez une variable local sans instruction using comme ci-dessous :

using System;
using System.Threading;

class Test
{
    static void Main()
    {
        bool firstInstance;
        
        // Code a ne pas utiliser
        Mutex mutex = new Mutex(true, 
                                @"Global\Jon.Skeet.MutexTestApp",
                                out firstInstance);
        
        if (!firstInstance)
        {
            Console.WriteLine ("Other instance detected; aborting.");
            return;
        }
        
        Console.WriteLine ("We're the only instance running - yay!");
        for (int i=0; i < 10; i++)
        {
            Console.WriteLine (i);
            Thread.Sleep(1000);
        }
    }
}

En exécutant le code ci-dessus en mode debug (ou le Garbage Collector est très conservateur), vous trouverez probablement que tout fonctionne correctement. En dehors du mode debug, le Garbage Collector peut repérer que la variable mutex n'est plus utilisée après son initialisation, il peut alors collecter la variable à tout moment et détruire le mutex. L'instruction using utilisée précédemment est seulement l'une des techniques pour contourner ce problème. Vous pouvez aussi déclarer la variable statique ou utiliser GC.KeepAlive(mutex); à la fin de la méthode pour être sûr que le Garbage Collector ne supprime pas la variable.


Page suivante : Volatilité, Atomicité et Interblocages
Page précédente : Interblocages, utilisation de Wait et Pulse

Lire la version originale de cette page

Revenir sur la page d'accueil de sylvain114