Dans les pages précédentes, lorsque j'ai introduit le sujet des accès concurrents aux données, j'ai annoncé qu'il y avait une raison plus subtile au fait que le premier exemple de code n'était pas Thread-safe. Cela vient de la volatilité et de la mise en cache des variables. Voici un exemple qui va vous expliquer le problème un peu plus facilement :
using System; using System.Threading; public class Test { static bool stop; static void Main() { ThreadStart job = new ThreadStart(ThreadJob); Thread thread = new Thread(job); thread.Start(); // Laisse le temps au thread de démarrer Thread.Sleep(2000); // Demande l'arret du comptage stop = true; } static void ThreadJob() { int count = 0; while (!stop) { Console.WriteLine ("Extra thread: count {0}", count); Thread.Sleep(100); count++; } } } |
Ce code est vraiment simple. Un booléen stop
est surveillé par
un nouveau thread, ce dernier va compter jusqu'à ce qu'il repère le passage
de la variable stop
à la valeur true
. Dans le thread principal,
nous faisons un appel à Sleep
pendant quelques secondes, puis nous
mettons la variable stop
à true
.
Donc le nouveau thread devrait compter pendant quelques secondes puis s'arrêter, non ?
En fait, c'est sûrement ce qui se passera si vous exécutez le code,
mais ce comportement n'est pas garantie. La boucle while
du nouveau
thread pourrait tourner indéfiniment, sans jamais vraiment regarder si la variable
stop
a été mise à true
. Cela vous parait bizarre ?
Bienvenu dans l'étrange et merveilleux monde des modèles de mémoires.
Dans les nouveaux ordinateurs, la mémoire est une affaire très compliquée avec des registres, différents niveaux de cache et plusieurs processeurs partageant la mémoire principale, mais pas forcément les caches... De plus, si un processeur sait qu'il va bientôt devoir lire une partie de mémoire, il peut décider de la lire en avance... L'idée qu'il n'y a qu'une seule section de mémoire accessible de façon simple, est pratique pour les programmeurs, mais mauvaise en terme de performances. Les fabriquants de matériel et les constructeurs de compilateur (y compris les développeurs du compilateur JIT) ont travaillés très dur pour faire du code rapide et facile à écrire. Le modèle de mémoire d'une plateforme est la spécification de ce que les développeurs peuvent faire de façon sûre, sans trop connaître les détails de ce que fait le matériel. Ce la veut dire (dans notre cas) que si vous avez suivi les règles du modèle de mémoire, vous pouvez sans problème faire tourner du code .NET sur n'importe quel CPU équipé d'un CLR, et cela que le modèle de mémoire matériel soit soit "fort" ou "faible". Un modèle de mémoire "fort" et un modèle qui donne beaucoup de garanties alors qu'un modèle de mémoire "faible" donne peu de garantie. Un modèle de mémoire faible permet souvent de meilleures performances mais demande plus travail de la part du développeur.
Le modèle de mémoire de .NET indique à quel moment les lectures/écritures se font réellement, comparé au moment où elles apparaissent dans la séquence d'instructions. Les lectures/écritures peuvent être réordonnées de différentes façons sans violer les lois du modèle de mémoire. Comme il existe des lectures/écritures "normales", il existe des lectures/écritures volatiles. Chaque lecture qui apparaît après une lecture volatile dans la liste des instructions s'exécute après la lecture volatile dans le modèle de mémoire (ils ne peuvent pas être réordonnées avant la lecture volatile). Une écriture volatile se comporte de manière différente : toutes écritures qui apparaissent avant l'écriture volatile dans la séquence d'instruction sont exécutées avant l'écriture volatile dans le modèle de mémoire.
Ne vous inquiétez pas si ce qui précède ne vous paraît pas très claire,
la page des ressources contient quelques liens
qui vous aideront si vous voulez vraiment approfondir le sujet. Mais la règle
est simple : lorsque vous devez accéder à une donnée partagée, vous
devez vous assurer que la donnée que vous lisez est valide et que l'écriture
des modifications que vous ferez sera faite en un temps correcte.
Il y a deux moyens de faire cela : les variables volatiles ou
l'utilisation de lock
.
Une variable qui est déclarée volatile utilise des lectures et des écritures
volatiles pour tous ses accès. Une variable ne peut être déclarée volatile que
si elle est d'un type suivant : référence, byte
,
sbyte
, short
, ushort
,
int
, uint
, char
, float
, ou
bool
, ou une énumération basée sur les types byte
,
sbyte
, short
, ushort
,
int
, ou uint
. Si vous voulez seulement partager
une seule donnée et que son type fait partie de la liste ci-dessus, la manière
la plus simple de le faire est d'utiliser une variable volatile.
Toutefois, notez que pour une référence, seul l'accès à la variable elle-même
est volatile, si vous écrivez quelque chose à l'intérieur de l'instance,
cette écriture ne sera pas volatile. Personnellement, je n'utilise pas beaucoup
les variables volatiles en leur préférant l'utilisation des lock
.
Nous avons vu comment utiliser lock
pour limiter l'accès d'une
variable à un seul thread. Cette instruction a aussi un autre effet :
un appel à Monitor.Enter
effectue une lecture volatile implicitement.
Et un appel à Monitor.Exit
effectue une écriture volatile implicitement.
Ces deux fonctionnalités se combinent habilement puisqu'une lecture volatile
est effectuée à l'entrée du lock, donc si vous faites une lecture à l'intérieur
du lock, vous êtes sûr que celle-ci se fera depuis la mémoire principale ;
et parce que vous êtes dans un lock vous savez que rien d'autre ne pourra
changer la valeur de la variable. De la même manière, si vous effectuez
une écriture vous êtes sûr que rien d'autre n'essaiera de lire la valeur de la
variable entre son écriture et l'écriture volatile du lock, en admettant
bien sûr que tous les accès à la variable soit couverts par le même lock.
Si vous utilisez un moniteur pour accéder à une variable et un autre moniteur
pour accéder à la même variable, la volatilité et le verrouillage ne
fonctionneront plus, et vous n'aurez pas de garantie sur la
fraîcheur des données. Heureusement, il y a peu de raisons pour vouloir
essayer cela.
Revenons à notre programme. Il est pour l'instant défectueux parce que le
nouveau thread peut ne lire qu'une fois (éventuellement dans un registre)
la valeur de stop
et ne jamais la relire depuis la mémoire principale.
De toutes façons, même s'il la lecture se faisait depuis la mémoire principale,
il se peut que le thread original n'y écrive jamais rien. Pour corriger ce problème,
nous pouvons soit rendre la variable stop
volatile, soit utiliser des verrous.
La solution de la variable volatile est simple, il suffit d'ajouter le mot clé
volatile
devant la déclaration de la variable. La solution
utilisant des verrous nécessite un peu plus de travail, mais ce cela peut
quand même se faire facilement en utilisant une propriété pour faire le verrouillage.
Dans ce cas, si vous faites toujours référence à la variable à travers la propriété,
vous n'avez pas besoin de mettre des lock
partout.
Voici le code complet avec une propriété plaçant les verrous.
using System.Threading; public class Test { static bool stop; static readonly object stopLock = new object(); static bool Stop { get { lock (stopLock) { return stop; } } set { lock (stopLock) { stop = value; } } } static void Main() { ThreadStart job = new ThreadStart(ThreadJob); Thread thread = new Thread(job); thread.Start(); // Laisse le temps au thread de démarrer Thread.Sleep(2000); // Demande l'arret du comptage Stop = true; } static void ThreadJob() { int count = 0; while (!Stop) { Console.WriteLine ("Extra thread: count {0}", count); Thread.Sleep(100); count++; } } } |
Malheureusement, il n'y a pas de moyen de demander au compilateur de générer
une erreur si vous accédez directement à stop
, vous devez donc faire
très attention de toujours utiliser la propriété.
Depuis .Net 1.1, il y a un autre moyen pour mettre en place une barrière de
mémoire : Thread.MemoryBarrier()
.
Dans les futures version, il se pourrait que la méthode soit séparée en deux :
une barrière mémoire en "écriture" et une barrière mémoire en "lecture".
Je conseille de ne pas utiliser cette méthode, à moins que vous soyez un expert.
Et même parmi les experts l'utilisation (où et quand) de cette méthode semble discutée.
Vous pouvez vous reporter à la section des ressources
pour plus d'informations.
Cette section est presque un aparté. Si vous écrivez du code thread-safe dès le début, la notion d'atomicité n'est pas très importante pour vous. Néanmoins, il est bon de savoir ce que c'est.
Une opération est atomique si elle est indivisible. En d'autres termes, rien d'autre ne peut s'insérer au milieu. Donc avec une écriture atomique, il ne peut pas y avoir d'autre thread effectuant une lecture au milieu de l'écriture et lisant la moitié de la nouvelle valeur et la moitié de l'ancienne valeur. De la même façon, avec une lecture atomique, il ne peut pas y avoir de thread changeant la valeur à mit chemin de la lecture et se finissant de nouveau avec une valeur lue ni ancienne, ni nouvelle.
Si la mémoire est correctement alignée (ce qui est fait par défaut),
la CLR garantie des lectures et des écritures atomiques pour les types qui
dont la taille ne dépasse pas celle d'un entier natif.
En d'autres termes, si un thread change la valeur d'un int
correctement aligné de 0 à 5 et qu'un autre thread lit la valeur de la
variable, le second thread lira toujours 0 ou 5, jamais 1 ou 4 par exemple.
Par contre, pour un long
sur une machine 32 bits, si un thread
change la valeur de 0 à 0x0123456789abcdef, il n'y a aucune garantie pour
qu'un autre thread ne vois pas la valeur 0x0123456700000000 ou
0x0000000089abcdef. Il ne faut pas avoir de chance pour que cela arrive,
mais écrire du code thread-safe consiste justement à ne pas compter sur
la chance pour qu'un programme se déroule correctement.
Heureusement, en utilisant les techniques que j'ai évoquées précédemment, vous
n'avez que très rarement à vous préoccuper de l'atomicité.
Evidemment, si vous utiliser les lock
, vous n'avez aucune inquiétude à avoir
puisque vous êtes déjà certain que les lectures et écriture ne peuvent se chevaucher.
Si vous utilisez des variables volatiles, il y a une petite probabilité pour que vous
ayez des problèmes. Tous les types qui peuvent être volatiles peuvent aussi
être lu et écrit de façon atomique, mais si l'alignement des variables n'est pas
correct, vous aurez quand même des lectures/écritures non atomiques
(voilà une raison de plus d'utiliser les verrous).
Interlocked
Pour des opérations très simples comme compter, l'utilisation des lock
peut
demander beaucoup d'efforts (et éventuellement de performances) par rapport
à l'opération en elle même. La classe Interlocked
apporte
des méthodes pour effectuer des modifications atomiques : échanges
(en effectuant éventuellement une comparaison), incrémentation et décrémentation.
Les méthodes Exchange
et CompareExchange
permettent de
traiter des variables de type int
, object
ou float
.
Les méthodes Increment
et Decrement
permettent d'agir des
variables de type int
ou long
.
Certaines personnes préfèrent utiliser un seul outil (les verrous) pour régler
les problèmes de volatilités, atomicité et accès concurrent. Néanmoins, cela
peut avoir un effet négatif sur les performances. Si vous écrivez du code
critique qui a besoin de tourner très vite, il est préférable
d'utiliser cette classe car c'est le moyen le plus rapide d'effectuer
les opérations proposées.
Voici un exemple (le même que le premier exemple utilisé pour illustrer
les accès concurrents, re-écrit pour être thread-safe
en utilisant la classe Interlocked
) :
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++) { Interlocked.Increment(ref count); } thread.Join(); Console.WriteLine ("Final count: {0}", count); } static void ThreadJob() { for (int i=0; i < 5; i++) { Interlocked.Increment(ref count); } } } |
Page suivante : Les threads dans les Windows Forms
Page précédente : WaitHandles - Auto/ManualResetEvent et Mutex
Lire la version originale de cette page
Revenir sur la page d'accueil de sylvain114