Introduction aux plates-formes SMP pour Android

Les versions de plate-forme Android 3.0 et ultérieures sont optimisées pour d'architectures multiprocesseurs. Ce document présente les problèmes pouvant survenir lors de l'écriture de code multithread pour des systèmes multiprocesseurs symétriques en C, C++ et en langage de programmation Java (ci-après simplement appelé "Java" par souci de concision). Il s'agit d'une introduction aux développeurs d'applications Android, et non d'une présentation complète. sur le sujet.

Introduction

SMP est l'acronyme de "Symmetric Multi-Processor". Il décrit une conception dans deux cœurs de CPU identiques ou plus partagent l'accès à la mémoire principale. Jusqu'au il y a quelques années, tous les appareils Android étaient opérationnels (Uni-Processor).

La plupart, voire la totalité, des appareils Android ont toujours eu plusieurs processeurs, mais Auparavant, seul l'un d'entre eux était utilisé pour exécuter des applications, tandis que d'autres s'occupaient de divers bits de l'appareil du matériel (par exemple, la radio). Les processeurs peuvent avoir des architectures différentes, et les programmes qui y étaient exécutés ne pouvaient pas utiliser la mémoire principale pour communiquer avec chacun autre.

La plupart des appareils Android vendus aujourd'hui sont construits autour de conceptions SMP, rendant les choses un peu plus compliquées pour les développeurs de logiciels. Conditions de concurrence dans un programme multithread peut ne pas causer de problèmes visibles sur un uniprocesseur, mais peuvent échouer régulièrement si deux ou plusieurs de vos threads s'exécutent simultanément sur différents cœurs. De plus, le code peut être plus ou moins sujet aux défaillances lorsqu'il est exécuté sur différentes architectures de processeurs, voire sur différentes implémentations de la même architecture. Le code qui a été testé minutieusement sur x86 peut se briser de manière importante sur ARM. Le code peut commencer à échouer lorsqu'il est recompilé avec un compilateur plus moderne.

Le reste de ce document expliquera pourquoi et vous indiquera ce que vous devez faire pour s'assurer que votre code se comporte correctement.

Modèles de cohérence de mémoire: pourquoi les SMP sont un peu différentes

Il s'agit d'une présentation rapide et brillante d'un sujet complexe. Certaines zones être incomplètes, mais elles ne doivent pas être trompeuses ni erronées. En verrez dans la section suivante, ces détails ne sont généralement pas importants.

Reportez-vous à la section Complément d'informations à la fin du document pour en savoir plus qui fournit des indications vers un traitement plus approfondi du sujet.

Les modèles de cohérence de mémoire, ou simplement "modèles de mémoire", décrivent garantit que le langage de programmation ou l'architecture matérielle concernant les accès à la mémoire. Par exemple, Si vous écrivez une valeur pour l'adresse A, puis que vous écrivez une valeur pour l'adresse B, peut garantir que chaque cœur de CPU voit ces écritures se produire de façon commande.

