std::atomic
existe porque muchos ISA tienen soporte directo de hardware para ello
Lo que dice el estándar C ++ sobre std::atomic
ha sido analizado en otras respuestas.
Así que ahora veamos qué std::atomic
compila para obtener un tipo diferente de información.
La conclusión principal de este experimento es que las CPU modernas tienen soporte directo para operaciones de números enteros atómicos, por ejemplo, el prefijo LOCK en x86, y std::atomic
básicamente existe como una interfaz portátil para esas instrucciones: ¿Qué significa la instrucción "lock" en el ensamblaje x86? En aarch64, se usaría LDADD .
Este soporte permite alternativas más rápidas a métodos más generales como std::mutex
, por ejemplo , que puede hacer que las secciones de múltiples instrucciones más complejas sean atómicas, a costa de ser más lento que std::atomic
porque std::mutex
hace futex
llamadas al sistema en Linux, que es mucho más lento que las instrucciones de usuario emitidas por std::atomic
, vea también: ¿std :: mutex crea una cerca?
Consideremos el siguiente programa de subprocesos múltiples que incrementa una variable global en varios subprocesos, con diferentes mecanismos de sincronización según la definición de preprocesador utilizada.
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 aguas arriba .
Compilar, ejecutar y desmontar:
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
Salida de condición de carrera extremadamente "incorrecta" para main_fail.out
:
expect 400000
global 100000
y salida determinista "correcta" de los otros:
expect 400000
global 400000
Desmontaje de 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
Desmontaje de 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
Desmontaje de 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
Conclusiones:
la versión no atómica guarda el global en un registro e incrementa el registro.
Por lo tanto, al final, es muy probable que cuatro escrituras vuelvan a ser globales con el mismo valor "incorrecto" 100000
.
std::atomic
compila a lock addq
. El prefijo LOCK realiza la siguiente inc
búsqueda, modificación y actualización de memoria atómicamente.
nuestro prefijo LOCK de ensamblaje en línea explícito se compila casi de la misma manera que std::atomic
, excepto que inc
se usa nuestro en lugar de add
. No estoy seguro de por qué eligió GCC add
, teniendo en cuenta que nuestro INC generó una decodificación de 1 byte más pequeño.
ARMv8 podría usar LDAXR + STLXR o LDADD en las CPU más nuevas: ¿Cómo inicio hilos en C simple?
Probado en Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.
a.fetch_add(12)
si quieres un RMW atómico.