assembly - 为什么获取语义仅适用于读取,而不适用于写入? LL/SC 如何获取 CAS 锁定而不用临界区进行存储重新排序?

标签 assembly cpu-architecture stdatomic compare-and-swap spinlock

首先,考虑发布语义。如果数据集受到自旋锁(互斥锁等)的保护 - 无论使用什么具体实现;现在,假设 0 表示它空闲,1 - 繁忙)。更改数据集后,线程将 0 存储到自旋锁地址。为了强制在将 0 存储到自旋锁地址之前所有先前操作的可见性,存储是使用释放语义执行的,这意味着在此存储之前所有先前的读取和写入应对其他线程可见。无论这是通过完全屏障还是单个存储操作的释放标记来完成,都是实现细节。 (我希望)这是毫无疑问的清楚。

然后,考虑自旋锁所有权被占用的时刻。为了防止竞争,这是任何类型的比较和设置操作。通过单指令 CAS 实现(X86、Sparc...),这是组合读取和写入。 X86 原子 XCHG 也是如此。与LL/SC (大多数 RISC),这属于:

  1. 读取(LL)自旋锁位置,直到它显示空闲状态。 (可以通过一种 CPU 停顿来优化。)
  2. 写入 (SC) 值“占用”(在我们的例子中为 1)。 CPU公开操作是否成功(条件标志、输出寄存器等)
  3. 检查写入(SC)结果,如果失败,则转至步骤 1。

在所有情况下,对其他线程可见以表明自旋锁已被占用的操作正在向其位置写入 1,并且应在该写入和对受自旋锁保护的数据集的后续操作之间提交屏障。除了允许 CAS 或 LL/SC 操作之外,读取此自旋锁不会对保护方案提供任何帮助。

但是所有真正实现的方案都允许在读取(或 CAS)上进行获取语义修改,而不是写入。因此,LL/SC 方案将需要对自旋锁进行额外的最终读取和获取操作以提交所需的屏障。但典型的输出中没有这样的指令。例如,如果在 ARM 上编译:

  for(;;) {
    int e{0};
    int d{1};
    if (std::atomic_compare_exchange_weak_explicit(p, &e, d,
          std::memory_order_acquire,
          std::memory_order_relaxed)) {
      return;
    }
  }

它的输出首先包含 LDAXR == LL+acquire,然后是 STXR == SC (其中没有屏障,因此不能保证其他线程会看到它?)这可能不是我的工件,而是生成的,例如在glibc中:pthread_spin_trylock调用__atomic_compare_exchange_weak_acquire(并且不再有障碍),它属于GCC内置__atomic_compare_exchange_n,在互斥体读取时获取,在互斥体上不释放写作。

在这个考虑中我似乎错过了一些主要细节。有人能纠正一下吗?

这也可以分为 2 个子问题:

SQ1:指令序列如下:

(1) load_linked+acquire mutex_address     ; found it is free
(2) store_conditional mutex_address       ; succeeded
(3) read or write of mutex-protected area

是什么阻止 CPU 重新排序 (2) 和 (3),从而导致其他线程看不到互斥体被锁定?

SQ2:是否有一个设计因素表明仅在加载时获取语义?

我见过一些无锁代码的例子,例如:

线程 1:

var = value;
flag.store(true, std::memory_order_release);

线程 2:

if (flag.load(std::memory_order_acquire)) {
   // We already can access it!!!
   value = var;
   ... do something with value ...
}

但这应该在互斥锁保护样式稳定工作之后才能工作。

最佳答案

Its output contains first LDAXR == LL+acquire, then STXR == SC
(without barrier in it, so, there is no guarantee other threads will see it?)

嗯?存储始终对其他线程可见;存储缓冲区总是尽快耗尽自身。问题只是是否要阻止此线程中的后续加载/存储,直到存储缓冲区为空。 (例如,这是 seq-cst 纯存储所必需的)。

