独立的读-修改-写序列

3
我正在通过Relacy运行一堆算法来验证它们的正确性,然后我偶然发现了一些我不太理解的东西。这里是一个简化版本:
#include <thread>
#include <atomic>
#include <iostream>
#include <cassert> 

struct RMW_Ordering
{
    std::atomic<bool> flag {false};
    std::atomic<unsigned> done {0}, counter {0};
    unsigned race_cancel {0}, race_success {0}, sum {0};

    void thread1() // fail
    {
        race_cancel = 1; // data produced

        if (counter.fetch_add(1, std::memory_order_release) == 1 &&
            !flag.exchange(true, std::memory_order_relaxed))
        {
            counter.store(0, std::memory_order_relaxed);
            done.store(1, std::memory_order_relaxed);
        }
    }

    void thread2() // success
    {
        race_success = 1; // data produced

        if (counter.fetch_add(1, std::memory_order_release) == 1 &&
            !flag.exchange(true, std::memory_order_relaxed))
        {
            done.store(2, std::memory_order_relaxed);
        }
    }

    void thread3()
    {
        while (!done.load(std::memory_order_relaxed)); // livelock test
        counter.exchange(0, std::memory_order_acquire);
        sum = race_cancel + race_success;
    }
};

int main()
{
    for (unsigned i = 0; i < 1000; ++i)
    {
        RMW_Ordering test;

        std::thread t1([&]() { test.thread1(); });    
        std::thread t2([&]() { test.thread2(); });
        std::thread t3([&]() { test.thread3(); });

        t1.join();
        t2.join();
        t3.join();

        assert(test.counter == 0);
    }

    std::cout << "Done!" << std::endl;
}

两个线程竞争进入一个受保护的区域,最后一个修改“done”,从无限循环中释放第三个线程。这个例子有些牵强,但原始代码需要通过“flag”来声明此区域以发出“完成”的信号。
最初,“fetch_add”具有“acq_rel”排序,因为我担心交换可能在它之前重新排序,可能导致一个线程声明标志,首先尝试“fetch_add”检查,并防止另一个线程(通过增量检查)成功地修改计划。在使用Relacy进行测试时,我想看看如果将“acq_rel”改为“release”,我预期会发生的活锁是否会发生,令我惊讶的是,没有发生。然后我对所有内容都使用了“relaxed”,再次没有活锁。
我试图在C++标准中找到任何关于此的规则,但只找到了以下内容:

1.10.7 此外,还有宽松的原子操作,并不是同步操作,还有原子读取修改写入操作,具有特殊的特性。

29.3.11 原子读取修改写入操作应始终读取与其关联的写入之前(在修改顺序中)写入的最后一个值。

我是否可以始终依赖RMW操作不被重新排序 - 即使它们影响不同的内存位置 - 并且标准中是否有保证这种行为的内容?

编辑:

我想出了一个更简单的设置,应该能更好地解释我的问题。以下是该CppMem脚本:

int main() 
{
    atomic_int x = 0; atomic_int y = 0;
{{{
{
    if (cas_strong_explicit(&x, 0, 1, relaxed, relaxed))
    {
        cas_strong_explicit(&y, 0, 1, relaxed, relaxed);
    }
}
|||
{
    if (cas_strong_explicit(&x, 0, 2, relaxed, relaxed))
    {
        cas_strong_explicit(&y, 0, 2, relaxed, relaxed);
    }
}
|||
{
    // Is it possible for x and y to read 2 and 1, or 1 and 2?
    x.load(relaxed).readsvalue(2);
    y.load(relaxed).readsvalue(1);
}
}}}
  return 0; 
}

我认为该工具并不足够复杂,无法评估这种情况,但它似乎表明这是可能的。以下是几乎相当的Relacy设置:
#include "relacy/relacy_std.hpp"

struct rmw_experiment : rl::test_suite<rmw_experiment, 3>
{
    rl::atomic<unsigned> x, y;

    void before()
    {
        x($) = y($) = 0;
    }

    void thread(unsigned tid)
    {
        if (tid == 0)
        {
            unsigned exp1 = 0;
            if (x($).compare_exchange_strong(exp1, 1, rl::mo_relaxed))
            {
                unsigned exp2 = 0;
                y($).compare_exchange_strong(exp2, 1, rl::mo_relaxed);
            }
        }
        else if (tid == 1)
        {
            unsigned exp1 = 0;
            if (x($).compare_exchange_strong(exp1, 2, rl::mo_relaxed))
            {
                unsigned exp2 = 0;
                y($).compare_exchange_strong(exp2, 2, rl::mo_relaxed);
            }
        }
        else
        {
            while (!(x($).load(rl::mo_relaxed) && y($).load(rl::mo_relaxed)));
            RL_ASSERT(x($) == y($));
        }
    }
};

int main()
{
    rl::simulate<rmw_experiment>();
}

断言从未被违反,因此根据Relacy的说法,1和2(或其反向)是不可能的。
2个回答

2

我还没有完全理解你的代码,但是加粗的问题有一个简单的答案:

即使它们影响不同的内存位置,我是否总是可以依靠RMW操作不被重新排序

不,你不能。同一线程中两个放松的RMW的编译时重新排序是完全允许的。(我认为大多数CPU上运行时重新排序两个RMW可能在实践中是不可能的。ISO C ++对此没有区分编译时与运行时)

但请注意,原子RMW包括加载和存储两部分,这两部分必须保持在一起。因此,任何类型的RMW都不能在获取操作之前向前移动,也不能在释放操作之后向后移动。

当然,RMW本身作为释放和/或获取操作也可以阻止在一个方向或另一个方向上进行重新排序。


