std::mutex创建障栅吗?

32
如果我锁定一个std::mutex,我是否总是会得到内存屏障?我不确定它是暗示还是强制你获得屏障。
更新:
在RMF的评论后发现了这个参考资料。 多线程编程和内存可见性

在单个执行系统单元上,您不需要CPU栅栏。 - curiousguy
3个回答

17

据我理解,此处涉及到:

1.10 多线程执行和数据竞争

第5段:

该库定义了许多原子操作(第29条款)和互斥锁操作(第30条款),这些操作被特别标识为同步操作。这些操作在使一个线程的赋值对另一个线程可见方面发挥着特殊作用。一个或多个内存位置上的同步操作是消费操作、获取操作、释放操作或获取和释放操作。没有关联内存位置的同步操作是屏障,可以是获取屏障、释放屏障或获取和释放屏障。此外,还有松散的原子操作,它们不是同步操作,以及具有特殊特征的原子读-修改-写操作。(例如,调用获取互斥锁的函数将在组成互斥锁的位置上执行获取操作。相应地,释放相同互斥锁的调用将在这些相同位置上执行释放操作。非正式地说,在A上执行释放操作会强制先前对其他内存位置的副作用对稍后在A上执行的消费或获取操作的其他线程变得可见。“松散”的原子操作尽管不能导致数据竞争,但它们不是同步操作。-注释结束)


12

解锁互斥锁与锁定互斥锁是同步的。我不知道编译器在实现时有哪些选项,但您可以获得与栅栏相同的效果。


4
我认为OP正在询问除互斥锁本身以外的其他内存位置的防护措施。 - Mysticial
3
我不理解。在C++中,围栏并不影响特定的内存位置。 - R. Martinho Fernandes
11
我理解您的意思是:假设核心A写入A[0],并释放互斥锁。然后核心B获取互斥锁并在缓存一致性将新值传播到核心B之前读取A[0]。换句话说,互斥锁是否强制所有内存位置在返回前都是最新的? - Mysticial
1
就像围栏一样,它只强制使一些内存位置保持最新状态(那些根据“与同步”关系需要在内存模型中可见的更改)。 - R. Martinho Fernandes
1
如果有帮助的话,栅栏仅被定义为建立“与同步”关系,没有其他内容。您从栅栏中获得的所有保证都只是内存模型规则的结果。由于互斥锁也建立了“与同步”关系,它们的效果是相同的(好吧,互斥锁还有其他效果,但这里不相关)。 - R. Martinho Fernandes
显示剩余4条评论

-1

对于特定互斥量M的互斥操作(锁定或解锁),仅当M由不同线程共享并且它们执行这些操作时,与同步或内存可见性相关的任何目的才有用。如果本地定义并仅由一个线程使用的互斥量则不提供任何有意义的同步。

【注意:我在这里描述的优化可能没有被许多编译器实现,这些编译器可能将这些互斥和原子同步操作视为“黑盒子”,无法进行优化(甚至不应该进行优化,以保留代码生成的可预测性和某些特定模式,这是一个错误的论点)。即使在更简单的情况下,我也不会惊讶于零个编译器执行了这种优化,但毫无疑问,它们是合法的。】

编译器可以轻松确定一些变量永远不会被多个线程(或任何异步执行)使用,尤其是对于地址未被取出的自动变量(也没有对其的引用)。这些对象在此处称为“线程专用”。(所有候选进行寄存器分配的自动变量都是线程专用的。)

对于线程私有的互斥锁,锁定/解锁操作不需要生成任何有意义的代码:没有原子比较和交换,没有栅栏,并且通常根本不需要保留状态,除了“安全互斥锁”的情况外,在这种情况下,递归锁定的行为被定义明确并且应该失败(为了使序列m.lock(); bool locked = m.try_lock();工作,您至少需要保留一个布尔状态)。

对于任何线程私有的原子对象也是如此:只需要裸露的非原子类型即可执行正常操作(因此,fetch-add 1变成了常规后增量)。

