为什么 acquire 语义只用于读取,而不用于写入? LL/SC acquire CAS 如何在不使用关键部分对存储重新排序的情况下获取锁?
Why is acquire semantics only for reads, not writes? How can an LL/SC acquire CAS take a lock without the store reordering with the critical section?
首先,考虑发布语义。如果数据集受自旋锁保护(互斥锁等 - 无论使用何种具体实现;现在,假设 0 表示它空闲,1 - 忙)。更改数据集后,线程将 0 存储到自旋锁地址。为了在将 0 存储到自旋锁地址之前强制所有先前操作的可见性,存储以释放语义执行,这意味着所有先前的读取和写入都应在该存储之前对其他线程可见。实现细节是通过完整屏障完成的,还是单个存储操作的释放标记。这是(我希望)毫无疑问地清楚。
然后,考虑自旋锁所有权被取得的时刻。为了防止竞争,这是任何一种比较和设置操作。使用单指令 CAS 实现(X86、Sparc...),这是结合读写的。 X86 原子 XCHG 也一样。对于 LL/SC(大多数 RISC),这会落到:
- 读取 (LL) 自旋锁位置,直到它显示自由状态。 (可以用一种 CPU 停顿来优化。)
- 写入 (SC) 值 "occupied"(在我们的例子中为 1)。 CPU 暴露操作是否成功(条件标志、输出寄存器等)
- 检查写入(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) 进行重新排序,导致其他线程看不到互斥体已被锁定?
问题 2:是否有设计因素建议仅在加载时获取语义?
我看过一些无锁代码的例子,比如:
线程 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?)
嗯?商店总是对其他线程可见;存储缓冲区总是尽可能快地自行耗尽。问题只是是否在 this 线程中稍后阻塞 loads/stores 直到存储缓冲区为空。 (例如,这对于 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 can't 这样做)。 因此原子 RMW 在临界区中的任何操作之前出现在全局顺序中,并且足以获取锁。它不必等待更早的操作,如缓存未命中存储;它可以让他们进入关键部分。但这不是问题。
但是,如果您 使用了 acq_rel CAS,它在较早 loads/stores 完成之前无法获取锁(因为释放存储端的语义)。
我不确定对于原子 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 cmpxchg 的存储端仍然是发布存储(但不是顺序发布),因为 x86 内存模型。
我的推理的其他确认,即 mo_acquire
对于 CAS 的“成功”方面足以获取锁:
- https://en.cppreference.com/w/cpp/atomic/memory_order 表示“Mutex 上的 lock() 操作也是获取操作”
- Glibc 的
pthread_spin_trylock
在互斥锁上使用 GCC 内置 __atomic_compare_exchange_n
,只有获取,而不是 acq_rel 或 seq_cst。我们知道很多聪明人都研究过 glibc。并且在没有有效加强到seq-cst asm的平台上,如果有bug 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 的全部意义在于防止这种情况发生。较弱的顺序让它作为一个整体移动,而不是分开。
SQ2: Is there a design factor that suggests having acquire semantics only on loads?
是的,考虑纯负载和纯存储,而不是 RMW。 Jeff Preshing on acq and rel semantics.
发布存储的单向屏障自然适用于 on real CPUs. CPUs "want" to load early and store late. Perhaps Jeff Preshing's article Memory Barriers Are Like Source Control Operations is a helpful analogy for how CPUs interact with coherent cache. (Also related: 提到,以及为什么编译时重新排序可以帮助编译器优化)
只能更早出现而不是更晚出现的存储基本上需要刷新存储缓冲区。即松弛存储后跟完整屏障(如 atomic_thread_fence(seq_cst)
,例如 ARM dsb ish
或 x86 mfence
或锁定操作)。 这是你从 seq-cst 商店得到的东西。 所以我们或多或少已经有了一个名字,而且它非常昂贵。
我从 other source 那里得到了一个我认为最终合适的答案;这是我的翻译和改写。
禁止指令错序的原则不是某种隐式内存屏障——它可能根本没有实现,操作仍然是正确的——但事实上自旋锁获取被检查,除非它成功,线程不得继续进行数据访问。 AArch64 示例代码(来自同一个回答者)是:
; Spinlock Acquire
PRFM PSTL1KEEP, [X1] ; preload into cache in unique state
Loop
LDAXR W5, [X1] ; read lock with acquire
CBNZ W5, Loop ; check if 0
STXR W5, W0, [X1] ; attempt to store new value
CBNZ W5, Loop ; test if store succeeded and retry if not
; loads and stores in the critical region can now be performed
STR X25, [X10]
; Spinlock Release
STLR WZR, [X1] ; clear the lock with release semantics
STXR 本身可以通过其他后续访问进行重新排序,但由于下一个 CBNZ,它将不允许提交后续指令,除非 STXR 成功。 (CPU 通常,如果预测有用,则可以对它们执行一些操作,但除非执行明确到达它们,否则不应提交它们的结果。)
这在解释时看起来很明显,但之前还不是这样,看来是我的错:(
(回答者建议阅读 ARM® Architecture Reference Manual (ARMv8) K11 部分了解更多详情。)
然而,这并不以任何方式反驳需要以原子方式向其他参与者表示 LL/SC 对,如果需要的话 - 这是一个几乎正交的问题。
首先,考虑发布语义。如果数据集受自旋锁保护(互斥锁等 - 无论使用何种具体实现;现在,假设 0 表示它空闲,1 - 忙)。更改数据集后,线程将 0 存储到自旋锁地址。为了在将 0 存储到自旋锁地址之前强制所有先前操作的可见性,存储以释放语义执行,这意味着所有先前的读取和写入都应在该存储之前对其他线程可见。实现细节是通过完整屏障完成的,还是单个存储操作的释放标记。这是(我希望)毫无疑问地清楚。
然后,考虑自旋锁所有权被取得的时刻。为了防止竞争,这是任何一种比较和设置操作。使用单指令 CAS 实现(X86、Sparc...),这是结合读写的。 X86 原子 XCHG 也一样。对于 LL/SC(大多数 RISC),这会落到:
- 读取 (LL) 自旋锁位置,直到它显示自由状态。 (可以用一种 CPU 停顿来优化。)
- 写入 (SC) 值 "occupied"(在我们的例子中为 1)。 CPU 暴露操作是否成功(条件标志、输出寄存器等)
- 检查写入(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) 进行重新排序,导致其他线程看不到互斥体已被锁定?
问题 2:是否有设计因素建议仅在加载时获取语义?
我看过一些无锁代码的例子,比如:
线程 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?)
嗯?商店总是对其他线程可见;存储缓冲区总是尽可能快地自行耗尽。问题只是是否在 this 线程中稍后阻塞 loads/stores 直到存储缓冲区为空。 (例如,这对于 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 can't 这样做)。 因此原子 RMW 在临界区中的任何操作之前出现在全局顺序中,并且足以获取锁。它不必等待更早的操作,如缓存未命中存储;它可以让他们进入关键部分。但这不是问题。
但是,如果您 使用了 acq_rel CAS,它在较早 loads/stores 完成之前无法获取锁(因为释放存储端的语义)。
我不确定对于原子 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 cmpxchg 的存储端仍然是发布存储(但不是顺序发布),因为 x86 内存模型。
我的推理的其他确认,即 mo_acquire
对于 CAS 的“成功”方面足以获取锁:
- https://en.cppreference.com/w/cpp/atomic/memory_order 表示“Mutex 上的 lock() 操作也是获取操作”
- Glibc 的
pthread_spin_trylock
在互斥锁上使用 GCC 内置__atomic_compare_exchange_n
,只有获取,而不是 acq_rel 或 seq_cst。我们知道很多聪明人都研究过 glibc。并且在没有有效加强到seq-cst asm的平台上,如果有bug 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 的全部意义在于防止这种情况发生。较弱的顺序让它作为一个整体移动,而不是分开。
SQ2: Is there a design factor that suggests having acquire semantics only on loads?
是的,考虑纯负载和纯存储,而不是 RMW。 Jeff Preshing on acq and rel semantics.
发布存储的单向屏障自然适用于
只能更早出现而不是更晚出现的存储基本上需要刷新存储缓冲区。即松弛存储后跟完整屏障(如 atomic_thread_fence(seq_cst)
,例如 ARM dsb ish
或 x86 mfence
或锁定操作)。 这是你从 seq-cst 商店得到的东西。 所以我们或多或少已经有了一个名字,而且它非常昂贵。
我从 other source 那里得到了一个我认为最终合适的答案;这是我的翻译和改写。
禁止指令错序的原则不是某种隐式内存屏障——它可能根本没有实现,操作仍然是正确的——但事实上自旋锁获取被检查,除非它成功,线程不得继续进行数据访问。 AArch64 示例代码(来自同一个回答者)是:
; Spinlock Acquire PRFM PSTL1KEEP, [X1] ; preload into cache in unique state Loop LDAXR W5, [X1] ; read lock with acquire CBNZ W5, Loop ; check if 0 STXR W5, W0, [X1] ; attempt to store new value CBNZ W5, Loop ; test if store succeeded and retry if not ; loads and stores in the critical region can now be performed STR X25, [X10] ; Spinlock Release STLR WZR, [X1] ; clear the lock with release semantics
STXR 本身可以通过其他后续访问进行重新排序,但由于下一个 CBNZ,它将不允许提交后续指令,除非 STXR 成功。 (CPU 通常,如果预测有用,则可以对它们执行一些操作,但除非执行明确到达它们,否则不应提交它们的结果。)
这在解释时看起来很明显,但之前还不是这样,看来是我的错:(
(回答者建议阅读 ARM® Architecture Reference Manual (ARMv8) K11 部分了解更多详情。)
然而,这并不以任何方式反驳需要以原子方式向其他参与者表示 LL/SC 对,如果需要的话 - 这是一个几乎正交的问题。