内存模型中的排序和可见性?

29

我试图查找有关此内容的详细信息,甚至阅读了有关互斥锁和原子的标准...但仍然无法理解C++11内存模型可见性保证。 据我所知,互斥锁除了实现互斥访问之外,最重要的功能是确保可见性。即每次只有一个线程增加计数器是不够的,重要的是该线程增加由上一个使用互斥锁的线程存储的计数器(当讨论互斥锁时,人们为什么不经常提到这一点,我真的不知道为什么,也许是因为我的老师不好:))。 所以我可以告诉原子操作并不强制立即可见性: (来自维护boost::thread并实现了c++11线程和互斥库的人):

使用memory_order_seq_cst的栅栏不强制立即对其他线程可见(MFENCE指令也不会)。 C++0x内存排序约束只是这样-排序约束。memory_order_seq_cst操作形成一个总序,但是对于该序没有任何限制,除了所有线程都必须同意它,而且不能违反其他排序约束。特别地,线程可能会继续一段时间看到“旧”的值,前提是看到的值与约束一致的顺序。

我可以接受这一点。但问题是我很难理解有关原子的C++11构造物中哪些是“全局的”,哪些仅确保原子变量的一致性。 特别地,我不明白以下任何一个(如果有)内存序列确保在加载和存储之前和之后都有内存栅栏: http://www.stdthread.co.uk/doc/headers/atomic/memory_order.html

据我所知,std::memory_order_seq_cst会插入内存障碍,而其他方式仅强制执行对某些内存位置上操作的排序。

因此,有人能否澄清这一点,我想许多人将使用std::atomic制造可怕的错误,尤其是如果他们不使用默认的(std::memory_order_seq_cst内存排序)。
2. 如果我是正确的,那么这段代码中的第二行是否是多余的:

atomicVar.store(42);
std::atomic_thread_fence(std::memory_order_seq_cst);  

3. std::atomic_thread_fence是否与mutex有类似的要求,以确保在非原子变量上实现seq一致性,需要在加载之前执行std::atomic_thread_fence(std::memory_order_seq_cst), 并且在存储之后执行std::atomic_thread_fence(std::memory_order_seq_cst)?

4. 是否

  {
    regularSum+=atomicVar.load();
    regularVar1++;
    regularVar2++;
    }
    //...
    {
    regularVar1++;
    regularVar2++;
    atomicVar.store(74656);
  }

等同于

std::mutex mtx;
{
   std::unique_lock<std::mutex> ul(mtx);
   sum+=nowRegularVar;
   regularVar++;
   regularVar2++;
}
//..
{
   std::unique_lock<std::mutex> ul(mtx);
    regularVar1++;
    regularVar2++;
    nowRegularVar=(74656);
}
我认为不会,但我想确认一下。
编辑: 5. 断言能触发吗? 只有两个线程存在。
atomic<int*> p=nullptr; 

第一个线程写入

{
    nonatomic_p=(int*) malloc(16*1024*sizeof(int));
    for(int i=0;i<16*1024;++i)
    nonatomic_p[i]=42;
    p=nonatomic;
}

第二个线程读取

{
    while (p==nullptr)
    {
    }
    assert(p[1234]==42);//1234-random idx in array
}
2个回答

29
如果你喜欢处理屏障,那么a.load(memory_order_acquire)相当于a.load(memory_order_relaxed)然后跟着一个atomic_thread_fence(memory_order_acquire)。同样地,a.store(x,memory_order_release)等价于在调用a.store(x,memory_order_relaxed)之前调用atomic_thread_fence(memory_order_release)memory_order_consume是一个特殊情况,仅适用于依赖数据。 memory_order_seq_cst是特殊的,并且形成横跨所有memory_order_seq_cst操作的总序列。与其他访问模式混合使用时,对于读取加载操作,它与acquire相同,对于写入存储操作,它与release相同。 memory_order_acq_rel是用于读改写操作的,等效于对RMW的读部分进行acquire,对写部分进行release。
对原子操作使用顺序约束可能会或可能不会导致实际的屏障指令,这取决于硬件架构。在某些情况下,如果您将排序约束放在原子操作上而不是使用单独的屏障,则编译器将生成更好的代码。
在x86上,加载始终是acquire,存储始终是release。 memory_order_seq_cst需要使用MFENCE指令或带有LOCK前缀的指令进行更强的排序(在此实现中,可以选择使存储具有更强的排序还是使加载具有更强的排序)。因此,独立的acquire和release屏障是无操作的,但atomic_thread_fence(memory_order_seq_cst)不是(再次需要MFENCELOCKed指令)。
顺序约束的一个重要效果是对其他操作进行排序。
std::atomic<bool> ready(false);
int i=0;

