繁体   English   中英

独立的读-修改-写顺序

[英]Independent Read-Modify-Write Ordering

我通过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 ,从无限循环中释放第三个线程。 该示例有点人为,但原始代码需要通过标志声明该区域以表示“完成”。

最初, fetch_addacq_rel排序,因为我担心交换可能会在它之前被重新排序,可能导致一个线程声明标志,首先尝试fetch_add检查,并阻止另一个线程(通过增量检查)成功修改日程安排。 在使用 Relacy 进行测试时,我想如果我从acq_rel切换到release ,我会看到我期望发生的活锁是否会发生,令我惊讶的是,它没有发生。 然后我对所有事情都使用了放松,再次,没有活锁。

我试图在 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(或相反)是不可能的。

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

我是否可以始终依赖未重新排序的 RMW 操作 - 即使它们影响不同的内存位置

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

但请注意,原子 RMW 包括加载和存储,并且这两个部分必须保持在一起。 因此,任何类型的 RMW 都不能提前通过获取操作,或稍后通过释放操作。

此外,当然,作为释放和/或获取操作的 RMW 本身可以停止在一个或另一个方向上重新排序。


当然,C++ 内存模型并没有根据访问缓存一致共享内存的本地重新排序来正式定义,只是根据与另一个线程同步并创建发生之前/之后的关系。 但是,如果您忽略 IRIW 重新排序(2 个读取器线程不同意对不同变量进行独立存储的两个写入器线程的顺序),则几乎可以用 2 种不同的方式对同一事物进行建模。

在您的第一个示例中,保证flag.exchange始终counter.fetch_add之后执行,因为&&短路 - 即,如果第一个表达式解析为 false,则永远不会执行第二个表达式。 C++ 标准保证了这一点,因此编译器不能对这两个表达式重新排序(无论它们使用哪种内存顺序)。

正如 Peter Cordes 已经解释的那样,C++ 标准没有说明是否或何时可以根据原子操作对指令进行重新排序。 通常,大多数编译器优化依赖于as-if

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

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

这里的关键方面是“可观察的行为”。 假设您在两个不同的原子对象上有两个松弛的原子负载AB ,其中AB之前排序。

std::atomic<int> x, y;

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

先序关系是先发生关系定义的一部分,因此人们可能会假设这两个操作不能重新排序。 但是,由于这两个操作是宽松的,因此无法保证“可观察行为”,即,即使使用原始顺序, x.load ( A ) 也可能返回比y.load ( B ) 更新的结果,因此编译器可以自由地对它们重新排序,因为最终的程序将无法区分差异(即,可观察到的行为是等效的)。 如果它不相等,那么您将遇到竞争条件! ;-)

为了防止这种重新排序,您必须依赖(线程间)发生在关系之前。 如果x.load ( A ) 将使用memory_order_acquire ,那么编译器将不得不假设此操作与某些释放操作同步,从而建立(线程间)发生在关系之前。 假设某个其他线程执行两个原子更新:

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

如果获取-加载A看到由存储-释放D存储的值,则这两个操作彼此同步,从而建立发生在之前的关系。 由于y.storex.store之前被x.store ,而x.load在之前被排序,happens-before 关系的传递性保证y.store发生在y.load之前。 重新排序两个加载或两个存储会破坏这个保证,因此也会改变可观察的行为。 因此,编译器无法执行此类重新排序。

一般来说,争论可能的重新排序是错误的方法。 在第一步中,您应该始终确定所需的happens-before 关系(例如, y.store必须在y.load之前发生)。 下一步是确保在所有情况下正确建立这些先发生关系。 至少这就是我为我的无锁算法实现处理正确性参数的方式。

关于 Relacy:Relacy 仅模拟内存模型,但它依赖于编译器生成的操作顺序。 因此,即使编译器可以重新排序两条指令,但选择不重新排序,您将无法通过 Relacy 识别这一点。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM