Pourquoi i ++ n’est-il pas atomique?

Pourquoi i++ n’est-il pas atomique en Java?

Pour aller un peu plus loin dans Java, j’ai essayé de compter la fréquence d’exécution de la boucle dans les threads.

J’ai donc utilisé un

 private static int total = 0; 

dans la classe principale.

J’ai deux fils.

  • Thread 1: Imprime System.out.println("Hello from Thread 1!");
  • Thread 2: Imprime System.out.println("Hello from Thread 2!");

Et je compte les lignes imprimées par le fil 1 et le fil 2. Mais les lignes du fil 1 + les lignes du fil 2 ne correspondent pas au nombre total de lignes imprimées.

Voici mon code:

 import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.logging.Level; import java.util.logging.Logger; public class Test { private static int total = 0; private static int countT1 = 0; private static int countT2 = 0; private boolean run = true; public Test() { ExecutorService newCachedThreadPool = Executors.newCachedThreadPool(); newCachedThreadPool.execute(t1); newCachedThreadPool.execute(t2); try { Thread.sleep(1000); } catch (InterruptedException ex) { Logger.getLogger(Test.class.getName()).log(Level.SEVERE, null, ex); } run = false; try { Thread.sleep(1000); } catch (InterruptedException ex) { Logger.getLogger(Test.class.getName()).log(Level.SEVERE, null, ex); } System.out.println((countT1 + countT2 + " == " + total)); } private Runnable t1 = new Runnable() { @Override public void run() { while (run) { total++; countT1++; System.out.println("Hello #" + countT1 + " from Thread 2! Total hello: " + total); } } }; private Runnable t2 = new Runnable() { @Override public void run() { while (run) { total++; countT2++; System.out.println("Hello #" + countT2 + " from Thread 2! Total hello: " + total); } } }; public static void main(Ssortingng[] args) { new Test(); } } 

i++ n’est probablement pas atomique en Java car l’atomicité est une exigence particulière qui n’est pas présente dans la majorité des utilisations de i++ . Cette exigence a un surcoût important: il est très coûteux de réaliser une opération d’incrément atomique; cela implique une synchronisation aux niveaux logiciel et matériel qui ne doit pas nécessairement être présente dans un incrément ordinaire.

Vous pourriez faire l’argument que i++ aurait dû être conçu et documenté comme effectuant spécifiquement un incrément atomique, de sorte qu’un incrément non atomique soit effectué en utilisant i = i + 1 . Cependant, cela briserait la “compatibilité culturelle” entre Java et C et C ++. De même, cela enlèverait une notation commode que les programmeurs familiers avec les langages de type C tiennent pour acquis, en lui donnant une signification spéciale qui ne s’applique que dans des circonstances limitées.

Le code de base C ou C ++ comme for (i = 0; i < LIMIT; i++) se traduirait par Java for (i = 0; i < LIMIT; i = i + 1) ; car il serait inapproprié d'utiliser l' i++ atomique. Pire encore, les programmeurs venant de C ou d'autres langages de type C en Java utiliseraient de toute façon i++ , ce qui entraînerait une utilisation inutile des instructions atomiques.

Même au niveau du jeu d'instructions de la machine, une opération de type incrémentiel n'est généralement pas atomique pour des raisons de performances. Dans x86, une instruction spéciale "préfixe de locking" doit être utilisée pour que l'instruction inc atomique: pour les mêmes raisons que ci-dessus. Si inc était toujours atomique, il ne serait jamais utilisé lorsqu'une inc non atomique est requirejse; les programmeurs et les compilateurs génèrent du code qui charge, ajoute 1 et stocke, car cela serait beaucoup plus rapide.

Dans certaines architectures de jeux d’instructions, il n’existe pas d’ inc atomique ou peut-être pas d’ inc du tout; pour faire un développement atomique sur MIPS, vous devez écrire une boucle logicielle qui utilise le ll et le sc : load-linked, et store-conditionnel. Load-linked lit le mot et store-conditionnel stocke la nouvelle valeur si le mot n'a pas changé, sinon il échoue (ce qui est détecté et provoque une nouvelle tentative).

i++ implique deux opérations:

  1. lire la valeur actuelle de i
  2. incrémenter la valeur et l’assigner à i

Lorsque deux threads exécutent i++ sur la même variable en même temps, ils peuvent tous deux obtenir la même valeur actuelle de i , puis l’incrémenter et la définir sur i+1 Vous obtiendrez donc une seule incrémentation au lieu de deux.

Exemple :

 int i = 5; Thread 1 : i++; // reads value 5 Thread 2 : i++; // reads value 5 Thread 1 : // increments i to 6 Thread 2 : // increments i to 6 // i == 6 instead of 7 

