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 |
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. 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
oupthread_mutex_t
C++11) ou les blocssynchronized
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 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
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 = ...
|
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 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:- 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, qui n'est pas entièrement conforme aux normes linguistiques actuelles. Dans ce style, les variables
volatile
sont utilisées à la place des variablesatomic
, 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:
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é 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 :
- 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é. - 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 deuxmemory_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<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
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
nidouble
. Pour C++, une affectation atomique est requise ; sa construction en place ne fonctionnera pas, car la construction d'unatomic
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
etjava.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 |
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 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 "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 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
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 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