原子操作 propagation/visibility(原子负载 vs 原子 RMW 负载)

Atomic operation propagation/visibility (atomic load vs atomic RMW load)

上下文

我正在用 C++ 编写线程安全的 protothread/coroutine library,并且我正在使用原子技术使任务切换无锁。我希望它尽可能高效。我对原子和无锁编程有一般的了解,但我没有足够的专业知识来优化我的代码。我做了很多研究,但很难找到我的具体问题的答案:What is the propagation delay/visiblity for different atomic operations under different memory orders?

当前假设

我了解到对内存的更改是从其他线程传播的,因此它们可能变得可见:

  1. 对不同观察者的顺序不同,
  2. 一些 延迟。

我不确定这种延迟的可见性和不一致的传播是否仅适用于非原子读取,或者也适用于原子读取,这可能取决于使用的内存顺序。当我在 x86 机器上开发时,我无法在弱排序系统上测试行为。

无论操作类型和使用的内存顺序如何,所有原子读取是否总是读取最新值?

我很确定所有 read-modify-write (RMW) 操作 always 读取任何线程写入的最新值,无论使用的内存顺序如何。 顺序一致操作似乎也是如此,但前提是对变量的所有其他修改也顺序一致。据说两者都很慢,这对我的任务不利。如果不是所有原子读取都获得最新值,那么根据我目前的理解,我将不得不使用 RMW 操作来读取原子变量的最新值,或者在 while 循环中使用原子读取。

写入的传播(忽略副作用)是否取决于内存顺序和使用的原子操作?

(这个问题只有当上一个问题的答案是不是所有的原子读总是读取最近的值时才有意义。请仔细阅读,我不问边的可见性和传播-这里的效果。我只关心原子变量本身的值。) 这意味着根据用于修改原子变量的操作,可以保证任何后续原子读取都会收到变量的最新值。因此,我必须在保证始终读取最新值的操作之间做出选择,或者使用宽松的原子读取,与这种特殊的写入操作一起保证对其他原子操作的修改的即时可见性。

原子锁是免费的吗?

首先,让我们摆脱房间里的大象:在您的代码中使用 atomic 并不能保证实现无锁。 atomic 只是无锁实现的一个推动因素。 is_lock_free() 会告诉您对于 C++ 实现和您正在使用的基础类型它是否真的是无锁的。

最新值是多少?

术语 "latest" 在多线程领域中是非常模糊的。因为 OS 可能使一个线程休眠的 "latest" 可能不再是另一个活动线程的最新消息。

std::atomic only guarantees is a protection against racing conditions, by ensuring that R, M and RMW 在一个线程中对一个原子执行是原子执行的,没有任何中断,并且所有其他线程看到之前的值或之后的值,但看不到中间值。因此 atomic 通过在同一原子对象上的并发操作之间创建顺序来同步线程。

您需要将每个线程视为一个具有自己时间的平行宇宙,并且不知道平行宇宙中的时间。就像在量子物理学中一样,在一个线程中你唯一能知道的关于另一个线程的就是你能观察到的(即宇宙之间的 "happened before" 关系)。

这意味着您不应该将多线程时间想象成跨所有线程的绝对 "latest"。您需要将时间视为相对于其他线程的时间。这就是为什么原子不创建绝对最新的,而只确保原子将具有的连续状态的顺序排序。

传播

传播不依赖于内存顺序,也不依赖于执行的原子操作。 memory_order is about sequential constraints on non-atomic variables around atomic operations that are seen like fences. The best explanation of how this works is certainly Herb Sutters presentation,如果您正在处理多线程优化,那绝对值得花一个半小时。

虽然特定的 C++ 实现可能会以影响传播的方式实现某些原子操作,但您不能依赖任何此类观察,因为无法保证传播以相同的方式工作在下一个版本的编译器或另一个 CPU 架构上的另一个编译器中流行。

但是传播重要吗?

designing lock-free algorithms时,很想读取原子变量来获取最新的状态。但是,尽管这种只读访问是原子的,但紧随其后的操作却不是。因此,以下指令可能会假定一个已经过时的状态(例如,因为线程在原子读取后立即进入睡眠状态)。

if(my_atomic_variable<10) 假设你读到 9。假设你在最好的世界中,9 将是所有并发线程设置的绝对最新值。它的值与<10的比较不是原子的,所以当比较成功,if分支时,my_atomic_variable可能已经有了一个新的值10。而且这种问题无论在什么情况下都可能发生传播的速度有多快,即使读取可以保证始终获得最新值。我什至还没有提到 ABA problem