L’important est le JLS (Java Language Specification) plutôt que la manière dont les différentes implémentations de la JVM peuvent ou non avoir implémenté une certaine fonctionnalité du langage. Le JLS définit l’opérateur ++ postfix dans la clause 15.14.2 qui dit que “la valeur 1 est ajoutée à la valeur de la variable et la sum est stockée dans la variable”. Nulle part il ne mentionne ni ne suggère le multithreading ou l’atomicité. Pour ceux-ci, le JLS fournit volatile et synchronisé . En outre, il existe le package java.util.concurrent.atomic (voir http://docs.oracle.com/javase/7/docs/api/java/util/concurrent/atomic/package-summary.html )

Pourquoi i ++ n’est-il pas atomique en Java?

Divisons l’opération d’incrément en plusieurs instructions:

Fil 1 & 2:

  1. Récupère la valeur du total de la mémoire
  2. Ajouter 1 à la valeur
  3. Ecrire en mémoire

S’il n’y a pas de synchronisation, disons que le thread 1 a lu la valeur 3 et l’a incrémenté à 4, mais ne l’a pas réécrit. À ce stade, le changement de contexte se produit. Le thread deux lit la valeur 3, l’incrémente et le changement de contexte se produit. Bien que les deux threads aient augmenté la valeur totale, ce sera toujours la condition de 4 courses.

i++ est une déclaration qui implique simplement 3 opérations:

  1. Lire la valeur actuelle
  2. Ecrire une nouvelle valeur
  3. Stocker la nouvelle valeur

Ces trois opérations ne sont pas destinées à être exécutées en une seule étape, en d’autres termes, i++ n’est pas une opération composée . En conséquence, toutes sortes de problèmes peuvent survenir lorsque plusieurs threads sont impliqués dans une opération unique mais non composée.

À titre d’exemple, imaginons ce scénario:

Heure 1 :

 Thread A fetches i Thread B fetches i 

Temps 2 :

 Thread A overwrites i with a new value say -foo- Thread B overwrites i with a new value say -bar- Thread B stores -bar- in i // At this time thread B seems to be more 'active'. Not only does it overwrite // its local copy of i but also makes it in time to store -bar- back to // 'main' memory (i) 

Temps 3 :

 Thread A attempts to store -foo- in memory effectively overwriting the -bar- value (in i) which was just stored by thread B in Time 2. Thread B has nothing to do here. Its work was done by Time 2. However it was all for nothing as -bar- was eventually overwritten by another thread. 

Et voila. Une condition de course


C’est pourquoi i++ n’est pas atomique. Si c’était le cas, rien de tout cela ne serait arrivé et chaque fetch-update-store se produirait de manière atomique. C’est exactement ce à quoi AtomicInteger est destiné et, dans votre cas, cela irait probablement bien.

PS

Un excellent livre couvrant tous ces problèmes, puis certains: la concurrence Java en pratique

Il y a deux étapes:

  1. aller chercher de la mémoire
  2. mettre i + 1 à i

donc ce n’est pas une opération atomique. Lorsque thread1 exécute i ++ et que thread2 exécute i ++, la valeur finale de i peut être i + 1.

Dans la JVM, un incrément implique une lecture et une écriture, donc ce n’est pas atomique.

Si l’opération i++ était atomique, vous n’auriez pas la chance d’en lire la valeur. C’est exactement ce que vous voulez faire en utilisant i++ (au lieu d’utiliser ++i ).

Par exemple, regardez le code suivant:

 public static void main(final Ssortingng[] args) { int i = 0; System.out.println(i++); } 

Dans ce cas, on s’attend à ce que la sortie soit: 0 (parce que nous affichons un incrément, par exemple première lecture, puis mise à jour)

C’est l’une des raisons pour lesquelles l’opération ne peut pas être atomique, car vous devez lire la valeur (et faire quelque chose avec elle), puis mettre à jour la valeur.

L’autre raison importante est que faire quelque chose de façon atomique prend généralement plus de temps à cause du locking. Il serait idiot que toutes les opérations sur les primitives prennent un peu plus de temps pour les rares cas où les utilisateurs souhaitent avoir des opérations atomiques. C’est pourquoi ils ont ajouté AtomicInteger et d’ autres classes atomiques au langage.

La concurrence (la classe Thread et autres) est une fonctionnalité ajoutée dans la version 1.0 de Java . i++ été ajouté dans la version bêta avant cela, et en tant que tel, il est encore plus que probable dans sa mise en œuvre (plus ou moins) originale.

Il appartient au programmeur de synchroniser les variables. Consultez le tutoriel Oracle à ce sujet .

Edit: Pour clarifier, i ++ est une procédure bien définie, antérieure à Java, et les concepteurs de Java ont donc décidé de conserver la fonctionnalité d’origine de cette procédure.

L’opérateur ++ a été défini dans B (1969), qui précède java et threading par un petit peu.