void thread_1()
{
    i=42;
    ready.store(true,memory_order_release);
}

void thread_2()
{
    while(!ready.load(memory_order_acquire)) std::this_thread::yield();
    assert(i==42);
}
thread_2 会一直旋转,直到从 ready 中读取到 true。由于在 thread_1 中对 ready 的存储是释放操作,而加载是获取操作,因此存储操作与加载操作之间存在“同步-关系”,并且对 i 的存储操作发生在对 i 进行断言时从中加载的操作之前,这样断言就不会触发。第二行中的数字2)可忽略。
atomicVar.store(42);
std::atomic_thread_fence(std::memory_order_seq_cst);  

因为存储到atomicVar默认使用memory_order_seq_cst,所以确实可能是多余的。但是,如果该线程上存在其他非memory_order_seq_cst原子操作,则栅栏可能会产生影响。例如,它将作为后续a.store(x,memory_order_relaxed)的释放栅栏。

3)栅栏和原子操作不像互斥锁那样工作。您可以使用它们来构建互斥锁,但它们并不像互斥锁那样工作。您永远不需要使用atomic_thread_fence(memory_order_seq_cst)。没有任何原子操作需要是memory_order_seq_cst,并且可以在不使用它的情况下实现对非原子变量的排序,就像上面的示例一样。

4)不,这些不等价。没有互斥锁的代码片段因此具有数据竞争和未定义行为。

5)不,您的断言不会触发。使用默认内存序memory_order_seq_cst时,对原子指针p的存储和加载与上面我的示例中的存储和加载类似,并且对数组元素的存储保证发生在读取之前。