STXR 是专有的并且与 LL 相关。 因此,它和加载在全局操作顺序中是不可分割的,作为原子 RMW 操作的加载和存储端,就像 x86 在带有 lock cmpxchg 的一条指令中所做的那样。 (这实际上是一个夸大的说法: For purposes of ordering, is atomic read-modify-write one operation or two? - 即使加载不能,您也可以观察到原子 RMW 重新排序的存储端对后续操作的一些影响。我不确定我完全理解这仍然是安全的,但确实如此。)

原子 RMW 可以提前移动(因为获取加载可以做到这一点,宽松的存储也可以做到这一点)。但它不能稍后移动(因为 acquire-loads 无法做到这一点)。 因此,原子 RMW 以全局顺序出现在关键部分中的任何操作之前,并且足以获取锁定。它不必等待较早的操作,例如缓存未命中存储;它可以让他们进入关键部分。但这不是问题。

但是,如果您使用了 acq_rel CAS,则在完成所有早期加载/存储之前它无法获取锁定(因为存储端的释放语义)。

我不确定原子 RMW 的 acq_rel 和 seq_cst 之间是否存在任何 asm 差异。 IIRC,在 PowerPC 上是的。不是在 x86 上,所有 RMW 都是 seq_cst。 AArch64 ARMv8.3 具有 ldapr,它允许 acq_rel 而无需 seq_cst。在此之前,只有ldar STLr/STLxr:它只有relaxed和sequential-release。


LDAR + STR 就像 x86 cmpxchg 没有锁前缀:获取加载和单独存储。 (不同之处在于,由于 x86 内存模型,x86 cmpxchg 的存储端仍然是释放存储(但不是顺序释放)。


我的推理的其他确认,即 CAS“成功”端的 mo_acquire 足以获取锁定:

  • https://en.cppreference.com/w/cpp/atomic/memory_order说“互斥锁上的 lock() 操作也是获取操作”
  • Glibc 的 pthread_spin_trylock 在互斥体上使用 GCC 内置 __atomic_compare_exchange_n,仅使用 acquire,而不使用 acq_rel 或 seq_cst。我们知道很多聪明人都研究过 glibc。在没有有效增强 seq-cst asm 的平台上,如果有 bug,很可能会被注意到。

what prevents CPU against reordering (2) and (3), with result that other threads won't see mutex is locked?

这需要其他线程将 LL 和 SC 视为单独的操作,而不是原子 RMW。 LL/SC 的全部目的就是防止这种情况发生。较弱的排序让它作为一个整体移动,而不是 split 。

SQ2: Is there a design factor that suggests having acquire semantics only on loads?

是的,考虑纯负载和纯存储,而不是 RMW。 Jeff Preshing on acq and rel semantics .

发布存储的单向屏障自然可以很好地与 store buffer 配合使用。在真实的CPU上。 CPU“希望”尽早加载并延迟存储。也许是 Jeff Preshing 的文章 Memory Barriers Are Like Source Control Operations这是 CPU 如何与一致缓存交互的一个有用的类比。 (也相关:this answer 提到了为什么编译时重新排序可以帮助编译器优化)

只能较早出现而不能较晚出现的存储基本上需要刷新存储缓冲区。即宽松存储后跟完整屏障(例如atomic_thread_fence(seq_cst),例如ARM dsb ish或x86 mfence或锁定操作)。 这就是你从 seq-cst 商店得到的东西。所以我们或多或少已经有了它的名字,而且非常昂贵。

关于assembly - 为什么获取语义仅适用于读取,而不适用于写入? LL/SC 如何获取 CAS 锁定而不用临界区进行存储重新排序?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/58361491/

相关文章:

debugging - 如何解释 OllyDbg 中的 EFL?

assembly - MIPS 打印浮点系统调用仅打印零

c++ - 使用valgrind获取clock_gettime的汇编代码?

cpu - CPU 利用率如何介于 0% 和 100% 之间

assembly - 缓存未命中会陷入哪些危险?

arm - 为什么ARM有16个寄存器?

c - 为什么编程语言严格依赖原始类型,而汇编更灵活?

c - Xcode 和 C11 stdatomic.h

c++ - std::atomic 中的任何内容都是免等待的?

c++ - GCC 原子 shared_ptr 实现