【问题标题】:The cost of atomic counters and spinlocks on x86(_64)x86(_64) 上的原子计数器和自旋锁的成本
【发布时间】:2014-10-11 08:46:06
【问题描述】:

前言

我最近遇到了一些同步问题,这导致我去了spinlocksatomic counters。然后我又搜索了一下,这些是如何工作的,发现std::memory_order 和内存屏障(mfencelfencesfence)。

所以现在看来​​,我应该对自旋锁使用 acquire/release,对计数器使用 relaxed

一些参考

x86 MFENCE - Memory Fence
x86 LOCK - Assert LOCK# Signal

问题

默认情况下这三个操作(锁定 = test_and_set,解锁 = clear,增量 = operator++ = fetch_add)的机器码是什么(编辑:见下文) (seq_cst) 内存顺序和获取/释放/放松(按照这三个操作的顺序)。 有什么区别(哪些内存屏障在哪里)以及成本(多少 CPU 周期)?

目的

我只是想知道我的旧代码 (未指定内存顺序 = 使用的 seq_cst) 到底有多糟糕,我是否应该创建一些从 std::atomic 派生的 class atomic_counter 但使用 relaxed内存排序 (以及在某些地方使用获取/释放而不是互斥锁的良好自旋锁......或者使用来自boost库的东西 - 到目前为止我一直避免boost)。。 p>

我的知识

到目前为止,我确实了解自旋锁保护的不仅仅是自身(但也有一些共享资源/内存),因此,必须有一些东西可以使多个线程/内核的一些内存视图保持一致(就是那些获取/释放和内存栅栏)。原子计数器只为自己而存在,只需要那个原子增量(不涉及其他内存,我读它时并不真正关心它的值,它提供信息并且可能是几个周期旧的,没问题)。有一些 LOCK 前缀和一些像 xchg 这样的指令隐含地有它。我的知识到此结束,我不知道缓存和总线的真正工作原理以及背后的原理(但我知道 现代 CPU 可以重新排序指令,并行执行它们并使用内存缓存和一些同步)。 感谢您的解释。

PS:我现在有旧的 32 位电脑,只能看到 lock addl 和简单的 xchg,没有别的 - 所有版本看起来都一样(解锁除外),memory_order 对我的没有任何影响旧PC(解锁除外,发布使用move而不是xchg)。对于 64 位 PC 来说会是这样吗? (编辑:见下文)我必须关心内存顺序吗? (回答:不,不多,解锁时释放可以节省几个周期,仅此而已。)

代码:

#include <atomic>
using namespace std;

atomic_flag spinlock;
atomic<int> counter;

void inc1() {
    counter++;
}
void inc2() {
    counter.fetch_add(1, memory_order_relaxed);
}
void lock1() {
    while(spinlock.test_and_set()) ;
}
void lock2() {
    while(spinlock.test_and_set(memory_order_acquire)) ;
}
void unlock1() {
    spinlock.clear();
}
void unlock2() {
    spinlock.clear(memory_order_release);
}

int main() {
    inc1();
    inc2();
    lock1();
    unlock1();
    lock2();
    unlock2();
}

g++ -std=c++11 -O1 -S(32bit Cygwin,缩短输出)

__Z4inc1v:
__Z4inc2v:
    lock addl   $1, _counter    ; both seq_cst and relaxed
    ret
__Z5lock1v:
__Z5lock2v:
    movl    $1, %edx
L5:
    movl    %edx, %eax
    xchgb   _spinlock, %al      ; both seq_cst and acquire
    testb   %al, %al
    jne L5
    rep ret
__Z7unlock1v:
    movl    $0, %eax
    xchgb   _spinlock, %al      ; seq_cst
    ret
__Z7unlock2v:
    movb    $0, _spinlock       ; release
    ret

