J'ai entendu dire que i ++ n'est pas thread-safe, est-ce ++ i thread-safe?

90

J'ai entendu dire que i ++ n'est pas une instruction thread-safe car dans l'assemblage, il se réduit à stocker la valeur d'origine en tant que température quelque part, à l'incrémenter, puis à la remplacer, ce qui pourrait être interrompu par un changement de contexte.

Cependant, je m'interroge sur ++ i. Pour autant que je sache, cela se réduirait à une seule instruction d'assemblage, telle que «ajouter r1, r1, 1» et comme il ne s'agit que d'une seule instruction, il serait ininterrompu par un changement de contexte.

Quelqu'un peut-il clarifier? Je suppose qu'une plate-forme x86 est utilisée.

Samoz
la source
Juste une question. De quel type de scénario deux threads (ou plus) ont-ils besoin pour accéder à une variable comme celle-là? Je demande honnêtement ici, pas de critique. C'est juste à cette heure, ma tête ne peut penser à aucun.
OscarRyz
5
Une variable de classe dans une classe C ++ maintenant un nombre d'objets?
paxdiablo
1
Bonne vidéo sur le sujet que je viens de regarder aujourd'hui parce qu'un autre gars me l'a dit: youtube.com/watch?v=mrvAqvtWYb4
Johannes Schaub - litb
1
repensé en C / C ++; Java n'est pas pris en compte ici, C # est similaire, mais il manque une sémantique mémoire aussi rigide.
Tim Williscroft
1
@Oscar Reyes Disons que vous avez deux threads utilisant tous les deux la variable i. Si le premier thread n'augmente le thread qu'à un certain point et que l'autre ne le diminue que lorsqu'il se trouve à un autre moment, vous devrez vous soucier de la sécurité des threads.
samoz

Réponses:

158

Vous avez mal entendu. Il se peut que ce "i++"soit thread-safe pour un compilateur et une architecture de processeur spécifiques, mais ce n'est pas du tout obligatoire dans les normes. En fait, comme le multi-threading ne fait pas partie des normes ISO C ou C ++ (a) , vous ne pouvez pas considérer que quoi que ce soit soit thread-safe en fonction de ce à quoi vous pensez qu'il sera compilé.

Il est tout à fait possible de ++icompiler en une séquence arbitraire telle que:

load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory

qui ne serait pas thread-safe sur mon processeur (imaginaire) qui n'a pas d'instructions d'incrémentation de mémoire. Ou cela peut être intelligent et le compiler en:

lock         ; disable task switching (interrupts)
load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory
unlock       ; enable task switching (interrupts)

lockdésactive et unlockactive les interruptions. Mais, même dans ce cas, cela peut ne pas être thread-safe dans une architecture qui a plus d'un de ces processeurs partageant la mémoire (le lockpeut désactiver uniquement les interruptions pour un processeur).

Le langage lui-même (ou les bibliothèques pour lui, s'il n'est pas intégré au langage) fournira des constructions thread-safe et vous devriez les utiliser plutôt que de dépendre de votre compréhension (ou peut-être d'un malentendu) du code machine qui sera généré.

Des choses comme Java synchronizedet pthread_mutex_lock()(disponibles pour C / C ++ sous certains systèmes d'exploitation) sont ce que vous devez examiner (a) .


(a) Cette question a été posée avant l'achèvement des normes C11 et C ++ 11. Ces itérations ont maintenant introduit la prise en charge des threads dans les spécifications du langage, y compris les types de données atomiques (bien qu'ils, et les threads en général, soient facultatifs, au moins en C).

paxdiablo
la source
8
+1 pour souligner qu'il ne s'agit pas d'un problème spécifique à la plateforme, sans parler d'une réponse claire ...
RBerteig
2
félicitations pour votre badge argent C :)
Johannes Schaub - litb
Je pense que vous devriez préciser qu'aucun système d'exploitation moderne n'autorise les programmes en mode utilisateur à désactiver les interruptions et que pthread_mutex_lock () ne fait pas partie de C.
Bastien Léonard
@Bastien, aucun système d'exploitation moderne ne fonctionnerait sur un processeur sans instruction d'incrémentation de mémoire :-) Mais votre point est pris à propos de C.
paxdiablo
5
@Bastien: Bull. Les processeurs RISC n'ont généralement pas d'instructions d'incrémentation de mémoire. Le tripplet charger / ajouter / stocker est la façon dont vous faites cela sur, par exemple, PowerPC.
derobert le
42

Vous ne pouvez pas faire une déclaration générale sur ++ i ou i ++. Pourquoi? Pensez à incrémenter un entier 64 bits sur un système 32 bits. À moins que la machine sous-jacente n'ait une instruction à quatre mots «charger, incrémenter, stocker», l'incrémentation de cette valeur nécessitera plusieurs instructions, dont chacune peut être interrompue par un changement de contexte de thread.

De plus, il ++in'est pas toujours «d'en ajouter un à la valeur». Dans un langage comme C, l'incrémentation d'un pointeur ajoute en fait la taille de l'objet pointé. Autrement dit, si iest un pointeur vers une structure de ++i32 octets , ajoute 32 octets. Alors que presque toutes les plates-formes ont une instruction "incrémenter la valeur à l'adresse mémoire" qui est atomique, toutes n'ont pas une instruction atomique "ajouter une valeur arbitraire à la valeur à l'adresse mémoire".

Jim Mischel
la source
35
Bien sûr, si vous ne vous limitez pas aux entiers 32 bits ennuyeux, dans un langage comme C ++, ++, je peux vraiment être un appel à un service Web qui met à jour une valeur dans une base de données.
Eclipse
16

Ils sont tous deux non sécurisés pour les threads.

Un processeur ne peut pas faire de calcul directement avec la mémoire. Il le fait indirectement en chargeant la valeur de la mémoire et en faisant le calcul avec les registres du processeur.

i ++

register int a1, a2;

a1 = *(&i) ; // One cpu instruction: LOAD from memory location identified by i;
a2 = a1;
a1 += 1; 
*(&i) = a1; 
return a2; // 4 cpu instructions

++ i

register int a1;

a1 = *(&i) ; 
a1 += 1; 
*(&i) = a1; 
return a1; // 3 cpu instructions

Dans les deux cas, il existe une condition de concurrence qui aboutit à la valeur i imprévisible.

Par exemple, supposons qu'il existe deux threads ++ i simultanés, chacun utilisant respectivement les registres a1, b1. Et, avec le changement de contexte exécuté comme suit:

register int a1, b1;

a1 = *(&i);
a1 += 1;
b1 = *(&i);
b1 += 1;
*(&i) = a1;
*(&i) = b1;

En conséquence, i ne devient pas i + 2, il devient i + 1, ce qui est incorrect.

Pour remédier à cela, les processeurs moden fournissent une sorte d'instructions CPU LOCK, UNLOCK pendant l'intervalle de désactivation du changement de contexte.

Sur Win32, utilisez InterlockedIncrement () pour faire i ++ pour la sécurité des threads. C'est beaucoup plus rapide que de compter sur le mutex.

yogman
la source
6
"Un processeur ne peut pas faire de maths directement avec la mémoire" - Ce n'est pas exact. Il y a des CPU-s, où vous pouvez faire des maths "directement" sur des éléments de mémoire, sans avoir besoin de les charger d'abord dans un registre. Par exemple. MC68000
darklon
1
Les instructions LOCK et UNLOCK CPU n'ont rien à voir avec les changements de contexte. Ils verrouillent les lignes de cache.
David Schwartz
11

Si vous partagez même un int entre les threads dans un environnement multicœur, vous avez besoin de barrières de mémoire appropriées en place. Cela peut signifier utiliser des instructions imbriquées (voir InterlockedIncrement dans win32 par exemple), ou utiliser un langage (ou un compilateur) qui offre certaines garanties thread-safe. Avec la réorganisation des instructions au niveau du processeur, les caches et d'autres problèmes, à moins que vous n'ayez ces garanties, ne supposez pas que tout ce qui est partagé entre les threads est sûr.

Edit: Une chose que vous pouvez supposer avec la plupart des architectures est que si vous avez affaire à des mots simples correctement alignés, vous ne vous retrouverez pas avec un seul mot contenant une combinaison de deux valeurs qui ont été écrasées ensemble. Si deux écritures se superposent, l'une gagnera et l'autre sera rejetée. Si vous faites attention, vous pouvez en tirer parti et voir que ++ i ou i ++ sont thread-safe dans la situation à un seul écrivain / plusieurs lecteurs.

