Introduction à la programmation concurrente en Java (2/2)


Dans un précédent article, nous avons vu comment lancer plusieurs threads pour exécuter des traitements concurrents en Java, manuellement ou via le framework Executor.

Dans cet article, nous étudierons les problèmes qui se posent lorsque plusieurs threads tentent d'accéder simultanément à une ressource, ainsi que quelques techniques simples pour les résoudre.

La problème des accès concurrents

Le problème

Avant toute chose, il faut se rappeler que les différents threads s'exécutant au sein d'une application sont autorisés à accéder, en lecture comme en écriture, à toutes les données présentes dans l'espace mémoire réservée par cette application.

A cause de cet accès libre et de l'exécution concurrente des threads, il peut arriver que deux d'entre eux tentent d'accéder simultanément à une même donnée ; on parle alors de race condition.

Des accès simultanés en lecture seule sont évidemment inoffensifs. Mais deux problèmes fondamentaux surviennent lors des accès en écriture :

  • Un problème de visibilité : la modification apportée par un thread est-elle immédiatement visible par les autres threads ?
  • Un problème de cohérence : que se passe-t-il en cas de modification concurrente ? Est-ce le dernier thread qui écrit qui gagne, ou les données risquent-elles d'être corrompues ?

Ces problèmes sont en réalité les mêmes que ceux posés par le partage d'un fichier sur un réseau : que se passe-t-il si plusieurs personnes tentent de l'éditer simultanément ? Les modifications sont-elles immédiatement visibles par tous les participants ? Et si une personne effectue une copie locale du fichier au lieu de l'éditer à distance ?

Corruption des données

Pour démonter le risque de corruption des données en environnement multithreadé, prenons l'exemple d'un simple compteur. Notez que les méthodes increment(), decrement() ou getValue() lisent ou modifient la valeur du champ value, qui est donc notre donnée partagée.

public class Counter {
 
    private int value = 0;
 
    public void increment() {
        value++;
    }
 
    public int getValue() {
        return value;
    }
 
}

Pour tester le comportement de ce compteur, le test ci-dessous lance NB_THREADS threads qui effectuent chacun NB_INCREMENTS appels à la méthode increment(). A la fin du test, on s'attend donc à ce que la valeur du compteur soit de NB_THREADS * NB_INCREMENTS.

Exemple : avec 5 threads et 100 incréments, le compteur vaut normalement 500.

Le test est répété plusieurs fois afin de vérifier sa stabilité.

public class CounterTest {
 
    private static final int NB_THREADS = 1;
    private static final int NB_INCREMENTS = 1_000_000;
 
    public static void main(String[] args) throws InterruptedException {
 
        final Counter counter = new Counter();
 
        // A job that increments the counter NB_INCREMENT times
        Runnable incrementer = new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < NB_INCREMENTS; i++) {
                    counter.increment();
                }
            }
        };
 
        // Submit several jobs that increment the counter
        ExecutorService pool = Executors.newFixedThreadPool(4);
        for (int i = 0; i < NB_THREADS; i++) {
            pool.submit(incrementer);
        }
        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);
 
        // Verify the counter's final value
        int counterValue = counter.getValue();
        System.out.printf("%d thread(s) X %7d increments = %d %n", NB_THREADS, NB_INCREMENTS, counterValue);
 
    }
 
}

Avec 1 thread, tout se passe bien :

1 thread(s) X 1000000 increments = 1000000 
1 thread(s) X 1000000 increments = 1000000 
1 thread(s) X 1000000 increments = 1000000 
1 thread(s) X 1000000 increments = 1000000 

Tentons avec 2 threads :

2 thread(s) X 1000000 increments = 1007644 
2 thread(s) X 1000000 increments = 1999360 
2 thread(s) X 1000000 increments = 1999384 
2 thread(s) X 1000000 increments = 1784593 

Le résultat n'est visiblement pas celui que l'on espérait. Non seulement la valeur obtenue n'est pas correcte, mais elle varie à chaque test ! Comment expliquer ce comportement ?

Explication

Plusieurs facteurs expliquent les résultats surprenants obtenus plus haut.

