使用显式栅栏和std::atomic有什么区别?

35

假设目标平台上对齐的指针加载和存储自然是原子性的,那么这两者之间有什么区别:

// Case 1: Dumb pointer, manual fence
int* ptr;
// ...
std::atomic_thread_fence(std::memory_order_release);
ptr = new int(-4);
// Case 2: atomic var, automatic fence
std::atomic<int*> ptr;
// ...
ptr.store(new int(-4), std::memory_order_release);

还有这个:

// Case 3: atomic var, manual fence
std::atomic<int*> ptr;
// ...
std::atomic_thread_fence(std::memory_order_release);
ptr.store(new int(-4), std::memory_order_relaxed);

我原本认为它们都是等价的,但是Relacy只在第一种情况下检测到数据竞争:

struct test_relacy_behaviour : public rl::test_suite<test_relacy_behaviour, 2>
{
    rl::var<std::string*> ptr;
    rl::var<int> data;

    void before()
    {
        ptr($) = nullptr;
        rl::atomic_thread_fence(rl::memory_order_seq_cst);
    }

    void thread(unsigned int id)
    {
        if (id == 0) {
            std::string* p  = new std::string("Hello");
            data($) = 42;
            rl::atomic_thread_fence(rl::memory_order_release);
            ptr($) = p;
        }
        else {
            std::string* p2 = ptr($);        // <-- Test fails here after the first thread completely finishes executing (no contention)
            rl::atomic_thread_fence(rl::memory_order_acquire);

            RL_ASSERT(!p2 || *p2 == "Hello" && data($) == 42);
        }
    }

    void after()
    {
        delete ptr($);
    }
};

我联系了Relacy的作者,想知道这是否是预期行为,他说我的测试用例中确实存在数据竞争。 然而,我很难发现它在哪里;有人能指出这个竞争吗? 最重要的是,这三种情况之间有什么区别?

更新:我想到Relacy可能只是在抱怨跨线程访问变量的原子性(或者缺乏原子性)……毕竟,它不知道我只打算在具有自然对齐整数/指针访问的平台上使用此代码。

另一个更新:Jeff Preshing撰写了一篇精彩的博客文章解释显式屏障和内置屏障之间的区别(“屏障”与“操作”)。第二种和第三种情况显然不等同!(在某些微妙的情况下,无论如何都是如此。)


你肯定是想让发布在商店之后吧? - GManNickG
只需使用std::atomic即可。在某些架构上,使用松散模型可能会更快,但很少值得付出努力。请参见http://bartoszmilewski.com/2008/12/01/c-atomics-and-memory-ordering/。 - Axel Gneiting
@GMan:实际上,不是这样的。如果 release 操作在 store 操作之前执行,那么在该操作可见的情况下,所有在该操作之前完成的 store 操作都可以保证被看到(假设它是在 acquire 操作之后加载的)。如果 release 操作在 store 操作之后执行,那么变量的读取者(使用 acquire 语义)不能保证之前的 store 操作已经完成,即使它可以看到该 store 操作(因为 store 操作可能在 release 执行之前变得可见;此外,编译器或 CPU 可能会重新排序 store 操作)。 - Cameron
@Axel:谢谢,但实际上我已经努力使用宽松模型使事情正常运作了;-) 我只是想弄清楚为什么我的 relacy 测试在使用普通变量(和手动栅栏)时失败,而在使用宽松的 std::atomic 变量(和相同的手动栅栏)时成功。 - Cameron
1
仅使用 std::atomic 也足以满足这个需求。 - Axel Gneiting
显示剩余4条评论
5个回答

20
虽然各种答案都涵盖了潜在问题的一些方面,并提供了有用的信息,但没有一个答案能正确描述所有三种情况下的潜在问题。为了在线程之间同步内存操作,使用释放和获取屏障来指定顺序。在图表中,线程1中的内存操作A不能向下移动穿过(单向)释放屏障(无论是原子存储的释放操作还是后跟松散原子存储的独立释放屏障)。因此,内存操作A保证在原子存储之前发生。对于线程2中的内存操作B也是如此,它们不能向上移动穿过获取屏障;因此,原子加载在内存操作B之前发生。

enter image description here

The atomic "ptr" itself provides inter-thread ordering based on the guarantee that it has a single modification order. As soon as thread 2 sees a value for "ptr", it is guaranteed that the store (and thus memory operations A) happened before the load. Because the load is guaranteed to happen before memory operations B, the rules for transitivity say that memory operations A happen before B and synchronization is complete.
With that, let's look at your 3 cases.
Case 1 is broken because "ptr", a non-atomic type, is modified in different threads. That is a classical example of a data race and it causes undefined behavior.
Case 2 is correct. As an argument, the integer allocation with "new" is sequenced before the release operation. This is equivalent to:
// Case 2: atomic var, automatic fence
std::atomic<int*> ptr;
// ...
int *tmp = new int(-4);
ptr.store(tmp, std::memory_order_release);
案例3出现问题,尽管问题很微妙。问题在于,尽管ptr分配在独立栅栏之后顺序正确,但整数分配(new)也在栅栏之后顺序排列,导致整数存储位置存在数据竞争。