Éclipse
la source
En fait, faux dans les environnements où l'accès int (lecture / écriture) est atomique. Il existe des algorithmes qui peuvent fonctionner dans de tels environnements, même si l'absence de barrières de mémoire peut signifier que vous travaillez parfois sur des données obsolètes.
MSalters
2
Je dis simplement que l'atomicité ne garantit pas la sécurité des threads. Si vous êtes assez intelligent pour concevoir des structures de données ou des algorithmes sans verrouillage, alors allez-y. Mais vous devez toujours savoir quelles sont les garanties que votre compilateur va vous donner.
Eclipse
10

Si vous voulez un incrément atomique en C ++, vous pouvez utiliser des bibliothèques C ++ 0x (le std::atomictype de données) ou quelque chose comme TBB.

Il fut un temps où les directives de codage GNU disaient que la mise à jour des types de données qui correspondent à un mot était "généralement sûre", mais ce conseil est faux pour les machines SMP, faux pour certaines architectures et faux lors de l'utilisation d'un compilateur d'optimisation.


Pour clarifier le commentaire "mise à jour du type de données à un mot":

Il est possible que deux processeurs sur une machine SMP écrivent dans le même emplacement mémoire au cours du même cycle, puis tentent de propager la modification aux autres processeurs et au cache. Même si un seul mot de données est en cours d'écriture, de sorte que les écritures ne prennent qu'un seul cycle, elles se produisent également simultanément, vous ne pouvez donc pas garantir quelle écriture réussira. Vous n'obtiendrez pas de données partiellement mises à jour, mais une écriture disparaîtra car il n'y a pas d'autre moyen de gérer ce cas.

Compare-and-swap correctement les coordonnées entre plusieurs processeurs, mais il n'y a aucune raison de croire que chaque affectation de variable de types de données à un mot utilisera comparer et swap.

Et bien qu'un compilateur d'optimisation n'affecte pas la façon dont un chargement / stockage est compilé, il peut changer lorsque le chargement / stockage se produit, causant de sérieux problèmes si vous vous attendez à ce que vos lectures et vos écritures se produisent dans le même ordre qu'elles apparaissent dans le code source ( le plus connu étant le verrouillage à double vérification ne fonctionne pas en vanilla C ++).

REMARQUE Ma réponse initiale indiquait également que l'architecture Intel 64 bits était défectueuse dans le traitement des données 64 bits. Ce n'est pas vrai, j'ai donc modifié la réponse, mais ma modification affirmait que les puces PowerPC étaient cassées. Cela est vrai lors de la lecture de valeurs immédiates (c'est-à-dire des constantes) dans des registres (voir les deux sections intitulées "Chargement des pointeurs" sous le listing 2 et le listing 4). Mais il y a une instruction pour charger des données de la mémoire en un cycle ( lmw), donc j'ai supprimé cette partie de ma réponse.

Max Lybbert
la source
Les lectures et les écritures sont atomiques sur la plupart des processeurs modernes si vos données sont naturellement alignées et de la bonne taille, même avec SMP et l'optimisation des compilateurs. Il y a cependant beaucoup de mises en garde, en particulier avec les machines 64 bits, il peut donc être fastidieux de garantir que vos données répondent aux exigences de chaque machine.
Dan Olson
Merci pour la mise à jour. Correct, les lectures et les écritures sont atomiques car vous déclarez qu'elles ne peuvent pas être à moitié terminées, mais votre commentaire met en évidence la façon dont nous abordons ce fait dans la pratique. Même chose avec les barrières de mémoire, elles n'affectent pas la nature atomique de l'opération, mais comment nous l'abordons dans la pratique.
Dan Olson
4

Si votre langage de programmation ne dit rien sur les threads, mais s'exécute sur une plate-forme multithread, comment une construction de langage peut-elle être thread-safe?

Comme d'autres l'ont souligné: vous devez protéger tout accès multithread aux variables par des appels spécifiques à la plate-forme.

Il existe des bibliothèques qui font abstraction de la spécificité de la plate-forme, et le prochain standard C ++ a adapté son modèle de mémoire pour faire face aux threads (et peut donc garantir la sécurité des threads).

xtofl
la source
4

Même s'il est réduit à une seule instruction d'assemblage, incrémentant la valeur directement en mémoire, il n'est toujours pas thread-safe.

