Interblocages

Les interblocages font partie des problèmes les plus importants du multi-threading. Ce problème arrive lorsque parmi deux threads, chaque thread veut acquérir le moniteur que l'autre thread possède déjà. Chaque thread se bloque et attend que l'autre thread relâche son moniteur, les moniteurs ne sont donc jamais relâchés, et l'application plante (ou tout du moins, les threads impliqués dans l'interbloquage ne progressent plus).

Voici un exemple de code avec un interblocage :

using System;
using System.Threading;

public class Test
{
    static readonly object firstLock = new object();
    static readonly object secondLock = new object();
    
    static void Main()
    {
        new Thread(new ThreadStart(ThreadJob)).Start();
        
        // On attend pour être sure que l'autre thread
        // thread a acquit le firstLock
        Thread.Sleep(500);
        
        Console.WriteLine ("Locking secondLock");
        lock (secondLock)
        {
            Console.WriteLine ("Locked secondLock");
            Console.WriteLine ("Locking firstLock");
            lock (firstLock)
            {
                Console.WriteLine ("Locked firstLock");
            }
            Console.WriteLine ("Released firstLock");
        }
        Console.WriteLine("Released secondLock");
    }
    
    static void ThreadJob()
    {
        Console.WriteLine ("\t\t\t\tLocking firstLock");
        lock (firstLock)
        {
            Console.WriteLine("\t\t\t\tLocked firstLock");
            // On attend pour être sure que le premier
            // thread a acquit le secondLock
            Thread.Sleep(1000);
            Console.WriteLine("\t\t\t\tLocking secondLock");
            lock (secondLock)
            {
                Console.WriteLine("\t\t\t\tLocked secondLock");
            }
            Console.WriteLine ("\t\t\t\tReleased secondLock");
        }
        Console.WriteLine("\t\t\t\tReleased firstLock");
    }
}

Le résultat :

  
                                Locking firstLock
                                Locked firstLock
Locking secondLock
Locked secondLock
Locking firstLock
                                Locking secondLock

Comme vous pouvez le voir, chaque thread acquiert un verrou et essaie de récupérer l'autre. Les appels à la fonction Thread.Sleep ont été placés de telle façon qu'ils s'exécutent à des moments inopportun et font bloquer l'application. Vous allez avoir besoin de faire un Ctrl-C ou quelque chose de similaire pour tuer le programme.

Les interblocages peuvent être vraiment pénibles à débuger, ils peuvent arriver d'une façon qui semble aléatoire (ils sont donc difficiles à reproduire) et une fois que vous avez trouvé la raison de l'interblocage, il n'est pas toujours facile de trouver la solution pour le résoudre. La recherche de stratégies doit toujours être réfléchie, vous ne pouvez pas simplement supprimer toutes les instructions lock du code, sinon vous finirez avec des accès concurrent aux données.