当然,C ++内存模型没有以缓存一致的共享内存访问的局部重新排序为基础进行正式定义,只是以与另一个线程同步并创建先于/之后关系为基础进行定义。但是,如果忽略IRIW重新排序(2个读取器线程不同意两个独立存储到不同变量的写入器线程的顺序),这几乎是模拟同一件事情的两种不同方式。


1
谢谢你,彼得!我会接受这个答案,因为它回答了我的问题,尽管我仍然不确定Relacy为什么不能检测到计数器缺乏获取的问题,它应该模拟标准涵盖的最弱内存架构。即使是松散的通过了执行交错测试,我想我的对内存模型的理解有些缺陷!当我思考我的代码时,我发现更容易考虑执行重新排序,所以我用那些术语提出了问题。我还尝试过CppMem,但我无法弄清非CAS RMW的语法。 - Alloth
我更新了我的问题,并提供了一个更简单的设置,与原始代码清单相比应该更容易阅读。它展示了相同的想法,但实际上可以被CppMem解析(尽管我不确定正确性,因为它检测到了一个不存在的数据竞争)。 - Alloth

2
在您的第一个示例中,保证flag.exchange始终在counter.fetch_add之后执行,因为&&会短路——即如果第一个表达式解析为false,则不会执行第二个表达式。C++标准保证了这一点,因此编译器无法重新排序这两个表达式(无论它们使用哪种内存顺序)。
正如Peter Cordes已经解释的那样,C++标准没有说明指令何时可以与原子操作重新排序。通常,大多数编译器优化都依赖于“as-if”:
引用:

本国际标准中的语义描述定义了一个参数化的非确定性抽象机。本国际标准对符合要求的实现的结构没有任何要求。特别是,它们不需要复制或模拟抽象机的结构。相反,符合要求的实现需要模拟(只有)抽象机的可观察行为[..]。

这个规定有时被称为“as-if”规则,因为只要结果是程序的可观察行为就好像遵守了该要求,实现就可以自由地忽略本国际标准的任何要求,只要从程序的可观察行为可以确定。例如,如果实际实现可以推断出表达式的一部分未被使用且没有产生影响程序可观察行为的副作用,则不需要计算该表达式的值。

关键在于“可观察行为”。假设您有两个松散的原子加载AB,它们位于两个不同的原子对象上,其中AB之前排序。
std::atomic<int> x, y;

x.load(std::memory_order_relaxed); // A
y.load(std::memory_order_relaxed); // B

序列化关系是happens-before关系的定义的一部分,因此人们可能会认为这两个操作不能被重新排序。然而,由于这两个操作是松散的,所以无法保证“可观察行为”,即使按照原始顺序,x.load (A) 可能会返回比 y.load (B) 更新的结果,因此编译器可以自由地重新排序它们,因为最终程序无法区分它们(即,可观察行为是等效的)。如果不是等效的,则会出现竞争条件!;-)

为了防止这种重新排序,您必须依靠(线程间)happens-before关系。如果 x.load (A) 使用 memory_order_acquire,则编译器必须假定该操作与某个释放操作同步,从而建立一个(线程间)happens-before关系。假设另一个线程执行了两个原子更新:

y.store(42, std::memory_order_relaxed); // C
x.store(1, std::memory_order_release); // D

如果 acquire-load 操作 A 看到被 store-release 操作 D 存储的值,则两个操作将相互同步,从而建立 happens-before 关系。由于 y.storex.store 之前排序,x.load 在其之前排序,因此 happens-before 关系的传递性保证了 y.store 发生在 y.load 之前。重新排序两个加载或两个存储会破坏这种保证,从而改变可观察行为。因此,编译器无法执行这样的重新排序。
通常,关于可能的重新排序进行讨论是错误的方法。首先,您应该始终确定所需的 happens-before 关系(例如,y.store 必须在 y.load 之前发生)。然后,下一步是确保在所有情况下正确地建立这些 happens-before 关系。至少这就是我处理锁定自由算法实现的正确性参数的方式。
关于 Relacy:Relacy 仅模拟内存模型,但依赖于编译器生成的操作顺序。因此,即使编译器可以重新排序两个指令,但选择不这样做,您也无法使用 Relacy 找到这一点。

非常感谢您的澄清!巧合的是,早些时候我读到了一篇您与人合著的关于这个主题的论文,但我没有将它与我的问题联系起来。唯一未知的是硬件会做什么。我试着用Godbolt调试了一下,发现使用Power64 AT12编译器对于 this code sample 不会生成任何指令栅栏,尽管我不知道 exchange 所编译成的 LL/SC循环是否提供任何指令顺序保证。我怀疑它不会,但这个程序也通过了Relacy的测试... - Alloth
编译器优化必须与分离编译兼容(除非在特殊编译模式下),而不仅仅是(不存在的)“as if rule”。指令只能在本地有意义时重新排序,也就是说,不能跨越单独编译的代码进行重新排序。当编译后的代码稍后与未知的其他代码链接时,谈论“as if rule”似乎并不是很有帮助。 - curiousguy
@curiousguy 是的,标准没有定义所谓的“好像”规则,但是引用的声明通常被称为“好像”规则(这也在标准中指出;请参见引文的第二段)。但是,即使在组合多个编译单元时(甚至可能使用链接时间代码生成和整个程序优化),编译器必须确保“可观察行为”不受影响。 - mpoeter
@mpoeter 不仅可观察到的值(副作用,SE)被保存,因为根据定义,我们不知道其他TU中发生了什么,所以可能被其他TU访问的所有对象的状态必须根据ABI在内存中准确表示,遵循抽象机器,每当进行外部函数调用时。这比仅应用于SE的“好像”规则要强得多。这是“完全遵循ABI”规则。 - curiousguy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接