x86_64bit 更新:(参见unlock1 中的mfence

_Z4inc1v:
_Z4inc2v:
    lock addl   $1, counter(%rip)   ; both seq_cst and relaxed
    ret
_Z5lock1v:
_Z5lock2v:
    movl    $1, %edx
.L5:
    movl    %edx, %eax
    xchgb   spinlock(%rip), %al     ; both seq_cst and acquire
    testb   %al, %al
    jne .L5
    ret
_Z7unlock1v:
    movb    $0, spinlock(%rip)
    mfence                          ; seq_cst
    ret
_Z7unlock2v:
    movb    $0, spinlock(%rip)      ; release
    ret

【问题讨论】:

  • 如果你想知道机器码是什么,为什么不直接看一下编译结果??
  • @KerrekSB:这只是问题的一小部分。我可以看到一些栅栏和锁,但想知道它们的真正作用。
  • 使用g++ -fverbose-asm -O1 -std=c++11 -S 可能会提供更易读的汇编代码......
  • @BasileStarynkevitch:谢谢,已更新
  • 也许大部分实际成本是缓存同步。

标签: c++ multithreading c++11 atomic memory-fences


【解决方案1】:

spinlock 不使用 mfence,mfence 只强制序列化/刷新当前核心的数据。栅栏本身与原子操作没有任何关系。

对于自旋锁,您需要某种原子操作来将数据交换到内存位置。有许多不同的实现,针对不同的需求:例如,它是在内核还是用户空间工作?是公平锁吗?

一个非常简单和愚蠢的 x86 自旋锁看起来像这样(我的内核使用这个):

typedef volatile uint32_t _SPINLOCK __attribute__ ((aligned(16)));
static inline void _SPIN_LOCK(_SPINLOCK* lock) {
__asm (
       "cli\n"
       "lock bts %0, 0\n"
       "jnc 1f\n"
       "0:\n"
       "pause\n"
       "test %0, 1\n"
       "je 0b\n"
       "lock bts %0, 0\n"
       "jc 0b\n"
       "1:\n"
       :
       : "m"(lock)
       :
       );
}

逻辑很简单

  1. 测试并交换一下,如果为零,则表示未使用锁,我们得到了它。
  2. 如果bit不为零,则表示锁被其他人占用了,pause是cpu厂商推荐的一个提示,这样就不会看紧cpu烧坏了。
  3. 循环直到获得锁

注意 1. 你也可以使用内在函数和扩展来实现自旋锁,它应该非常相似。

注意 2. 自旋锁不是通过循环来判断的,一个理智的实现应该很快,例如,上面的实现你应该在设计良好的情况下第一次尝试获取锁,如果没有,修复算法或拆分锁防止/减少锁争用。

注意 3。您还应该考虑其他事情,例如公平性。

【讨论】:

  • 这对 x86_64 有效吗?也许我应该在我的问题中明确提到这一点(我不知道哪种架构使用内存围栏,我想回答我现在可以购买的所有英特尔/AMD PC - 都是 32/64 位......不包括 Itanimu,只是我能遇到的那些)。
  • 对于这个特定的实现,是的,它在源代码级别同时适用于 x86(32 位受保护的平面模式)和 x86_64(长模式),我还没有检查 32e(长兼容模式)模式.
【解决方案2】:

x86 主要有strong memory model,所有常见的存储/加载都隐含了释放/获取语义。只有 SSE 非临时存储操作例外,需要像往常一样订购 sfence。所有带有 LOCK 前缀的读-修改-写 (RMW) 指令都暗示了完整的内存屏障,即 seq_cst。

因此在 x86 上,我们有

  • test_and_set 可以用lock bts(用于按位操作)、lock cmpxchglock xchg(或只是xchg,这意味着lock)进行编码。如果需要,其他自旋锁实现可以使用lock inc(或dec)之类的指令,例如公平。使用释放/获取栅栏实现try_lock 是不可能的(至少无论如何你都需要独立的内存屏障mfence)。
  • clear 使用 lock and(按位)或 lock xchg 编码,不过,更高效的实现将使用普通写入 (mov) 而不是锁定指令。
  • fetch_add 编码为 lock add

删除 lock 前缀将不能保证 RMW 操作的原子性,因此在 C++ 视图中不能将此类操作严格解释为具有 memory_order_relaxed。但是在实践中,您可能希望在安全的情况下(在构造函数中,处于锁定状态)通过更快的非原子操作来访问原子变量。

根据我们的经验,执行哪个 RMW 原子操作并不重要,它们执行的周期数几乎相同(mfence 大约是锁操作的 x0.5)。您可以通过计算原子操作(和 mfence)的数量以及内存间接(缓存未命中)的数量来估计同步算法的性能。

【讨论】:

  • 你能找到这个参考吗?我一直相信记忆模型强;某些访问可以重新排序。
  • 我可以看到我的 32 位 x86 几乎没有区别(除了释放解锁 - 简单移动而不是 xchg),但是 x86_64 会一样吗?没有记忆栅栏之类的?所以答案是我真的不需要太在意(尤其是那些计数器)?
  • @JamesKanze,是的,它不是很强大,负载可以在写入之前重新排序,但除此之外它非常受限制。
  • 所以,如果我理解正确,xchg(或lock bts/cmpxchg)作为内存栅栏(读-修改-写指令),没有什么可以重新排序。锁是安全的,开锁没那么重要,因为lock-unlock之间什么都不能移到外面(以后可能会看到开锁,如果观察但不尝试锁不知道,但是没有任何锁是安全的其他记忆栅栏)。与那些计数器相同,无论我使用宽松还是默认,它都是原子的,并且没有任何东西可以重新排序。就这样?
  • @firda,你是完全正确的。还更新了一些关于原子性能的信息。
【解决方案3】:

我推荐:x86-TSO: A Rigorous and Usable Programmer's Model for x86 Multiprocessors

您的 x86 和 x86_64 确实“表现良好”。特别是,它们重新排序写入操作(并且任何推测性写入在它们位于 cpu/核心的写入队列中时都会被丢弃),并且它们重新排序-顺序读取操作。但是,它们会尽可能早地开始读取操作,这意味着读取和写入可以重新排序。 (读取写入队列中的内容会读取排队的值,因此相同位置的读取/写入不会重新排序。)所以:

  • read-modify-write 操作需要LOCKs,这使得它们隐含地memory_order_seq_cst

    因此,对于这些操作,通过削弱内存排序(在 x86/x86_64 上),您将一无所获。一般建议是“保持简单”并坚持使用 memory_order_seq_cst,这很高兴不会为 x86 和 x86_64 花费任何额外费用。

    对于比 Pentium 更新的任何东西,如果 cpu/core 已经具有对受影响内存的“独占”访问权限,LOCK 不会影响其他 cpu/cores,并且可能是一个相对简单的操作。

  • memory_order_acquire/_release 不需要mfence 或任何其他开销。

    因此,对于原子加载/存储,如果获取/释放就足够了,那么对于 x86/x86_64,这些操作是“免税的”。

  • memory_order_seq_cst 确实需要mfence...

...值得理解。

(注意:我们在这里讨论的是处理器如何处理编译器生成的指令。编译器对操作的重新排序是一个非常相似的问题,但这里没有解决。)

mfence 会停止 CPU/内核,直到所有挂起的写入都从写入队列中清除。特别是,在写队列为空之前,mfence 之后的任何读操作都不会开始。考虑两个线程:

  initial state: wa = wb = 0

  thread 'A'                    thread 'B'
    wa = 1 ;  (mov [wa] ← 1)      wb = 1 ;   (mov [wb] ← 1)
    a  = wb ; (mov ebx ← [wb])    b  = wa ;  (mov ebx ← [wa])

留给自己的设备,x86/x86_64 可以产生 (a = 1, b = 1), (a = 0, b = 1), (a = 1, b = 0) 和 (a = 0, b = 0)。最后一个是 invalid 如果您期望 memory_order_seq_cst - 因为您无法通过任何交错操作来获得它。发生这种情况的原因是wawb 的写入在各自的cpu/核心队列中排队,wawb 的读取都可以被调度并且都可以在任一写入之前完成。要实现memory_order_seq_cst,您需要一个mfence

  thread 'A'                    thread 'B'
    wa = 1 ;  (mov [wa] ← 1)      wb = 1 ;   (mov [wb] ← 1)
        mfence ;                      mfence
    a  = wb ; (mov ebx ← [wb])    b  = wa ;  (mov ebx ← [wa])

由于线程之间没有同步,结果可能是任何除了(a = 0,b = 0)。有趣的是,mfence 是为了线程本身的利益,因为它防止在写入完成之前开始读取操作。其他线程唯一关心的是写入发生的顺序,而 x86/x86_64 在任何情况下都不会重新排序。

因此,要实现 memory_order_seq_cst atomic_load()atomic_store(),需要在一个或多个存储之后和加载之前插入一个 mfence。在这些操作作为库函数实现的情况下,常见的约定是将mfence 添加到所有存储中,使负载“裸露”。 (逻辑是加载比存储更常见,将开销添加到存储似乎更好。)


至少对于自旋锁,您的问题似乎归结为自旋解锁操作是否需要mfence,以及它有什么不同。

C11 atomic_flag_clear() 隐含地是memory_order_seq_cst,需要mfence。 C11 atomic_flag_test_and_set() 不仅是一个读-修改-写操作,而且还隐含 memory_order_seq_cst -- 而LOCK 就是这样做的。

C11 在threads.h 库中不提供自旋锁。但是你可以使用atomic_flag——尽管对于你的x86/x86_64你有PAUSE指令问题需要处理。问题是,您是否需要 memory_order_seq_cst 为此,尤其是解锁?我认为答案是,诀窍是:atomic_flag_test_and_set_explicit(xxx, memory_order_acquire)atomic_flag_clear(xxx, memory_order_release)

FWIW,glibc pthread_spin_unlock() 没有 mfence。 gcc __sync_lock_release() 也没有(这明确是一个“释放”操作)。但是 gcc _atomic_clear() 与 C11 atomic_flag_clear() 对齐,并采用内存顺序参数。

mfence 对解锁有什么影响?显然,这对管道造成了很大的破坏,而且由于没有必要,因此确定其影响的确切规模并没有太大的收获,这将取决于具体情况。

【讨论】:

  • 感谢您对该主题的更多了解。结论似乎是让计数器保持原样(RMW = LOCK),并使用某些库中的自旋锁(例如pthread_spinlock_t)。
  • 您说“对于比 Pentium 更新的任何东西,如果 cpu/core 已经对受影响的内存具有“独占”访问权限,则 LOCK 不会影响其他 cpus/core,并且可能是一个相对简单的操作”这是不正确的。英特尔的系统手册指出,不仅所有 cpu 上的锁定操作都是有序的,而且从另一个 cpu 看到的任何 cpu 上的锁定指令都不能从内存中移动或向内存移动,因此锁定指令比你说的要强大得多,他们等待耗尽所有 cpu 上的存储缓冲区,因此所有 cpu 都会受到影响。它甚至对“p6 家族”这么说。
  • @JoshS:锁定会产生所有美妙的排序,并且当(但仅当)多个 CPU 尝试在同一块内存上执行锁定操作时,就会发生交互。请参阅英特尔“系统编程指南”的第 8.1.4 节,其中讨论了“缓存锁定”。我注意到同一手册的第 8.2.2 节——其中讨论了锁定操作的顺序——但我认为这并不意味着所有 CPU 都必须有效地执行您所描述的mfence。跨度>
  • 仔细观察,你可能是对的。这个经常从stackoverflow指向的页面暗示我是对的,但我真的不知道:
  • 愚蠢的规则没有让我完成评论,错误地点击返回然后跑了5分钟。呸!!!!这是那个页面...cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html它说(英特尔订单指令) lock xadd [add],0 (用于加载)和 mov [add],value (存储)形成一个顺序访问对,该对在处理器之间完全排序。它还声称(甚至更不可能)mfence; mov reg,[add] 可以替换锁定的xadd。如果其中任何一个是正确的,那么您可以做一些很棒的事情,创建偏向锁,而不会像 jvm 从慢速端锁定那样可怕。
【解决方案4】:

回复

以及成本(多少个 CPU 周期)?

至少在 x86 上,执行内存同步(原子操作、栅栏)的指令具有非常可变的 CPU 周期延迟。它们等待处理器存储缓冲区刷新到内存,这取决于存储缓冲区的内容。

例如,如果一个原子操作直接在 memcpy() 之后将多个高速缓存行推送到主内存,则延迟可能在 100 纳秒内。相同的原子操作,但经过一系列仅寄存器的算术指令后,可能只需要几个时钟周期。

【讨论】:

    猜你喜欢
    • 2011-10-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2019-04-04
    • 2011-02-12
    • 2014-05-21
    • 2012-06-25
    • 2011-06-25
    相关资源
    最近更新 更多