Classification des modèles de programmation simultanée
Dans la programmation simultanée, nous devons traiter deux problèmes clés : comment communiquer entre les threads et comment se synchroniser entre les threads (les threads font ici référence à des entités actives qui s'exécutent simultanément) . La communication fait référence au mécanisme par lequel les threads échangent des informations. En programmation impérative, il existe deux mécanismes de communication entre les threads : la mémoire partagée et la transmission de messages.
Dans le modèle de concurrence de mémoire partagée, l'état commun du programme est partagé entre les threads, et les threads communiquent implicitement en écrivant et en lisant l'état commun en mémoire. Dans le modèle de concurrence de transmission de messages, il n'y a pas d'état public entre les threads et les threads doivent communiquer explicitement en envoyant des messages explicitement.
La synchronisation fait référence au mécanisme utilisé par les programmes pour contrôler l'ordre relatif dans lequel les opérations se produisent entre les différents threads. Dans le modèle de concurrence de mémoire partagée, la synchronisation est effectuée explicitement. Les programmeurs doivent spécifier explicitement qu'une méthode ou un morceau de code doit être exécuté exclusivement entre les threads. Dans le modèle de concurrence de transmission de messages, la synchronisation est effectuée implicitement car l'envoi d'un message doit précéder la réception d'un message.
La concurrence de Java adopte un modèle de mémoire partagée. La communication entre les threads Java est toujours effectuée implicitement et l'ensemble du processus de communication est totalement transparent pour les programmeurs. Si un programmeur Java écrivant un programme multithread ne comprend pas comment fonctionne la communication implicite entre les threads, il risque de rencontrer toutes sortes d'étranges problèmes de visibilité de la mémoire.
Abstraction du modèle de mémoire Java
En Java, tous les champs d'instance, champs statiques et éléments de tableau sont stockés dans la mémoire tas, et la mémoire tas est partagée entre les threads (cet article utilise des "variables partagées " Ce terme fait référence aux champs d'instance, aux champs statiques et aux éléments de tableau). Les variables locales, les paramètres de définition de méthode (appelés paramètres de méthode formelle dans la spécification du langage Java) et les paramètres de gestionnaire d'exceptions ne sont pas partagés entre les threads, ils n'ont pas de problèmes de visibilité de la mémoire et ne sont pas affectés par le modèle de mémoire.
La communication entre les threads Java est contrôlée par le modèle de mémoire Java (appelé JMM dans cet article), qui détermine quand une écriture dans une variable partagée par un thread est visible par un autre thread. D'un point de vue abstrait, JMM définit la relation abstraite entre les threads et la mémoire principale : les variables partagées entre les threads sont stockées dans la mémoire principale (mémoire principale), et chaque thread possède une mémoire locale privée (mémoire locale), une copie de la mémoire partagée. La variable que le thread lit/écrit est stockée dans la mémoire locale. La mémoire locale est un concept abstrait de JMM et n'existe pas vraiment. Il couvre les caches, les tampons d'écriture, les registres et d'autres optimisations du matériel et du compilateur. Le diagramme schématique abstrait du modèle de mémoire Java est le suivant :
D'après la figure ci-dessus, si le thread A et le thread B veulent communiquer, ils doivent passer par les deux suivants étapes :
Tout d'abord, le thread A actualise les variables partagées mises à jour dans la mémoire locale A dans la mémoire principale.
Ensuite, le thread B accède à la mémoire principale pour lire les variables partagées que le thread A a déjà mises à jour.
Ce qui suit est un diagramme schématique pour illustrer ces deux étapes :
Comme le montre la figure ci-dessus, les mémoires locales A et B ont des copies du partage variable x dans la mémoire principale. Supposons qu'au départ, les valeurs x dans ces trois mémoires soient toutes 0. Lorsque le thread A est en cours d'exécution, il stocke temporairement la valeur x mise à jour (en supposant que la valeur est 1) dans sa propre mémoire locale A. Lorsque le thread A et le thread B doivent communiquer, le thread A actualise d'abord la valeur x modifiée dans sa mémoire locale dans la mémoire principale. À ce moment, la valeur x dans la mémoire principale devient 1. Par la suite, le thread B accède à la mémoire principale pour lire la valeur x mise à jour du thread A. À ce stade, la valeur x de la mémoire locale du thread B devient également 1.
Dans l'ensemble, ces deux étapes sont essentiellement le thread A qui envoie un message au thread B, et ce processus de communication doit passer par la mémoire principale. JMM offre aux programmeurs Java des garanties de visibilité de la mémoire en contrôlant l'interaction entre la mémoire principale et la mémoire locale de chaque thread.
Réorganisation
Afin d'améliorer les performances lors de l'exécution d'un programme, les compilateurs et les processeurs réorganisent souvent les instructions. Il existe trois types de réorganisation :
Réorganisation optimisée par le compilateur. Le compilateur peut réorganiser l'ordre d'exécution des instructions sans modifier la sémantique d'un programme monothread.
Réorganisation parallèle au niveau des instructions. Les processeurs modernes utilisent le parallélisme au niveau des instructions (ILP) pour exécuter plusieurs instructions de manière superposée. S'il n'y a pas de dépendances de données, le processeur peut modifier l'ordre dans lequel les instructions correspondent aux instructions machine.
Réorganisation du système de mémoire. Étant donné que le processeur utilise du cache et des tampons de lecture/écriture, les opérations de chargement et de stockage peuvent sembler exécutées dans le désordre.
Du code source Java à la séquence d'instructions finale effectivement exécutée, il subira les trois réordonnancements suivants :
Le 1 ci-dessus appartient à la réorganisation du compilateur, et les 2 et 3 appartiennent à la réorganisation du processeur. Ces réorganisations peuvent entraîner des problèmes de visibilité de la mémoire dans les programmes multithread. Pour les compilateurs, les règles de réorganisation du compilateur de JMM interdisent certains types de réorganisation du compilateur (toutes les réorganisations du compilateur ne sont pas interdites). Pour la réorganisation des processeurs, les règles de réorganisation des processeurs de JMM exigent que le compilateur Java insère des types spécifiques d'instructions de barrière de mémoire (Intel l'appelle barrière de mémoire) lors de la génération de séquences d'instructions, et utilise des instructions de barrière de mémoire pour interdire des types spécifiques d'instructions de processeur (pas tous les processeurs). la réorganisation doit être désactivée).
JMM est un modèle de mémoire au niveau du langage qui garantit une mémoire cohérente pour les programmeurs en interdisant des types spécifiques de réorganisation du compilateur et de réorganisation des processeurs sur différents compilateurs et différentes plates-formes de processeur. Visibilité garantie.
Réorganisation du processeur et instructions de barrière de mémoire
Les processeurs modernes utilisent des tampons d'écriture pour enregistrer temporairement les données écrites en mémoire. Le tampon d'écriture maintient le pipeline d'instructions en marche et évite les retards provoqués par le blocage du processeur en attendant que les données soient écrites en mémoire. Dans le même temps, en actualisant le tampon d'écriture dans un processus par lots et en fusionnant plusieurs écritures vers la même adresse mémoire dans le tampon d'écriture, l'utilisation du bus mémoire peut être réduite. Bien que les tampons d'écriture présentent de nombreux avantages, le tampon d'écriture de chaque processeur n'est visible que par le processeur sur lequel il se trouve. Cette fonctionnalité va avoir un impact important sur l'ordre d'exécution des opérations mémoire : l'ordre dans lequel le processeur lit/écrit les opérations mémoire n'est pas forcément cohérent avec l'ordre dans lequel la mémoire lit/écrit réellement les opérations ! Pour une explication spécifique, veuillez regarder l'exemple suivant :
Processeur A
Processeur B
a = 1; //A1
x = b; ; / /B1
y = a; //B2
État initial : a = b = 0
Le processeur permet à l'exécution d'obtenir le résultat : x = y = 0
Supposons que le processeur A et le processeur B effectuent des accès à la mémoire en parallèle dans l'ordre du programme, mais peuvent éventuellement obtenir le résultat x = y = 0. Les raisons spécifiques sont indiquées dans la figure ci-dessous :
Ici, le processeur A et le processeur B peuvent écrire la variable partagée dans leurs propres tampons d'écriture (A1, B1) en même temps, puis lire une autre variable partagée (A2, B2) dans la mémoire, et enfin écrire eux-mêmes les données sales. enregistré dans la zone de cache est vidé dans la mémoire (A3, B3). Lorsqu'il est exécuté dans ce timing, le programme peut obtenir le résultat x = y = 0.
À en juger par la séquence réelle des opérations de mémoire, l'opération d'écriture A1 n'est réellement exécutée que lorsque le processeur A exécute A3 pour actualiser son propre cache d'écriture. Bien que le processeur A effectue les opérations de mémoire dans l'ordre : A1->A2, l'ordre dans lequel les opérations de mémoire se produisent réellement est : A2->A1. A ce moment, la séquence d'opérations mémoire du processeur A est réorganisée (la situation du processeur B est la même que celle du processeur A, je n'entrerai donc pas dans les détails ici).
La clé ici est que, puisque le tampon d'écriture n'est visible que par son propre processeur, l'ordre dans lequel le processeur effectue les opérations de mémoire sera incompatible avec l'ordre réel dans lequel les opérations de mémoire sont effectuées. Étant donné que les processeurs modernes utilisent des tampons d’écriture, ils permettent de réorganiser les opérations d’écriture-lecture.
Ce qui suit est une liste des types de réorganisation autorisés par les processeurs courants :
Load-Load
Load-Store
Store-Store
Store-Load
Dépendances de données
sparc-TSO
N
N
N
Y
N
x86
N
N
N
Y
N
ia64
Y
Y
Y
Y
N
PowerPC
Y
Y
Y
Y
N
Activé Un « N » dans une cellule du tableau indique que le processeur n'autorise pas la réorganisation des deux opérations, et un « Y » indique que la réorganisation est autorisée.
D'après le tableau ci-dessus, nous pouvons voir que les processeurs courants autorisent la réorganisation Store-Load ; les processeurs courants ne permettent pas la réorganisation des opérations avec des dépendances de données. sparc-TSO et x86 ont des modèles de mémoire de processeur relativement puissants qui permettent uniquement la réorganisation des opérations d'écriture-lecture (car ils utilisent tous deux des tampons d'écriture).
※Remarque 1 : sparc-TSO fait référence aux caractéristiques du processeur sparc lorsqu'il est exécuté dans le modèle de mémoire TSO (Total Store Order).
※Remarque 2 : x86 dans le tableau ci-dessus inclut x64 et AMD64.
※Remarque 3 : Étant donné que le modèle de mémoire du processeur ARM est très similaire à celui du processeur PowerPC, cet article l'ignorera.
※Remarque 4 : La dépendance aux données sera spécifiquement expliquée plus tard.
Afin de garantir la visibilité de la mémoire, le compilateur Java insérera des instructions de barrière de mémoire aux emplacements appropriés dans la séquence d'instructions générée pour interdire des types spécifiques de réorganisation du processeur. JMM divise les instructions de barrière de mémoire en quatre catégories suivantes :
Type de barrière
Exemple d'instruction
Explication
Barrières LoadLoad
LoadLoad
Assurer le chargement des données Load1, avant Load2 ; et le chargement de toutes les instructions de chargement ultérieures.
StoreStore Barriers
Store1 ; StoreStore ; Store2
Garantit que les données de Store1 sont visibles par les autres processeurs (vidées en mémoire), avant le stockage dans Store2 et toutes les instructions de stockage ultérieures.
LoadStore Barriers
Load1; LoadStore; Store2
Garantit que les données Load1 sont chargées avant Store2 et que toutes les instructions de stockage suivantes sont vidées en mémoire.
StoreLoad Barriers
Store1; StoreLoad; Load2
Garantit que les données Store1 deviennent visibles pour les autres processeurs (faisant référence à leur vidage en mémoire) avant d'être chargées par Load2 et toutes les instructions de chargement ultérieures. Les barrières StoreLoad feront en sorte que toutes les instructions d'accès à la mémoire (instructions de stockage et de chargement) avant la barrière se terminent avant que les instructions d'accès à la mémoire après l'exécution de la barrière.
StoreLoad Barriers est une barrière "tout usage" qui a les effets des trois autres barrières en même temps. La plupart des multiprocesseurs modernes prennent en charge cette barrière (d'autres types de barrières peuvent ne pas être pris en charge par tous les processeurs). L'exécution de cette barrière peut être coûteuse car les processeurs actuels doivent généralement vider toutes les données du tampon d'écriture vers la mémoire (vidage complet du tampon).
arrive avant
À partir du JDK5, Java utilise le nouveau modèle de mémoire JSR-133 (sauf indication contraire, cet article se concentre sur le modèle de mémoire JSR-133). JSR-133 propose le concept d'arrive-avant, à travers lequel la visibilité de la mémoire entre les opérations est expliquée. Si les résultats d’une opération doivent être visibles par une autre opération, alors il doit y avoir une relation d’occurrence entre les deux opérations. Les deux opérations mentionnées ici peuvent être effectuées au sein d'un même thread ou entre différents threads. Les règles d'occurrence qui sont étroitement liées aux programmeurs sont les suivantes :
Règles de séquence de programme : chaque opération dans un thread se produit avant toute opération ultérieure dans ce thread.
Règle de verrouillage du moniteur : le déverrouillage d'un verrouillage du moniteur se produit avant le verrouillage ultérieur du verrouillage du moniteur.
Règles des variables volatiles : l'écriture dans un champ volatile se produit avant toute lecture ultérieure de ce champ volatile.
Transitivité : si A se produit avant B et que B se produit avant C, alors A se produit avant C.
Notez qu'il existe une relation d'occurrence entre deux opérations, ce qui ne signifie pas que la première opération doit être exécutée avant la seconde opération ! arrive-avant nécessite seulement que l'opération précédente (résultat de l'exécution) soit visible pour l'opération suivante, et que la première opération soit visible et ordonnée avant la deuxième opération. La définition de « arrive-avant » est très subtile. L'article suivant explique précisément pourquoi « arrive-avant » est défini de cette manière.
La relation entre arrive-avant et JMM est illustrée dans la figure ci-dessous :
Comme le montre la figure ci-dessus, une règle qui se produit avant correspond généralement à plusieurs règles de réorganisation du compilateur et des règles de réorganisation du processeur. Pour les programmeurs Java, la règle « arrive avant » est simple et facile à comprendre. Elle empêche les programmeurs d'apprendre des règles de réorganisation complexes et l'implémentation spécifique de ces règles afin de comprendre les garanties de visibilité mémoire fournies par JMM.
Ce qui précède est une analyse approfondie du modèle de mémoire Java : la partie de base. Pour plus de contenu connexe, veuillez faire attention au site Web PHP chinois (www.php.cn) !