删除对象后,未完成的存储会发生什么?

use*_*136 3 c++ x86 multithreading cpu-architecture memory-barriers

考虑以下简单函数(假设大多数编译器优化关闭)由具有存储缓冲区的 X86 CPU 上不同内核上的两个线程执行:

struct ABC
{
  int x;
  //other members.
};
void dummy(int index)
{
  while(true)
  {
    auto abc = new ABC;
    abc->x = index;
    cout << abc->x;
    // do some other things.
    delete abc;
  }
}
Run Code Online (Sandbox Code Playgroud)

这里,index是线程的索引;1 由线程1 传递,2 由线程2 传递。因此,线程 1 应该始终打印 1,线程 2 应该始终打印 2。

是否存在这样的情况,即存储到x被放入存储缓冲区并在执行delete之后提交?或者是否存在隐式内存屏障来确保在删除之前提交存储?或者一旦遇到删除,任何未完成的存储都会被丢弃?

这变得很重要的情况:

由于delete是将对象的内存返回到空闲列表中(用libc),因此有可能在thread1中刚刚释放的一块内存被thread2中的new操作符返回(不仅是虚拟地址,甚至是返回的底层物理地址可以是相同的)。如果未完成的存储可以在删除后执行,则线程 2 将 abc->x 设置为 2 后,线程 1 中的某些较旧的未完成存储可能会将其覆盖为 1。

这意味着在上面的程序中,thread2可以打印1,这是绝对错误的。线程1和线程2是完全独立的,从程序员的角度来看,线程之间没有数据共享,并且它们不必担心任何同步。

我在这里缺少什么?

Pet*_*des 10

在单线程内

CPU 必须保留单线程按程序顺序一次执行一条指令的假象。这是 OoO exec 的基本规则。 这意味着跟踪程序顺序,并确保加载始终看到与其一致的值,并且最终写入缓存的值也一致。

这非常类似于 C++ 的“as-if”规则,只是需要保留不同的可观察值。(与 CPU ISA 不同,C++ 对于合法允许其他线程观察的内容非常严格,但编译时和运行时内存重新排序都不能通过重新排序源代码行1来解释)

通过此核心监听存储缓冲区进行加载,如果加载正在重新加载尚未提交的存储,则从其中转发数据。

对于任何单独的内存位置,请确保其修改顺序与程序顺序相匹配,即不要将存储重新排序到同一位置。所以尘埃落定后的最终值是程序顺序中的最后一个。甚至其他线程的观察也会看到该位置的一致修改顺序;这就是为什么std::atomic能够保证每个对象单独存在修改顺序,如果程序顺序存储 B 然后 A,则无需对 A 然后 B 然后返回 A 进行额外的更改。ISO C++ 可以保证这一点,因为所有现实世界的 CPU 也保证它。

像这样的系统调用munmap是一种特殊情况,但就CPU 而言,new/ delete(和malloc/free )并不特殊:将一个块放在空闲列表上并让其他代码分配它只是另一种弄乱指针的情况基于数据结构。与往常一样,CPU 会跟踪其所做的任何重新排序,以确保负载看到正确的值。


被另一个线程重用

你担心这个并没有错。仅仅基于 CPU 架构,正确性并不是免费发生的;有缺陷的 libc 可能会出错并允许出现您所描述的问题。 @ixSci 的答案引用了 C++ 标准的相关部分。(内存访问的编译时排序。对 new/delete 的调用也是必要的,但是对于编译器不知道是“纯”的任何非内联函数调用,总是必须发生这种情况;任何函数都可能读取或写入内存,因此必须同步。)

如果内存被放置在可以被另一个线程重用的全局空闲列表上,则线程安全分配器将使用足够的同步来在先前使用然后删除内存的代码和刚刚分配它的另一个线程中的代码。

因此,任何旧线程存储到该内存块中的内容对于刚刚分配内存的线程来说都是可见的。所以他们不会踩它的商店。如果新线程将指向该内存的指针传递给第三个线程,则最好使用 acq/rel 或消耗/释放同步本身来确保第三个线程看到其存储,而不是仍然来自第一个线程的存储。


