Nous venons de voir comment créer un thread et comment le démarrer. Dans quelques rares cas, cela suffira, vous utiliserez un thread qui n'a besoin d'accéder à aucune donnée en dehors des siennes (le compteur en est un exemple). Mais beaucoup plus souvent, vous aurez besoin de plusieurs threads qui vont accéder aux même données, et c'est là que le problème débute. Regardons un exemple très simple :
using System; using System.Threading; public class Test { static int count=0; static void Main() { ThreadStart job = new ThreadStart(ThreadJob); Thread thread = new Thread(job); thread.Start(); for (int i=0; i < 5; i++) { count++; } thread.Join(); Console.WriteLine ("Final count: {0}", count); } static void ThreadJob() { for (int i=0; i < 5; i++) { count++; } } } |
Chaque thread incrémente simplement la variable count
, puis à la
fin le thread principal affiche la valeur finale de cette variable.
La seule nouveauté est l'appel à Thread.Join
dans le thread principal, qui
met en pause le thread principal jusqu'à ce que l'autre thread soit terminé.
A première vue, le résultat final devrait toujours être Final count: 10
. En fait,
les probabilités font que ce sera le résultat que vous obtiendrez si
vous exécutez le code ci-dessus, mais ce résultat n'est pas garantit.
Il y a deux raisons à cela, l'une très simple et l'autre un peu plus subtile.
Nous allons laisser la plus subtile
de côté pour le moment et nous concentrer sur la plus simple.
En réalité, l'expression count++;
fait trois choses : elle lit la valeur courante
de la variable cout
, elle l'incrémente, puis écrit la nouvelle valeur dans
la variable. Maintenant, si un thread lit la valeur courante et que l'autre thread
prend la main et exécute l'ensemble de l'opération d'incrémentation, lorsque le premier
thread reprend la main, l'idée qu'il se fait de la valeur de la variable count
est "périmée". Il va donc incrémenter l'ancienne valeur et écrire le résultat de cette incrémentation
(qui est faux) dans la variable.
La façon la plus facile pour montrer cela est de séparer les trois opérations et d'introduire
dans le code des appels à Sleep
, de façon à augmenter la probabilité d'apparition
de l'erreur. Il faut souligner que l'introduction d'appels à Sleep
ne devrait
jamais changer l'exactitude d'un programme, en terme de threading (tout thread peut dormir
à n'importe quel moment). En d'autres termes, vous ne pouvez jamais compter sur le fait que deux
opérations s'exécuteront successivement sans qu'un autre thread n'exécute du code entre les deux.
Afin de mettre en évidence ce qui se passe, j'ai inséré des affichages. L'activité du
thread principal apparaît sur la gauche pendant que celle du deuxième thread apparaît
sur la droite. Voici le code :
using System; using System.Threading; public class Test { static int count=0; static void Main() { ThreadStart job = new ThreadStart(ThreadJob); Thread thread = new Thread(job); thread.Start(); for (int i=0; i < 5; i++) { int tmp = count; Console.WriteLine ("Read count={0}", tmp); Thread.Sleep(50); tmp++; Console.WriteLine ("Incremented tmp to {0}", tmp); Thread.Sleep(20); count = tmp; Console.WriteLine ("Written count={0}", tmp); Thread.Sleep(30); } thread.Join(); Console.WriteLine ("Final count: {0}", count); } static void ThreadJob() { for (int i=0; i < 5; i++) { int tmp = count; Console.WriteLine ("\t\t\t\tRead count={0}", tmp); Thread.Sleep(20); tmp++; Console.WriteLine ("\t\t\t\tIncremented tmp to {0}", tmp); Thread.Sleep(10); count = tmp; Console.WriteLine ("\t\t\t\tWritten count={0}", tmp); Thread.Sleep(40); } } } |
Et voici le resultat que l'on peut obtenir :
Read count=0 Read count=0 Incremented tmp to 1 Written count=1 Incremented tmp to 1 Written count=1 Read count=1 Incremented tmp to 2 Read count=1 Written count=2 Read count=2 Incremented tmp to 2 Incremented tmp to 3 Written count=2 Written count=3 Read count=3 Read count=3 Incremented tmp to 4 Incremented tmp to 4 Written count=4 Written count=4 Read count=4 Read count=4 Incremented tmp to 5 Written count=5 Incremented tmp to 5 Written count=5 Read count=5 Incremented tmp to 6 Written count=6 Final count: 6 |
En jetant un coup d'oeil aux premières lignes, on se rend vite compte du problème :
le thread principal lit la valeur 0, l'autre thread incrémente count
à 1,
et ensuite, le thread principal incrémente l'ancienne valeur de 0 à 1, et écrit cette
valeur dans la variable. La même chose se reproduit plusieurs fois et, à la fin, la variable
count
contient la valeur 6 au lieu de 10.
Monitor.Enter
/Exit
et utilisation de lock
Afin de régler le problème présenté ci-dessus, nous avons besoin d'être sûr que pendant qu'un thread effectue les opérations read/increment/write, aucun autre thread ne va essayer de faire la même chose. Et c'est là que les moniteurs entre en scène. Un moniteur (théorique) est associé à chaque objet .NET. Un thread ne peut acquérir (entrer dans) moniteur à la seule condition qu'aucun autre thread ne possède déjà ce moniteur. Le moniteur n'est disponible pour les autres thread que lorsque le thread qui le possède sera sorti du moniteur autant de fois qu'il y est entré. Si un thread essaie d'entrer dans un moniteur qui est déjà possédé par un autre thread, il va se bloquer jusqu'à ce qu'il puisse acquérir le moniteur. Dans le cas ou plusieurs threads essaient d'acquérir un même moniteur, seul un thread pourra entrer dans le moniteur quand le thread le possédant en sortira. Les autres threads devront attendre la sortie du thread suivant.
Dans notre exemple, il nous faut donc mettre en place un accès exclusif à la variable count
pendant que l'on effectue l'opération d'incrémentation. La première chose dont on a besoin est
d'un objet à utiliser pour le lock. J'aborderai le choix de cet objet plus en détail
dans un autre chapitre,
mais pour l'instant, l'on va ajouter une nouvelle variable countLock
dont le but
est simplement de servir de base pour le lock. Cette variable est initialisée avec un nouvel objet
et ne sera jamais modifié par la suite. Le fait que la valeur de la variable ne soit pas
changé est un point très important, sinon, un thread pourrait acquérir le moniteur d'un objet
pendant qu'un autre thread acquière le moniteur d'un autre objet (dans ce cas, l'on se retrouve avec
le même problème que décrit plus haut).
Ensuite, on place simplement chaque opération d'incrémentation entre un paire de Monitor.Enter
et Monitor.Exit
:
using System; using System.Threading; public class Test { static int count=0; static readonly object countLock = new object(); static void Main() { ThreadStart job = new ThreadStart(ThreadJob); Thread thread = new Thread(job); thread.Start(); for (int i=0; i < 5; i++) { Monitor.Enter(countLock); int tmp = count; Console.WriteLine ("Read count={0}", tmp); Thread.Sleep(50); tmp++; Console.WriteLine ("Incremented tmp to {0}", tmp); Thread.Sleep(20); count = tmp; Console.WriteLine ("Written count={0}", tmp); Monitor.Exit(countLock); Thread.Sleep(30); } thread.Join(); Console.WriteLine ("Final count: {0}", count); } static void ThreadJob() { for (int i=0; i < 5; i++) { Monitor.Enter(countLock); int tmp = count; Console.WriteLine ("\t\t\t\tRead count={0}", tmp); Thread.Sleep(20); tmp++; Console.WriteLine ("\t\t\t\tIncremented tmp to {0}", tmp); Thread.Sleep(10); count = tmp; Console.WriteLine ("\t\t\t\tWritten count={0}", tmp); Monitor.Exit(countLock); Thread.Sleep(40); } } } |
Cette fois ci, le résultat est quand même meilleur :
Read count=0 Incremented tmp to 1 Written count=1 Read count=1 Incremented tmp to 2 Written count=2 Read count=2 Incremented tmp to 3 Written count=3 Read count=3 Incremented tmp to 4 Written count=4 Read count=4 Incremented tmp to 5 Written count=5 Read count=5 Incremented tmp to 6 Written count=6 Read count=6 Incremented tmp to 7 Written count=7 Read count=7 Incremented tmp to 8 Written count=8 Read count=8 Incremented tmp to 9 Written count=9 Read count=9 Incremented tmp to 10 Written count=10 Final count: 10 |
Le fait que les incréments soient alternés est simplement dû aux appels à la fonction sleep
,
dans un système normal, il pourrait y avoir 2 incrémentations dans un thread, puis 3 dans l'autre...
La chose importante est que chaque incrément soit 'isolé' des autres incréments, avec 1 seul incrément
se déroulant à un instant donné.
Pourtant, il y a une probabilité (petite, mais pas nulle) pour que le code au-dessus plante.
Si une instruction au milieu de l'incrémentation (comme un appel à Console.WriteLine
,
par exemple) lance une exception, le thread va garder le moniteur et donc l'autre thread ne pourra
jamais acquérir le moniteur et continuer son exécution. La solution la plus évidente à ce problème est
de placer l'appel Monitor.Exit
dans un block finally
et tout ce qui suit
l'appel à Monitor.Enter
dans bloque try
.
Tout comme l'instruction using
fait un appel automatique à la méthode Dispose
dans un bloc finally
, le C# fournie l'instruction lock
qui appelle automatiquement
Monitor.Enter
et Monitor.Exit
dans un bloc try/finally.
Cela permet de simplifier la synchronisation, puisque l'on a plus besoin de s'assurer
que les appels à Enter
et Exit
sont partout équilibrés.
L'instruction lock
permet aussi d'être sûr que l'on n'essaie pas
de sortir d'un moniteur dans lequel l'on n'est pas entré : par exemple, dans le code
au-dessus, si l'on change la valeur de countLock
et que l'on fait pointer
cette variable vers un objet différent au milieu de l'opération d'incrément, la sortie du moniteur
va échouer et devrais en théorie lancer une SynchronizationLockException
(en fait,
l'exception ne sera pas lancée à cause d'un bug dans le framework 1.0 et 1.1, mais cela est une
autre histoire). L'instruction lock
fait automatiquement une copie de la référence
spécifiée, et appel les 2 fonctions Enter
et Exit
avec la même référence
sauvegardée.
Nous pouvons donc ré-écrire le code précédent de façon plus claire et plus robuste :
using System; using System.Threading; public class Test { static int count=0; static readonly object countLock = new object(); static void Main() { ThreadStart job = new ThreadStart(ThreadJob); Thread thread = new Thread(job); thread.Start(); for (int i=0; i < 5; i++) { lock (countLock) { int tmp = count; Console.WriteLine ("Read count={0}", tmp); Thread.Sleep(50); tmp++; Console.WriteLine ("Incremented tmp to {0}", tmp); Thread.Sleep(20); count = tmp; Console.WriteLine ("Written count={0}", tmp); } Thread.Sleep(30); } thread.Join(); Console.WriteLine ("Final count: {0}", count); } static void ThreadJob() { for (int i=0; i < 5; i++) { lock (countLock) { int tmp = count; Console.WriteLine ("\t\t\t\tRead count={0}", tmp); Thread.Sleep(20); tmp++; Console.WriteLine ("\t\t\t\tIncremented tmp to {0}", tmp); Thread.Sleep(10); count = tmp; Console.WriteLine ("\t\t\t\tWritten count={0}", tmp); } Thread.Sleep(40); } } } |
Page suivante : Interblocages, utilisation de Wait et Pulse
Page précédente : Passer des paramètres aux threads
Lire la version originale de cette page
Revenir sur la page d'accueil de sylvain114