Sur n'importe quelle architecture, certains types de données peuvent être lus de manière atomique et écrits de manière atomique, tandis que d'autres prendront plusieurs cycles d'horloge et peuvent être interrompus au milieu de l'opération, provoquant une corruption si ces données sont partagées entre les threads.
Sur les microcontrôleurs AVR 8 bits (ex: le mcu ATmega328, utilisé par l'Arduino Uno ou Mini), seuls les types de données 8 bits ont des lectures et écritures atomiques. J'ai eu un marathon de débogage de 25 heures en < 2 jours, puis j'ai écrit cette réponse ici.
Sur les microcontrôleurs STM32 (32 bits), tout type de données de 32 bits ou moins est définitivement automatiquement atomique. Cela inclut bool/ _Bool, int8_t/ uint8_t, int16_t/ uint16_t, int32_t/ uint32_t, floatet tous les pointeurs. Les seuls types non atomiques sont int64_t/ uint64_t, double(8 octets) et long double(également 8 octets). J'ai écrit à ce sujet ici:
Maintenant, j'ai besoin de savoir pour mon ordinateur Linux 64 bits. Quels types sont définitivement automatiquement atomiques?
My computer has an x86-64 processor, and Linux Ubuntu OS.
Je suis d'accord avec les en-têtes Linux et les extensions gcc.
Je vois quelques éléments intéressants dans le code source de gcc indiquant qu'au moins le type 32 bits intest atomique. Ex : l'en-tête Gnu++ <bits/atomic_word.h>, qui est stocké /usr/include/x86_64-linux-gnu/c++/8/bits/atomic_word.hsur mon ordinateur et qui est ici en ligne, contient ceci :
typedef int _Atomic_word;
Donc, intest clairement atomique.
Et l'en-tête Gnu++ <bits/types.h>, inclus par <ext/atomicity.h>et stocké /usr/include/x86_64-linux-gnu/bits/types.hsur mon ordinateur, contient ceci :
/* C99: An integer type that can be accessed as an atomic entity,
even in the presence of asynchronous interrupts.
It is not currently necessary for this to be machine-specific. */
typedef int __sig_atomic_t;
Donc, encore une fois, intest clairement atomique.
Voici un exemple de code pour montrer de quoi je parle...
... quand je dis que je veux savoir quels types ont des lectures naturellement atomiques et des écritures naturellement atomiques, mais pas d'incrémentation, de décrémentation ou d'affectation composée atomiques.
volatile bool shared_bool;
volatile uint8_t shared u8;
volatile uint16_t shared_u16;
volatile uint32_t shared_u32;
volatile uint64_t shared_u64;
volatile float shared_f; // 32-bits
volatile double shared_d; // 64-bits
// Task (thread) 1
while (true)
{
// Write to the values in this thread.
//
// What I write to each variable will vary. Since other threads are reading
// these values, I need to ensure my *writes* are atomic, or else I must
// use a mutex to prevent another thread from reading a variable in the
// middle of this thread's writing.
shared_bool = true;
shared_u8 = 129;
shared_u16 = 10108;
shared_u32 = 130890;
shared_f = 1083.108;
shared_d = 382.10830;
}
// Task (thread) 2
while (true)
{
// Read from the values in this thread.
//
// What thread 1 writes into these values can change at any time, so I need
// to ensure my *reads* are atomic, or else I'll need to use a mutex to
// prevent the other thread from writing to a variable in the midst of
// reading it in this thread.
if (shared_bool == whatever)
{
// do something
}
if (shared_u8 == whatever)
{
// do something
}
if (shared_u16 == whatever)
{
// do something
}
if (shared_u32 == whatever)
{
// do something
}
if (shared_u64 == whatever)
{
// do something
}
if (shared_f == whatever)
{
// do something
}
if (shared_d == whatever)
{
// do something
}
}
Types C et types _AtomicC++std::atomic<>
Je sais que C11 et les versions ultérieures proposent des _Atomictypes, tels que celui-ci :
const _Atomic int32_t i;
// or (same thing)
const atomic_int_least32_t i;
Vois ici:
Et C++11 et versions ultérieures proposent des std::atomic<>types, tels que celui-ci :
const std::atomic<int32_t> i;
// or (same thing)
const atomic_int32_t i;
Vois ici:
And these C11 and C++11 "atomic" types offer atomic reads and atomic writes as well as atomic increment operator, decrement operator, and compound assignment...
... mais ce n'est pas vraiment ce dont je parle.
Je veux savoir quels types ont des lectures naturellement atomiques et des écritures naturellement atomiques uniquement. Pour ce dont je parle, l'incrémentation, la décrémentation et l'affectation composée ne seront pas naturellement atomiques.
Solution du problème
La réponse du point de vue du langage standard est très simple: aucun d'entre eux n'est « définitivement automatiquement » atomique.
Tout d'abord, il est important de faire la distinction entre deux sens de "atomique".
L'un est atomique quant aux signaux. Cela garantit, par exemple, que lorsque vous faites
x = 5sur unsig_atomic_t, alors un gestionnaire de signal invoqué dans le thread actuel verra l'ancienne ou la nouvelle valeur. Ceci est généralement accompli simplement en effectuant l'accès en une seule instruction, car les signaux ne peuvent être déclenchés que par des interruptions matérielles, qui ne peuvent arriver qu'entre les instructions. Par exemple, x86add dword ptr [var], 12345, même sanslockpréfixe, est atomique dans ce sens.L'autre est atomique par rapport aux threads, de sorte qu'un autre thread accédant simultanément à l'objet verra une valeur correcte. C'est plus difficile à faire correctement. En particulier, les variables ordinaires de type ne
sig_atomic_tsont pas atomiques par rapport aux threads. Vous avez besoin de_Atomicoustd::atomicpour l'obtenir.
Notez bien que les noms internes que votre implémentation choisit pour ses types ne prouvent rien. Je n'en déduirais typedef int _Atomic_word;certainement pas que « intest clairement atomique »; Je ne sais pas dans quel sens les implémenteurs utilisaient le mot "atomique", ou s'il est exact (pourrait être utilisé par du code hérité, par exemple). S'ils voulaient faire une telle promesse, ce serait dans la documentation, pas dans un inexpliqué typedefdans un en- bitstête qui n'est jamais destiné à être vu par le programmeur de l'application.
Le fait que votre matériel puisse rendre certains types d'accès "automatiquement atomiques" ne vous dit rien au niveau de C/C++. Par exemple, il est vrai sur x86 que les chargements et les stockages ordinaires en taille réelle vers des variables naturellement alignées sont atomiques. Mais en l'absence de std::atomic, le compilateur n'est pas obligé d' émettre des chargements et des mémoires pleine grandeur ordinaires ; il a le droit d'être intelligent et d'accéder à ces variables par d'autres moyens. Il "sait" que ce ne sera pas un problème, car l'accès simultané serait une course aux données, et bien sûr le programmeur n'écrirait jamais de code avec une course aux données, n'est-ce pas ?
Comme exemple concret, considérons le code suivant :
unsigned x;
unsigned foo(void) {
return (x >> 8) & 0xffff;
}
Une charge d'une belle variable entière 32 bits, suivie d'un peu d'arithmétique. Quoi de plus innocent? Regardez encore l'assembly émis par GCC 11.2 -O2 try on godbolt:
foo:
movzx eax, WORD PTR x[rip+1]
ret
Oh cher. Une charge partielle, et non alignée pour démarrer. AFAIK x86 ne fournit aucune promesse d'atomicité concernant les charges non alignées.
Voici un autre exemple amusant, cette fois sur ARM64. Les magasins 64 bits alignés sont atomiques, conformément à B2.2.1 du manuel de référence de l'architecture ARMv8-A. Donc ça a l'air bien:
unsigned long x;
void bar(void) {
x = 0xdeadbeefdeadbeef;
}
Mais, GCC 11.2 -O2 donne ( godbolt ):
bar:
adrp x1,.LANCHOR0
add x2, x1,:lo12:.LANCHOR0
mov w0, 48879
movk w0, 0xdead, lsl 16
str w0, [x1, #:lo12:.LANCHOR0]
str w0, [x2, 4]
ret
C'est deux 32 bits str, pas atomiques en aucune façon. Un lecteur peut très bien lire 0x00000000deadbeef.
Pourquoi le faire de cette façon? Matérialiser une constante 64 bits dans un registre prend plusieurs instructions sur ARM64, avec sa taille d'instruction fixe. Mais les deux moitiés de la valeur sont égales, alors pourquoi ne pas matérialiser la valeur 32 bits et la stocker dans chaque moitié ?
(Si vous le faites unsigned long *p; *p = 0xdeadbeefdeadbeef, vous obtenez stp w1, w1, [x0]( godbolt ). Ce qui semble plus prometteur car il s'agit d'une seule instruction, mais en fait, il s'agit toujours de deux écritures distinctes à des fins d'atomicité entre les threads.)
Il n'est vraiment pas sûr de supposer quoi que ce soit au-delà de ce que les langues garantissent réellement, ce qui n'est rien à moins que vous n'utilisiez std::atomic. Les compilateurs modernes connaissent très bien les limites exactes des règles du langage et optimisent de manière agressive. Ils peuvent et vont casser le code qui suppose qu'ils feront ce qui serait "naturel", si cela est en dehors des limites de ce que le langage promet, et ils le feront très souvent d'une manière à laquelle on ne s'attendrait jamais.
Aucun commentaire:
Enregistrer un commentaire