完全取消映射,因此访问该虚拟地址会出现错误

如果free涉及munmap使用syscall指令来运行更改页表的内核代码(使映射无效,因此加载/存储到它会出错),那么它本身将提供足够的序列化。现有的 CPU 不会重命名权限级别,因此它们不会通过系统调用指令对内核进行无序执行。

invlpg尽管在 x86-64 上已经是序列化指令,但操作系统仍需要围绕修改页表进行足够的内存屏障。(在 x86 术语中,这意味着耗尽 ROB 和存储缓冲区,因此所有先前的指令都已完全执行完毕,并将其结果写回 L1d 缓存(用于存储)。)因此,它不可能对依赖于早期加载/存储的情况进行重新排序。在该 TLB 条目上,甚至除了切换到内核模式之外。

(不过,切换到内核模式并不一定会耗尽存储缓冲区;这些存储的物理地址是已知的。TLB 检查是在执行存储地址微指令时完成的。因此,对页表的更改不会影响将它们记入记忆的过程。)


脚注 1:内存重新排序不是源代码重新排序

顺便说一句,内存重新排序不像 C++ 源代码中的语句或 asm 机器代码中的指令那样工作。内存重新排序是关于其他线程可以观察到的内容,即从缓存读取负载并存储最终提交到存储缓冲区远端的缓存。重新排序源代码来尝试解释这一点会破坏代码,违反了假设规则,但是内存重新排序可以产生这样的效果,同时仍然让线程的操作看到其自己存储的正确值,例如通过存储转发。这是因为现实世界的 ISA 没有顺序一致的内存模型;您需要额外订购才能恢复 SC。例如,即使是有序的 CPU 管道也可以使用可以命中/未命中的缓存来重新排序负载,甚至强排序的 x86 也允许 StoreLoad 重新排序:其内存模型基本上是程序顺序加上具有存储转发功能的存储缓冲区。

(评论中有关于编译时重新排序和源代码排序的讨论;这个问题没有这种误解。)

C++ as-if 规则与 CPU 执行时遵循的想法相同,只是 ISA 规则控制对外部可观察量的要求。没有哪个 ISA 具有像 ISO C++ 那样弱的内存排序规则,例如它们都保证一致的共享高速缓存,并且许多 CPU ISA 没有 UB。(尽管有些确实如此,例如称其为“不可预测”的行为。更常见的是某些寄存器中的不可预测或未定义的结果;用户/管理员权限分离要求对可能的行为进行限制,以便用户空间无法运行某些不受支持的行为指令序列,可能会接管或崩溃整个机器。)

有趣的事实:特别是在强顺序 x86 上,存储和加载顺序需要比大多数 ISA 更紧密地联系在一起;英特尔将存储缓冲区 + 加载缓冲区的组合称为“内存顺序缓冲区”,因为它还必须在架构允许(LoadLoad 排序)之前检测加载早期获取值的情况,但后来发现该核心丢失了访问权限到缓存线。或者在对存储转发进行错误推测的情况下,例如动态预测负载将从未知地址重新加载存储,但结果发现存储是不重叠的。无论哪种情况,CPU 都会将无序后端回退到一致的退役状态。(这称为管道核武器;此特定原因由machine_clears.memory_orderingperf 事件计数。)


ixS*_*Sci 6

根据 C++20 (new.delete.dataraces/p1),我们有以下保证:

对这些分配或释放特定存储单元的函数的调用应按单个总顺序发生,并且每个此类释放调用应按此顺序在 (6.9.2) 下一次分配(如果有)之前发生。

由于每个都delete 发生在任何new相同的内存之前,因此在这些运算符之前排序的内容也会发生在这些其他调用之前。以你的例子为例:

abc->x = index;顺序delete abc;发生在 before 之前auto abc = new ABC;,并且传递地abc->x = index;发生在 before 之前auto abc = new ABC;。这保证了abc->x = index;之前是完整的auto abc = new ABC;