这些转换合法的原因:

  • 显而易见的观察是,如果一个对象仅由一个线程或并行执行访问(它们甚至不会被异步信号处理程序访问),因此在汇编中使用非原子操作是无法检测到的
  • 不太明显的是,任何使用线程私有同步对象都不会暗示任何排序/内存可见性

所有同步对象都被指定为用于线程间通信的工具:它们可以保证一个线程中的副作用在另一个线程中可见;它们导致操作的明确定义顺序不仅存在于一个线程中(一个线程的操作的顺序执行顺序),而且存在于多个线程中。

一个常见的例子是使用原子指针类型发布信息:
共享数据是:
atomic<T*> shared; // null pointer by default

发布线程执行以下操作:

T *p = new T;
*p = load_info();
shared.store(p, memory_order_release);

消费线程可以通过加载原子对象值来检查数据是否可用,作为消费者:

T *p = shared.load(memory_order_acquire);
if (p) use *p;

(在此处等待可用性的定义方式不存在,这是一个简单的例子来说明所发布值的出版和使用。)

发布线程只需要在完成所有字段的初始化后设置原子变量;内存顺序是一个释放以传达内存操作已经完成的事实。

其他线程只需要获取内存顺序以连接与释放操作(如果有)的关系。如果该值仍然为零,则该线程对世界什么也不知道,获取是没有意义的;它不能对其采取行动。(当线程检查指针并看到空时,共享变量可能已经发生了变化。这无关紧要,因为设计者认为在该线程中没有值是可以管理的,否则它将按顺序执行操作。)

所有原子操作都旨在可能无锁,即无论其他线程正在做什么,甚至如果它们被卡住,都能在短时间内完成。这意味着您不能依赖于另一个线程已完成作业。

在线程通信基元的另一端,互斥锁不持有可用于在线程之间传递信息的值(*),但它们确保一个线程只能在另一个线程完成自己的锁定操作-操作-解锁操作后进入锁定操作-操作-解锁操作。

(*) 甚至不是布尔值,因为在线程之间使用互斥锁作为一般布尔信号(=二进制信号量)是明确禁止的。

互斥锁总是与一组共享变量一起使用:受保护的变量或对象V;这些V用于在线程之间传递信息,而互斥锁使得对该信息的访问在线程之间有序(或串行)。从技术上讲,除了第一个互斥锁(M)操作外,所有操作都与先前在M上解锁的操作成对:

  • M的锁定是对M的获取操作
  • M的解锁是对M的释放操作

锁定/解锁的语义在单个M上定义,因此让我们停止重复“在M上”;我们有线程A和B。B的锁定是与A的解锁配对的获取。两个操作一起形成了线程间同步。

[那么一个经常锁定M并且经常在没有其他线程在M上操作的情况下重新锁定M的线程怎么办?没有什么有趣的事情,获取仍然与释放配对,但是A = B,因此没有完成任何任务。解锁在执行的同一线程中排序,因此在特定情况下是无意义的,但通常线程无法告诉它是否无意义。语言语义甚至没有特殊处理它。]