Indiana Jones and the Lost Updates

Premièrement, certaines mises à jour ont été malencontreusement "écrasées".
Cela est dû au fait que, malgré les apparences, l'opération ++ utilisée pour incrémenter le compteur n'est pas atomique. Elle consiste en réalité en 3 opérations successives :

  • lecture de la valeur actuelle en mémoire
  • incrémentation de la valeur
  • écriture de la nouvelle valeur en mémoire

La séquence suivante peut alors se produire :

  • Un thread A lit la valeur actuelle (ex: 42) puis est mis en pause par le processeur ;
  • Un thread B lit également la valeur 42, l'incrémente, puis écrit 43 en mémoire ;
  • Le thread A se réveille, incrémente la valeur qu'il a lue et écrit également 43 en mémoire.

Une mise à jour a donc été oubliée au passage, ce qui explique (en partie) les valeurs incorrectes obtenues lors du test. La littérature anglophone parle de "lost updates".

La solution à ce problème consiste à s'assurer que les trois opérations seront toujours exécutées de manière atomique, c'est-à-dire que leur séquence sera toujours exécutée en intégralité et sans interruption de la part d'autres threads.
Les moniteurs et les locks de Java donnent justement cette garantie.

Cachez cette valeur que je ne saurais voir

La deuxième source de corruption vient du fait que chaque thread est autorisé, sous certaines conditions, à mettre en cache certaines valeurs dans des registres locaux plutôt que de les lire ou écrire dans la mémoire commune. On touche donc ici à un problème de visibilité et de fraîcheur des données.

La JVM est s'autorise ce genre de manipulation pour des raisons de performances : il est toujours plus rapide d'accéder à un registre de thread qu'à la mémoire commune.

Là encore, l'emploi judicieux de moniteurs ou de locks permet de garantir la bonne visibilité mémoire des données partagées.

Contrôler l'accès aux ressources

Voyons maintenant comment utiliser les mécanismes que Java met à notre disposition pour garantir l'atomicité des opérations et la fraîcheur des données.