Le modèle auquel la plupart des programmeurs sont habitués est le modèle séquentiel la cohérence, décrite comme suit (Adve & Gharachorloo:

  • Toutes les opérations de mémoire semblent s'exécuter une à la fois.
  • Toutes les opérations d'un même thread semblent s'exécuter dans l'ordre décrit par le programme de ce processeur.

Supposons temporairement que nous disposons d'un compilateur ou d'un interpréteur très simple sans surprise: il traduit dans le code source pour charger et stocker les instructions exactement commande correspondante, une instruction par accès. Par souci de simplicité, nous supposerons également que chaque thread s'exécute sur son propre processeur.

Si vous regardez un extrait de code et que vous constatez qu'il effectue des lectures et des écritures à partir de sur une architecture de processeur séquentiellement cohérente. Vous savez que le code effectue ces lectures et écritures dans l'ordre attendu. Il est possible que Le CPU réorganise en fait les instructions et retarde les lectures et les écritures, mais n'est pas un moyen pour le code exécuté sur l'appareil de dire que le CPU fait quelque chose que d'exécuter des instructions de manière simple. (Nous ignorons l'I/O du pilote d'appareil mappé en mémoire.)

Pour illustrer ces points, il est utile d'envisager de petits extraits de code, communément appelés tests litmus.

Voici un exemple simple, dans lequel le code s'exécute sur deux threads:

Thread 1 Thread 2
A = 3
B = 5
reg0 = B
reg1 = A

Dans cet exemple et tous les suivants, les emplacements de mémoire sont représentés par majuscules (A, B, C) et les registres du processeur commencent par "reg". Toute la mémoire est initialement zéro. Les instructions sont exécutées de haut en bas. Ici, le fil de discussion 1 stocke la valeur 3 à l'emplacement A, puis la valeur 5 à l'emplacement B. Le thread 2 charge la valeur de l'emplacement B dans reg0, puis la valeur de l'emplacement A dans reg1. (Notez que nous écrivons dans un ordre et lisons dans un autre.)

Les threads 1 et 2 sont censés s'exécuter sur des cœurs de processeur différents. Vous devez toujours faire cette hypothèse lorsque vous pensez au code multithread.

La cohérence séquentielle garantit que, une fois les deux threads terminés les registres présentent l'un des états suivants:

Registres États
reg0=5, reg1=3 possible (thread 1 exécuté en premier)
reg0=0, reg1=0 possible (le thread 2 s'est exécuté en premier)
reg0=0, reg1=3 possible (exécution simultanée)
reg0=5, reg1=0 jamais

Dans une situation où B=5 est affiché avant que le magasin n'arrive A, les lectures ou les écritures doivent se produire dans le désordre. Sur un à cohérence séquentielle, cela est impossible.

Les processeurs uni, y compris x86 et ARM, ont normalement une cohérence séquentielle. Les threads semblent s'exécuter de manière entrelacée, lorsque le noyau du système d'exploitation bascule entre elles. La plupart des systèmes SMP, y compris x86 et ARM, ne sont pas cohérentes séquentiellement. Par exemple, il est courant pour matériel de mise en mémoire tampon, les magasins arrivent en mémoire, de sorte qu'ils n'atteignent pas immédiatement la mémoire et ne deviennent visibles par les autres cœurs.

Les détails varient considérablement. Par exemple, x86, mais pas de manière séquentielle constante, garantit toujours que reg0 = 5 et reg1 = 0 reste impossible. Les magasins sont mis en mémoire tampon, mais leur ordre est conservé. ARM, en revanche, ne le fait pas. L'ordre des magasins mis en mémoire tampon n'est pas gérés, et les magasins peuvent ne pas atteindre tous les autres cœurs en même temps. Ces différences sont importantes pour les programmeurs d'assemblage. Cependant, comme nous le verrons ci-dessous, les programmeurs C, C++ ou Java peuvent et doit programmer de manière à masquer ces différences architecturales.

Jusqu'à présent, nous avons supposé de manière irréaliste que seul le matériel réorganise les instructions. En réalité, le compilateur réorganise également les instructions pour améliorer les performances. Dans notre exemple, le compilateur peut décider que certains le code du thread 2 avait besoin de la valeur de reg1 avant d'en avoir besoin. reg1. Il se peut aussi qu'un code précédent ait déjà chargé A, et que le compilateur peut décider de réutiliser cette valeur au lieu de charger à nouveau A. Dans les deux cas, les charges vers reg0 et reg1 pourraient être réorganisées.

la réorganisation des accès vers différents emplacements de mémoire, soit dans le matériel, soit dans le compilateur, est car cela n'affecte pas l'exécution d'un seul thread. cela peut améliorer considérablement les performances. Comme nous le verrons, avec un peu d'attention, nous pouvons également empêcher qu'il n'affecte les résultats des programmes multithread.

Étant donné que les compilateurs peuvent également réorganiser les accès à la mémoire, ce problème ce qui n'est pas nouveau pour les réseaux sociaux. Même sur un monoprocesseur, un compilateur pourrait réorganiser les chargements vers reg0 et reg1 dans notre exemple, et le thread 1 pourrait être planifié entre le les instructions réorganisées. Mais si notre compilateur ne se réorganise pas, nous pourrions n'observerez jamais ce problème. Sur la plupart des SMP ARM, même sans compilateur de la réorganisation, la réorganisation sera probablement visible, peut-être après une très grande le nombre d'exécutions réussies. Sauf si vous programmez en langage d'assemblage, les SMP ne font généralement que rendre plus probable que vous rencontriez des problèmes qui étaient présents depuis le début.

Une programmation sans course de données

Heureusement, il existe généralement un moyen facile d'éviter de penser à l'une des ces détails. Si vous suivez quelques règles simples, il est généralement sans danger d'oublier toute la section précédente, à l'exception de la "cohérence séquentielle" . Malheureusement, les autres complications peuvent devenir visibles si vous enfreindre accidentellement ces règles.

Les langages de programmation modernes encouragent ce que l'on appelle une approche de programmation d'application. Tant que vous ne promettez pas de "courses aux données", et éviter une poignée de constructions qui indiquent au compilateur le contraire, le compilateur et le matériel promettent de fournir des résultats cohérents dans un ordre séquentiel. Cela ne fait pas signifient vraiment qu'ils évitent la réorganisation des accès à la mémoire. Cela signifie que si vous suivez les règles, vous ne serez pas en mesure de voir que les accès à la mémoire sont réorganisée. C'est un peu comme vous dire que la saucisse est délicieuse et et appétissants, tant que vous promettez de ne pas visiter fabrique de saucisses. Les courses aux données révèlent la horrible vérité sur la mémoire réorganisation.

Qu'est-ce qu'une "course aux données" ?

Une course de données se produit lorsqu'au moins deux threads accèdent simultanément aux mêmes données ordinaires et qu'au moins l'un d'eux les modifie. Par "ordinary données" nous entendons un élément qui n'est pas spécifiquement un objet de synchronisation destinées à la communication par thread. mutex, variables de condition, Java les objets atomiques volatiles ou C++ ne sont pas des données ordinaires et leurs accès sont autorisés à faire la course. En fait, ils servent à éviter des conflits de données sur d'autres d'objets.

Pour déterminer si deux threads accèdent simultanément au même emplacement de la mémoire, nous pouvons ignorer la discussion sur la réorganisation de la mémoire ci-dessus, et suppose une cohérence séquentielle. Le programme suivant n'a pas de concurrence aux données Si A et B sont des variables booléennes ordinaires initialement "false" :

Thread 1 Thread 2
if (A) B = true if (B) A = true

Comme les opérations ne sont pas réorganisées, les deux conditions seront évaluées comme fausses, et aucune des variables n'est mise à jour. Il ne peut donc pas y avoir de course de données. Il y a vous n'avez pas besoin de penser à ce qui pourrait se produire si la charge provenant de A et stocker dans B dans Le thread 1 a été réorganisé. Le compilateur n'est pas autorisé à réorganiser le thread 1 en le réécrivant sous la forme "B = true; if (!A) B = false". Cela reviendrait à faire des saucisses en plein centre-ville en plein jour.

Les courses de données sont officiellement définies sur des types intégrés de base tels que les entiers et les références ou pointeurs. L'attribution à un int tout en le lisant simultanément dans un autre thread est clairement une course de données. Mais le langage C++ bibliothèque standard et les bibliothèques Java sont écrites pour vous permettre aussi des courses de données au niveau de la bibliothèque. Elles promettent de ne pas introduire de concurrences entre les données sauf s'il existe des accès simultanés au même conteneur, au moins qui la met à jour. Mettre à jour un set<T> dans un thread tout en en les lisant simultanément dans un autre, permet à la bibliothèque d'introduire une course aux données, et peut donc être considéré de manière informelle comme une "course aux données au niveau de la bibliothèque". Inversement, mise à jour d'une set<T> dans un thread pendant la lecture un autre, n'entraîne pas de concurrence de données, car la bibliothèque promet de ne pas introduire de concurrence des données (de bas niveau) dans ce cas.

Normalement, les accès simultanés à différents champs d'une structure de données ne peuvent pas entraîner une course de données. Cependant, il existe une exception importante cette règle: les séquences contiguës de champs de bits en C ou C++ sont traitées comme un "emplacement mémoire" unique. Accéder à n'importe quel champ de bits dans une telle séquence est traité comme un accès à chacun d'entre eux afin de déterminer l'existence d'une concurrence de données. Cela reflète l'incapacité des équipements pour mettre à jour des bits individuels sans également lire et réécrire les bits adjacents. Les programmeurs Java n'ont pas les mêmes préoccupations.

Éviter les concurrences de données

Les langages de programmation modernes offrent un certain nombre de synchronisations pour éviter les conflits entre les données. Voici les outils les plus élémentaires:

Verrouillages ou mutex
Les mutexs
(std::mutex ou pthread_mutex_t C++11) ou les blocs synchronized en Java peuvent être utilisés pour s'assurer que certaines sections de code ne s'exécutent pas simultanément avec d'autres sections de code accédant aux mêmes données. Nous désignerons ces installations et d'autres installations similaires de manière générique par "serrures". Acquérir systématiquement un verrouillage spécifique avant d'accéder à un la structure des données et la libération ultérieure, permet d'éviter les concurrences de données la structure des données. Il garantit également que les mises à jour et les accès sont atomiques, c'est-à-dire qu'aucune autre mise à jour de la structure de données ne peut s'exécuter au milieu. C'est bien mérité de loin l'outil le plus courant pour empêcher les concurrences de données. L'utilisation de Java Blocs synchronized ou lock_guard C++ ou unique_lock pour garantir que les verrous sont correctement libérés dans le l'événement d'une exception.
Variables volatiles/atomiques
Java fournit des champs volatile compatibles avec l'accès simultané sans introduire de concurrence entre les données. Depuis 2011, les langages C et C++ sont compatibles les variables atomic et les champs ayant une sémantique similaire. Il s'agit généralement plus difficiles à utiliser que les serrures, car elles ne garantissent les accès individuels à une seule variable sont atomiques. (En C++, il s'agit normalement s’étend aux opérations de lecture-modification-écriture simples, comme les incréments. Java nécessite des appels de méthode spéciaux pour cela.) Contrairement aux verrous, les variables volatile ou atomic ne peuvent pas être utilisé directement pour éviter que d'autres threads n'interfèrent avec des séquences de code plus longues.

Il est important de noter que volatile a des significations très différentes en C++ et en Java. En C++, volatile n'empêche pas les conflits de données, bien que le code plus ancien l'utilise souvent comme solution de contournement pour le manque d'objets atomic. Ce n'est plus recommandé. dans C++, utilisez atomic<T> pour les variables pouvant être simultanément accessibles par plusieurs threads. C++ volatile est destiné à des registres d’appareils et autres.

Les variables atomic C/C++ ou les variables volatile Java peuvent être utilisées pour éviter les conflits de données sur d'autres variables. Si flag est déclaré de type atomic<bool> ou atomic_bool (C/C++) ou volatile boolean (Java), et qu'il est initialement faux, l'extrait suivant est exempt de conflit de données :

Thread 1 Thread 2
A = ...
  flag = true
while (!flag) {}
... = A

Étant donné que le thread 2 attend que flag soit défini, l'accès à A du thread 2 doit se produire après et non simultanément avec le l'attribution à A dans le fil de discussion 1. Il n'y a donc pas de concurrence des données A La course sur flag ne compte pas comme une course aux données, puisque les accès volatiles/atomiques ne sont pas des "accès à la mémoire ordinaires".

L'implémentation est nécessaire pour empêcher ou masquer suffisamment le réordonnancement de la mémoire afin que le code tel que le test de l'indicateur précédent se comporte comme prévu. Cela rend normalement les accès à la mémoire volatile/atomique sensiblement plus chers que les accès ordinaires.

Bien que l'exemple précédent ne soit pas une concurrence de données, les verrous avec Object.wait() en Java ou variables de condition en C/C++ généralement fournir une meilleure solution qui n'implique pas d'attendre dans une boucle pendant de la batterie.

Lorsque la réorganisation de la mémoire devient visible

La programmation sans course de données nous évite normalement de traiter explicitement avec des problèmes de réorganisation des accès à la mémoire. Cependant, il existe plusieurs cas dans quelle réorganisation devient visible:
  1. Si votre programme présente un bogue entraînant une course involontaire des données, les transformations matérielles et du compilateur peuvent devenir visibles, et le comportement de votre programme peuvent être surprenantes. Par exemple, si nous avons oublié de déclarer flag volatile dans l'exemple précédent, le thread 2 peut rencontrer une A non initialisé. Le compilateur peut également décider que l'indicateur ne peut pas changer pendant la boucle du thread 2 et transformer le programme en
    Thread 1 Thread 2
    A = ...
      flag = true
    reg0 = flag; tandis que (!reg0) {}
    ... = A
    Lors du débogage, il est possible que la boucle continue indéfiniment le fait que flag est vrai.
  2. C++ fournit des installations pour assouplir explicitement une cohérence séquentielle même s'il n'y a pas de concurrence. Opérations atomiques peut accepter des arguments memory_order_... explicites. De même, le Le package java.util.concurrent.atomic fournit un accès d'installations similaires, en particulier lazySet(). et Java les programmeurs font parfois des courses intentionnelles des données pour obtenir un effet similaire. Tous ces éléments permettent d'améliorer les performances à grande échelle en termes de complexité de programmation. Nous n'en parlons que brièvement ci-dessous.
  3. Certains codes C et C++ sont écrits dans un style plus ancien, qui n'est pas entièrement conforme aux normes linguistiques actuelles. Dans ce style, les variables volatile sont utilisées à la place des variables atomic, et l'ordre de la mémoire est explicitement interdit en insérant des barrières ou des barrières. Cela nécessite un raisonnement explicite sur le réordonnancement des accès et la compréhension des modèles de mémoire matérielle. Un style de codage le long de ces lignes est toujours utilisée dans le noyau Linux. Il ne doit pas dans de nouvelles applications Android. Nous n'aborderons pas non plus ce sujet ici.

S'entraîner

Il peut être très difficile de déboguer les problèmes de cohérence de la mémoire. Si une causes du verrouillage, de la déclaration atomic ou volatile du code pour lire des données obsolètes, vous ne pourrez peut-être pas en examinant les vidages de mémoire avec un débogueur. Lorsque vous pourrez de débogage, il est possible que les cœurs de processeur aient tous observé l'ensemble le contenu de la mémoire et les registres CPU se trouveront dans un état "impossible".

Ce qu’il ne faut pas faire en C

Voici quelques exemples de code incorrect, ainsi que des méthodes simples pour les corriger. Avant cela, nous devons discuter de l'utilisation d'un langage de base .

C/C++ et "volatile"

Les déclarations volatile C et C++ sont un outil très spécial. Ils empêchent le compilateur de réorganiser ou de supprimer les accès volatils. Cela peut être utile pour le code accédant aux registres de périphériques matériels, mappée à plusieurs emplacements, ou en connexion avec setjmp En revanche, contrairement à Java, C et C++ volatile volatile n'est pas conçu pour la communication par thread.

En C et C++, les accès aux données volatile peuvent être réorganisés avec les accès aux données non volatiles, et aucune garantie d'atomicité n'est fournie. Par conséquent, volatile ne peut pas être utilisé pour partager des données entre des threads dans du code portable, même sur un processeur monocœur. C volatile n'a généralement pas empêcher la réorganisation des accès par le matériel, qui est donc en soi moins utile dans environnements SMP multithread. C'est la raison pour laquelle C11 et C++11 prennent en charge Objets atomic. Vous devriez les utiliser à la place.

De nombreux anciens codes C et C++ utilisent toujours volatile pour la communication de threads. Cela fonctionne souvent correctement pour les données qui correspondent dans un registre de machines, à condition qu'elles soient utilisées avec des clôtures explicites ou dans des cas dans lequel l'ordre de la mémoire n'est pas important. Mais son fonctionnement n'est pas garanti correctement avec les futurs compilateurs.

Exemples

Dans la plupart des cas, il est préférable d'utiliser un verrouillage (comme un pthread_mutex_t ou un std::mutex C++11) plutôt qu'une opération atomique, mais nous utiliserons ce dernier pour illustrer comment il serait utilisé dans une situation pratique.

MyThing* gGlobalThing = NULL;  // Wrong!  See below.
void initGlobalThing()    // runs in Thread 1
{
    MyStruct* thing = malloc(sizeof(*thing));
    memset(thing, 0, sizeof(*thing));
    thing->x = 5;
    thing->y = 10;
    /* initialization complete, publish */
    gGlobalThing = thing;
}
void useGlobalThing()    // runs in Thread 2
{
    if (gGlobalThing != NULL) {
        int i = gGlobalThing->x;    // could be 5, 0, or uninitialized data
        ...
    }
}

L'idée ici est d'allouer une structure, d'initialiser ses champs et, à la fin, de la "publier" en la stockant dans une variable globale. À ce stade, n'importe quel autre thread peut le voir, mais ce n'est pas un problème puisqu'il est entièrement initialisé. n'est-ce pas ?

Le problème est que le magasin de gGlobalThing a pu être observé avant l'initialisation des champs, généralement parce que le compilateur ou le processeur a réorganisé les magasins à gGlobalThing et thing->x Un autre fil de discussion qui lit depuis thing->x pourrait voir 5, 0 ou même des données non initialisées.

Le principal problème rencontré ici est une concurrence entre les données sur gGlobalThing. Si le thread 1 appelle initGlobalThing() alors que le thread 2 les appels useGlobalThing(), gGlobalThing peuvent être à lire en cours d'écriture.

Vous pouvez résoudre ce problème en déclarant gGlobalThing comme atomique. En C++11:

atomic<MyThing*> gGlobalThing(NULL);

Cela garantit que les écritures seront visibles par les autres threads dans le bon ordre. Cela garantit également d'éviter d'autres défaillances normalement autorisés, mais peu susceptibles de se produire en conditions réelles Matériel Android Par exemple, cela permet de s'assurer Pointeur gGlobalThing qui n'a été que partiellement écrit.

Ce qu'il ne faut pas faire en Java

Nous n'avons pas évoqué les fonctionnalités intéressantes du langage Java, un rapide coup d’œil à ceux-ci en premier.

Techniquement, Java ne nécessite pas que le code soit exempt de conflits de données. Et voilà il s'agit d'une petite quantité de code Java très soigné et qui fonctionne correctement en présence de courses de données. Cependant, l'écriture d'un tel code et nous n'en parlerons que brièvement ci-dessous. Pour rendre les choses importantes Pire encore, les experts qui ont spécifié la signification de ce code ne croient plus est correcte. (cette spécification convient aux données sans concurrence de données). du code d'accès.)

Pour l'instant, nous adhérerons au modèle de données sans concurrence pour lequel Java fournit essentiellement les mêmes garanties que C et C++. Là encore, le langage fournit certaines primitives, qui assouplissent explicitement la cohérence séquentielle, Appels lazySet() et weakCompareAndSet() dans le pays suivant : java.util.concurrent.atomic. Comme pour C et C++, nous allons les ignorer pour le moment.

Mots clés "synchronized" et "volatile" de Java

Le mot clé "synchronized" fournit le verrouillage intégré au langage Java sur le mécanisme d'attention. Chaque objet est associé à un écran permettant de fournir qui s'excluent mutuellement. Si deux threads tentent de se "synchroniser" le même objet, l'un d'eux attendra que l'autre se termine.

Comme nous l'avons mentionné ci-dessus, le volatile T de Java est l'équivalent de atomic<T> en C++11. Les accès simultanés aux Les champs volatile sont autorisés et n'entraînent pas de concurrences entre les données. Les lazySet() et autres sont ignorés. et les courses de données, le rôle de la VM Java vous assurer que le résultat apparaît toujours dans un ordre cohérent.

En particulier, si le thread 1 écrit dans un champ volatile et le thread 2 lit ensuite ce même champ et voit , le thread 2 est aussi assuré de voir toutes les écritures précédemment effectuées par thread 1. En termes d'effet de mémoire, écrire dans une valeur volatile est similaire à un écran d'affichage, et la lecture à partir d'une source volatile s'apparente à une acquisition d'écran.

Il existe une différence notable par rapport au fichier atomic de C++: Si nous écrivons volatile int x; en Java, x++ est identique à x = x + 1. cette effectue une charge atomique, incrémente le résultat, puis effectue une Google Store. Contrairement à C++, l'incrément dans son ensemble n'est pas atomique. Les opérations d'incrémentation atomique sont fournies le java.util.concurrent.atomic.

Exemples

Voici une implémentation simple et incorrecte d'un compteur monotone: (Java théorie et pratique: gérer la volatilité).

class Counter {
    private int mValue;
    public int get() {
        return mValue;
    }
    public void incr() {
        mValue++;
    }
}

Supposons que get() et incr() sont appelés à partir de plusieurs et nous voulons nous assurer que chaque thread voit le nombre actuel get() est appelé. Le problème le plus flagrant est que mValue++ correspond en réalité à trois opérations:

  1. reg = mValue
  2. reg = reg + 1
  3. mValue = reg

Si deux threads s'exécutent simultanément dans incr(), l'une des mises à jour pourraient être perdues. Pour rendre l'incrément atomique, nous devons déclarer incr() "synchronisés".

Elle ne fonctionne pas encore, surtout sur les plates-formes de réseaux sociaux. Il y a toujours une concurrence des données, dans la mesure où get() peut accéder à mValue simultanément avec incr() Sous les règles Java, l'appel get() peut être sont réorganisés par rapport à un autre code. Par exemple, si nous lisons deux compteurs, les résultats peuvent sembler incohérents parce que les appels get() que nous avons réorganisés, soit par le matériel, soit par compilateur. Nous pouvons corriger le problème en déclarant get() comme étant synchronisé. Avec cette modification, le code est évidemment correct.

Malheureusement, nous avons introduit la possibilité d'un conflit de verrouillage, ce qui pourrait nuire aux performances. Au lieu de déclarer get() comme étant synchronisé, nous pourrions déclarer mValue avec "volatile". Notez que incr() doit toujours utiliser synchronize, car mValue++ n'est pas une opération atomique unique.) Cela évite également toutes les concurrences de données, ce qui préserve la cohérence séquentielle. incr() sera un peu plus lent, car il entraîne à la fois une entrée et une sortie de surveillance. et les frais généraux associés à un magasin volatile, mais get() est plus rapide. Par conséquent, même en l'absence de conflit, une victoire si les lectures dépassent considérablement le nombre d'écritures. Consultez également AtomicInteger pour découvrir comment supprimer le bloc synchronisé.)

