31

如果我锁定一个std::mutex,我总是会得到一个内存围栏吗?我不确定它是否暗示或强制您获得围栏。

更新:

在 RMF 的评论之后找到此参考资料。

多线程编程和内存可见性

4

3 回答 3

16

据我了解,这包括在:

1.10 多线程执行和数据竞争

第 5 段:

该库定义了许多原子操作(第 29 条)和互斥体上的操作(第 30 条),它们被特别标识​​为同步操作。这些操作在使一个线程中的分配对另一个线程可见方面起着特殊的作用。一个或多个内存位置上的同步操作是消耗操作、获取操作、释放操作或获取和释放操作。没有关联内存位置的同步操作是栅栏,可以是获取栅栏、释放栅栏,或者同时是获取栅栏和释放栅栏。此外,还有非同步操作的宽松原子操作,以及具有特殊特性的原子读-修改-写操作。[注:例如,获取互斥锁的调用将对包含互斥锁的位置执行获取操作。相应地,释放相同互斥锁的调用将对这些相同位置执行释放操作。非正式地,在 A 上执行释放操作会强制其他内存位置上的先前副作用对稍后在 A 上执行消费或获取操作的其他线程可见。“放松”原子操作不是同步操作,尽管,像同步操作一样,他们不能为数据竞赛做出贡献。——尾注] 在 A 上执行释放操作会强制其他内存位置上的先前副作用对稍后在 A 上执行消费或获取操作的其他线程可见。“宽松”原子操作不是同步操作,尽管与同步操作一样,它们不能有助于数据竞赛。——尾注] 在 A 上执行释放操作会强制其他内存位置上的先前副作用对稍后在 A 上执行消费或获取操作的其他线程可见。“宽松”原子操作不是同步操作,尽管与同步操作一样,它们不能有助于数据竞赛。——尾注]

于 2012-06-23T21:10:11.533 回答
12

解锁互斥锁与锁定互斥锁同步。我不知道编译器有哪些实现选项,但你会得到与栅栏相同的效果。

于 2012-06-23T21:02:56.327 回答
-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;

(这里没有定义等待可用性的方法,这是一个简单的例子来说明发布值的发布和消费。)

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

其他线程只需要一个获取内存命令来“连接”释放操作(如果有的话)。如果该值仍然为零,则线程对世界一无所知,获取无意义;它无法对其采取行动。(当线程检查指针并看到一个空值时,共享变量可能已经被更改了。这并不重要,因为设计者认为该线程中没有值是可管理的,或者它会执行操作按顺序排列。)

所有原子操作都旨在减少锁定,也就是说,无论其他线程正在做什么,即使它们被卡住,也可以在很短的有限时间内完成。这意味着您不能依赖另一个线程完成工作。

在线程通信原语谱的另一端,互斥锁不包含可用于在线程之间传输信息的值 (*),但它们确保一个线程只能在另一个线程之后进入 lock-ops-unlock 序列已经完成了他自己的锁定-操作-解锁序列。

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

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

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

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

[如果一个线程经常锁定 M 并且经常重新锁定 M 而同时没有任何其他线程作用于 M 呢?没什么有趣的,获取仍然与发布配对,但是 A = B 所以什么都没有完成。解锁是在同一个执行线程中排序的,因此在这种特殊情况下它是没有意义的,但一般来说,一个线程无法判断它是没有意义的。它甚至不是语言语义的特殊情况。]

同步发生在作用于互斥体的线程集 T 之间:保证没有其他线程能够查看这些 T 执行的任何内存操作。请注意,实际上在大多数真实计算机上,一旦内存修改命中缓存,如果所有CPU检查相同的地址,所有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 不能以明确定义的线程间方式交换信息(它们仍然可以通过系统交换信息,例如通过磁盘文件、套接字,用于系统特定的共享内存)。

(该观察结果可能允许编译器在不进行完整程序分析的情况下优化某些线程,即使某些常见的可变对象是通过具有静态成员的第三方类“拉取”的。)

另一种解释方式是说这些原语的语义没有泄漏:只有那些参与的线程才能获得定义的结果。

请注意,这仅适用于获取和释放操作的规范级别,而不是顺序一致的操作(这是对原子对象的操作的默认顺序,您没有指定内存顺序):所有顺序一致的操作(在原子对象或栅栏)以明确定义的全局顺序出现。然而,对于没有共同原子对象的独立线程来说,这并不意味着什么。

操作顺序与容器中元素的顺序不同,您可以真正在容器中导航,或者说文件按名称排序。只有对象是可观察的,操作的顺序不是。说有一个明确定义的顺序仅意味着值似乎没有向后改变(相对于一些抽象顺序)。

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

你可以说所有互斥操作都有一个全局顺序,它只是没有用,就像定义不相关集合之和的顺序一样。

于 2019-05-25T13:22:35.243 回答