Java Language Piège: les variables partagées nécessitent une synchronisation correcte


Exemple

Considérez cet exemple:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (!stop) {
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        tt.stop = true;            // Tell child thread to stop.
    }
}

L'intention de ce programme est de démarrer un thread, de le laisser fonctionner pendant 1000 millisecondes, puis de l'arrêter en définissant l'indicateur d' stop .

Cela fonctionnera-t-il comme prévu?

Peut-être que oui, peut-être que non.

Une application ne s'arrête pas nécessairement lorsque la méthode main revient. Si un autre thread a été créé et que ce thread n'a pas été marqué comme un thread de démon, l'application continuera à s'exécuter une fois le thread principal terminé. Dans cet exemple, cela signifie que l'application continuera à s'exécuter jusqu'à la fin du thread enfant. Cela devrait se tt.stop lorsque tt.stop est défini sur true .

Mais ce n'est pas vraiment vrai. En fait, le thread enfant s'arrête après avoir observé l' stop avec la valeur true . Est-ce que ça va arriver? Peut-être que oui, peut-être que non.

La spécification de langage Java garantit que les lectures et écritures de mémoire effectuées dans un thread sont visibles pour ce thread, conformément à l'ordre des instructions dans le code source. Cependant, en général, cela n'est PAS garanti lorsqu'un thread écrit et qu'un autre thread (ultérieurement) lit. Pour que la visibilité soit garantie, il doit exister une chaîne d' événements-avant les relations entre une écriture et une lecture ultérieure. Dans l'exemple ci-dessus, il n'existe pas de chaîne de ce type pour la mise à jour de l'indicateur d' stop . Par conséquent, il n'est pas garanti que le thread enfant verra stop change à true .

(Note aux auteurs: il devrait y avoir une rubrique distincte sur le modèle de mémoire Java pour entrer dans les détails techniques approfondis.)

Comment pouvons-nous résoudre le problème?

Dans ce cas, il existe deux méthodes simples pour s’assurer que l’ stop mise à jour est visible:

  1. Déclarez stop d'être volatile ; c'est à dire

     private volatile boolean stop = false;
    

    Pour une variable volatile , le JLS spécifie qu'il y a une relation " passe-avant" entre un thread écrit par un et une lecture ultérieure par un second thread.

  2. Utilisez un mutex pour synchroniser comme suit:

public class ThreadTest implements Runnable {
   
    private boolean stop = false;
    
    public void run() {
        long counter = 0;
        while (true) {
            synchronize (this) {
                if (stop) {
                    break;
                }
            }
            counter = counter + 1;
        }
        System.out.println("Counted " + counter);
    }

    public static void main(String[] args) {
        ThreadTest tt = new ThreadTest();
        new Thread(tt).start();    // Create and start child thread
        Thread.sleep(1000);
        synchronize (tt) {
            tt.stop = true;        // Tell child thread to stop.
        }
    }
}

En plus de s’assurer de l’exclusion mutuelle, le JLS spécifie qu’il existe une relation avant-après entre la libération d’un mutex dans un thread et l’obtention du même mutex dans un second thread.

Mais l'assignation n'est-elle pas atomique?

Oui, ça l'est!

Cependant, cela ne signifie pas que les effets de la mise à jour seront visibles simultanément sur tous les threads. Seule une chaîne appropriée de relations préalables garantira cela.

Pourquoi ont-ils fait ça?

Les programmeurs qui font de la programmation multithread en Java pour la première fois trouvent le modèle de mémoire difficile. Les programmes se comportent de manière non intuitive car l'attente naturelle est que les écritures soient visibles de manière uniforme. Alors, pourquoi les concepteurs Java conçoivent le modèle de mémoire de cette manière.

Cela se résume à un compromis entre performance et facilité d'utilisation (pour le programmeur).