代码等同于:

// Case 3: atomic var, manual fence
std::atomic<int*> ptr;
// ...
std::atomic_thread_fence(std::memory_order_release);

int *tmp = new int(-4);
ptr.store(tmp, std::memory_order_relaxed);

如果将其映射到上面的图表中,new运算符应该是内存操作A的一部分。当被排序到释放栅栏之下时,排序保证不再有效,整数分配实际上可能会与线程2中的内存操作B重新排序。因此,线程2中的load()可能会返回垃圾或引起其他未定义的行为。

2
@Igor 由于整数分配在释放障栅之后被排序,因此此操作与“ptr.store(mo_relaxed)”之间不存在线程内顺序。分配在分配给“ptr”之前完成(确实发生了 happens-before),但是该保证仅在同一线程内有效。没有由释放障栅强制执行的排序,除其他效果外,线程B可能会以无序方式观察这些操作(从技术上讲是未定义行为)。 - LWimsey
1
所以,在这种情况下,“操作”是指在分配器内执行的任何操作(例如设置一些元数据等)吗?让我们忘记将内存初始化为“-4”。在这种情况下,如果线程B从“ptr”读取非空值,则该值必须是对某个内存区域的有效指针,对吗?(即使线程B没有看到更新的分配器元数据)。或者这个假设是错误的,如果我们读/写该内存,会发生一些不好的事情吗? - Igor
3
“operations”指的是“int *tmp = ...”和“ptr.store(tmp, mo_relaxed)”。你的假设是错误的;一旦线程B观察到“ptr”的新值,它仍然可能没有看到该内存位置上的“-4”(未初始化),或者该内存位置本身甚至可能无效。这就是未定义行为的问题,您无法真正推理它;但是如果“tmp = new…”在栅栏之前有序执行,则所有操作都是明确定义的。 - LWimsey
3
@LWimsey,你的回答非常好,并指出只有地址已经被修复,所有其他的分配工作可能仍未完成(并且也不保证在另一个线程中可见,就像你解释的那样)。我强烈建议C++程序员永远不要假设标准没有明确规定的任何同步类型。 - Arne Vogel
1
@User10482 保证在释放栅栏之后的松弛原子存储不能与栅栏之前的(非原子)操作重新排序。 因此,您可以说,在释放栅栏之前的操作和释放栅栏之后的松弛原子存储不能穿过栅栏本身。 释放栅栏之后的存储和获取栅栏之前的加载必须是原子操作,否则会产生数据竞争,这是未定义的行为。 有关更多详细信息,请在标准文档中搜索“数据竞争”。 - LWimsey
显示剩余5条评论

15

我认为代码存在竞态条件。情况1和情况2并不相同。

29.8 [atomics.fences]

-2- 如果存在对某个原子对象 M 进行操作的原子操作 X 和 Y,其中 fence A 在 X 之前序列化,X 修改 M,Y 在 fence B 之后序列化,并且 Y 读取了由 X 写入或在假设的 release 序列中 X 将要写入的任何副作用产生的值,则该 release fence A 与 acquire fence B 同步。

在情况1中,由于 ptr 不是原子对象,因此对 ptr 的存储和加载不是原子操作,因此您的 release fence 无法与 acquire fence 同步。

情况2和情况3是等价的 (实际上,不完全等价,请参见LWimsey的评论和答案), 因为 ptr 是原子对象,而 store 是原子操作。( [atomic.fences] 的第三和第四段描述了如何使 fence 与原子操作同步.)

仅针对原子对象和原子操作定义了 fence 的语义。您的目标平台和实现是否提供更强的保证(例如将任何指针类型视为原子对象)最多只能是实现定义的。

注意:对于情况2和情况3,ptr 上的 acquire 操作可能发生在 store 之前,因此将从未初始化的 atomic<int*> 中读取垃圾值。仅使用 acquire 和 release 操作(或 fence)不能确保存储在加载之前发生,它只能确保如果加载读取存储的值,则代码正确同步。


