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 peut survenir lors de l'écriture de code multithread pour les systèmes multiprocesseurs symétriques en C, C++ et Java de programmation d'application (appelé simplement "Java" ci-après dans le but de la brièveté). 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 échecs lorsqu'il est exécuté sur différents sur des architectures de processeur, ou même sur différentes implémentations de l'architecture. Le code qui a été minutieusement testé sur x86 peut présenter des dysfonctionnements 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 ceci (Adve & Gharachorloo:
- Toutes les opérations de mémoire semblent s'exécuter une par une
- 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 du code source pour charger et stocker les instructions exactement commande correspondante, une instruction par accès. Nous supposons également que pour simplicité que chaque thread 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 ignorerons E/S de pilote de périphérique mappé à la 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 |
reg0 = B |
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. Thread 2 charge la valeur de l'emplacement B dans reg0, puis charge la valeur à partir de l'emplacement A dans reg1. (Notez que nous écrivons dans un seul ordre et que nous lisons une autre.)
Les threads 1 et 2 sont censés s'exécuter sur des cœurs de processeur différents. Toi doivent toujours faire cette supposition lorsque du code multithread.
La cohérence séquentielle garantit qu'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 (thread 2 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 maintenu. 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 assembleur langage, les réseaux sociaux augmentent généralement la probabilité que vous rencontriez des problèmes qui 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 aux données se produit lorsqu'au moins deux threads accèdent simultanément les mêmes données ordinaires et au moins l’une d’entre elles 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 concurrence entre les 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 Thread
1 en le réécrivant comme "B = true; if (!A) B = false
". Ce serait
comme faire des saucisses en
ville en plein jour.
Les races de données sont officiellement définies sur des types intégrés de base tels que les entiers et
ou des pointeurs. Attribution à un int
simultanément
les lire dans un autre thread
constitue clairement une course aux 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 l'une des
qui la met à jour. Mettre à jour un set<T>
dans un thread tout en
en le lisant simultanément dans un autre,
permet à la bibliothèque d'introduire une
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 peut introduire une concurrence entre les 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
- mutex (C++11
std::mutex
oupthread_mutex_t
), ou Les blocssynchronized
en Java peuvent être utilisés pour garantir que certains ne s'exécutent pas simultanément avec d'autres sections de code accédant les mêmes données. Nous ferons référence à ces installations et à d'autres installations similaires de manière générique. comme des « 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 d’autres mises à jour de la structure de données peuvent 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 Blocssynchronized
oulock_guard
C++ ouunique_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 variablesatomic
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 variablesvolatile
ouatomic
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
présente des différences
en C++ et Java. En C++, volatile
n'empêche pas les données
des courses, même si un code plus ancien l'utilise souvent comme solution de contournement
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.
Variables atomic
C/C++ ou variables volatile
Java
permet d'éviter les conflits entre les données sur d'autres variables. Si flag
correspond à
déclaré comme ayant le type atomic<bool>
ou atomic_bool
(C/C++) ou volatile boolean
(Java),
et qu'elle est initialement "false", l'extrait de code suivant n'est pas soumis à une concurrence de données:
Thread 1 | Thread 2 |
---|---|
A = ...
|
while (!flag) {}
|
É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 requise pour empêcher ou masquer la réorganisation de la mémoire pour que le code comme le test litmus 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: <ph type="x-smartling-placeholder">- </ph>
- 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 uneA
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 enThread 1 Thread 2 A = ...
flag = truereg0 = flag; tandis que (!reg0) {}
... = Aflag
est vrai. - 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 packagejava.util.concurrent.atomic
fournit un accès d'installations similaires, en particulierlazySet()
. 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. - Certains codes C et C++ sont écrits dans un style plus ancien, pas complètement
conforme aux normes linguistiques en vigueur, où
volatile
les variables sont utilisées à la place des variablesatomic
, et l'ordre de la mémoire n'est pas explicitement interdite en insérant ce que l'on appelle des clôtures ou barrières. Cela nécessite un raisonnement explicite concernant l'accès la réorganisation 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 éléments volatiles.
d'accès. 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++, accède à volatile
.
les données peuvent être réorganisées en accédant à des données non volatiles, et il n'y a pas
des garanties d'atomicité. Vous ne pouvez donc pas utiliser volatile
pour partager des données entre
les threads dans du code portable, même sur un uniprocesseur. 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.
Une grande partie du code C et C++ plus ancien utilise toujours volatile
pour le thread
de la communication. 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 verrou
pthread_mutex_t
ou C++11 std::mutex
) plutôt qu'une
atomique, mais nous utiliserons cette dernière
pour illustrer la façon dont elles seraient
utilisée 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 est d'allouer une structure, d'initialiser ses champs nous la "publions" 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 n'exige pas de code exempt 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.
Java « synchronisé » et « volatile » mots clés
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 à
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:
reg = mValue
reg = reg + 1
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é de conflit de verrouillage,
pourrait entraver les 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
. Ainsi, l’attribution
sGoodies = goods
peut être observé avant l'initialisation de la
dans goods
. Si vous déclarez sGoodies
avec le
Mot clé volatile
, la cohérence séquentielle est restaurée et tout fonctionnera
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 que nous voulons avoir une seule instance d'un Helper
associé à 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 s'agir
défini simultanément avec 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 fil
pourrait trouver
Valeur helper
non nulle, mais ses champs ne sont pas encore définis et prêts à être utilisés.
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 :
- Effectuez l'opération simple et supprimez la vérification externe. Cela garantit que
nous ne
Examinez la valeur de
helper
en dehors d'un bloc synchronisé. - Déclarez
helper
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 la réorganisation.
d'autres types de mémoire accèdent
entre eux en 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, en fournissant des données correctes (cohérentes
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, vous pouvez assouplir l'ordre avec
memory_order
... ou lazySet()
peuvent améliorer les performances
avantages, mais nécessite une compréhension plus approfondie que ce que nous avons montré jusqu'à présent.
Une grande partie du code existant utilisant
on découvre qu’ils ont des bogues
après coup. Si possible, évitez-les.
Si vos cas d'utilisation ne correspondent 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 adapté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. Objets des classes telles que String et Integer de Java contiennent des données qui ne peuvent pas être modifiées une fois qu'un est créé, évitant ainsi tout risque de concurrence de données sur ces objets. Le livre Efficace Java, 2e édition contient des instructions spécifiques dans l'article 15: Réduire la mutabilité. Noter dans en particulier l'importance de déclarer les champs Java "finals" (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 nécessitent un soin particulier 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
éviter les erreurs courantes de programmation simultanées, mais elles vous aideront
éviter les échecs mystérieux associés à l'optimisation des compilateurs et des SMP
d'incidents.
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. Consultez les techniques de construction sûres en Java pour en savoir plus. détails
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 pour
accède aux variables déclarées en tant que volatile
.
Vous devez généralement les éviter, sauf s'il existe des raisons urgentes de performances 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, les gains de performances sont limités aux magasins et 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 de l'atomique faiblement ordonné est compliquée. 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 deuxmemory_order_relaxed
magasins peuvent devenir visibles dans le désordre, même s'ils sont séparés par une section critique. - Lorsqu'elle est utilisée de manière abusive en tant que compteur partagé, une variable Java ordinaire peut apparaître
à un autre thread pour diminuer, alors qu'il n'est incrémenté que d'une
un autre fil de discussion. Mais ce n'est pas le cas pour l'architecture atomique C++
memory_order_relaxed
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 elles ne s'appliquent qu'au C++.
Accès hors course
Il est assez courant qu'une variable soit atomique, car elle est parfois
lire simultanément avec une écriture, mais tous les accès ne présentent pas ce problème.
Par exemple, une variable
doivent être atomiques, car elles sont lues 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'il n'est pas nécessaire que des contraintes d'ordre supplémentaires soient
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 prise en compte.
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
Parfois, les données sont modifiées
en parallèle par plusieurs threads, mais
examiné 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'y a aucun moyen de savoir si les accès à ces données
a été réorganisé, et le code C++ peut utiliser un memory_order_relaxed
.
Les compteurs d'événements simples en sont un exemple courant. Puisqu'il s'agit Si courante, il convient 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 mentionné précédemment, précédemment, C++ garantit qu’un deuxième chargement d’un tel compteur ne renvoie une valeur inférieure à un chargement antérieur 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. La charge
les opérations continuent 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<mutex> 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
C'est l'optimisation la plus bénéfique
peut être l'introduction de myHelper
pour éliminer
une seconde charge, même si un futur
compilateur pourrait 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,
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 testé comme initialisé, une seconde charge 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
suivants doivent être atomiques,
car les mises à jour partielles ne devraient pas être visibles. Pour Java, le champ
ne doit pas être de type
long
nidouble
. Pour C++, une affectation atomique est requise ; sa construction en place ne fonctionnera pas, car la construction d'uneatomic
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. Ici, les règles de champ final Java assurez-vous que si un lecteur voit la référence, il verra également les champs finaux 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 origine ethnique des 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ément » est globalement bon
Ces définitions sont instructives, surtout si vous
envisagent d'utiliser des opérations atomiques faiblement ordonnées en C++.
(La spécification Java actuelle définit uniquement
lazySet()
de manière 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
etjava.util.concurrent.atomic
pour découvrir celles qui sont 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 vont devoir implémenter. mais les discussions sont instructives.)
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. (Il s'agit aussi souvent
appelé "barrière" instructions, mais cela risque de prêter à confusion
Barrières de type pthread_barrier
, qui font beaucoup plus
que celle-ci.) La signification précise de
les instructions de clôture est un sujet assez compliqué
la manière dont les garanties fournies par plusieurs types de clôtures
interagissent, et comment elles se combinent avec
d'autres garanties de commande 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 de tri le plus basique est celui fourni par C++
memory_order_acquire
et memory_order_release
Opérations atomiques: opérations de mémoire précédant un magasin de versions
doit être visible 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 atomic
C++
ou Java volatile
, les deux ayant initialement la valeur "false".
Thread 1 | Thread 2 |
---|---|
flag1 = true |
flag2 = true |
La cohérence séquentielle implique que l'une des attributions
flag
n doit être exécuté en premier et être vu par la
dans l'autre thread. Ainsi, nous ne verrons jamais
ces threads exécutent simultanément
le « contenu critique ».
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 une fois beaucoup plus fort que nécessaire, car cette clôture commande généralement
tous les accès à la mémoire antérieurs par rapport à tous les accès ultérieurs.)
Nous pourrions plutôt associer la clôture supplémentaire à de manière séquentielle des chargements plus cohérents. Comme les magasins sont moins fréquents, décrit est plus courant et utilisé sur Android.
Comme nous l'avons vu dans une section précédente, nous devons insérer une barrière de stockage/charge 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 "release" (2) |
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 concentrez-vous sur le placement des clôtures ARMv7 ici, car il éclaire davantage 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. Fortement recommandé pour 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
. En bas de la page, une section intitulée "Propriétés de cohérence de la mémoire" explique les garanties des 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
niatomic
. 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 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 quelques exemples utiles et de l'art 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
proches de la norme C++14, qui comprend des modifications mineures dans ce domaine
de 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