std::atomic - 宽松排序的行为

std::atomic - behaviour of relaxed ordering

以下对 print 的调用是否会导致输出 stale/unintended 个值?

std::mutex g;
std::atomic<int> seq;
int g_s = 0;
int i = 0, j = 0, k = 0; // ignore fact that these could easily made atomic

// Thread 1
void do_work() // seldom called
{
    // avoid over
    std::lock_guard<std::mutex> lock{g};
    i++; 
    j++;
    k++;
    seq.fetch_add(1, std::memory_order_relaxed);
}

// Thread 2
void consume_work() // spinning
{
    const auto s = g_s;
    // avoid overhead of constantly acquiring lock
    g_s = seq.load(std::memory_order_relaxed);
    if (s != g_s)
    { 
       // no lock guard
       print(i, j, k);
    }
}

是的,可以。

首先,锁守卫对你的代码没有任何影响。一个锁必须至少被两个线程使用才能生效。

线程2可以随时读取。它可以读取递增的 i 和不递增的 jk。从理论上讲,它甚至可以读取一个奇怪的部分值,该值是通过在更新组成 i 的各个字节之间进行读取而获得的 - 例如从 0xFF 递增到 0x100 结果读取 0x1FF0x0 - 但不是在这些更新恰好是原子的 x86 上。

即使忽略陈旧性,这也会导致数据争用和 UB。

线程 2 可以读取 ijk,而线程 1 正在修改它们,您不同步对这些变量的访问。如果线程 2 不遵守 g,则将其锁定在线程 1 中毫无意义。

TL:DR:这太坏了; 改用 Seq Lock。 或者 RCU,如果你的数据结构更大。


是的,您有 data-race UB,实际上很可能是陈旧的值;不一致的值(来自不同的增量)也是如此。 ISO C++ 对会发生什么没有什么可说的,所以它取决于它如何为某些真实机器编译,以及 reader 中的中断/上下文切换发生在读取其中一些多个变量的过程中。例如如果 reader 在阅读 ij 之间出于任何原因休眠,您可能会错过许多更新,或者至少得到一个与您的 [=10] 不匹配的 j =].


放松 seq 与作家+reader 使用 lock_guard

我假设编写器看起来是一样的,所以原子 RMW 增量在临界区内。
我正在想象 reader 像现在这样检查 seq,之后才锁定,在运行 print.

的块内

即使您确实使用 lock_guard 来确保 reader 获得了所有三个变量的一致快照(您无法通过将它们分别设为原子来获得),我'我不确定 relaxed 在理论上是否足够。它可能在实际机器的大多数实际实现中都在实践中(编译器必须假设可能有一个 reader 以某种方式同步,即使在实践中没有)。如果我要锁定 reader.

,我至少会使用 release/acquire 作为 seq

获取互斥体是一个 acquire 操作,与对互斥体对象的 std::memory_order_acquire 加载相同。关键部分内的宽松增量在编写者获得锁之前不能对其他线程可见。

但是在 reader 中,使用 if( xyz != seq.load(relaxed) ) { take_lock; ... },加载不能保证“发生在 之前”获取锁。在许多 ISA 上的实践中,尤其是 x86,其中所有原子 RMW 都是完整的内存屏障。但是在 ISO C++ 中,也许还有一些实际的实现,松弛的负载有可能重新排序到 reader 的临界区。当然,ISO C++ 并没有根据“重新排序”来定义事物,仅根据允许查看的同步和值加载来定义。

(这种重新排序可能不完全合理;这意味着读取端必须根据对加载结果的分支预测/推测实际获取锁。可能像 x86 对事务内存所做的那样使用锁省略,除了没有 x86 的强内存顺序?)

无论如何,推理起来很容易,而且释放/获取操作在大多数 CPU 上都非常便宜。如果你预计它会很昂贵,并且检查经常是错误的,你可以再次检查 acquire 负载,或者在 if 中放置一个 acquire fence 所以它不会发生在 no-new-work 路径上。


使用序列锁

通过 使用序列计数器作为 Seq Lock 的一部分可以更好地解决您的问题,因此 reader 和 writer 都不需要互斥体。 (总结:写前自增,然后touch payload,再自增。在reader中,读入ijk到local temporaries,然后查看再次检查序列号以确保它是相同的,并且是偶数。具有适当的内存屏障。 有关实际详细信息,请参阅下面的维基百科文章 and/or link,但与现在相比真正的变化是序列号必须增加 2。如果您无法处理,请使用单独的实际锁的计数器,seq 作为有效负载的一部分。)

如果您不想在 reader 中使用互斥体,在编写器中使用互斥体只会在 implementation-detail side-effects 方面有所帮助,例如确保存储到内存实际上发生了,如果 do_work 内联到某个调用者,则不会将 i 保存在调用的寄存器中。

顺便说一句,如果只有一个作者,更新 seq 不需要是原子 RMW。您可以放宽负载并单独存储递增的临时文件(具有释放语义)。

Seq Lock 适用于廉价读取和偶尔写入,使 reader 重试。 显示适当的防护。

它依赖于 non-atomic 读取 可能 有数据竞争,但如果您的序列计数器检测到撕裂,则不会使用结果。 C++ 没有定义这种情况下的行为,但它在实践中适用于实际实现。 (C++ 主要在硬件竞争检测的情况下保持其选项打开,而普通 CPU 不会这样做。)

如果你有多个写入者,你仍然会使用普通锁来实现它们之间的互斥。或者将序列计数器用作自旋锁,因为编写器通过使计数为奇数来获取它。否则你只需要序列计数器。


你的全局g_s只是为了跟踪reader看到的最新序列号?将它存储在数据旁边会破坏一些 purpose/benefit,因为这意味着 reader 正在写入与写入器相同的缓存行,a假设彼此附近声明的变量最终都在一起。考虑在函数内部将其设置为 static,或将其与其他内容分开,或使用填充,如 alignas(64)128。 (不过,这并不能保证编译器不会将它放在其他变量之前;结构可以让您控制所有变量的布局。通过足够的对齐,您可以确保它们不在同一位置对齐的缓存行对。)