1
是的,在第5行中,对于nonatomic_p[i]的赋值不能在p的赋值之后移动。 - Anthony Williams
1
如果这两个代码块在不同的线程上运行(我假设它们是这样的,因此需要互斥锁),那么就会出现数据竞争,因为没有任何东西来排序对regularVar1regularVar2的写入。如果有任何其他线程从regularVar1regularVar2读取而没有进行同步,那么也会出现数据竞争。 - Anthony Williams
@AnthonyWilliams:但是你回答的1)解决了查询中的“排序”部分。那么可见性呢?或者说在所有情况下,排序是否意味着可见性,即如果对“i”的存储发生在对“i”的加载之前,由于写缓冲区或底层缓存架构的一致性,仍然可能看到“i”的旧值? - user1715122
如果一个特定的存储操作发生在同一位置的特定读取操作之前,那么该读取操作必须看到已写入的值或者稍后的值。 - Anthony Williams
3
如果考虑系统中的其他松弛变量,并且读者不与'a'进行同步,那么a.load(mo_acquire)a.load(mo_relaxed); fence(mo_acquire)更弱。Preshing解释(http://preshing.com/20131125/acquire-and-release-fences-dont-work-the-way-youd-expect/)fence是双向屏障,但acquire-load只是单向屏障:早期的loads可以重新排序到`a.load(mo_acquire)`一直到临界区域或任何其他地方。但是load-fence会阻止*所有*加载在其两侧重新排序。因此,您的前几句话并不完全准确。 - Peter Cordes
显示剩余3条评论

7
据我所知,std::memory_order_seq_cst在某些内存位置上仅强制操作的顺序,而其他模式则插入内存屏障。这实际上取决于你正在做什么以及你使用的平台。像x86这样的平台上的强内存排序模型将为内存屏障操作的存在创建不同的要求,与IA64、PowerPC、ARM等平台上的较弱排序模型相比。 std :: memory_order_seq_cst的默认参数确保根据平台使用适当的内存屏障指令。在像x86这样的平台上,除非进行读取-修改-写入操作,否则不需要完整的内存屏障。根据x86内存模型,所有加载都具有加载获取语义,而所有存储都具有存储释放语义。因此,在这些情况下,std :: memory_order_seq_cst枚举基本上创建了一个无操作,因为x86的内存模型已经确保这些类型的操作在线程之间是一致的,因此没有实现这些部分内存屏障的汇编指令。因此,如果在x86上显式设置std :: memory_order_release或std :: memory_order_acquire设置,则相同的无操作条件也将成立。此外,在这些情况下要求完整的内存屏障将是一种不必要的性能障碍。正如注意到的那样,它仅在读取-修改-存储操作中需要。但是,在其他具有较弱内存一致性模型的平台上,情况不同,因此使用std :: memory_order_seq_cst将使用适当的内存屏障操作,而无需用户明确指定他们是否需要加载获取、存储释放或完整的内存屏障操作。这些平台具有用于执行此类内存一致性协定的特定机器指令,并且std :: memory_order_seq_cst设置将解决正确的情况。如果用户想要专门调用其中一个操作,则可以通过显式的std :: memory_order枚举类型进行,但这并不是必需的...编译器会解决正确的设置。
是的,如果他们不知道自己在做什么,并且不了解某些操作中所调用的内存屏障语义类型,则如果他们尝试显式声明内存屏障类型并且其不正确,尤其是在本质上较弱的平台上,将会犯很多错误。
最后,请记住,在涉及互斥锁的情况#4中,需要发生两件不同的事情:
  1. 编译器不得跨互斥锁和临界区重新排序操作(特别是在优化编译器的情况下)。
  2. 必须创建所需的内存屏障(取决于平台),以维护所有存储在临界区之前完成的状态,以及互斥变量的读取,以及在退出临界区之前完成的所有存储。

默认情况下,由于使用std::memory_order_seq_cst实现原子存储和加载,因此使用原子也将实现满足条件1和2的适当机制。 话虽如此,在第一个示例中,加载将强制执行块的获取语义,而存储将强制执行释放语义。 然而,在“关键部分”内部,这两个操作之间不会强制执行任何特定的排序。 在第二个示例中,您有两个具有获取语义的锁定部分。 由于在某些时候您必须释放锁定(具有释放语义),因此两个代码块不等效。 在第一个示例中,您已经创建了一个大的“关键部分”(假设所有这些都发生在同一线程上)。 在第二个示例中,您有两个不同的关键部分。

附言:我发现以下PDF文件特别有启示性,您也可能会发现: http://www.nwcpp.org/Downloads/2008/Memory_Fences.pdf


我认为(在您的#2中),进入关键部分之前的加载和存储可以移动到关键部分内,而进入CS后的加载和存储也可以移动到CS内。这意味着编译器仍然可以在CS边界周围重新排序(在一定程度上)与非CS相关的加载和存储。这是因为这些加载/存储最初不是CS的一部分。但是不能将任何内容从CS之前移动到CS之后...只能移动到其中间。 - SoapBox
是的,就我理解的获取和释放语义的含义而言,那是正确的。 - Jason
在C++11中,使用给定的原子操作套件是否可能构建自己的工作互斥量? - Omnifarious
是的和不是的...你可以创建一个互斥锁,其中lock()将是自旋锁(即繁忙等待操作),但如果您要创建实际使用操作系统资源将线程置于睡眠状态的操作,则必须调用某种基于操作系统的系统调用(即Linux上的futex等)。 话虽如此,如果您愿意,您可以使用原子操作来创建整个用户空间线程库...据我所知,那将是制作真正的用户空间“互斥锁”的唯一方法...您仍然需要进行内核调用,但不一定是为了互斥锁本身... - Jason
@Jason - 所以,使用原子操作套件可以说“在此操作之前发生的所有写入都需要在此操作的结果之前出现在内存中。”换句话说,可以通知编译器某些重新排序是无效的。 - Omnifarious
@Omnifarious 是的...我实际上刚刚在结尾处修改了我的答案,这是因为原子存储和加载也默认实现了std::memory_order_seq_cst内存顺序。使用此栅栏的副产品是它将指示C++编译器不要重新排序指令。因此,通过使用std::memory_order_seq_cst,您实际上获得了编译器和内存栅栏...只有在使用了std::atomic_thread_fence的另一个成员以允许松散排序的原子加载和存储时,才需要实现显式栅栏。 - Jason

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