读取的唯一好处是避免数据竞争和 UB。但是如果你想跨线程同步decisions/actions,你需要使用一个RMW,比如compare-and-swap (e.g. atomic_compare_exchange_strong),这样原子操作的顺序就会产生可预测的结果。

多线程是一个令人惊讶的领域。 首先,原子读取不是在写入之后排序的。我读一个值并不意味着它是以前写的。有时,此类读取可能会看到(间接地,通过其他线程)同一线程的某些后续原子写入的结果。

顺序一致性显然与可见性和传播有关。当一个线程写入原子 "sequentially consistent" 时,它会使其之前的所有写入对其他线程可见(传播)。在这种情况下,(顺序一致的)读取相对于写入进行排序。

通常,性能最高的操作是 "relaxed" 原子操作,但它们提供最低限度的排序保证。原则上总是存在一些因果悖论... :-)

经过一番讨论,我得出以下结论:首先,让我们定义原子变量的最新值是什么意思:在挂钟时间,对原子变量的最新写入,所以,从外部观察者的角度来看。如果同时有多个最后写入(即,在同一周期内在多个内核上),那么选择其中一个并不重要。

  1. 任何内存顺序的原子加载都不能保证读取到 最新的 值。这意味着写入必须先传播,然后才能访问它们。这种传播相对于它们的执行顺序可能是乱序的,并且对于不同的观察者来说顺序也不同。

    std::atomic_int counter = 0;
    
    void thread()
    {
        // Imagine no race between read and write.
        int value = counter.load(std::memory_order_relaxed);
        counter.store(value+1, std::memory_order_relaxed);
    }
    
    for(int i = 0; i < 1000; i++)
        std::async(thread);
    

    在这个例子中,根据我对规范的理解,即使没有读写执行干扰,仍然可以多次执行 thread 读取相同的值,因此在结束时,counter 不会是 1000。这是因为在使用正常读取时,尽管线程保证以正确的顺序读取对同一变量的修改(它们不会读取新值,而在下一个值上读取一个旧值),它们不能保证将全局最新写入值读取到变量。

    这就产生了相对性效应(就像在爱因斯坦的物理学中一样)每个线程都有自己的"truth",这正是我们需要使用顺序一致性的原因(或 acquire/release) 来恢复因果关系:如果我们简单地使用松弛负载,那么我们甚至可以破坏因果关系和明显的时间循环,这可能是由于指令重新排序与无序传播相结合而发生的。内存排序将确保独立线程感知的那些独立现实至少在因果关系上是一致的。

  2. 原子读取-修改-写入 (RMW) 操作(例如交换、compare_exchange、fetch_add、...)保证对上述定义的最新值进行操作.这意味着写入的传播是强制的,并导致内存的一个通用视图(如果您进行的所有读取都是使用 RMW 操作从原子变量进行的),独立于线程。因此,如果您使用 atomic.compare_exchange_strong(value,value, std::memory_order_relaxed)atomic.fetch_or(0, std::memory_order_relaxed),那么您一定会看到一个包含所有原子变量的全局修改顺序。 请注意,这不能保证非 RMW 读取的任何顺序或因果关系。

    std::atomic_int counter = 0;
    
    void thread()
    {
        // Imagine no race between read and write.
        int value = counter.fetch_or(0, std::memory_order_relaxed);
        counter.store(value+1, std::memory_order_relaxed);
    }
    
    for(int i = 0; i < 1000; i++)
        std::async(thread);
    

    在这个例子中(再次假设 none 的 thread() 执行相互干扰),在我看来规范禁止 value 包含任何东西而是全球最新的书面价值。所以,counter最后总是1000。

现在,什么时候使用哪种读法?

如果你只需要每个线程内的因果关系(对于以何种顺序发生的事情可能仍然有不同的看法,但至少每个 reader 对世界都有因果一致的看法),那么原子加载acquire/release 或顺序一致性就足够了。

但是如果你还需要新的读取(这样你就不能读取全局(跨所有线程)最新值以外的值),那么你应该使用 RMW 操作来读取。单独这些不会为非原子和非 RMW 读取创建因果关系,但所有线程中的所有 RMW 读取共享完全相同的世界视图,该视图始终是最新的。

所以,总结:如果允许不同的世界观,使用原子负载,但如果你需要一个objective现实, 使用RMW加载。