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

What happens to outstanding stores after an object is deleted?

考虑以下简单函数(假设大多数编译器优化已关闭)由 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;
  }
}

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

会不会有store to x 放到store buffer中,执行delete后commit的情况?或者是否存在确保存储在删除之前提交的隐式内存屏障?还是一旦遇到删除,所有未完成的存储就被丢弃?

这变得重要的情况:

由于delete returns对象的内存到free list(用libc),有可能thread1中刚刚free的一块内存被thread2中的new操作符返回了(不仅是虚拟地址,连返回的底层物理地址也可以相同)。 如果未完成的存储可以在删除后执行,则可能是在线程 2 将 abc->x 设置为 2 之后,来自线程 1 的一些较旧的未完成存储将其覆盖为 1。

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

我在这里错过了什么?

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

Calls to these functions that allocate or deallocate a particular unit of storage shall occur in a single total order, and each such deallocation call shall happen before (6.9.2) the next allocation (if any) in this order.

既然每个 delete 都发生在 同一内存的任何 new 之前,那么 顺序在 之前这些运算符也 发生在 这些其他调用之前。以你的例子为例:

abc->x = index; 排在 delete abc; 之前,发生在 auto abc = new ABC; 之前,abc->x = index; 发生在 auto abc = new ABC; 之前。这保证 abc->x = index;auto abc = new ABC;.

之前完成

在单个线程内

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

这很像 C++ 的“as-if”规则,只是需要保留不同的可观察值。 (与 CPU ISA 不同,C++ 在法律上允许其他线程观察的内容非常严格,但是 compile-time 和 run-time memory-reordering 都不能通过重新排序源代码行来解释1)

此核心加载会侦听存储缓冲区,如果加载正在重新加载尚未提交的存储,则从中转发数据。

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

munmap这样的系统调用是一个特例,但除此之外new/delete(和malloc/free ) 就 CPU 而言并不特殊 :将一个块放在空闲列表中并让其他代码分配它只是另一种乱用 pointer-based 数据结构的情况.一如既往,CPU 会跟踪其所做的任何重新排序,以确保负载看到正确的值。


被另一个线程重用

你担心这个并没有错;仅基于 CPU 体系结构,这里不会免费发生正确性;有问题的 libc 可能会弄错并允许您描述的问题。 引用了C++标准的相关部分。 (Compile-time 内存访问顺序。调用 new/delete 也是必要的,但是对于编译器不知道的任何 non-inline 函数调用总是必须发生是“纯”的;任何函数都可能读取或写入内存,因此它必须同步。)

如果内存放置在可以被另一个线程重用的全局 free-list 上,a thread-safe 分配器 将使用足够的同步来创建先前使用然后删除内存的代码与刚刚分配它的另一个线程中的代码之间的 C++ happens-before 关系

因此,任何 old-thread 存储到此内存块中的内容对于刚刚分配内存的线程都是可见的。所以他们不会踩到它的商店。如果新线程将指向此内存的指针传递给第 3 个线程,它最好使用 acq/rel 或 consume/release 同步本身来确保第 3 个线程看到它的存储,而不是第一个线程的存储。


完全取消映射,因此无法访问该虚拟地址

如果 free 涉及 munmap,它使用 syscall 指令到 运行 更改页表的内核代码( 使映射无效,因此 loads/stores 到它会出错 ),它本身将提供足够的序列化。现有的 CPU 不会重命名特权级别,因此它们不会 out-of-order 通过系统调用指令执行到内核中。

这取决于 OS 围绕修改页表做足够的 memory-barriering,尽管在 x86-64 上 invlpg 已经是一个 序列化指令 . (在 x86 术语中,这意味着耗尽 ROB 和存储缓冲区,因此所有先前的指令都已完全执行完毕,并将其结果写回 L1d 缓存(用于存储)。)所以它不可能与依赖于早期加载/存储的重新排序在该 TLB 条目上,甚至除了切换到内核模式之外。

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


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

顺便说一句,内存重新排序不像 C++ 源代码中的重新排序语句或 asm 机器代码中的指令那样工作;内存重新排序是关于 other 线程可以观察到的,因为从缓存读取的负载和存储最终提交到存储缓冲区远端的缓存。重新排序源代码以尝试解释这会破坏代码,违反 as-if 规则,但是 memory-reordering 可以 产生这样的效果,同时仍然让线程的操作看到正确的值对于自己的商店,例如通过 store-forwarding。那是因为使用 real-world ISA 没有顺序一致的内存模型;您需要额外订购才能恢复 SC。例如,即使 in-order CPU 管道也可以使用可以 hit-under-miss 的缓存重新排序负载,甚至 strongly-ordered x86 也允许 StoreLoad 重新排序:它的内存模型基本上是 program-order 加上带有 store-forwarding.

的存储缓冲区

(评论中有关于compile-time重新排序和源排序的讨论;问题没有这种误解。)

C++ as-if 规则与 CPU 执行时所遵循的思想相同,只​​是 ISA 的规则决定了对外部可观察对象的要求。没有哪个 ISA 的 memory-ordering 规则像 ISO C++ 一样弱,例如它们都保证一致的共享缓存,许多 CPU ISA 没有 UB。 (尽管有些人这样做,例如称其为“不可预测的”行为。更常见的是某些寄存器中的不可预测或未定义的 result;user/supervisor 特权分离要求限制哪些行为是可能的,所以 user-space 不能 运行 某些不受支持的指令序列,并且可能会接管或使整个机器崩溃。)

有趣的事实:具体来说,在 strongly-ordered x86 上,存储和加载顺序需要比大多数 ISA 更紧密地联系在一起; Intel 将存储缓冲区 + 加载缓冲区的组合称为内存顺序缓冲区,因为它还必须检测加载在体系结构上允许(加载加载排序)之前提前取值的情况,但后来发现这个核心失去了访问权限到缓存行。或者在 mis-speculation 关于 store-forwarding 的情况下,例如动态预测加载将从未知地址重新加载存储,但后来发现存储是 non-overlapping。在任何一种情况下,CPU 都会将 out-of-order back-end 倒回到一致的退休状态。 (这称为管道核弹;此特定原因由 machine_clears.memory_ordering 性能事件计算。)