Voici un autre exemple, semblable aux exemples C précédents:

class MyGoodies {
    public int x, y;
}
class MyClass {
    static MyGoodies sGoodies;
    void initGoodies() {    // runs in thread 1
        MyGoodies goods = new MyGoodies();
        goods.x = 5;
        goods.y = 10;
        sGoodies = goods;
    }
    void useGoodies() {    // runs in thread 2
        if (sGoodies != null) {
            int i = sGoodies.x;    // could be 5 or 0
            ....
        }
    }
}

Cela présente le même problème que le code C, à savoir qu'il y a une course aux données sur sGoodies. Par conséquent, l'affectation sGoodies = goods peut être observée avant l'initialisation des champs dans goods. Si vous déclarez sGoodies avec le mot clé volatile, la cohérence séquentielle est rétablie et tout fonctionne comme prévu.

Notez que seule la référence sGoodies elle-même est volatile. La les accès aux champs qu'il contient ne le sont pas. Une fois que sGoodies est volatile et que l'ordre de la mémoire est correctement préservé, les champs ne sont pas accessibles simultanément. L'instruction z = sGoodies.x effectuera un chargement volatile de MyClass.sGoodies suivie d'une charge non volatile de sGoodies.x. Si vous créez une annonce locale référence MyGoodies localGoods = sGoodies, un z = localGoods.x ultérieur n'effectuera aucune charge volatile.

