std::atomic
之所以存在,是因为许多ISA对它都有直接的硬件支持
C ++标准所说的内容std::atomic
已在其他答案中进行了分析。
因此,现在让我们看看要std::atomic
编译什么以获得另一种见解。
此实验的主要成果是现代CPU直接支持原子整数操作,例如x86中的LOCK前缀,并且std::atomic
基本上作为这些指令的可移植接口存在:x86汇编中的“ lock”指令是什么意思?在aarch64中,LDADD将使用。
这种支持允许更快地替代更通用的方法,例如std::mutex
,它可以使更复杂的多指令节成为原子,但代价是要慢一些,std::atomic
因为std::mutex
它使futex
Linux中的系统调用速度比,发出的userland指令慢得多std::atomic
。另请参见:std :: mutex是否会创建围栏?
让我们考虑以下多线程程序,该程序在多个线程之间增加全局变量,并根据使用的预处理器定义使用不同的同步机制。
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上游。
编译,运行和反汇编:
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
极有可能出现以下“错误”竞争条件输出main_fail.out
:
expect 400000
global 100000
确定性的其他“正确”输出:
expect 400000
global 400000
拆卸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
拆卸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
拆卸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
结论:
非原子版本将全局变量保存到寄存器,并递增寄存器。
因此,最后,很有可能发生四次以相同的“错误”值回到全局的写操作100000
。
std::atomic
编译为lock addq
。LOCK前缀使以下inc
原子获取,修改和更新内存成为可能。
我们的显式内联程序的LOCK前缀可以编译为与几乎相同的东西std::atomic
,除了inc
使用代替add
。不确定为什么GCC选择了add
考虑到我们的INC产生的解码小了1个字节,。
ARMv8可以在较新的CPU中使用LDAXR + STLXR或LDADD: 如何在普通C语言中启动线程?
在Ubuntu 19.10 AMD64,GCC 9.2.1,Lenovo ThinkPad P51中进行了测试。
a.fetch_add(12)
原子RMW,则需要使用类似的东西。