std::atomic
existiert, weil viele ISAs direkte Hardwareunterstützung dafür haben
Was der C ++ - Standard sagt, std::atomic
wurde in anderen Antworten analysiert.
Nun wollen wir sehen, was std::atomic
kompiliert wird, um einen anderen Einblick zu erhalten.
Die wichtigste Erkenntnis aus diesem Experiment ist, dass moderne CPUs direkte Unterstützung für atomare Ganzzahloperationen, beispielsweise das LOCK-Präfix in x86, haben und im std::atomic
Grunde genommen als tragbare Schnittstelle zu diesen Anweisungen existieren: Was bedeutet der Befehl "lock" in der x86-Assembly? In aarch64 würde LDADD verwendet.
Diese Unterstützung ermöglicht eine schnelleren Alternativen zu allgemeineren Methoden wie std::mutex
, die komplexen Multi-Instruktions Abschnitte Atom machen kann, auf Kosten des Seins langsamer als , std::atomic
weil std::mutex
es macht futex
Systemaufrufe in Linux, die Art und Weise langsamer als die Userland - Anweisungen ist emittieren durch std::atomic
, siehe auch: Erstellt std :: mutex einen Zaun?
Betrachten wir das folgende Multithread-Programm, das eine globale Variable über mehrere Threads mit unterschiedlichen Synchronisationsmechanismen inkrementiert, je nachdem, welche Präprozessordefinition verwendet wird.
main.cpp
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
size_t niters;
#if STD_ATOMIC
std::atomic_ulong global(0);
#else
uint64_t global = 0;
#endif
void threadMain() {
for (size_t i = 0; i < niters; ++i) {
#if LOCK
__asm__ __volatile__ (
"lock incq %0;"
: "+m" (global),
"+g" (i) // to prevent loop unrolling
:
:
);
#else
__asm__ __volatile__ (
""
: "+g" (i) // to prevent he loop from being optimized to a single add
: "g" (global)
:
);
global++;
#endif
}
}
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 = 10;
}
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();
uint64_t expect = nthreads * niters;
std::cout << "expect " << expect << std::endl;
std::cout << "global " << global << std::endl;
}
GitHub stromaufwärts .
Kompilieren, ausführen und zerlegen:
comon="-ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic main.cpp -pthread"
g++ -o main_fail.out $common
g++ -o main_std_atomic.out -DSTD_ATOMIC $common
g++ -o main_lock.out -DLOCK $common
./main_fail.out 4 100000
./main_std_atomic.out 4 100000
./main_lock.out 4 100000
gdb -batch -ex "disassemble threadMain" main_fail.out
gdb -batch -ex "disassemble threadMain" main_std_atomic.out
gdb -batch -ex "disassemble threadMain" main_lock.out
Sehr wahrscheinlich "falsche" Ausgabe der Rennbedingungen für main_fail.out
:
expect 400000
global 100000
und deterministische "richtige" Ausgabe der anderen:
expect 400000
global 400000
Demontage von main_fail.out
:
0x0000000000002780 <+0>: endbr64
0x0000000000002784 <+4>: mov 0x29b5(%rip),%rcx # 0x5140 <niters>
0x000000000000278b <+11>: test %rcx,%rcx
0x000000000000278e <+14>: je 0x27b4 <threadMain()+52>
0x0000000000002790 <+16>: mov 0x29a1(%rip),%rdx # 0x5138 <global>
0x0000000000002797 <+23>: xor %eax,%eax
0x0000000000002799 <+25>: nopl 0x0(%rax)
0x00000000000027a0 <+32>: add $0x1,%rax
0x00000000000027a4 <+36>: add $0x1,%rdx
0x00000000000027a8 <+40>: cmp %rcx,%rax
0x00000000000027ab <+43>: jb 0x27a0 <threadMain()+32>
0x00000000000027ad <+45>: mov %rdx,0x2984(%rip) # 0x5138 <global>
0x00000000000027b4 <+52>: retq
Demontage von main_std_atomic.out
:
0x0000000000002780 <+0>: endbr64
0x0000000000002784 <+4>: cmpq $0x0,0x29b4(%rip) # 0x5140 <niters>
0x000000000000278c <+12>: je 0x27a6 <threadMain()+38>
0x000000000000278e <+14>: xor %eax,%eax
0x0000000000002790 <+16>: lock addq $0x1,0x299f(%rip) # 0x5138 <global>
0x0000000000002799 <+25>: add $0x1,%rax
0x000000000000279d <+29>: cmp %rax,0x299c(%rip) # 0x5140 <niters>
0x00000000000027a4 <+36>: ja 0x2790 <threadMain()+16>
0x00000000000027a6 <+38>: retq
Demontage von main_lock.out
:
Dump of assembler code for function threadMain():
0x0000000000002780 <+0>: endbr64
0x0000000000002784 <+4>: cmpq $0x0,0x29b4(%rip) # 0x5140 <niters>
0x000000000000278c <+12>: je 0x27a5 <threadMain()+37>
0x000000000000278e <+14>: xor %eax,%eax
0x0000000000002790 <+16>: lock incq 0x29a0(%rip) # 0x5138 <global>
0x0000000000002798 <+24>: add $0x1,%rax
0x000000000000279c <+28>: cmp %rax,0x299d(%rip) # 0x5140 <niters>
0x00000000000027a3 <+35>: ja 0x2790 <threadMain()+16>
0x00000000000027a5 <+37>: retq
Schlussfolgerungen:
Die nichtatomare Version speichert die globale Version in einem Register und erhöht das Register.
Daher werden am Ende sehr wahrscheinlich vier Schreibvorgänge mit demselben "falschen" Wert von auf global zurückgeführt 100000
.
std::atomic
kompiliert zu lock addq
. Das LOCK-Präfix bewirkt, dass der folgende inc
Speicher atomar abgerufen, geändert und aktualisiert wird.
Unser explizites Inline-Assembly-LOCK-Präfix wird fast genauso kompiliert wie std::atomic
, außer dass unser inc
anstelle verwendet wird add
. Ich bin mir nicht sicher, warum GCC sich entschieden hat add
, wenn man bedenkt, dass unser INC eine um 1 Byte kleinere Decodierung generiert hat.
ARMv8 kann in neueren CPUs entweder LDAXR + STLXR oder LDADD verwenden: Wie starte ich Threads in normalem C?
Getestet in Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.
a.fetch_add(12)
wenn Sie ein atomares RMW wollen.