Un idiome plus courant en programmation Java est le fameux verrouillage" :

class MyClass {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized (this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }
}

L'idée est de disposer d'une seule instance d'un objet Helper associée à une instance de MyClass. Nous ne devons créer une seule fois. Nous le créons et le renvoyons via un getHelper() dédié . Pour éviter une concurrence dans laquelle deux threads créent l'instance, nous devons synchroniser la création d'objets. Cependant, nous ne voulons pas payer les frais généraux le bloc "synchronisé" à chaque appel. Nous ne le faisons La valeur de helper est actuellement nulle.

Les données font l'objet d'une concurrence sur le champ helper. Il peut être défini simultanément avec le helper == null dans un autre thread.

Pour voir comment cela peut échouer, le même code a été légèrement réécrit, comme s'il était compilé dans un langage de type C. (J'ai ajouté quelques champs d'entiers pour représenter Helper’s l'activité du constructeur):

if (helper == null) {
    synchronized() {
        if (helper == null) {
            newHelper = malloc(sizeof(Helper));
            newHelper->x = 5;
            newHelper->y = 10;
            helper = newHelper;
        }
    }
    return helper;
}

Rien ne peut empêcher le matériel ou le compilateur en remplaçant la commande du magasin par helper par x/y champs. Un autre thread peut trouver helper non nul, mais ses champs ne sont pas encore définis et prêts à l'emploi. Pour obtenir plus d'informations et d'autres modes de défaillance, consultez la documentation le lien "Déclaration du verrouillage de la route" dans l'annexe pour en savoir plus, ou 71 ("Use Lazy initialization judiciousy") dans le livre Effective Java, 2e édition.

Vous disposez de deux options pour corriger ces erreurs :

  1. Effectuez l'opération simple et supprimez la vérification externe. Cela garantit que nous n'examinons jamais la valeur de helper en dehors d'un bloc synchronisé.
  2. Déclarez helper comme variable volatile. Avec cette petite modification, le code dans l'exemple J-3 fonctionnera correctement sur Java 1.5 et versions ultérieures. (Vous pouvez une minute pour vous convaincre que c'est vrai.)

Voici une autre illustration du comportement de volatile:

class MyClass {
    int data1, data2;
    volatile int vol1, vol2;
    void setValues() {    // runs in Thread 1
        data1 = 1;
        vol1 = 2;
        data2 = 3;
    }
    void useValues() {    // runs in Thread 2
        if (vol1 == 2) {
            int l1 = data1;    // okay
            int l2 = data2;    // wrong
        }
    }
}

En examinant useValues(), si le Thread 2 n'a pas encore observé la la mise à jour vers vol1, il ne peut pas savoir si data1 ou La valeur data2 est déjà définie. Une fois que la mise à jour vol1, il sait que data1 est accessible en toute sécurité et lire correctement sans introduire de concurrence entre les données. Toutefois, il ne peut faire aucune hypothèse concernant data2, car ce magasin a été après la période volatile du magasin.