Lors de l'incrémentation d'une valeur en mémoire, le matériel effectue une opération de «lecture-modification-écriture»: il lit la valeur de la mémoire, l'incrémente et la réécrit en mémoire. Le matériel x86 n'a aucun moyen d'incrémenter directement sur la mémoire; la RAM (et les caches) est uniquement capable de lire et de stocker des valeurs, pas de les modifier.

Supposons maintenant que vous ayez deux cœurs séparés, soit sur des sockets séparés, soit en partageant un seul socket (avec ou sans cache partagé). Le premier processeur lit la valeur et avant de pouvoir réécrire la valeur mise à jour, le second processeur la lit. Une fois que les deux processeurs ont réécrit la valeur, elle n'aura été incrémentée qu'une seule fois, pas deux fois.

Il existe un moyen d'éviter ce problème; Les processeurs x86 (et la plupart des processeurs multicœurs que vous trouverez) sont capables de détecter ce type de conflit dans le matériel et de le séquencer, de sorte que toute la séquence de lecture-modification-écriture semble atomique. Cependant, comme cela est très coûteux, cela ne se fait qu'à la demande du code, sur x86 généralement via le LOCKpréfixe. D'autres architectures peuvent le faire par d'autres moyens, avec des résultats similaires; par exemple, les comparatifs et échanges atomiques liés à la charge / au stockage et à l'échange (les processeurs x86 récents ont également ce dernier).

Notez que l'utilisation volatilen'aide pas ici; il indique seulement au compilateur que la variable a peut-être été modifiée en externe et que la lecture de cette variable ne doit pas être mise en cache dans un registre ou optimisée. Cela n'oblige pas le compilateur à utiliser des primitives atomiques.

Le meilleur moyen est d'utiliser des primitives atomiques (si votre compilateur ou vos bibliothèques en ont), ou de faire l'incrémentation directement dans l'assembly (en utilisant les instructions atomiques correctes).

CesarB
la source
2

Ne supposez jamais qu'un incrément se compilera en une opération atomique. Utilisez InterlockedIncrement ou toute autre fonction similaire existant sur votre plate-forme cible.

Edit: Je viens de rechercher cette question spécifique et l'incrémentation sur X86 est atomique sur les systèmes à processeur unique, mais pas sur les systèmes multiprocesseurs. L'utilisation du préfixe de verrouillage peut le rendre atomique, mais c'est beaucoup plus portable simplement d'utiliser InterlockedIncrement.

Dan Olson
la source
1
InterlockedIncrement () est une fonction Windows; toutes mes machines Linux et mes machines OS X modernes sont basées sur x64, donc dire InterlockedIncrement () est «beaucoup plus portable» que le code x86 est plutôt faux.
Pete Kirkham
C'est beaucoup plus portable dans le même sens que C est beaucoup plus portable que l'assemblage. Le but ici est de vous éviter de vous fier à un assemblage généré spécifique pour un processeur spécifique. Si d'autres systèmes d'exploitation sont votre préoccupation, InterlockedIncrement est facilement encapsulé.
Dan Olson
2

Selon cette leçon d'assemblage sur x86, vous pouvez ajouter de manière atomique un registre à un emplacement mémoire , de sorte que votre code peut potentiellement exécuter de manière atomique '++ i' ou 'i ++'. Mais comme dit dans un autre article, le C ansi n'applique pas l'atomicité à l'opération '++', vous ne pouvez donc pas être sûr de ce que votre compilateur va générer.

Selso Liberado
la source
1

Le standard C ++ de 1998 n'a rien à dire sur les threads, bien que le prochain standard (attendu cette année ou la prochaine) le fasse. Par conséquent, vous ne pouvez rien dire d'intelligent sur la sécurité des threads des opérations sans faire référence à l'implémentation. Ce n'est pas seulement le processeur utilisé, mais la combinaison du compilateur, du système d'exploitation et du modèle de thread.

En l'absence de documentation contraire, je ne suppose pas que toute action est thread-safe, en particulier avec les processeurs multicœurs (ou les systèmes multiprocesseurs). Je ne ferais pas non plus confiance aux tests, car les problèmes de synchronisation des threads ne sont susceptibles de survenir que par accident.

Rien n'est thread-safe sauf si vous avez une documentation indiquant que c'est pour le système particulier que vous utilisez.

David Thornley
la source
1