La solution (qui est plus facile à dire qu'à faire) est de s'assurer que tous les threads s'approprient les verrous dans le même ordre. Dans le code ci-dessus, vous pouvez décider de ne jamais acquérir le secondLock tant vous n'avez pas déjà le firstLock. Cela est assez facile à faire dans une seule classe, mais lorsque l'on a besoin de faire des appels entre plusieurs classes, ou d'utiliser des delegates (pour lesquels on ne sait pas toujours quel code est appelé), cela peut vite devenir épouvantable. De manière générale, il est déconseillé de faire appel à des méthodes externes à votre propre classe à l'intérieur de blocs lock, sauf si, bien sûr vous êtes certain que ces méthodes ne posent aucun verrous.

Les méthodes de la classe Monitor

Si vous jetez un coup d'oeil à la documentation pour les méthodes Monitor.Enter et Monitor.Exit vous verrez que la classe contient aussi beaucoup d'autres méthodes.

La méthode Monitor.TryEnter est la plus facile à décrire. Elle essaie simplement d'acquérir un verrou, mais sans bloquer (ou en bloquant pendant un temps défini) si le verrou ne peut être acquis.

Les autres méthodes (Wait, Pulse et PulseAll) vont toutes ensemble. Elles sont utilisées pour envoyer des signaux entre les threads. Le concept est qu'un thread appel Wait, qui le fait s'arrêter jusqu'à ce qu'un autre thread appelle Pulse ou PulseAll. La différence entre Pulse et PulseAll est le nombre de threads réveillés : Pulse ne réveille qu'un seul thread en attente (ayant fait appel à Wait) alors que PulseAll reveille tous les thread en attente sur le moniteur. Cela ne veut pas dire qu'ils vont tous se mettre immédiatement en exécution car, de toutes façons, afin de pouvoir appeler l'une de ces trois méthodes, le thread a besoin de posséder le moniteur de l'objet passé en paramètre de la fonction. Lorsqu'on fait un appel à Wait, le moniteur est relâché, mais il a besoin d'être récupéré avant que le thread ne s'exécute vraiment. Cela veut dire que le thread va bloquer encore jusqu'à ce que le thread qui appelle Pulse ou PulseAll relâche le moniteur (qu'il doit posséder pour pouvoir faire appel à la méthode) et si plusieurs thread sont réveillés, ils vont tous essayer d'acquérir le moniteur, mais bien sur, un seul arrivera à le posséder en même temps. Il faut bien faire attention que l'appel à Wait relâche le moniteur sur le quel vous attendez. Cela est un point important car sinon, le code peut mener à un interblocage.

L'utilisation la plus courante de ces méthodes est la mise en place d'une relation producteur/consommateur dans laquelle l'un des thread insère du travail dans une queue (liste FIFO), et l'autre thread récupère le travail. Le thread "consommateur" extrait les données de la queue jusqu'à ce qu'elle soit vide puis attend sur un verrou. Le thread "producteur" fait un appel à Pulse sur le verrou quand il ajoute des données dans la queue. Si vous êtes préoccupé par les performances, le thread "producteur" peut ne faire appel à Pulse que quand il ajoute des données dans une liste précédemment vide. Voici un exemple :

using System;
using System.Collections;
using System.Threading;

public class Test
{
    static ProducerConsumer queue;
    
    static void Main()
    {
        queue = new ProducerConsumer();
        new Thread(new ThreadStart(ConsumerJob)).Start();
        
        Random rng = new Random(0);
        for (int i=0; i < 10; i++)
        {
            Console.WriteLine ("Producing {0}", i);
            queue.Produce(i);
            Thread.Sleep(rng.Next(1000));
        }
    }
    
    static void ConsumerJob()
    {
        // On s'assure d'avoir un suite aléatoire
        // différente du premier thread
        Random rng = new Random(1);
        for (int i=0; i < 10; i++)
        {
            object o = queue.Consume();
            Console.WriteLine ("\t\t\t\tConsuming {0}", o);
            Thread.Sleep(rng.Next(1000));
        }
    }
}

public class ProducerConsumer
{
    readonly object listLock = new object();
    Queue queue = new Queue();

    public void Produce(object o)
    {
        lock (listLock)
        {
            queue.Enqueue(o);

            // Nous avons besoin de toujours faire appel à pulse,
            // même si la queue n'était pas vide avant.
            // Sinon, si nous ajoutons beaucoup d'objets
            // en peu de temps, il se peut qu'il n'y ai qu'un pulse,
            // réveillant un seul thread, même s'il y a plusieurs
            // threads en attente d'objets.
            Monitor.Pulse(listLock);
        }
    }
    
    public object Consume()
    {
        lock (listLock)
        {
            // Si la liste est vide, on attend l'ajout d'un objet.
            // Remarque : c'est une boucle while, car le thread peu
            // recevoir un pulse mais na pas se réveiller avant
            // qu'un un autre thread arrive et consomme l'objet ajouté.
            // Dans ce cas le thread doit attendre un autre pulse.
            while (queue.Count==0)
            {
                // Le thread relâche le verrou listLock, et ne le
                // récupère qu'après avoir été réveillé par un Pulse
                Monitor.Wait(listLock);
            }
            return queue.Dequeue();
        }
    }
}

Voici les résultats que j'ai obtenu :

  
Producing 0
                                Consuming 0
Producing 1
                                Consuming 1
Producing 2
                                Consuming 2
Producing 3
                                Consuming 3
Producing 4
Producing 5
                                Consuming 4
Producing 6
                                Consuming 5
                                Consuming 6
Producing 7
                                Consuming 7
Producing 8
                                Consuming 8
Producing 9
                                Consuming 9

Maintenant, rien ne vous empêche de mettre en place plus d'un consommateur ou producteur. Tout se passera correctement et chaque objet produit ne sera consommé qu'une fois et cela presque immédiatement s'il y a assez de consommateur attendant pour travailler.

Les deux méthodes Pulse et PulseAll sont faites pour être utilisé dans des situations différentes :

Il faut noter que l'utilisation de ces méthodes peut facilement conduire à un interblocage. Si un thread A détient les verrous X et Y et attend sur sur le verrou Y mais que le thread B a besoin d'acquérir le verrou X avant d'acquérir le verrou Y pour faire un appel à Pulse, le thread B ne pourra rien faire du tout car seul le verrou sur lequel attend le thread A est relâché. Pour éviter cela, il est souvent possible de faire que le thread faisant appel à Wait ne détient que le verrou sur lequel il attend. Lorsque ce n'est pas possible il faut faire extrêmement attention à tout ce qui va s'exécuter afin d'éviter un interblocage.


Page suivante : WaitHandles - Auto/ManualResetEvent et Mutex
Page précédente : Accès concurrents aux données et verrouillages


Lire la version originale de cette page

Revenir sur la page d'accueil de sylvain114