Notez que volatile ne peut pas être utilisé pour empêcher le réordonnancement d'autres accès à la mémoire qui se font concurrence. Cela n'est pas garanti générer une instruction de cloisonnement de la mémoire de la machine. Il peut être utilisé pour empêcher les données en exécutant du code uniquement lorsqu'un autre thread a satisfait à une une certaine condition.

Que faire ?

En C/C++, préférez C++11. de synchronisation comme std::mutex. Si ce n'est pas le cas, utilisez les opérations pthread correspondantes. Cela inclut les barrières de mémoire appropriées, qui fournissent un comportement correct (séquentiellement cohérent, sauf indication contraire) et efficace sur toutes les versions de la plate-forme Android. Veillez à les utiliser correctement. Par exemple, gardez à l'esprit que "attente" pour une variable de condition peut est renvoyé sans être signalé, et doit donc apparaître dans une boucle.

Il est préférable d'éviter d'utiliser directement les fonctions atomiques, sauf si la structure de données que vous mettez en œuvre est extrêmement simple, comme un compteur. Le verrouillage et le déverrouillage d'un mutex pthread nécessite une seule opération atomique chacun, et coûtent souvent moins d'un défaut de cache (miss) vous n'allez pas faire beaucoup d'économies en remplaçant les appels mutex par des opérations atomiques. Les conceptions sans verrouillage pour des structures de données non triviales nécessitent de veiller à ce que les opérations de niveau supérieur sur la structure de données semblent atomiques (dans leur ensemble, et pas seulement leurs éléments explicitement atomiques).

Si vous utilisez des opérations atomiques, l'assouplissement de l'ordre avec memory_order... ou lazySet() peut offrir des avantages en termes de performances, mais nécessite une compréhension plus approfondie que celle que nous avons transmise jusqu'à présent. Une grande partie du code existant qui les utilise présente des bugs après coup. Si possible, évitez-les. Si votre cas d'utilisation ne correspond pas exactement à l'un de ceux de la section suivante, assurez-vous d'être un expert ou d'en avoir consulté un.

Évitez d'utiliser volatile pour la communication des threads en C/C++.

En Java, les problèmes de simultanéité sont souvent mieux résolus en à l'aide d'une classe utilitaire appropriée le package java.util.concurrent. Le code est bien écrit et et testés sur des réseaux sociaux.

Le plus sûr est sans doute de rendre vos objets immuables. Les objets de classes telles que String et Integer de Java contiennent des données qui ne peuvent pas être modifiées une fois un objet créé, ce qui évite tout risque de course de données sur ces objets. Le livre Effective Java, 2nd Ed. contient des instructions spécifiques dans la section "Élément 15 : Minimiser la mutabilité". Notez en particulier l'importance de déclarer les champs Java comme "final" (Bloch).

Même si un objet est immuable, n'oubliez pas que le communiquer à un autre sans aucun type de synchronisation constitue une course aux données. Cela peut parfois sont acceptables en Java (voir ci-dessous), mais exigent une attention toute particulière et peuvent entraîner un code fragile. S'il n'est pas très critique pour les performances, ajoutez volatile. En C++, communiquer un pointeur ou référence à un objet immuable sans synchronisation appropriée, comme toute course aux données, est un bug. Dans ce cas, il est raisonnablement probable d'entraîner des plantages intermittents, car Par exemple, le thread de réception peut voir une table de méthode non initialisée en raison d'une réorganisation du magasin.

Si ni une classe de bibliothèque existante, ni une classe immuable ne sont l'instruction Java synchronized ou C++ Utilisez lock_guard / unique_lock pour protéger accède à tout champ accessible par plusieurs threads. Si les mutex n'arrivent pas s'adaptent à votre situation, vous devez déclarer les champs partagés volatile ou atomic, mais vous devez faire attention à pour comprendre les interactions entre les threads. Ces déclarations ne vous éviteront pas les erreurs de programmation concurrente courantes, mais elles vous aideront à éviter les échecs mystérieux associés à l'optimisation des compilateurs et des erreurs SMP.

Vous devez éviter "publication" référence à un objet, c'est-à-dire la mettre à la disposition d'autres dans son constructeur. C'est moins critique en C++ ou si vous vous en nos « courses sans données » en Java. Mais c'est toujours un bon conseil, et devient est essentiel si votre code Java s'exécuter dans d'autres contextes où le modèle de sécurité Java est important et non fiables peut introduire une concurrence entre les données en accédant à ces données référence d'objet. Il est également essentiel si vous choisissez d'ignorer nos avertissements et d'utiliser certaines des techniques dans la section suivante. Pour en savoir plus, consultez (Safe Construction Techniques in Java).

En savoir plus sur les commandes de mémoire faibles

C++11 et versions ultérieures fournissent des mécanismes explicites pour assouplir le code garanties de cohérence pour les programmes sans concurrence de données. Contenu explicite memory_order_relaxed, memory_order_acquire (chargements uniquement), et les arguments memory_order_release(stocke uniquement) pour les opérations fournissent chacune des garanties strictement inférieures à celles des opérations par défaut, implicite : memory_order_seq_cst. memory_order_acq_rel fournit à la fois memory_order_acquire et Garanties memory_order_release pour les opérations atomiques de lecture/modification et d'écriture opérations. La valeur de memory_order_consume n'est pas encore suffisante bien spécifié ou implémenté pour être utile, et à ignorer pour le moment.

Les méthodes lazySet dans Java.util.concurrent.atomic sont semblables aux magasins memory_order_release C++. Java les variables ordinaires sont parfois utilisées pour remplacer memory_order_relaxed accès, bien qu'il s'agisse en fait encore plus faible. Contrairement à C++, il n'existe pas de véritable mécanisme d'accès non ordonné aux variables déclarées comme volatile.

En général, évitez de les utiliser, sauf si vous avez des raisons de performances urgentes de les utiliser. Sur les architectures de machines faiblement ordonnées comme ARM, leur utilisation entraînera généralement de l'ordre de quelques dizaines de cycles de machine pour chaque opération atomique. Sur x86, l'amélioration des performances est limitée aux magasins et est probablement moins visible. L'avantage peut diminuer si le nombre de cœurs augmente, à mesure que le système de mémoire devient un facteur limitant.

La sémantique complète des atomes faiblement ordonnés est complexe. En général, ils ont besoin une compréhension précise des règles du langage, que nous n'entrez pas ici. Exemple :

  • Le compilateur ou le matériel peut déplacer memory_order_relaxed accède à une section critique limitée par un verrou, mais pas en dehors. l'acquisition et la publication. Cela signifie que deux memory_order_relaxed magasins peuvent devenir visibles dans le désordre, même s'ils sont séparés par une section critique.
  • Une variable Java ordinaire, lorsqu'elle est utilisée comme compteur partagé, peut sembler diminuer pour un autre thread, même si elle n'est incrémentée que par un seul autre thread. Ce n'est toutefois pas le cas pour les memory_order_relaxed atomiques C++.

Attention, Nous fournissons ici un petit nombre d'idiomes qui semblent couvrir de nombreux cas d'utilisation des systèmes atomiques mal ordonnés. Bon nombre d'entre eux ne s'appliquent qu'à C++.

Accès hors course

Il est assez courant qu'une variable soit atomique, car elle est parfois lue en même temps qu'une écriture, mais ce problème ne se produit pas pour tous les accès. Par exemple, une variable peut devoir être atomique, car elle est lue en dehors d'une section critique, mais toutes les mises à jour sont protégées par un verrouillage. Dans ce cas, une lecture qui se trouve être protégées par la même serrure car il ne peut pas y avoir d'écritures simultanées. Dans ce cas, le un accès hors concurrence (charger dans ce cas), peut être annoté avec memory_order_relaxed sans modifier l'exactitude du code C++. L'implémentation du verrouillage applique déjà l'ordre de mémoire requis par rapport à l'accès par d'autres threads, et memory_order_relaxed spécifie qu'aucune contrainte d'ordre supplémentaire ne doit être appliquée pour l'accès atomique.

