std::atomic 到底是什么?
What exactly is std::atomic?
我知道 std::atomic<>
是一个原子对象。但原子性到什么程度呢?据我了解,操作可以是原子的。使对象原子化究竟意味着什么?例如如果有两个线程同时执行下面的代码:
a = a + 12;
那么整个操作(比如说add_twelve_to(int)
)是原子的吗?还是对变量 atomic 进行了更改(因此 operator=()
)?
I understand that std::atomic<>
makes an object atomic.
这是一个视角问题...您不能将它应用于任意对象并使它们的操作成为原子操作,但可以使用为(大多数)整数类型和指针提供的特化。
a = a + 12;
std::atomic<>
不会(使用模板表达式)将其简化为单个原子操作,而是 operator T() const volatile noexcept
成员执行 a
的原子 load()
,然后添加十二,operator=(T t) noexcept
执行 store(t)
.
std::atomic<> 的每个实例化和完全特化表示不同线程可以同时操作的类型(它们的实例),而不会引发未定义的行为:
Objects of atomic types are the only C++ objects that are free from data races; that is, if one thread writes to an atomic object while another thread reads from it, the behavior is well-defined.
In addition, accesses to atomic objects may establish inter-thread synchronization and order non-atomic memory accesses as specified by std::memory_order
.
std::atomic<>
包装在 C++ 之前的 11 次操作,必须使用(例如)interlocked functions with MSVC or atomic bultins 在 GCC 的情况下执行。
此外,std::atomic<>
通过允许指定同步和排序约束的各种 memory orders 为您提供更多控制。如果您想阅读更多有关 C++ 11 原子和内存模型的信息,这些链接可能会有用:
- C++ atomics and memory ordering
- Comparison: Lockless programming with atomics in C++ 11 vs. mutex and RW-locks
- C++11 introduced a standardized memory model. What does it mean? And how is it going to affect C++ programming?
- Concurrency in C++11
请注意,对于典型用例,您可能会使用 overloaded arithmetic operators or another set of them:
std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this
因为运算符语法不允许指定内存顺序,这些操作将以std::memory_order_seq_cst
执行,因为这是C++ 11中所有原子操作的默认顺序。它保证了顺序一致性(总计全局排序)在所有原子操作之间。
然而,在某些情况下,这可能不是必需的(而且没有免费的东西),因此您可能希望使用更明确的形式:
std::atomic<long> value {0};
value.fetch_add(1, std::memory_order_relaxed); // Atomic, but there are no synchronization or ordering constraints
value.fetch_add(5, std::memory_order_release); // Atomic, performs 'release' operation
现在,你的例子:
a = a + 12;
不会评估为单个原子操作:它将导致 a.load()
(它本身是原子的),然后在该值与 12
和 a.store()
(也是原子的)之间添加) 的最终结果。如前所述,此处将使用 std::memory_order_seq_cst
。
然而,如果你写 a += 12
,它将是一个原子操作(正如我之前提到的)并且大致等同于 a.fetch_add(12, std::memory_order_seq_cst)
。
关于您的评论:
A regular int
has atomic loads and stores. Whats the point of wrapping it with atomic<>
?
您的陈述仅适用于为存储 and/or 负载提供这种原子性保证的架构。有些架构不这样做。此外,通常要求操作必须在 word-/dword-aligned 地址上执行才能是原子的 std::atomic<>
是保证在 每个 平台上都是原子的,无需额外要求。此外,它允许您编写如下代码:
void* sharedData = nullptr;
std::atomic<int> ready_flag = 0;
// Thread 1
void produce()
{
sharedData = generateData();
ready_flag.store(1, std::memory_order_release);
}
// Thread 2
void consume()
{
while (ready_flag.load(std::memory_order_acquire) == 0)
{
std::this_thread::yield();
}
assert(sharedData != nullptr); // will never trigger
processData(sharedData);
}
请注意,断言条件将始终为真(因此永远不会触发),因此您始终可以确保在 while
循环退出后数据已准备就绪。那是因为:
store()
标志是在设置 sharedData
之后执行的(我们假设 generateData()
总是 return 有用的东西,特别是从不 returns NULL
) 并使用 std::memory_order_release
命令:
memory_order_release
A store operation with this memory order performs the release
operation: no reads or writes in the current thread can be reordered
after this store. All writes in the current thread are visible in
other threads that acquire the same atomic variable
sharedData
在 while
循环退出后使用,因此在 load()
from flag 之后将 return 一个非零值。 load()
使用 std::memory_order_acquire
顺序:
std::memory_order_acquire
A load operation with this memory order performs the acquire operation
on the affected memory location: no reads or writes in the current
thread can be reordered before this load. All writes in other threads
that release the same atomic variable are visible in the current
thread.
这使您可以精确控制同步,并允许您明确指定代码 may/may not/will/will 的行为方式。如果只保证原子性本身,这是不可能的。特别是涉及到非常有趣的同步模型时,例如 release-consume ordering.
std::atomic
存在是因为许多 ISA 对它有直接的硬件支持
C++ 标准关于 std::atomic
的内容已在其他答案中进行了分析。
现在让我们看看 std::atomic
编译成什么以获得不同的见解。
这个实验的主要收获是现代 CPU 直接支持原子整数操作,例如 x86 中的 LOCK 前缀,并且 std::atomic
基本上作为这些指令的可移植接口存在:What does the "lock" instruction mean in x86 assembly? In aarch64, LDADD 将被使用。
这种支持允许更快地替代更通用的方法,例如 std::mutex
,它可以使更复杂的多指令部分原子化,但代价是比 std::atomic
慢,因为 std::mutex
它在 Linux 中进行 futex
系统调用,这比 std::atomic
发出的用户态指令慢得多,另请参阅:Does std::mutex create a fence?
让我们考虑以下多线程程序,它在多个线程中递增一个全局变量,根据使用的预处理器定义使用不同的同步机制。
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;
}
编译,运行反汇编:
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 [=14=]x1,%rax
0x00000000000027a4 <+36>: add [=14=]x1,%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 [=15=]x0,0x29b4(%rip) # 0x5140 <niters>
0x000000000000278c <+12>: je 0x27a6 <threadMain()+38>
0x000000000000278e <+14>: xor %eax,%eax
0x0000000000002790 <+16>: lock addq [=15=]x1,0x299f(%rip) # 0x5138 <global>
0x0000000000002799 <+25>: add [=15=]x1,%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 [=16=]x0,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 [=16=]x1,%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:How do I start threads in plain C?
在 Ubuntu 19.10 AMD64、GCC 9.2.1、Lenovo ThinkPad P51 中测试。
我知道 std::atomic<>
是一个原子对象。但原子性到什么程度呢?据我了解,操作可以是原子的。使对象原子化究竟意味着什么?例如如果有两个线程同时执行下面的代码:
a = a + 12;
那么整个操作(比如说add_twelve_to(int)
)是原子的吗?还是对变量 atomic 进行了更改(因此 operator=()
)?
I understand that
std::atomic<>
makes an object atomic.
这是一个视角问题...您不能将它应用于任意对象并使它们的操作成为原子操作,但可以使用为(大多数)整数类型和指针提供的特化。
a = a + 12;
std::atomic<>
不会(使用模板表达式)将其简化为单个原子操作,而是 operator T() const volatile noexcept
成员执行 a
的原子 load()
,然后添加十二,operator=(T t) noexcept
执行 store(t)
.
std::atomic<> 的每个实例化和完全特化表示不同线程可以同时操作的类型(它们的实例),而不会引发未定义的行为:
Objects of atomic types are the only C++ objects that are free from data races; that is, if one thread writes to an atomic object while another thread reads from it, the behavior is well-defined.
In addition, accesses to atomic objects may establish inter-thread synchronization and order non-atomic memory accesses as specified by
std::memory_order
.
std::atomic<>
包装在 C++ 之前的 11 次操作,必须使用(例如)interlocked functions with MSVC or atomic bultins 在 GCC 的情况下执行。
此外,std::atomic<>
通过允许指定同步和排序约束的各种 memory orders 为您提供更多控制。如果您想阅读更多有关 C++ 11 原子和内存模型的信息,这些链接可能会有用:
- C++ atomics and memory ordering
- Comparison: Lockless programming with atomics in C++ 11 vs. mutex and RW-locks
- C++11 introduced a standardized memory model. What does it mean? And how is it going to affect C++ programming?
- Concurrency in C++11
请注意,对于典型用例,您可能会使用 overloaded arithmetic operators or another set of them:
std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this
因为运算符语法不允许指定内存顺序,这些操作将以std::memory_order_seq_cst
执行,因为这是C++ 11中所有原子操作的默认顺序。它保证了顺序一致性(总计全局排序)在所有原子操作之间。
然而,在某些情况下,这可能不是必需的(而且没有免费的东西),因此您可能希望使用更明确的形式:
std::atomic<long> value {0};
value.fetch_add(1, std::memory_order_relaxed); // Atomic, but there are no synchronization or ordering constraints
value.fetch_add(5, std::memory_order_release); // Atomic, performs 'release' operation
现在,你的例子:
a = a + 12;
不会评估为单个原子操作:它将导致 a.load()
(它本身是原子的),然后在该值与 12
和 a.store()
(也是原子的)之间添加) 的最终结果。如前所述,此处将使用 std::memory_order_seq_cst
。
然而,如果你写 a += 12
,它将是一个原子操作(正如我之前提到的)并且大致等同于 a.fetch_add(12, std::memory_order_seq_cst)
。
关于您的评论:
A regular
int
has atomic loads and stores. Whats the point of wrapping it withatomic<>
?
您的陈述仅适用于为存储 and/or 负载提供这种原子性保证的架构。有些架构不这样做。此外,通常要求操作必须在 word-/dword-aligned 地址上执行才能是原子的 std::atomic<>
是保证在 每个 平台上都是原子的,无需额外要求。此外,它允许您编写如下代码:
void* sharedData = nullptr;
std::atomic<int> ready_flag = 0;
// Thread 1
void produce()
{
sharedData = generateData();
ready_flag.store(1, std::memory_order_release);
}
// Thread 2
void consume()
{
while (ready_flag.load(std::memory_order_acquire) == 0)
{
std::this_thread::yield();
}
assert(sharedData != nullptr); // will never trigger
processData(sharedData);
}
请注意,断言条件将始终为真(因此永远不会触发),因此您始终可以确保在 while
循环退出后数据已准备就绪。那是因为:
store()
标志是在设置sharedData
之后执行的(我们假设generateData()
总是 return 有用的东西,特别是从不 returnsNULL
) 并使用std::memory_order_release
命令:
memory_order_release
A store operation with this memory order performs the release operation: no reads or writes in the current thread can be reordered after this store. All writes in the current thread are visible in other threads that acquire the same atomic variable
sharedData
在while
循环退出后使用,因此在load()
from flag 之后将 return 一个非零值。load()
使用std::memory_order_acquire
顺序:
std::memory_order_acquire
A load operation with this memory order performs the acquire operation on the affected memory location: no reads or writes in the current thread can be reordered before this load. All writes in other threads that release the same atomic variable are visible in the current thread.
这使您可以精确控制同步,并允许您明确指定代码 may/may not/will/will 的行为方式。如果只保证原子性本身,这是不可能的。特别是涉及到非常有趣的同步模型时,例如 release-consume ordering.
std::atomic
存在是因为许多 ISA 对它有直接的硬件支持
C++ 标准关于 std::atomic
的内容已在其他答案中进行了分析。
现在让我们看看 std::atomic
编译成什么以获得不同的见解。
这个实验的主要收获是现代 CPU 直接支持原子整数操作,例如 x86 中的 LOCK 前缀,并且 std::atomic
基本上作为这些指令的可移植接口存在:What does the "lock" instruction mean in x86 assembly? In aarch64, LDADD 将被使用。
这种支持允许更快地替代更通用的方法,例如 std::mutex
,它可以使更复杂的多指令部分原子化,但代价是比 std::atomic
慢,因为 std::mutex
它在 Linux 中进行 futex
系统调用,这比 std::atomic
发出的用户态指令慢得多,另请参阅:Does std::mutex create a fence?
让我们考虑以下多线程程序,它在多个线程中递增一个全局变量,根据使用的预处理器定义使用不同的同步机制。
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;
}
编译,运行反汇编:
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 [=14=]x1,%rax
0x00000000000027a4 <+36>: add [=14=]x1,%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 [=15=]x0,0x29b4(%rip) # 0x5140 <niters>
0x000000000000278c <+12>: je 0x27a6 <threadMain()+38>
0x000000000000278e <+14>: xor %eax,%eax
0x0000000000002790 <+16>: lock addq [=15=]x1,0x299f(%rip) # 0x5138 <global>
0x0000000000002799 <+25>: add [=15=]x1,%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 [=16=]x0,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 [=16=]x1,%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:How do I start threads in plain C?
在 Ubuntu 19.10 AMD64、GCC 9.2.1、Lenovo ThinkPad P51 中测试。