读取原子修改的值是否需要内存屏障?

Is a memory barrier required to read a value that is atomically modified?

鉴于以下情况:

class Foo
{
public:
    void Increment()
    {
        _InterlockedIncrement(&m_value); // OSIncrementAtomic
    }

    long GetValue()
    {
        return m_value;
    }

private:
    long m_value;
};

读取m_value是否需要内存屏障?我的理解是 _InterlockedIncrement 将生成一个完整的内存屏障,并确保在任何后续加载发生之前存储该值。所以从这方面来说这听起来很安全,但是,m_value 是否可以被缓存,即 GetValue() return 是否可以成为一个过时的值,即使是在原子递增时?

Jeff Preshing 的优秀文章供参考:https://preshing.com/20120515/memory-reordering-caught-in-the-act/

其他上下文: 我正在关注有关无锁编程的一系列文章,特别是在此处查看 unfinishedJobs 变量的用法和 HasJobCompleted 的潜在实现: https://blog.molecular-matters.com/2015/08/24/job-system-2-0-lock-free-work-stealing-part-1-basics/

void Wait(const Job* job)
{
  // wait until the job has completed. in the meantime, work on any other job.
  while (!HasJobCompleted(job))
  {
    Job* nextJob = GetJob();
    if (nextJob)
    {
      Execute(nextJob);
    }
  }
}

Determining whether a job has completed can be done by comparing unfinishedJobs with 0.

那么,在这种情况下,HasJobCompleted 的可能实现是否需要内存屏障?

是的,障碍应该是双方的:阅读和写作。想象一下,您有一些 write-buffer 和一个 loading-queue,其中所有内容都可能是 out-of-order。所以你在写你的东西时刷新 write-buffer 但你需要处理的 loading-queue 在另一个线程(处理器)上,它对你的刷新一无所知。所以它总是一个成对的过程。

您也可以从编译器的角度来考虑它:除非编译器被迫序列化访问,否则它可以自由地重新排序它可以安全地(根据它的观点)做的任何事情。

就是说,这都是关于序列化而不是原子性的。这完全是另一回事。你的写作是原子的 _InterlockedIncrement 但阅读不是原子的 return m_value。这就是一场比赛。

此外,我看到您的代码需要原子性,但我认为不需要序列化。你没有用你的 m_value 保护任何东西。至于“过时”的价值:通常你不能保证在某个时间点你不会有过时的价值,即使有障碍。 RMW-operations 需要最新值,而其他则不需要。所以有一个障碍将有助于更快地获得最新的价值,但仅此而已。有了你的代码而忘记了比赛,编译器可能会安全地假设你没有修改 m_value 并缓存它。关于 CPU.

也可以这样说

综上所述:当您需要不受保护的变量时,只需使用 std::atomic。它将确保该值不被任何实体缓存。

简答:是。

你的“阅读”应该有“获取”的顺序,所以你的Increment()结果将在另一个线程中可见,当它在 incremnet 之后执行“释放”时。

不,您不需要障碍,但如果 reader 和编写者在不同的线程中调用这些函数,您的代码无论如何都会被破坏。特别是如果 reader 在循环中调用读取函数。

TL:DR:使用 C++11 std::atomic<long> m_value,增量为 return m_value++,reader 为 return m_value。这将为您提供 data-race-free 程序中的顺序一致性:执行将像线程 运行 一样工作,并带有一些源顺序交错。 (除非您违反规则并拥有其他非 atomic 共享数据。)如果您希望执行增量的线程永远知道它们的值,那么您肯定希望 return 来自 Increment 的值生产的。对于像 int sequence_num = shared_counter++; 这样的用例,在 count++; tmp = count;.

之间可以看到另一个线程的增量,在之后执行单独的加载完全被破坏了

如果在与 reader/writer 相同的线程中对 other 对象的操作不需要如此强的顺序,return m_value.load(std::memory_order_acquire) 就足够了对于大多数用途,m_value.fetch_add(1, std::memory_order_acq_rel)。很少有程序实际上在任何地方都需要 StoreLoad 障碍;即使使用 acq_rel,原子 RMW 实际上也不能重新排序。 (在 x86 上,它们的编译结果与您使用 seq_cst 相同。)