Il n'y a pas de véritable analogie à cela en Java.

L'exactitude du résultat n'est pas garantie

Lorsque nous n'utilisons un chargement de course que pour générer une indication, pour qu'aucun ordre de mémoire ne soit appliqué. Si la valeur est non fiable, nous ne pouvons pas non plus utiliser le résultat de manière fiable pour déduire quoi que ce soit d'autres variables. Il n'y a donc pas de problème si l'ordre de la mémoire n'est pas garanti et que la charge est fourni avec un argument memory_order_relaxed.

Une approche est l'utilisation de compare_exchange C++. pour remplacer de manière atomique x par f(x). Chargement initial de x pour calculer f(x) n'a pas besoin d'être fiable. En cas d'erreur, compare_exchange échouera et nous allons réessayer. Le chargement initial de x peut être utilisé Un argument memory_order_relaxed ordre de mémoire uniquement pour le compare_exchange réel.

Données modifiées de façon atomique, mais non lues

Il arrive que les données soient modifiées en parallèle par plusieurs threads, mais qu'elles ne soient pas examinées tant que le calcul parallèle n'est pas terminé. Un bon un compteur incrémenté de manière atomique (par exemple, en utilisant fetch_add() en C++ ou atomic_fetch_add_explicit() dans C) par plusieurs threads en parallèle, mais le résultat de ces appels est toujours ignorée. La valeur obtenue n'est lue qu'à la fin, une fois toutes les mises à jour terminées.

Dans ce cas, il n'est pas possible de savoir si les accès à ces données ont été réorganisés. Par conséquent, le code C++ peut utiliser un argument memory_order_relaxed.

Les compteurs d'événements simples en sont un exemple courant. Étant donné qu'il est très courant, il est intéressant de faire quelques observations à ce sujet :

  • L'utilisation de memory_order_relaxed améliore les performances, sans toutefois résoudre le problème de performances le plus important: chaque mise à jour nécessite un accès exclusif à la ligne de cache contenant le compteur. Ce entraîne un défaut de cache chaque fois qu'un nouveau thread accède au compteur. Si les mises à jour sont fréquentes et alternent entre les threads, il est beaucoup plus rapide pour éviter de mettre à jour le compteur partagé à chaque fois, par exemple en utilisant des compteurs thread-local et en les additionnant à la fin.
  • Cette technique peut être combinée avec la section précédente : lisent simultanément les valeurs approximatives et non fiables pendant leur mise à jour ; avec toutes les opérations utilisant memory_order_relaxed. Mais il est important de traiter les valeurs obtenues comme étant totalement peu fiables. Ce n'est pas parce que le nombre semble avoir été incrémenté une fois signifie qu'un autre thread peut être considéré comme ayant atteint le point à laquelle l'incrément a été effectué. L'incrément peut à la place réorganisé à l'aide d'un code antérieur. (Comme pour le cas similaire que nous avons mentionné précédemment, C++ garantit qu'une deuxième charge d'un tel compteur ne renverra pas une valeur inférieure à une charge antérieure dans le même thread. À moins que bien sûr, le compteur a débordé.)
  • Il est fréquent de trouver du code qui tente de calculer des valeurs approximatives en effectuant des lectures et des écritures atomiques (ou non) individuelles, de ne pas rendre l'incrément dans son ensemble atomique. L'argument habituel est que c'est "assez proche" pour les compteurs de performances, etc. Ce n'est généralement pas le cas. Lorsque les mises à jour sont suffisamment fréquentes qui vous intéressent), une grande partie de ces chiffres sont généralement perdu. Sur un appareil à quatre cœurs, plus de la moitié des décomptes peuvent généralement être perdus. (Exercice facile: élaborez un scénario à deux threads dans lequel le compteur est mis à jour un million de fois, alors que la valeur de compteur finale est de 1).

Communication simple avec des indicateurs

Un magasin memory_order_release (ou une opération de lecture-modification-écriture) garantit que si par la suite, memory_order_acquire charge (ou opération lecture-modification-écriture) lit la valeur écrite, elle observez également les magasins (ordinaires ou atomiques) ayant précédé la Un magasin memory_order_release. À l'inverse, les charges précédant memory_order_release n'observeront aucune les magasins qui ont suivi le chargement de memory_order_acquire. Contrairement à memory_order_relaxed, cela permet d'effectuer de telles opérations atomiques pour communiquer la progression d'un thread à un autre.

Par exemple, nous pouvons réécrire l'exemple de verrouillage ci-dessus en C++ en tant que

class MyClass {
  private:
    atomic<Helper*> helper {nullptr};
    mutex mtx;
  public:
    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper == nullptr) {
        lock_guard<mutex> lg(mtx);
        myHelper = helper.load(memory_order_relaxed);
        if (myHelper == nullptr) {
          myHelper = new Helper();
          helper.store(myHelper, memory_order_release);
        }
      }
      return myHelper;
    }
};

L'acquisition de la charge et la version du magasin de sortie permettent de s'assurer helper, ses champs sont également correctement initialisés. Nous avons également pris en compte l'observation précédente selon laquelle les charges de travail hors course peut utiliser memory_order_relaxed.

Un programmeur Java pourrait représenter helper comme java.util.concurrent.atomic.AtomicReference<Helper> et utiliser lazySet() comme magasin de versions. Les opérations de chargement continueront d'utiliser des appels get() simples.

Dans les deux cas, nous avons concentré nos efforts sur l'initialisation qui est peu susceptible d'être critique pour les performances. Voici un compromis plus lisible:

    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper != nullptr) {
        return myHelper;
      }
      lock_guard&ltmutex> lg(mtx);
      if (helper == nullptr) {
        helper = new Helper();
      }
      return helper;
    }

Le chemin d'accès rapide est identique, mais il utilise les valeurs par défaut, à cohérence séquentielle, sur les environnements lents non critiques chemin d'accès.

Même ici, helper.load(memory_order_acquire) est sont susceptibles de générer le même code sur les applications comme une référence simple (cohérente séquentiellement) helper L'optimisation la plus bénéfique ici peut être l'introduction de myHelper pour éliminer une deuxième charge, bien qu'un futur compilateur puisse le faire automatiquement.

L'ordonnancement d'acquisition/libération n'empêche pas les magasins d'obtenir est retardé, et ne garantit pas que les magasins deviennent visibles pour les autres threads. dans un ordre cohérent. Par conséquent, il n'est pas compatible avec mais un schéma de codage assez courant, illustré par l'exclusion mutuelle de Dekker, algorithme: tous les threads définissent d'abord un indicateur indiquant qu'ils souhaitent quelque chose ; Si un thread t remarque alors qu'aucun autre thread n'est il peut continuer en toute sécurité, sachant qu'il existe il n'y aura aucune interférence. Aucun autre fil de discussion ne sera en mesure de continuer, car l'indicateur t est toujours défini. Cette opération échoue si l'option est accessible via l'ordre d'acquisition/de publication, car cela n'a pas empêcher les autres utilisateurs de voir le drapeau d'un fil de discussion en retard, ne s'est pas déroulée correctement. memory_order_seq_cst par défaut l'empêche.

Champs immuables

