C++无锁并发示例安全性疑问:为何会出现数据覆盖?
嘿,我完全理解你的困惑——你已经用上了原子变量的acquire/release内存语义,也用了原子索引来管理共享数组,结果却还是出现了线程覆盖数据的情况,对吧?咱们把这个问题拆解清楚:
核心误区:acquire/release不保证操作序列的原子性
首先要明确:acquire/release语义解决的是内存可见性和操作顺序一致性问题,但它无法保证多个独立原子操作组成的序列是原子的。
你提到的场景里,大概率是类似这样的执行流程(模拟常见的错误实现):
// 共享变量 std::atomic<bool> flag_atomic{true}; std::atomic<int> idx_atomic{0}; int shared_array[100]; // 线程1/线程2的执行逻辑 void thread_func(int value) { if (flag_atomic.load(std::memory_order_acquire)) { int idx = idx_atomic.load(std::memory_order_relaxed); shared_array[idx] = value; idx_atomic.store(idx + 1, std::memory_order_release); } }
问题出在操作的间隙
我们来看一个典型的并发时序:
- 线程A调用
flag_atomic.load(acquire),得到true,确认可以写入 - 线程B几乎同时调用
flag_atomic.load(acquire),同样得到true - 线程A加载
idx_atomic的值,得到0 - 线程B也加载
idx_atomic的值,同样得到0(因为线程A还没执行后续的store操作) - 线程A把自己的value写入
shared_array[0] - 线程A把
idx_atomic更新为1(release语义确保这个更新对后续acquire的线程可见) - 线程B把自己的value写入
shared_array[0],直接覆盖了线程A的数据 - 线程B把
idx_atomic更新为1
这里的关键是:检查flag和获取索引是两个独立的原子操作,中间没有任何互斥或同步机制阻止其他线程插入执行。acquire/release只能保证当线程A更新idx_atomic后,后续线程加载idx_atomic能看到最新值,但它管不了线程B在线程A更新索引前就已经拿到了旧的索引值。
怎么解决这个问题?
要避免覆盖,你需要把“获取可用索引”变成一个原子的递增操作,而不是分开的load和store。比如用fetch_add原子地获取下一个索引:
void thread_func(int value) { if (flag_atomic.load(std::memory_order_acquire)) { int idx = idx_atomic.fetch_add(1, std::memory_order_acq_rel); if (idx < 100) { // 确保数组不越界 shared_array[idx] = value; } } }
fetch_add是一个单一的原子操作,它会同时完成“读取当前值”和“递增并写入新值”的动作,这样两个线程绝对不会拿到同一个索引。
另外,如果你的flag_atomic是用来控制是否允许写入的开关,还要注意:如果flag被设置为false的时机和索引操作没有同步,还是可能出现问题,但核心的覆盖问题已经通过原子递增解决了。
再总结一下
acquire/release语义的作用是确保:
- 一个线程在
release操作前的所有内存写操作,对后续acquire该原子变量的线程可见 - 操作的执行顺序不会被编译器或CPU乱序
但它不能阻止多个线程同时进入写入逻辑——只要你的操作序列不是原子的,并发线程就可能在间隙中插入执行,导致数据覆盖。
内容的提问来源于stack exchange,提问作者Joe Oram