您不能在线程之间强制排序;负载要么看到该值,要么不看到该值,具体取决于读取线程是在获取/尝试获取负载值之前还是之后看到来自编写器的无效。线程的全部意义在于它们 运行 在 lock-step 中彼此。


Data-race UB:

循环读取 m_value 可以将负载提升到循环之外,因为它不是 atomic(甚至 volatile 作为 hack)。这是 data-race UB,编译器会破坏你的 reader。参见 this and

障碍不是这里的 problem/solution,只是强制 re-checking 内存(或当前 CPU 看到的 cache-coherent 内存视图;实际 CPU L1d 和 L2 这样的缓存对此不是问题)。这不是障碍的真正作用;他们命令该线程访问一致的缓存。 C++ 线程仅 运行 跨内核,具有一致的缓存。

但是如果没有非常令人信服的理由,请认真对待自己的原子。 When to use volatile with multi threading? 几乎从来没有。该答案解释了缓存一致性,并且您不需要障碍来避免看到陈旧的值。

在许多 real-world C++ 实现中,像 std::atomic_thread_fence() 这样的东西也将是一个“编译器障碍”,它迫使编译器甚至从内存中重新加载 non-atomic 变量,即使没有 volatile,但这是一个实现细节。所以它可能碰巧在某些 ISA 的某些编译器上工作得很好。而且对于编译器发明的多重加载仍然不是完全安全的;有关详细信息的示例,请参阅 LWN 文章 Who's afraid of a big bad optimizing compiler?;主要针对 Linux 内核如何使用 volatile 滚动自己的原子,de-facto 受 GCC/clang.

支持

“最新值”

初学者经常对此感到恐慌,并认为 RMW 操作在某种程度上更好,因为它们的指定方式。由于它们是读 + 写绑定在一起的,并且每个内存位置都有一个修改顺序 分开 ,RMW 操作必须等待对缓存行的写访问,这意味着在单个位置序列化所有写入和 RMW。

原子变量的普通加载仍然保证运行(通过实际实现)及时查看值。 (ISO C++ 只建议值 应该 在有限的时间内及时看到,但当然实际实现可以做得更好,因为它们 运行 在 cache-coherent CPU硬件。)

两个线程之间没有“立即”这样的东西;另一个线程中的加载看到存储的值,或者在存储对其他线程可见但不可见之前 运行。通过线程调度等,总是有可能一个线程会加载一个值,但很长时间都不会使用它;加载时是新鲜的。

所以这与正确性几乎无关,剩下的就是担心 inter-thread 延迟。在某些情况下,障碍可能会有所帮助(以减少以后内存操作的争用,而不是更快地主动刷新存储,障碍只是等待以正常方式发生)。所以这通常是一个非常小的影响,而不是使用额外屏障的理由。

. And see my comments on https://github.com/dotnet/runtime/issues/67330#issuecomment-1083539281 and 通常没有, 如果有,也不多。

当然不足以让 reader 放慢 reader 的速度只是为了让它比其他 atomic 变量更晚地查看这个 atomic 变量,如果你没有'需要正确的顺序。或者放慢编写器的速度,让它坐在那里什么都不做 也许 让它更快地完成一个 RFO 几个周期,而不是完成其他有用的工作。

如果您对线程的使用在 inter-core 延迟方面遇到瓶颈,那么值得,这可能是您需要重新考虑的迹象k 你的设计。

没有障碍或顺序,只需 std::atomicmemory_order_relaxed,您通常仍会在 40 纳秒内看到其他内核上的数据(在现代 x86 desktop/laptop 上),大约就像两个线程都使用原子 RMW 一样。它不可能被延迟任何显着的时间,比如一微秒,如果你为很多早期的商店创造了很多争用,所以他们都需要很长时间才能提交,然后才能提交。您绝对不必担心长时间看不到商店。 (这当然只适用于 atomic 或 hand-rolled 具有 volatile 的原子。普通 non-volatile 加载可能只在循环开始时检查一次,然后再也不会。那是为什么它们不能用于多线程。)