Si un champ d'objet est initialisé lors de la première utilisation et n'a jamais été modifié, il peut être possible de l'initialiser, puis de le lire en utilisant des commandes des accès ordonnés. En C++, elle peut être déclarée en tant que atomic. et accessible à l'aide de memory_order_relaxed ou en Java, il pourraient être déclarées sans volatile et accessibles sans des mesures spéciales. Pour cela, les conditions suivantes doivent être remplies:

  • La valeur du champ lui-même doit pouvoir être identifiée. s'il a déjà été initialisé. Pour accéder au champ, la valeur test-and-return de chemin rapide doit lire le champ une seule fois. En Java, cette dernière est essentielle. Même si le champ est initialisé, une deuxième importation peut lire la valeur non initialisée précédente. En C++ l'option "Lire une fois" est simplement une bonne pratique.
  • L'initialisation et les chargements ultérieurs doivent être atomiques, c'est-à-dire que les mises à jour partielles ne doivent pas être visibles. Pour Java, le champ ne doit pas être de type long ni double. Pour C++, une affectation atomique est requise ; sa construction en place ne fonctionnera pas, car la construction d'un atomic n'est pas atomique.
  • Les initialisations répétées doivent être sûres, car plusieurs threads peut lire simultanément la valeur non initialisée. En C++, il s'agit généralement découle du « très copiable » imposée pour tous les types atomiques ; avec des pointeurs imbriqués et imbriqués, de la désallocation copier le constructeur, et ne serait pas triviablement copiable. Pour Java, Certains types de références sont acceptés:
  • Les références Java sont limitées aux types immuables ne contenant que des . Le constructeur du type immuable ne doit pas publier une référence à l'objet. Dans ce cas, les règles de champ final Java garantissent que si un lecteur voit la référence, il verra également les champs finals initialisés. C++ n’a pas d’analogique à ces règles et les pointeurs vers des objets dont vous êtes propriétaire sont également inacceptables pour cette raison (dans et de ne pas respecter le règlement du "contenu particulièrement copiable" exigences).

Remarques finales

