问题的历史。 在 C++20 之前,对现有非原子对象应用原子操作需要繁琐的变通方法,因为 std::atomic 要求对象从一开始就构造为原子。程序员常常尝试危险的 reinterpret_cast 操作,将普通对象视为原子,违反严格别名规则,并由于对象生命周期不匹配而导致未定义行为。 C++20 引入的 std::atomic_ref 填补了这一空白,提供了一种不拥有的视图,临时赋予现有对象原子语义,而不改变其存储类型或生命周期。
问题描述。 std::atomic 施加了特定的表示要求——例如无锁位标志或内部互斥体——通常会改变对象的大小或对齐方式,与基础类型 T 相比。因此,类型为 int 的对象与 std::atomic<int> 不兼容,无法进行指针釜底抽薪。此外,std::atomic_ref 要求所引用的对象满足严格的对齐约束;具体来说,对象的地址必须至少对齐到 alignof(std::atomic_ref<T> ),对于许多平台来说,这等于 alignof(T),但对于特定硬件的原子指令可能会更大。违反此对齐前提条件会导致未定义行为,可能在严格架构(如 ARM)上表现为错误读取或硬件异常。
解决方案。 std::atomic_ref 作为一个轻量级包装器,持有指向目标对象的指针,应用编译器内在函数或硬件指令以强制执行原子性,而不假设存储是 std::atomic 实例。它尊重现有对象的生命周期,同时为每个操作的持续时间提供与 std::atomic 相同的内存排序保证。为了安全使用,开发者必须确保对象对齐适当,通常通过 alignas 说明符或通过验证 std::atomic_ref<T>::required_alignment 是否满足,从而实现对遗留数据结构或兼容 C 的布局的无锁并发访问。
#include <atomic> #include <cstdint> #include <iostream> struct alignas(alignof(std::atomic_ref<std::uint64_t>)) Data { std::uint64_t value; }; int main() { Data d{42}; std::atomic_ref<std::uint64_t> ref(d.value); ref.fetch_add(8, std::memory_order_relaxed); std::cout << d.value << " "; // 输出: 50 }
问题描述。 在高频交易应用程序中,遗留的 C 结构定义了市场信息包布局,包含一个需要由网络线程进行原子更新的 double 价格字段,而策略线程在读取该字段。交易所要求精确的二进制兼容性,防止修改结构以使用 std::atomic<double>,并且延迟要求禁止使用互斥锁或内存拷贝。我们面临数据竞争,部分写入 double (在 x86-64 上如果没有适当对齐则为非原子)导致策略线程在高波动峰值时期读取损坏的“幽灵”值。
考虑的不同解决方案。 第一个方法涉及使用 std::atomic<bool> 标志进行双缓冲,保留两个结构副本并原子性地切换指针。尽管是无锁的,但这使得内存消耗加倍,并在 NUMA 节点之间引入缓存行跳动,导致微基准测试的性能下降约 15%。第二种方法考虑将 std::memcpy 复制到本地 std::atomic<double> 变量,但由于额外的拷贝违反了实时约束,并且如果 memcpy 在更新中间发生,则仍然存在撕裂读取。第三种解决方案使用 std::atomic_ref 直接引用 C 结构中的价格字段,利用硬件 CAS(比较并交换)指令而不改变结构布局。
选择了哪种解决方案,为什么。 我们选择 std::atomic_ref,因为它提供了真正的零开销抽象:在 x86-64 上生成的汇编与手写的 lock cmpxchg 指令相同,没有额外的分配或间接调用。与双缓冲方法不同,它保持热数据的单缓存行驻留,保留对微秒级延迟至关重要的 L1 缓存局部性。至关重要的是,它遵循外部 C 库的 ABI 约束,同时通过硬件强制原子性消除了数据竞争。
结果。 实施后,系统实现了一致的无锁更新,延迟不到微秒,消除了通过 ThreadSanitizer 运行验证的幽灵值异常。对齐验证(alignas)确保了无需更改代码即可移植到 ARM64 服务器,并且与双缓冲基线相比,由于减少了缓存压力,吞吐量提高了 12%。
为什么将非原子指针转换为 std::atomic<T>* 会导致未定义行为,而 std::atomic_ref 是安全的?
通过 reinterpret_cast 转换创建了指向 std::atomic<T> 类型对象的指针,但是存储实际包含 T 类型的对象。这违反了 C++ 对象模型的严格别名规则和生命周期要求,因为 std::atomic<T> 可能与 T 具有不同的大小、对齐或内部状态(例如自旋锁)。 std::atomic_ref 设计为显式引用 T 对象的不同引用类型,并通过特定于实现的内在函数对其应用原子操作,而不假装存储是不同类型,从而保留原始对象的生命周期和布局。
是否 std::atomic_ref 与引用的对象的构造同步?
不。 std::atomic_ref 仅对通过它执行的操作提供原子性,但不与引用对象的构造建立先发生关系。如果线程 A 构造了一个对象,而线程 B 立即创建了对它的 std::atomic_ref,那么线程 B 可能会看到未初始化的内存,除非线程 A 执行了释放操作(例如,存储到 std::atomic<bool>)并且线程 B 在访问 atomic_ref 之前执行了获取操作。atomic_ref 本身假设对象已经存在并可访问,但是在构造期间的并发非原子写入仍然是数据竞争,而没有外部同步。
是否可以与 const 对象一起使用 std::atomic_ref,且有哪些限制?
是的,std::atomic_ref<const T> 是有效的,并允许对声明为 const 的对象执行原子读取操作(如 load),前提是该对象在最初声明时没有以允许编译器优化以缓存值在寄存器中的方式声明为 const。但是,不能从 const T& 构造 std::atomic_ref<T>(非 const),因为这会违反常量正确性。此外,即使是 atomic_ref<const T>,基础对象也不得位于只读内存中(例如 .rodata 段),因为在大多数架构上,硬件原子指令甚至在读取操作中也需要可写的缓存行。