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.
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 :
Pulse
. S'il y a plusieurs threads en attente,
cela sera même moins efficace d'utiliser PulseAll
, il n'y
a aucune raison de réveiller plusieurs threads quand on sait qu'un seul
d'entre eux passera en exécution et quand le choix du thread réveillé
n'est pas important.
PulsseAll
pour être sûr que le thread en attente
sur la condition qui vient juste de se produire sera notifié et pourra
passer en exécution.
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