Bien que ce document ne se contente pas de survoler le sujet, il n'a pas pour objectif gérer plus qu’une gouge superficielle. Il s'agit d'un sujet très vaste et profond. Un peu domaines à explorer plus en détail:

  • Les modèles de mémoire Java et C++ réels sont exprimés en termes de Relation happens-before qui spécifie quand deux actions sont garanties se produire dans un certain ordre. Lorsque nous avons défini une concurrence entre les données, nous avons nous avons parlé de deux accès à la mémoire qui se produisent « simultanément ». Officiellement, cela se définit comme aucun des deux ne se produisant avant l'autre. Il est instructif d'apprendre les définitions réelles de se produit avant et synchronizes-with dans le modèle de mémoire Java ou C++. Bien que la notion intuitive de "simultanéité" soit généralement suffisante, ces définitions sont instructives, en particulier si vous envisagez d'utiliser des opérations atomiques faiblement ordonnées en C++. (La spécification Java actuelle ne définit lazySet() que de manière très informelle.)
  • Découvrez ce que les compilateurs sont autorisés ou non à faire lors de la réorganisation du code. (La spécification JSR-133 contient d'excellents exemples de transformations légales qui entraînent des résultats inattendus.)
  • Découvrez comment écrire des classes immuables en Java et C++. (Vous pouvez aussi plutôt que de simplement "ne rien changer après la construction".)
  • Appliquez les recommandations de la section "Simultanéité" de la page Java, 2e édition. (Par exemple, vous devez éviter d'appeler des méthodes devant être remplacés dans un bloc synchronisé.)
  • Consultez les API java.util.concurrent et java.util.concurrent.atomic pour voir les options disponibles. Envisagez d'utiliser des annotations de simultanéité telles que @ThreadSafe et @GuardedBy (à partir de net.jcip.annotations).

La section Complément d'informations de l'annexe contient des liens vers des documents et des sites Web qui mieux mettre en lumière ces sujets.

Annexe

Implémenter des magasins de synchronisation

(Ce n'est pas quelque chose que la plupart des programmeurs se retrouveront à implémenter, mais la discussion est éclairante.)

Pour les petits types intégrés comme int et pour le matériel compatible avec Android, les instructions de chargement et de stockage ordinaires permettent de s'assurer sera rendue visible soit dans son intégralité, soit pas du tout, qui charge le processeur au même emplacement. Par conséquent, une notion de base de l'atomicité est fournie sans frais.

Comme nous l'avons vu précédemment, cela ne suffit pas. Afin d'assurer un contrôle séquentiel la cohérence, nous devons également empêcher la réorganisation des opérations et nous assurer que les opérations de mémoire deviennent visibles pour les autres processus de manière cohérente commande. Il s'avère que cette dernière option est automatique du matériel, à condition que nous prenions des décisions éclairées pour appliquer nous l'ignorons donc dans la plupart des cas.

L'ordre des opérations de mémoire est préservé en empêchant la réorganisation par le compilateur et empêcher la réorganisation par le matériel. Ici, nous allons nous concentrer sur le second.

L'ordre de la mémoire sur ARMv7, x86 et MIPS est appliqué avec "cloisonnement" instructions qui empêcher approximativement les instructions qui suivent la clôture de devenir visibles avant les instructions qui précèdent la clôture. (Ces instructions sont également communément appelées "instructions de barrière", mais cela risque de prêter à confusion avec les barrières de style pthread_barrier, qui font bien plus que cela.) La signification précise des instructions de barrière est un sujet assez complexe qui doit traiter de la manière dont les garanties fournies par plusieurs types de barrières différents interagissent et de la façon dont elles se combinent avec d'autres garanties d'ordonnancement généralement fournies par le matériel. Il s'agit d'une vue d'ensemble générale. Nous allons donc ne pas oublier ces détails.

Le type de garantie d'ordre le plus élémentaire est celui fourni par les opérations atomiques memory_order_acquire et memory_order_release de C++ : les opérations de mémoire précédant un magasin de libération doivent être visibles après une charge d'acquisition. Sur ARMv7, il s'agit appliquée par:

  • Faites précéder les instructions du magasin d'instructions appropriées concernant les clôtures. Cela empêche tous les accès précédents à la mémoire d'être réorganisés avec le pour stocker des instructions. (Cela empêche également inutilement de réorganiser avec instructions de stockage ultérieures.)
  • En suivant les instructions de chargement avec des instructions appropriées, ce qui empêche la charge d'être réorganisée avec des accès ultérieurs. (Encore une fois, en fournissant un ordre inutile avec au moins des chargements antérieurs.)

Ensemble, ils suffisent pour l'ordonnancement des acquisitions et des versions C++. Elles sont nécessaires, mais pas suffisantes, pour Java volatile. ou C++ avec une cohérence séquentielle atomic.

Pour voir ce dont nous avons besoin d'autre, examinons le fragment de l'algorithme de Dekker. que nous avons brièvement mentionnés plus tôt. flag1 et flag2 sont des variables atomic C++ ou volatile Java, toutes deux initialement fausses.

Thread 1 Thread 2
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

La cohérence séquentielle implique que l'une des attributions flagn doit être exécuté en premier et être vu par la dans l'autre fil de discussion. Par conséquent, nous ne verrons jamais ces threads exécuter la "chose critique" simultanément.

Mais le cloisonnement requis pour l'ordonnancement de l'acquisition et de la libération n'ajoute des clôtures au début et à la fin de chaque fil de discussion, ce qui n'aide pas ici. Nous devons également veiller à ce qu'en cas de volatile magasin sur atomic est suivi de charge volatile/atomic, les deux ne sont pas réorganisées. Pour cela, ajoutez une clôture, pas seulement avant un et séquentiellement cohérents, mais aussi après. (C'est encore beaucoup plus fort que nécessaire, car cette barrière ordonne généralement tous les accès mémoire précédents par rapport à tous les suivants.)

Nous pourrions plutôt associer la clôture supplémentaire à de manière séquentielle des chargements plus cohérents. Étant donné que les magasins sont moins fréquents, la convention que nous avons décrite est plus courante et utilisée sur Android.

Comme nous l'avons vu dans une section précédente, nous devons insérer une barrière de stockage/chargement entre les deux opérations. Le code exécuté dans la VM pour un accès volatile ressemblera à ceci:

charge volatile magasin instable
reg = A
fence for "acquire" (1)
fence for "release" (2)
A = reg
fence for later atomic load (3)

Les architectures de machines réelles fournissent généralement plusieurs types de qui ordonnent différents types d'accès un coût différent. Le choix entre ceux-ci est subtil et influencé par la nécessité de s'assurer que les magasins sont visibles pour les autres cœurs un ordre cohérent, et que l'ordre de la mémoire imposé par le la combinaison de plusieurs clôtures se compose correctement. Pour en savoir plus, consultez la page de l'université de Cambridge avec collecte des mappages de l'atomique sur les processeurs réels.

Sur certaines architectures, notamment x86, et "libérer" ces barrières sont inutiles, car le matériel est toujours implicitement permet d'établir un ordre suffisant. Ainsi, sur x86, seule la dernière clôture (3) est vraiment généré. De même, sur x86, la couche atomique de lecture-modification-écriture les opérations incluent implicitement une clôture solide. Ainsi, ils n'ont jamais sans aucune clôture. Sur ARMv7, toutes les barrières dont nous avons parlé ci-dessus sont obligatoire.

ARMv8 fournit des instructions LDAR et STLR qui appliquer les exigences de Java volatile ou C++ à cohérence séquentielle et les stocke. Elles évitent les contraintes de réorganisation inutiles que nous mentionnées ci-dessus. Le code Android 64 bits sur ARM les utilise. Nous avons choisi de nous concentrer sur l'emplacement des barrières ARMv7 ici, car il met en lumière les exigences réelles.

Complément d'informations

Pages Web et documents offrant une plus grande profondeur ou une plus grande étendue Plus généralement, les articles se trouvent en haut de la liste.

Modèles de cohérence de mémoire partagée: tutoriel
Écrit en 1995 par Adve & Gharachorloo, c'est un bon point de départ si vous voulez approfondir les modèles de cohérence de la mémoire.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
Barrières de mémoire
Un joli petit article qui résume les problèmes.
https://fr.wikipedia.org/wiki/Barrière_de_la_mémoire
Principes de base des threads
Présentation de la programmation multithread en C++ et Java, par Hans Boehm. Discussion sur les concurrences de données et les méthodes de synchronisation de base
http://www.hboehm.info/c++mm/threadsintro.html
La simultanéité Java en pratique
Publié en 2006, ce livre couvre un large éventail de sujets dans le détail. Recommandé vivement à tous ceux qui écrivent du code multithread en Java.
http://www.javaconcurrencyinpractice.com
Questions fréquentes sur JSR-133 (modèle de mémoire Java)
Présentation en douceur du modèle de mémoire Java, avec une explication de la synchronisation, des variables volatiles et de la construction des champs finaux. (Elle est un peu dépassée, en particulier lorsqu'il s'agit d'autres langues.)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
Validité des transformations de programme dans le modèle de mémoire Java
Explication plutôt technique des problèmes qui subsistent avec le modèle de mémoire Java. Ces problèmes ne concernent pas les entreprises programmes.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf
Présentation du package java.util.concurrent
Documentation du package java.util.concurrent. Près du bas de la page se trouve une section intitulée "Propriétés de cohérence de la mémoire" qui explique les garanties fournies par les différentes classes.
java.util.concurrent Résumé du package
Théorie et pratique de Java: techniques de construction sûres en Java
Cet article examine en détail les risques liés à l'échappement des références lors de la construction d'objets et fournit des consignes pour les constructeurs sécurisés.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html
Théorie et pratique de Java: gérer la volatilité
Cet article décrit ce que vous pouvez et ne pouvez pas accomplir avec des champs volatils en Java.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html
Déclaration "Double-Checked Locking is Broken" (Le verrouillage à double vérification n'est pas fonctionnel)
Explication détaillée de Bill Pugh sur les différentes manières dont le verrouillage vérifié est rompu sans volatile ni atomic. Inclut C/C++ et Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[ARM] Tests et livre de recettes Barrier Litmus
Discussion sur les problèmes liés à ARM SMP, éclairée par de courts extraits de code ARM. Si les exemples de cette page ne sont pas spécifiques ou si vous souhaitez lire la description formelle de l'instruction DMB, lisez ceci. Cette section décrit également les instructions utilisées pour les barrières de mémoire sur le code exécutable (cela peut être utile si vous générez du code à la volée). Notez que cette version est antérieure à ARMv8, qui prend en charge des instructions de tri de la mémoire supplémentaires et a été déplacée vers un du modèle de mémoire. (Pour en savoir plus, consultez le manuel de référence de l'architecture ARM® ARMv8, pour le profil d'architecture ARMv8-A.)
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf
Barrières de mémoire du noyau Linux
Documentation sur les barrières de mémoire du noyau Linux. Inclut des exemples utiles et des illustrations ASCII.
http://www.kernel.org/doc/Documentation/memory-barriers.txt
ISO/CEI JTC1 SC22 WG21 (normes C++) 14882 (langage de programmation C++), section 1.10 et clause 29 ("Bibliothèque d'opérations atomiques")
Brouillon de norme pour les fonctionnalités d'opérations atomiques en C++. Cette version est proche de la norme C++14, qui inclut des modifications mineures dans ce domaine par rapport à C++11.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(introduction : http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf)
ISO/CEI JTC1 SC22 WG14 (normes C) 9899 (langage de programmation C) chapitre 7.16 ("Atomics <stdatomic.h>")
Projet de norme pour les fonctionnalités de fonctionnement atomique ISO/IEC 9899-201x C. Pour en savoir plus, consultez également les rapports de défauts ultérieurs.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
Mappages C/C++11 avec les processeurs (Université de Cambridge)
Collection de traductions de Jaroslav Sevcik et Peter Sewell de l'atomique C++ à divers jeux d'instructions courants de processeur.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html
Algorithme de Dekker
La "première solution correcte connue au problème d'exclusion mutuelle dans la programmation simultanée". L'article Wikipédia contient l'algorithme complet, avec une discussion sur la façon dont il devrait être mis à jour pour fonctionner avec les compilateurs d'optimisation modernes et le matériel SMP.
https://fr.wikipedia.org/wiki/Algorithme_de_Dekker
Commentaires sur ARM et Alpha, et adresses de dépendances
Un e-mail envoyé par Catalin Marinas à la liste de diffusion du noyau de bras. Comprend un bon résumé des dépendances d'adresse et de contrôle.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
Ce que tout programmeur doit savoir sur la mémoire
Article très long et détaillé d'Ulrich Drepper sur les différents types de mémoire, en particulier les caches de processeur.
http://www.akkadia.org/drepper/cpumemory.pdf
Raisonnement du modèle de mémoire faiblement cohérent ARM
Cet article a été écrit par Chong & Ishtiaq, ARM, Ltd. Il tente de décrire le modèle de mémoire ARM SMP de manière rigoureuse, mais accessible. La définition de l'observabilité utilisée ici provient de cet article. Là encore, cette version est antérieure à ARMv8.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711
Le livre de recettes JSR-133 pour les rédacteurs de compilation
Doug Lea a écrit cet article en complément de la documentation sur JSR-133 (modèle de mémoire Java). Il contient les premières consignes de mise en œuvre pour le modèle de mémoire Java qui a été utilisé par de nombreux rédacteurs de compilation encore largement citée et susceptible de fournir des informations. Malheureusement, les quatre variétés de clôtures évoquées ici ne sont pas adaptées compatible avec les architectures compatibles avec Android et les mappages C++11 ci-dessus sont désormais une meilleure source de recettes précises, même pour Java.
http://g.oswego.edu/dl/jmm/cookbook.html
x86-TSO: un modèle de programmeur rigoureux et utilisable pour les multiprocesseurs x86
Description précise du modèle de mémoire x86. Descriptions précises de du modèle de mémoire ARM sont malheureusement beaucoup plus compliqués.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf