在理论上,你可以拥有最有效率的代码,因为没有比必要更多的同步。
但是,在实践中,几乎没有CPU提供完全对应获取/释放内存顺序的指令(也许未来
ARMv8.3-A会提供)。所以你必须检查每个目标生成的代码。
例如,在x86_64上,
fetch_sub(std::memory_order_acq_rel)
和
fetch_sub(std::memory_order_release)
将会产生完全相同的指令。
因此,虽然在理论上你的代码看起来最优,但在实践中,你得到的代码不如选择更简单的方法优化。
std::atomic<int> cnt;
int* p;
void optimal_in_therory() {
if (cnt.fetch_sub(1, std::memory_order_release) == 1) {
cnt.load(std::memory_order_acquire);
delete p;
}
}
void optimal_in_practice_on_x86_64() {
if (cnt.fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete p;
}
}
汇编语言:
optimal_in_therory():
lock sub DWORD PTR cnt[rip], 1
je .L4
rep ret
.L4:
mov eax, DWORD PTR cnt[rip]
mov rdi, QWORD PTR p[rip]
mov esi, 4
jmp operator delete(void*, unsigned long)
optimal_in_practice_on_x86_64():
lock sub DWORD PTR cnt[rip], 1
je .L7
rep ret
.L7:
mov rdi, QWORD PTR p[rip]
mov esi, 4
jmp operator delete(void*, unsigned long)
有一天我会住在理论里,因为在理论中一切都很顺利 -皮埃尔·德斯普罗热
为什么编译器要保留这个额外的负载?
根据标准,优化器可以省略在非易失性原子上执行的冗余加载。例如,如果您的代码中添加了三个额外的负载:
cnt.load(std::memory_order_acquire);
cnt.load(std::memory_order_acquire);
cnt.load(std::memory_order_acquire);
使用GCC或Clang,这三个加载项将出现在汇编中:
mov eax, DWORD PTR cnt[rip]
mov eax, DWORD PTR cnt[rip]
mov eax, DWORD PTR cnt[rip]
这是一个非常糟糕的性能优化。我认为它保存原样是因为"波动性"和"原子性"之间的历史混淆。虽然几乎所有程序员都知道volatile没有原子变量的属性,但仍有许多代码是按照原子具有volatile属性的想法编写的:"原子访问是可观察行为"。 根据标准,它不是(标准中有关此事实的显式
示例注释)。这是SO上经常出现的问题。
因此,从理论上讲,您的代码是最优代码,但编译器会将代码优化为如果原子也是volatile一样。解决方法是用Kieth在其评论中提出的atomic_thread_fence替换load。我不是硬件专家,但我想象这样的障碍可能会引起比必要更多的内存"synchronization"(或者至少在理论上是这样的;))。
为什么我认为您的代码在理论上是最优的?
一个单一对象的最后一个shared_ptr必须在不引起数据竞争的情况下调用该对象的析构函数。析构函数可能访问对象的值,因此析构函数调用必须发生在指向对象的指针“失效”之后。
因此,delete p; 必须在共享同一指向对象的所有其他 shared_ptr 的析构函数调用之后“发生”。
在标准中,“happens before”由以下段落定义:
[intro.races]/9:
如果一个评估A与另一个评估B存在跨线程发生关系,则A在B之前发生,如果:
[intro.races]/10:
如果一个评估A在评估B之前发生(或者等价地,B在A之后发生),则需要以下条件之一:
因此,在排在
delete p
之前的
fetch_sub
和其他
fetch_sub
之间必须存在“同步与”关系。
根据
[atomics.order]/2:
如果原子操作A对原子对象M执行释放操作,则执行从A开始的释放序列中的任何副作用的原子操作B与执行从M执行获取操作并获取其值的原子操作B同步。
因此,
delete p
必须在执行一个获取操作之后排序,该操作加载一个在所有其他
fetch_sub
的释放序列中的值。
根据
[expr.races]/5,在cnt的修改顺序中,最后一个fetch_sub将属于所有其他release fetch_sub的release sequence,因为fetch_sub是一个read-modify-write操作,就像fetch_add一样(假设没有其他操作发生在cnt上)。
所以delete p将发生在所有其他fetch_sub之后,只有在调用delete p之前才会产生“同步”。恰好不超过必要的范围。
atomic_thread_fence
而不是调用p->cnt.load(std::memory_order_acquire);
来强制排序。 - keithstd::shared_ptr
吗?你销毁了控制块吗? - curiousguydelete p
会销毁控制块。完整代码在这里。 - Lingxi