La solution la plus simple est souvent la plus sûre et la plus maintenable (à défaut d'être la plus performante). Puisque le problème vient de l'accès concurrent à la donnée, supprimons l'accès concurrent ! Ou du moins contrôlons-le, par exemple à l'aide de verrous.

Java propose deux types de verrous, les moniteurs et les locks. Si leur syntaxe d'utilisation diffère, leur principe de fonctionnement est identique : un thread souhaitant accéder à une donnée partagée doit préalablement obtenir le verrou qui la protège, et le libérer ensuite.

Pour chaque donnée exposée à une utilisation concurrente, il convient donc de définir (et de documenter[1] !) quel verrou la protège, puis de s'assurer que tous les accès à cette donnée (en lecture comme en écriture) sont convenablement protégés.

Maintenant, mettons toute cette théorie en pratique.

Les moniteurs

Tout d'abord, voyons les moniteurs, également appelés "verrous implicites" ou "verrous intrinsèques".

Chaque instance d'objet Java possède un moniteur, sur lequel on peut décider de se baser pour protéger une donnée. Attention, ce moniteur n'est pas un champ de l'instance ; c'est plutôt une de ses propriétés intrinsèques.

Mode d'emploi

Le mot-clé synchronized permet de demander l'acquisition d'un moniteur donné pour la durée d'un bloc de code :

synchronized(someInstance) {
    // Accès aux données protégées par le moniteur de "someInstance"
}

Une question se pose alors : quel moniteur utiliser ?

Une bonne pratique générale consiste à séparer les données métiers des données techniques. Dans une table de base de données par exemple, la colonne servant de clé primaire est souvent une clé purement technique.

Le même raisonnement s'applique avec les moniteurs : il est fréquent de créer une instance d'un quelconque objet (généralement un simple Object) dans l'unique but de disposer de son moniteur.

Avantage additionnel, le nom de cette instance technique permet d'expliciter son rôle. Par exemple, le verrou protégeant la donnée clientList peut être appelé clientListMonitor.

Passons à la mise en oeuvre.
Pour sécuriser notre compteur, nous utiliserons une instance technique appelée valueMonitor, déclarée private et final.

public class Counter {
 
    private int value = 0;
    private final Object valueMonitor = new Object(); // Protects "value"
 
    public void increment() {
        // Acquire the monitor before modifying the data
        synchronized (valueMonitor) {
            value++;
        }
    }
 
    public int getValue() {
        // Acquire the monitor before reading the data too !
        synchronized (valueMonitor) {
            return value;
        }
    }
 
}

L'exécution du test avec 5 threads donne cette fois le résultat attendu, et de manière reproductible :

5 thread(s) X 1000000 increments = 5000000
5 thread(s) X 1000000 increments = 5000000
5 thread(s) X 1000000 increments = 5000000
5 thread(s) X 1000000 increments = 5000000

Syntaxe alternative

Note : il existe également une syntaxe alternative, que personnellement je déconseille : l'utilisation du mot-clé synchronized directement dans la signature des méthodes :

public synchronized void foo() {
   // (...)
}

Si cette syntaxe vous est sans doute plus familière, elle présente beaucoup d'inconvénients :

  • Le moniteur utilisé est celui de l'instance courante de la classe, c'est-à-dire un objet utile à l'application
  • Le moniteur n'est pas nommé, donc peu explicite
  • Le moniteur est acquis pendant toute la durée de la méthode, même si 90% de son code n'en a pas besoin

Utilisez donc plutôt un moniteur explicite !

Avantages et limitations

Les moniteurs présentent deux avantages :

  • Ils sont simples à utiliser,
  • Il est impossible d'oublier de libérer le verrou : la sémantique du langage le garantit, à la sortie du bloc protégé.

Mais ils présentent également un inconvénient majeur : un thread en attente d'un moniteur est bloqué jusqu'à ce qu'il l'obtienne. Impossible d'abandonner après un certain temps, d'être prévenu par un autre thread que ce n'est plus la peine d'attendre (interruption), de simplement tester l'état du moniteur avant d'essayer de le verrouiller...

Les moniteurs doivent donc être utilisés avec beaucoup de précaution, pour éviter de provoquer des deadlocks irrattrapables.

Les locks

Pour pallier les problèmes des moniteurs, Java 5 propose dans son package java.util.concurrent.locks une nouvelle variété de verrous : les locks, représentés par l'interface Lock.

Mode d'emploi

Le principe d'utilisation d'un lock est exactement le même que celui d'un moniteur : il faut le verrouiller avant d'accéder à la ressource partagée, puis le déverrouiller ensuite.

Par contre, sa syntaxe est plus verbeuse, car il n'existe pas de support natif des locks au niveau du langage même.

L'usage canonique est le suivant :

Lock lock = new ReentrantLock();
lock.lock();
try {
    // Read/Write resource
} finally {
    lock.unlock();
}

Mettons à jour notre compteur pour utiliser un lock au lieu d'un moniteur :

public class Counter {
 
    private int value = 0;
    private final Lock valueLock = new ReentrantLock();
 
    public void increment() {
        // Acquire the monitor before modifying the data
        valueLock.lock();
        try {
            value++;
        } finally {
            valueLock.unlock();
        }
    }
 
    public int getValue() {
        // Acquire the monitor before reading the data too !
        valueLock.lock();
        try {
            return value;
        } finally {
            valueLock.unlock();
        }
 
    }
 
}

L'exécution du test avec 5 threads produit évidemment le même résultat qu'avec un moniteur.

5 thread(s) X 1000000 increments = 5000000
5 thread(s) X 1000000 increments = 5000000
5 thread(s) X 1000000 increments = 5000000
5 thread(s) X 1000000 increments = 5000000

Fonctionnalités avancées

Pour le moment, les locks semblent moins pratiques que les moniteurs ; leur syntaxe est plus verbeuse, et le risque d'erreur est plus élevé. Toute leur puissance réside en fait dans leurs fonctionnalités avancées, qui les rendent indispensables sur des projets complexes.

Read/Write Lock

Tout d'abord, étudions le ReadWriteLock.

Cette classe part du constat qu'il n'est pas dangereux de laisser plusieurs threads accéder en lecture seule à une ressource : en l'abence de toute de modification, il est impossible de corrompre la donnée. Dans ces conditions, imposer l'acquisition d'un verrou exclusif est donc inutile, et ne ferait que brider artificiellement les performances du système.
En revanche, toute opération de modification doit être réalisée sous la protection d'un verrou exclusif.

L'interface ReadWriteLock, et son implémentation ReentrantReadWriteLock, propose donc de différencier les deux modes d'accès grâce à deux sous-verrous : un pour la lecture (readLock), et un pour l'écriture (writeLock). Le premier peut être acquis par plusieurs threads simultanément, alors que le second est exclusif (et exclusif avec les verrous en lecture également).

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

Dans notre exemple, la méthode getValue() est une opération de lecture : elle peut donc être protégée par un readLock. La méthode increment(), au contraire, modifie la valeur du compteur, et doit donc être réalisée de manière atomique ; elle sera donc protégée par un writeLock.

public class CounterRWL {
 
    private int value = 0;
    private final ReadWriteLock valueLock = new ReentrantReadWriteLock();
 
    public void increment() {
        // Acquire the monitor before modifying the data
        valueLock.writeLock().lock();
        try {
            value++;
        } finally {
            valueLock.writeLock().unlock();
        }
    }
 
    public int getValue() {
        // Acquire the monitor before reading the data too !
        valueLock.readLock().lock();
        try {
            return value;
        } finally {
            valueLock.readLock().unlock();
        }
    }
 
}

L'utilisation d'un ReadWriteLock dans notre exemple n'améliore pas significativement ses performances, car nous appelons principalement la méthode increment(). En revanche, une structure de type cache, utilisée à 90% en lecture et 10% en écriture, verrait sa scalabilité considérablement améliorée.

Lock avec timeout

Le plus grand danger auquel font face les applications multithreadées est le fameux, terrible deadlock.

Cette situation fort déplaisante survient lorsque deux (ou plus) threads tentent chacun d'obtenir un verrou déjà possédé par l'autre. Ces threads se bloquent alors mutuellement, et ne peuvent être débloqués que par un arrêt de la JVM - un peu gênant en production...

Pour éviter cela, les locks proposent une variante de l'opération de verrouillage, permettant de définir un délai maximal d'attente (timeout) : tryLock().
Cette méthode tente d'obtenir le verrou, mais renonce après un certain temps, évitant ainsi de rester bloqué indéfiniment.

ReentrantLock lock = new ReentrantLock();
if (lock.tryLock()) {
    try {
        // Read/Write resource
    } finally {
        lock.unlock();
    }
}

Notez la présence d'un bloc try/finally assurant la libération du verrou en toutes circonstances.

Pour illustrer son utilisation, je vous propose un exemple légèrement plus complexe ; prenez le temps de l'étudier !

L'application ci-dessous demande son nom à l'utilisateur, puis attend une saisie sur l'entrée standard (System.in). Un thread tourne en arrière-plan, et vérifie périodiquement si un nom a été saisi ; dans la négative, il demande à l'utilisateur de se dépêcher.

Afin de réagir immédiatement à la saisie du nom de l'utilisateur (et non après un certain temps), un lock est utilisé. Celui-ci est détenu par le thread principal, et relâché dès que la saisie est effectuée. Le thread de vérification, quant à lui, utilise tryLock() pour patienter deux secondes entre chaque relance.

public class ImpatientGreeter {
 
    private String name;
    private Lock nameLock = new ReentrantLock();
 
    private String[] sentences = new String[]{
            "Please, tell me your name !",
            "Tell me your name, or else !!!",
            "Y U NO tell me your name ???",
            "Come on, what's your name ?"
    };
 
    public static void main(String[] args) throws IOException {
        new ImpatientGreeter().launch();
    }
 
    private void launch() {
        System.out.println("Hi, what's your name ?");
 
        // The impatient greeter thread. Asks for the name every 2 seconds.
        Thread readerThread = new Thread("reader") {
            @Override
            public void run() {
                while (true) {
                    try {
                        // Try to acquire the lock, but only for 2 seconds
                        if (nameLock.tryLock(2, TimeUnit.SECONDS)) {
                            try {
                                System.out.println("Hi, " + name + " !");
                                break;
                            } finally {
                                nameLock.unlock();
                            }
                        } else {
                            // Couldn't lock, ask again
                            System.out.println(sentences[ThreadLocalRandom.current().nextInt(sentences.length)]);
                        }
                    } catch (InterruptedException e) {
                        break;
                    }
                }
            }
        };
        readerThread.start();
 
        // Wait for the user to enter his name in the console
        try (InputStreamReader isr = new InputStreamReader(System.in);
             BufferedReader reader = new BufferedReader(isr)) {
            nameLock.lock();
            try {
                // Wait for the name, while holding the lock
                name = reader.readLine();
            } finally {
                nameLock.unlock();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
 
    }
 
}

Ce qui donne :

Hi, what's your name ?
Y U NO tell me your name ???
Please, tell me your name !
Please, tell me your name !
Y U NO tell me your name ???
toto
Hi, toto !

Avantages et limitations

Les locks offrent des fonctionnalités uniques qui permettent de déjouer certains problèmes de deadlock. Utilisés à bon escient, ils peuvent rendre les applications concurrentes considérablement plus robustes et performantes.

Toutefois, leur syntaxe plus verbeuse et l'absence de support natif par le langage, notamment pour le déverrouillage automatique, peut augmenter les risques d'erreurs.

Ma recommandation est donc de privilégier les moniteurs en première approche, et de passer aux locks lorsque leurs fonctionnalités avancées peuvent faire la différence.

Conclusion

Dans cet article, nous avons vu qu'autoriser l'accès concurrent à une ressource pouvait mener à la corruption de celle-ci.

Pour résoudre ce problème, Java propose deux mécanismes de verrouillage : les moniteurs (synchronized) et les locks (dans java.util.concurrent.locks.Lock). Nous avons vu le mode d'emploi ainsi que les avantages et inconvénients de chaque solution.

Quelques ressources pour aller plus loin :

Note

[1] Par exemple à l'aide des annotations proposées sur le site du livre Java Concurrency In Practice : http://jcip.net/


Commentaires

1. Le mardi 17 avril 2012, 11:51 par fabszn

Hello,

Article intéressant. Merci

Est ce que le sujet de confinement des données aurait pu être abordé ici?
C'est une technique assez classique permettant de gérer l'accès concurrent sur des données dans un environnement multi threadé.

2. Le mardi 17 avril 2012, 12:05 par Olivier Croisier

Je me suis volontairement limité à une introduction aux principes essentiels de la programmation concurrente en Java. Les aspects plus évolués (performance, design patterns, techniques diverses) sont plus du ressort d'une formation ou d'un livre (voir les ressources en bas de l'article).

3. Le mardi 17 avril 2012, 14:42 par Thomas Broyer

À noter: les 4 annotations proposées dans Java Concurrency in Practice font partie du projet jsr-305, utilisé notamment par Guava; donc si vous utilisez Guava, vous les avez déjà à dispo:
http://code.google.com/p/jsr-305/

4. Le mardi 17 avril 2012, 18:10 par Bastien Jansen

Il me semble avoir déjà vu des synchronized(this) {} au lieu d'un synchronized (unAutreObjet) {}, est-ce qu'il y a des avantages/inconvénients par rapport à la méthode que tu utilises ?

5. Le mardi 17 avril 2012, 18:15 par Olivier Croisier

synchronized(this) revient presque au même que de déclarer la méthode "synchronized" : on utilise le même moniteur, mais synchronized(this) permet de ne verrouilller qu'une portion de code au lieu de toute la méthode.

Quoi qu'il en soit, c'est déconseillé puisqu'on utilise un moniteur associé à une classe "utile" et surtout visible de toute l'application (risque que qu'une autre portion de code l'utilise également comme moniteur).

Fil des commentaires de ce billet

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.