发生的同步是在一组线程T作用于互斥锁之间:没有其他线程保证能够查看这些T执行的任何内存操作。请注意,在大多数实际计算机上,一旦内存修改命中缓存,如果它们检查相同的地址,所有CPU都会查看它,通过缓存一致性的力量。但是C/C++线程(#)不是根据全局一致的缓存规范指定的,也不是根据在CPU上可见的排序规范指定的,因为编译器本身可以假设非原子对象在程序没有同步的情况下不会以任意方式发生变异(CPU不能假设任何这样的事情,因为它没有原子与非原子内存位置的概念)。这意味着在您所针对的CPU/内存系统中可用的保证通常不适用于C/C++高级模型。您绝不能将普通的C/C++代码用作高级汇编;只有通过在代码中使用volatile(几乎到处都是),您才能模糊地接近高级汇编(但并不完全)。
(#)“C/C++线程/语义”而不是“C/C++编程语言线程语义”:C和C++基于同步原语的相同规范,这并不意味着有一个C/C++语言。

由于互斥操作对M的影响仅仅是通过序列化访问使用M的线程来访问某些数据,因此其他线程不会看到任何影响。 在技术术语中,同步关系是在使用相同同步对象的线程之间(在该上下文中为互斥体,在原子使用上下文中为相同的原子对象)。

即使编译器在汇编语言中发出内存屏障,它也不必假定解锁操作使得在解锁之前执行的更改对于集合T之外的线程产生影响。

这允许将线程集合分解为程序分析的一部分:如果程序并行运行两个线程集合U和V,并且创建U和V使得U和V不能访问任何公共同步对象(但它们可以访问公共非原子对象),那么可以从线程语义的角度分别分析U和V的交互,因为U和V无法以定义良好的线程间方式交换信息(它们仍然可以通过系统交换信息,例如通过磁盘文件,套接字,用于特定系统的共享内存)。

(这个观察结果可能允许编译器优化一些线程,而无需进行完整的程序分析,即使一些常见的可变对象是通过具有静态成员的第三方类“拉入”的。)

另一种解释是,这些原语的语义不会泄漏:只有参与的线程才能得到定义明确的结果。

请注意,这仅适用于获取和释放操作的规范级别,而不适用于顺序一致操作(这是原子对象上操作的默认顺序,如果您没有指定内存顺序):所有顺序一致的操作(原子对象或栅栏上的操作)都按照明确定义的全局顺序发生。然而,这并不意味着对于没有共同原子对象的独立线程有任何意义。

操作顺序不像容器中元素的顺序那样,您可以真正地浏览容器,或者说文件按名称排序。 只有对象是可观察的,操作顺序不是。说有一个明确定义的顺序只意味着值似乎没有被证明向后更改(与某些抽象顺序相比)。

如果您有两个无关的集合是有序的,例如具有通常顺序的整数和具有词典顺序的单词),则可以将这些集合的总和定义为具有与两个顺序兼容的顺序。您可以在数字之前、之后或交替放置单词。因为当它们不来自同一集合时,两个集合中的元素之间没有任何关系,所以您可以自由地做任何想做的事情。

你可以说,所有互斥操作存在全局顺序,只是没有用处,就像定义无关集合的总和顺序一样。

根据原子操作章节(在@Alok Save的回答中引用),获取互斥锁是一个memory_order_acquire操作,释放则是对互斥锁对象本身的释放操作。该回答并未支持其互斥锁可以被移除的断言。虽然这很有可能发生,因为它们没有被定义为acq_rel,所以获取可以任意提前,释放可以任意延迟,允许任何东西与任何东西重新排序。你的回答应该阐述这个观点或类似的观点。那么你就有了一个有趣的观点。 - Peter Cordes
@PeterCordes 我同意它们是获取和释放操作;它们不像栅栏(作用于所有内容),而像原子加载和存储(作用于某个东西:指定的原子对象)。这个论点与为什么当对象在线程之间不共享时,原子操作可以被优化掉的论点相同。我将更清楚地解释它。 - curiousguy
这就是我说的:释放 操作 而不是释放 栅栏。否则,它们无法像我描述的那样重新排序。 - Peter Cordes
@PeterCordes 发布栅栏可以重新排序很多东西,因为非原子操作可以在栅栏两侧传递,直到它们穿过先前的原子存储。 - curiousguy
@PeterCordes “这个回答没有支持它的断言,即互斥锁可以被删除。” 如果它们什么也不做,它们可以编译为NOP。 “尽管这很有道理,因为它们没有被定义为acq_rel” 无关紧要:这些互斥锁不与其他线程交互。 “因此,获取可以任意早地移动,释放可以任意晚地移动,” 无关紧要:操作没有被移动,但编译成了空代码(在代码生成时被抑制)。 “允许任何东西与任何东西重新排序” 我不知道你怎么会在锁定周围移动解锁。我希望通常情况下不能。 - curiousguy
显示剩余3条评论

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