Accès concurrents aux données

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.

Acces exclusif - 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