Une architecture informatique moderne se compose de plusieurs processeurs (cœurs) avec des ensembles de registres individuels. La mémoire principale est accessible à tous les processeurs ou à des groupes de processeurs. Une autre propriété du matériel informatique moderne est que l'accès aux registres est généralement plus rapide que l'accès à la mémoire principale. À mesure que le nombre de cœurs évolue, il est facile de voir que lire et écrire dans la mémoire principale peut devenir le principal goulot d'étranglement des performances d'un système.

Cette incompatibilité est résolue en implémentant un ou plusieurs niveaux de mémoire cache entre les cœurs du processeur et la mémoire principale. Chaque cœur accède aux cellules mémoire via son cache. Normalement, une lecture en mémoire principale se produit uniquement en cas d’échec du cache et une écriture en mémoire principale ne se produit que si une ligne de cache doit être vidée. Pour une application dans laquelle le jeu d'emplacements de mémoire de chaque cœur peut tenir dans son cache, la vitesse de base n'est plus limitée par la vitesse / la bande passante de la mémoire principale.

Mais cela nous pose un nouveau problème lorsque plusieurs cœurs lisent et écrivent des variables partagées. La dernière version d'une variable peut se trouver dans le cache d'un cœur. À moins que ce noyau vide la ligne de cache dans la mémoire principale ET que d'autres cœurs invalident leur copie en cache des versions antérieures, certains d'entre eux risquent de voir des versions obsolètes de la variable. Mais si les caches étaient vidés en mémoire chaque fois qu'il y avait une écriture en cache ("juste au cas où" il y aurait une lecture par un autre noyau), cela consommerait inutilement la bande passante de la mémoire principale.

La solution standard utilisée au niveau du jeu d'instructions matérielles consiste à fournir des instructions pour l'invalidation du cache et la mise en cache du cache, et laisse le compilateur décider du moment où il doit les utiliser.

Retour à Java Le modèle de mémoire est conçu pour que les compilateurs Java ne soient pas obligés d'émettre des instructions d'invalidation de cache et d'écriture directe lorsqu'ils ne sont pas vraiment nécessaires. L'hypothèse est que le programmeur utilisera un mécanisme de synchronisation approprié (par exemple des mutex primitifs, volatile classes de concurrence de niveau supérieur volatile , etc.) pour indiquer qu'il a besoin de visibilité de la mémoire. En l'absence d'une relation se produit avant , les compilateurs Java sont libres de supposer qu'aucune opération de cache (ou similaire) n'est requise.

Cela présente des avantages significatifs en termes de performances pour les applications multithread, mais l'inconvénient est que l'écriture d'applications multithread correctes n'est pas simple. Le programmeur doit comprendre ce qu'il fait.

Pourquoi ne puis-je pas reproduire cela?

Il existe un certain nombre de raisons pour lesquelles de tels problèmes sont difficiles à reproduire:

  1. Comme expliqué ci-dessus, le fait de ne pas traiter correctement les problèmes de visibilité de la mémoire signifie généralement que votre application compilée ne gère pas correctement les caches de mémoire. Cependant, comme nous l’avons mentionné plus haut, les caches de mémoire sont souvent vidés.

  2. Lorsque vous modifiez la plate-forme matérielle, les caractéristiques des caches mémoire peuvent changer. Cela peut entraîner un comportement différent si votre application ne se synchronise pas correctement.

  3. Vous observez peut-être les effets d'une synchronisation fortuite . Par exemple, si vous ajoutez des traces de trace, il se produit généralement une synchronisation en arrière-plan dans les flux d'E / S qui provoque des vidages de cache. Ainsi, l'ajout de traces rend souvent l'application différente.

  4. L'exécution d'une application sous un débogueur le compile différemment par le compilateur JIT. Les points d'arrêt et les étapes simples aggravent la situation. Ces effets changeront souvent le comportement d'une application.

Ces problèmes rendent les bogues dus à une synchronisation inadéquate particulièrement difficile à résoudre.