6
并非所有平台都有这样的说明。在具有此类说明的平台上,C++屏障可能与这些说明相对应,您的代码可能会起作用,但是标准是以更抽象的术语定义的。 C++屏障可用于向多个松散的原子操作序列中添加同步,例如,您可以对五个不同的原子对象进行五个松散的存储,并仅使用单个释放屏障,还可以进行五个松散的加载,并且仅需要一个获取屏障。这可能比进行五个seqcst存储和五个seqcst加载要便宜。在您的代码中,由于只有一个原子对象,我建议您只需使用“atomic<string *>”。 - Jonathan Wakely
1
Jonathan:啊哈,谢谢你的回答。它填补了我理解中的一个空白 :-) 据我所知,所有现代处理器(如x86、x86-64、PowerPC和ARM)都将对齐的int和指针加载/存储原子化处理--但正如你所说,这是实现定义的,并不被C++标准保证。@thb:我相信在x86上,获取和释放栅栏是无操作的(所有加载和存储本质上具有获取和释放语义)。 - Cameron
1
只是补充一下,对于下一个阅读我之前评论的可怜人来说,即使在某个平台上,指针和整数的对齐加载/存储是原子性的,这并不意味着您可以不使用std::atomic。它的意思是“如果您不使用std::atomic,则您的代码可能有效,但不能保证” - 特别是编译器的优化可能会突然(微妙地)破坏先前工作的代码。请参见http://software.intel.com/en-us/blogs/2013/01/06/benign-data-races-what-could-possibly-go-wrong - Cameron
2
@JonathanWakely 我怀疑第二种情况和第三种情况是否等价。第二种情况似乎是正确的,但在第三种情况中,整数内存(new)的分配在释放栅栏之后被排序,这意味着即使另一个线程在加载ptr后正确地发出获取栅栏,它仍然可能指向垃圾,因为该内存分配没有得到正确的同步。 - LWimsey
在这两种情况下,获取操作将读取正确的 ptr 值(因为它在存储/释放操作中可用),但不会读取 ptr 指向的值。获取与释放同步适用于在释放之前发生的内存操作,这就是问题所在。在第二种情况下,new 是一个参数,因此在释放操作之前被技术上排序(正如应该的那样)。然而,在第三种情况下,它在(独立的)释放栅栏之后排序,因此未能满足跨线程的“发生在”关系。 - LWimsey
显示剩余5条评论

14

一些相关参考:

以上一些内容可能会引起您和其他读者的兴趣。


1
谢谢提供这些链接!几周前我阅读了Linux内核内存屏障笔记,它们非常有帮助。这篇从硬件角度看内存屏障的概述也很有用。 - Cameron
你提到的概述看起来不错。我已经将它添加到列表中。好奇地问一下,你和我处于同样的位置吗?多年来,我使用任务分叉和/或锁文件进行了一些简单的并发编程,但没有更复杂的东西。然后,新的C++11标准带来了令人头疼的1.10节关于并发的内容,所以我自然而然地想开始学习这个C++并发是什么。链接列表是我目前学习的成果。你也是这样吗,还是从另一个角度入手? - thb
我在编程方面没有太多经验,但最近对音频编程产生了兴趣,这往往非常注重性能,否则音频可能会出现故障;由于音频数据通常通过另一个线程上的回调请求,这导致需要快速同步 - 无锁队列非常理想。因此,我阅读了一些关于无锁编程的文章,这让我了解到内存屏障,从而让我实现了一个无锁队列。这是一个稍微不同的视角 :-) - Cameron

1

原子变量的内存只能用于原子变量的内容。然而,普通变量(例如第1种情况中的ptr)则不同。一旦编译器有权写入它,它可以写入任何东西,甚至是在寄存器用完时的临时值。

请记住,您的示例非常简洁。给定稍微复杂一些的示例:

std::string* p  = new std::string("Hello");
data($) = 42;
rl::atomic_thread_fence(rl::memory_order_release);
std::string* p2 = new std::string("Bye");
ptr($) = p;

编译器选择重用您的指针是完全合法的。

std::string* p  = new std::string("Hello");
data($) = 42;
rl::atomic_thread_fence(rl::memory_order_release);
ptr($) = new std::string("Bye");
std::string* p2 = ptr($);
ptr($) = p;

为什么会这样呢?我不知道,可能是一些奇特的技巧来保持高速缓存行之类的东西。重点是,在情况1中,由于ptr不是原子性的,因此在线路'ptr($) = p'上的写入和'std::string* p2 = ptr($)'上的读取之间存在竞争情况,导致未定义的行为。在这个简单的测试案例中,编译器可能不会选择行使这个权利,它可能是安全的,但在更复杂的情况下,编译器有权滥用ptr,而Relacy则能够捕捉到这一点。

我最喜欢的关于这个主题的文章: http://software.intel.com/en-us/blogs/2013/01/06/benign-data-races-what-could-possibly-go-wrong


0
第一个示例中的竞争是在指针的发布和它所指向的内容之间。原因在于,您在栅栏后面(与指针的发布在同一侧)创建和初始化指针:
int* ptr;    //noop
std::atomic_thread_fence(std::memory_order_release);    //fence between noop and interesting stuff
ptr = new int(-4);    //object creation, initalization, and publication

假设 CPU 对齐的指针访问是原子性的,则可以通过编写以下代码来进行更正:

int* ptr;    //noop
int* newPtr = new int(-4);    //object creation & initalization
std::atomic_thread_fence(std::memory_order_release);    //fence between initialization and publication
ptr = newPtr;    //publication

请注意,尽管这在许多机器上可能运行良好,但在C++标准中,最后一行的原子性绝对没有任何保证。因此最好一开始就使用atomic<>变量。

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