J'ai vu un assemblage x86 dans la source de Qt:
q_atomic_increment:
movl 4(%esp), %ecx
lock
incl (%ecx)
mov $0,%eax
setne %al
ret
.align 4,0x90
.type q_atomic_increment,@function
.size q_atomic_increment,.-q_atomic_increment
Depuis Google, je savais que l'instruction lock
entraînerait le verrouillage du bus par le CPU, mais je ne sais pas quand le CPU libère le bus?
À propos de tout le code ci-dessus, je ne comprends pas comment ce code implémente le Add
?
LOCK
n'est pas une instruction elle-même: c'est un préfixe d'instruction, qui s'applique à l'instruction suivante. Cette instruction doit être quelque chose qui fait une lecture-modification-écriture sur la mémoire (INC
, XCHG
, CMPXCHG
etc.) --- dans ce cas, c'est le incl (%ecx)
instruction qui inc
rements le mot l
ong à l'adresse contenue dans le registre ecx
.
Le préfixe LOCK
garantit que le CPU possède la propriété exclusive de la ligne de cache appropriée pendant la durée de l'opération et fournit certaines garanties de commande supplémentaires. Cela peut être réalisé en affirmant un verrou de bus, mais le CPU évitera cela dans la mesure du possible. Si le bus est verrouillé, ce n'est que pour la durée de l'instruction verrouillée.
Ce code copie l'adresse de la variable à incrémenter hors de la pile dans le registre ecx
, puis il fait lock incl (%ecx)
pour incrémenter atomiquement cette variable de 1. Les deux instructions suivantes définissent le eax
registre (qui contient la valeur de retour de la fonction) à 0 si la nouvelle valeur de la variable est 0, et 1 sinon. L'opération est un incrément, pas un ajout (d'où le nom).
Ce que vous ne comprenez peut-être pas, c'est que le microcode requis pour incrémenter une valeur nécessite que nous lisions d'abord l'ancienne valeur.
Le mot-clé Lock oblige les multiples micro-instructions qui se produisent réellement à apparaître de manière atomique.
Si vous aviez 2 threads chacun essayant d'incrémenter la même variable, et qu'ils lisent tous les deux la même valeur d'origine en même temps, alors ils incrémentent tous les deux à la même valeur, et ils écrivent tous les deux la même valeur.
Au lieu d'avoir la variable incrémentée deux fois, ce qui est l'attente typique, vous finissez par incrémenter la variable une fois.
Le mot clé de verrouillage empêche cela de se produire.
De Google, je savais que les instructions de verrouillage entraîneraient le verrouillage du bus par le processeur, mais je ne sais pas quand le processeur libère le bus?
LOCK
est un préfixe d'instruction, donc il ne s'applique qu'à l'instruction suivante, la source n'est pas très claire ici mais la vraie instruction est LOCK INC
. Donc, le bus est verrouillé pour l'incrément, puis déverrouillé
À propos de tout le code ci-dessus, je ne comprends pas comment ces codes ont implémenté l'ajout?
Ils n'implémentent pas d'ajout, ils implémentent un incrément, ainsi qu'une indication de retour si l'ancienne valeur était 0. Un ajout utiliserait LOCK XADD
(cependant, les fenêtres InterlockedIncrement/Decrement sont également implémentées avec LOCK XADD
).
Threads C++ exécutables minimaux + exemple d'assemblage en ligne LOCK
main.cpp
#include <atomic>
#include <cassert>
#include <iostream>
#include <thread>
#include <vector>
std::atomic_ulong my_atomic_ulong(0);
unsigned long my_non_atomic_ulong = 0;
unsigned long my_Arch_atomic_ulong = 0;
unsigned long my_Arch_non_atomic_ulong = 0;
size_t niters;
void threadMain() {
for (size_t i = 0; i < niters; ++i) {
my_atomic_ulong++;
my_non_atomic_ulong++;
__asm__ __volatile__ (
"incq %0;"
: "+m" (my_Arch_non_atomic_ulong)
:
:
);
__asm__ __volatile__ (
"lock;"
"incq %0;"
: "+m" (my_Arch_atomic_ulong)
:
:
);
}
}
int main(int argc, char **argv) {
size_t nthreads;
if (argc > 1) {
nthreads = std::stoull(argv[1], NULL, 0);
} else {
nthreads = 2;
}
if (argc > 2) {
niters = std::stoull(argv[2], NULL, 0);
} else {
niters = 10000;
}
std::vector<std::thread> threads(nthreads);
for (size_t i = 0; i < nthreads; ++i)
threads[i] = std::thread(threadMain);
for (size_t i = 0; i < nthreads; ++i)
threads[i].join();
assert(my_atomic_ulong.load() == nthreads * niters);
assert(my_atomic_ulong == my_atomic_ulong.load());
std::cout << "my_non_atomic_ulong " << my_non_atomic_ulong << std::endl;
assert(my_Arch_atomic_ulong == nthreads * niters);
std::cout << "my_Arch_non_atomic_ulong " << my_Arch_non_atomic_ulong << std::endl;
}
Compiler et exécuter:
g++ -ggdb3 -O0 -std=c++11 -Wall -Wextra -pedantic -o main.out main.cpp -pthread
./main.out 2 10000
Sortie possible:
my_non_atomic_ulong 15264
my_Arch_non_atomic_ulong 15267
De cela, nous voyons que le préfixe LOCK a rendu l'addition atomique: sans lui, nous avons des conditions de concurrence sur de nombreux ajouts, et le nombre total à la fin est inférieur au 20000 synchronisé.
Voir aussi: A quoi ressemble le langage d'assemblage multicœur?
Testé dans Ubuntu 19.04 AMD64.