Jetez i dans le stockage local des threads; ce n'est pas atomique, mais cela n'a pas d'importance.


la source
1

AFAIK, selon le standard C ++, les lectures / écritures dans un intsont atomiques.

Cependant, tout ce que cela fait, c'est se débarrasser du comportement indéfini associé à une course aux données.

Mais il y aura toujours une course aux données si les deux threads tentent de s'incrémenter i.

Imaginez le scénario suivant:

Soit d' i = 0abord:

Thread A lit la valeur de la mémoire et stocke dans son propre cache. Le fil A incrémente la valeur de 1.

Le thread B lit la valeur de la mémoire et stocke dans son propre cache. Le fil B incrémente la valeur de 1.

Si tout cela est un seul thread, vous obtiendrez i = 2en mémoire.

Mais avec les deux threads, chaque thread écrit ses modifications et donc Thread A réécrit i = 1en mémoire et Thread B écrit i = 1en mémoire.

C'est bien défini, il n'y a pas de destruction ou de construction partielle ou de déchirement d'un objet, mais c'est toujours une course aux données.

Afin d'incrémenter de manière atomique, ivous pouvez utiliser:

std::atomic<int>::fetch_add(1, std::memory_order_relaxed)

Un ordre assoupli peut être utilisé parce que nous ne nous soucions pas de l'endroit où cette opération a lieu, tout ce qui nous importe, c'est que l'opération d'incrémentation est atomique.

Moshe Rabaev
la source
0

Vous dites "ce n'est qu'une instruction, ce serait ininterrompu par un changement de contexte". - c'est bien beau pour un seul processeur, mais qu'en est-il d'un processeur double cœur? Ensuite, vous pouvez vraiment avoir deux threads accédant à la même variable en même temps sans aucun changement de contexte.

Sans connaître la langue, la réponse est d'en tester le diable.

Chris
la source
4
Vous ne savez pas si quelque chose est thread-safe en le testant - les problèmes de thread peuvent être une occurrence sur un million. Vous le recherchez dans votre documentation. Si votre documentation ne garantit pas threadsafe, ce n'est pas le cas.
Eclipse
2
D'accord avec @Josh ici. Quelque chose n'est thread-safe que s'il peut être prouvé mathématiquement par une analyse du code sous-jacent. Aucune quantité de tests ne peut commencer à approcher cela.
Rex M
C'était une excellente réponse jusqu'à cette dernière phrase.
Rob K
0

Je pense que si l'expression "i ++" est la seule dans une déclaration, elle équivaut à "++ i", le compilateur est assez intelligent pour ne pas garder une valeur temporelle, etc. Donc si vous pouvez les utiliser de manière interchangeable (sinon vous avez gagné ne demandez pas lequel utiliser), peu importe celui que vous utilisez car ils sont presque identiques (sauf pour l'esthétique).

Quoi qu'il en soit, même si l'opérateur d'incrémentation est atomique, cela ne garantit pas que le reste du calcul sera cohérent si vous n'utilisez pas les verrous corrects.

Si vous voulez expérimenter par vous-même, écrivez un programme où N threads incrémentent simultanément une variable partagée M fois chacun ... si la valeur est inférieure à N * M, alors un incrément a été écrasé. Essayez-le avec le pré-incrément et le post-incrément et dites-nous ;-)

fortran
la source
0

Pour un compteur, je recommande d'utiliser l'idiome compare and swap qui est à la fois non verrouillable et thread-safe.

Le voici en Java:

public class IntCompareAndSwap {
    private int value = 0;

    public synchronized int get(){return value;}

    public synchronized int compareAndSwap(int p_expectedValue, int p_newValue){
        int oldValue = value;

        if (oldValue == p_expectedValue)
            value = p_newValue;

        return oldValue;
    }
}

public class IntCASCounter {

    public IntCASCounter(){
        m_value = new IntCompareAndSwap();
    }

    private IntCompareAndSwap m_value;

    public int getValue(){return m_value.get();}

    public void increment(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp != m_value.compareAndSwap(temp, temp + 1));

    }

    public void decrement(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp > 0 && temp != m_value.compareAndSwap(temp, temp - 1));

    }
}
AtariPete
la source
Cela ressemble à une fonction test_and_set.
samoz
1
Vous avez écrit «non verrouillable», mais «synchronisé» ne signifie-t-il pas verrouiller?
Corey Trager