原子操作传播/可见性(原子负载与原子RMW负载)

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

本文关键字:负载 RMW 传播 原子操作 可见性      更新时间:2023-10-16

上下文 

我在C 中编写了线程安全原始Rread/Coroutine库,我正在使用原子来使任务切换锁定。我希望它尽可能地表现。我对原子和无锁编程有一般的了解,但是我没有足够的专业知识来优化我的代码。我做了很多研究,但是很难找到解决我的特定问题的答案:在不同的记忆订单下,不同原子操作的传播延迟/可见性是什么?

当前假设 

我读到,对内存的变化是从其他线程传播的,以使它们变得可见:

  1. 在不同观察者的不同命令中
  2. 使用一些延迟。

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

不管使用的操作类型和内存顺序如何? 

我很确定所有 read-modify-write (rmw)操作始终读取任何线程所写的最新值,无论使用的内存顺序如何。对于依次一致操作似乎是正确的,但是只有所有对变量的所有其他修改也是依次一致的。据说两者都很慢,这对我的任务不利。如果并非全部的原子读取都具有最新的价值,那么我将不得不使用RMW操作来读取原子变量的最新值,或在我当前的理解中使用原子读取。

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

(对于上一个问题的答案是,并非所有的原子读物都始终读取最新价值。我只是关注原子变量本身的价值。)这意味着取决于用于修改原子变量的操作,可以保证任何以下任何原子读取都会收到该变量的最新值多变的。因此,我必须在保证始终读取最新值或使用放松的原子读物的操作之间进行选择,并与此特殊的写作操作一致,该操作可以保证对其他原子操作进行修改的即时可见性。

是原子锁定的吗?

首先,让我们摆脱房间中的大象:在代码中使用atomic不能保证无锁的实现。atomic只是无锁实现的推动者。is_lock_free()会告诉您C 实现和所使用的基础类型是否真的没有锁定。

最新值是多少?

"最新"一词在多线程世界中非常模棱两可。因为一个可能会在OS入睡的线程的"最新"是什么,所以可能不再是另一个活动线程的最新线程。

std::atomic仅保证是防止赛车条件的保护,通过确保在一个线程中在一个线程上执行的R,M和RMW在一个线程上执行,而无需任何中断,并且所有其他线程在,但从来没有什么之间。因此,atomic通过在同一原子对象上的并发操作之间创建订单来同步线程。

您需要将每个线程视为具有自己时间的平行宇宙,而这并不意识到平行宇宙中的时间。就像在量子物理学中一样,在一个线程中,您唯一可以知道的是您可以观察到的(即宇宙之间的"发生在"之间的"发生")。

这意味着您不应该构想多线程的时间,就好像所有线程中都有绝对的"最新"。您需要将时间视为与其他线程相对的时间。这就是为什么Atomics不会创建绝对最新的原因,而只能确保对原子将具有的连续状态进行顺序排序。

繁殖

传播不取决于内存顺序,也不取决于执行的原子操作。Memory_order是关于在原子操作周围的非原子变量上的顺序约束,这些变量被视为围栏。关于这种工作方式的最佳解释无疑是草药的伪装介绍,如果您正在努力进行多线程优化,那绝对值得一半。

尽管特定的C 实施可能会以影响传播的方式实施某些原子操作,但您不能依靠您会做的任何这样的观察结果,因为不能保证传播以相同的方式以相同的方式起作用编译器或另一个CPU体系结构上的另一个编译器上的下一个版本。

但是传播很重要?

设计无锁定算法时,很容易读取原子变量以获取最新状态。但是,尽管这样的仅阅读访问是原子的,但事后的动作却没有。因此,以下说明可能会假定已经已经过时的状态(例如,由于原子读取后的线程立即入睡)。

if(my_atomic_variable<10)并假设您阅读9.假设您处于最佳世界中,而9将是所有并发线程设定的绝对最新值。将其值与<10进行比较不是原子,因此,当比较成功并且if分支时,my_atomic_variable可能已经具有10个新值10。无论传播的速度有多快,即使读取将会发生,也可能会发生这种问题保证始终获得最新价值。而且我什至还没有提及ABA问题。

阅读的唯一好处是避免进行数据竞赛和UB。但是,如果您想同步跨线程的决策/操作,则需要使用RMW,例如比较和划分(例如atomic_compare_exchange_strong),以便对原子操作的排序产生可预测的结果。

在讨论之后,这是我的发现:首先,让我们定义一个原子变量的最新值的意思的意思是:在墙上的时间,最新的写入从外部观察者的角度来看,一个原子变量。如果有多个同时写的写(即,在同一周期中多个内核上),那么选择其中一个并不重要。

  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.这是因为当使用正常读取时,尽管线程可以按照正确的顺序读取对同一变量的修改(它们不会读取新值,并且在下一个值上读取较旧的值),但不能保证它们读取全球最新的书面值为变量。

    这创建了相对论效应(如爱因斯坦物理学中),每个线程都有自己的"真相",这正是我们需要使用顺序一致性(或获取/释放)来还原因果关系:如果我们简单地使用轻松的载荷,那么我们甚至可能会破裂的因果关系和明显的时间循环,这可能会由于指导与排序外传播的结合而进行。内存订购将确保那些单独线程所感知的那些单独的现实至少在因果关系一致。

  2. Atomic Read-Modify-Write(RMW)操作(例如Exchange,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);
    

    在此示例中(同样,在假设thread()执行都没有干扰的假设下),在我看来,规格避免了value包含除全球最新书面值以外的任何内容。因此,counter最终总是1000。

现在,何时使用哪种读取?&emsp;

如果您只需要每个线程中的因果关系(对哪个顺序发生的事情可能仍然有不同的看法,但是至少每个读者对世界都有因果关系一致的视图),则是原子载荷和获取/释放或序列一致性就足够了。

但是,如果您还需要新的读取(因此,除了全球(跨所有线程)最新值之外,切勿读取其他读取值),则应使用RMW操作进行阅读。单独的那些人并不能为非原子和非RMW读取因果关系,但是所有RMW在所有线程中读取的所有RMW都在世界上共享完全相同的观点,这始终是最新的。

因此,结论:如果允许不同的世界视图,请使用原子负载,但是如果您需要客观现实,请使用rmws加载。

多线程是令人惊讶的区域。首先,写入后未订购原子读。我读一个值并不意味着它是之前写的。有时,这样的读取可能会看到(通过其他线程的间接)结果的某些随后的原子写入同一线程的结果。

顺序一致性显然与可见性和传播有关。当线程写入原子"依次一致"时,它使其以前的所有写作都对其他线程(传播)可见。在这种情况下

通常,性能最多的操作是"放松"的原子操作,但它们在订购方面提供了最低限度的罪恶。原则上有一些因果关系悖论...: - )