【问题标题】:Locks around memory manipulation via inline assembly通过内联汇编锁定内存操作
【发布时间】:2016-09-11 12:18:48
【问题描述】:

我对低级的东西不熟悉,所以我完全不知道你在那里可能会遇到什么样的问题,我什至不确定我是否正确理解了“原子”这个词。现在我正在尝试通过扩展程序集围绕内存操作制作简单的原子锁。为什么?为了好奇。我知道我在这里重新发明轮子,并且可能过度简化了整个过程。

问题? 我在这里展示的代码是否实现了使内存操作既线程安全又可重入的目标?

  • 如果有效,为什么?
  • 如果不起作用,为什么?
  • 不够好?例如,我应该在 C 中使用 register 关键字吗?

我只是想做的事......

  • 在内存操作之前,锁定。
  • 内存操作后,解锁。

代码:

volatile int atomic_gate_memory = 0;

static inline void atomic_open(volatile int *gate)
{
    asm volatile (
        "wait:\n"
        "cmp %[lock], %[gate]\n"
        "je wait\n"
        "mov %[lock], %[gate]\n"
        : [gate] "=m" (*gate)
        : [lock] "r" (1)
    );
}

static inline void atomic_close(volatile int *gate)
{
    asm volatile (
        "mov %[lock], %[gate]\n"
        : [gate] "=m" (*gate)
        : [lock] "r" (0)
    );
}

然后是这样的:

void *_malloc(size_t size)
{
        atomic_open(&atomic_gate_memory);
        void *mem = malloc(size);
        atomic_close(&atomic_gate_memory);
        return mem;
}
#define malloc(size) _malloc(size)

.. calloc、realloc、free 和 fork 相同(对于 linux)。

#ifdef _UNISTD_H
int _fork()
{
        pid_t pid;
        atomic_open(&atomic_gate_memory);
        pid = fork();
        atomic_close(&atomic_gate_memory);
        return pid;
}
#define fork() _fork()
#endif

加载 atomic_open 的 stackframe 后,objdump 生成:

00000000004009a7 <wait>:
4009a7: 39 10                   cmp    %edx,(%rax)
4009a9: 74 fc                   je     4009a7 <wait>
4009ab: 89 10                   mov    %edx,(%rax)

另外,鉴于上面的反汇编;我可以假设我正在执行原子操作,因为它只有一条指令吗?

【问题讨论】:

  • 不,它不是线程安全的,因为两个线程可以同时运行 cmp 并假设它们可以获取锁。
  • 交错(多任务)也会导致同样的问题。在一个线程执行cmp 之后,下一个线程可能会获得cpu 并且也执行它的cmp
  • 显然有办法。推荐的是不使用汇编,但如果你确实想使用它,你应该使用原子指令,例如lock cmpxchg
  • 为什么需要叉子周围的锁? malloc 也不需要它们,因为 Linux 上的 malloc 实现是线程安全的,因此已经有了必要的锁。 fork 系统调用不会更改进程中其他线程可见的任何状态,因此没有什么可以用锁保护。
  • 虽然因为你对 fork 和 malloc 包装器使用相同的锁,但理论上如果你重新初始化子进程中的锁,它会让你在子进程中安全地调用 malloc,因为你知道 fork 没有中断另一个线程中的 malloc 调用。实际上,问题在于不仅您自己的代码可以调用 malloc,各种其他库函数都可以调用 malloc,并且这些调用不会受到您的包装器的保护。幸运的是,这一切在实践中都是不必要的,因为 Linux 的 glibc 使用 pthread_atfork 跨叉锁定自身。

标签: c assembly x86 locking spinlock


【解决方案1】:

我认为在 x86 上没有任何真正主要/明显的性能问题的简单自旋锁是这样的。当然,真正的实现会在旋转一段时间后使用系统调用(如 Linux futex),并且解锁必须检查是否需要通过另一个系统调用通知任何服务员。这个很重要;您不想永远浪费 CPU 时间(和能量/热量)无所事事。但从概念上讲,这是在您采用回退路径之前自旋锁的自旋部分。这是light-weight locking 如何实现的重要部分。 (在调用内核之前只尝试获取一次锁是一个有效的选择,而不是完全旋转。)

在 inline asm 中尽可能多地实现这一点,或者最好使用 C11 stdatomic,例如 semaphore implementation。这是 NASM 语法。在 GNU C 中,确保使用 "memory" clobber 来停止内存访问的编译时重新排序 (TTAS coherence issue?)

;;; UNTESTED ;;;;;;;;
;;; TODO: **IMPORTANT** fall back to OS-supported sleep/wakeup after spinning some
;;; e.g. Linux futex
    ; first arg in rdi as per AMD64 SysV ABI (Linux / Mac / etc)

;;;;;void spin_lock  (volatile char *lock)
global spin_unlock
spin_unlock:
       ; movzx  eax, byte [rdi]  ; debug check for double-unlocking.  Expect 1
    mov   byte [rdi], 0        ; lock.store(0, std::memory_order_release)
    ret

align 16
;;;;;void spin_unlock(volatile char *lock)
global spin_lock
spin_lock:
    mov   eax, 1                 ; only need to do this the first time, otherwise we know al is non-zero
.retry:
    xchg  al, [rdi]

    test  al,al                  ; check if we actually got the lock
    jnz   .spinloop
    ret                          ; no taken branches on the fast-path

align 8
.spinloop:                    ; do {
    pause
    cmp   byte [rdi], al      ; C++11
    jne   .retry              ; if (lock.load(std::memory_order_acquire) != 1)
    jmp   .spinloop

; if not translating this to inline asm, you could put the spin loop *before* the function entry point, saving the last jmp
; but since this is probably too simplistic for real use, I'm going to leave it as-is.

普通存储具有发布语义,但没有顺序一致性(您可以从 xchg 或其他东西中获得)。 Acquire/release 足以保护关键部分(因此得名)。


如果您使用的是原子标志位域,则可以使用lock bts(测试和设置)来等效于 xchg-with-1。您可以使用bttest。要解锁,您需要lock btr,而不仅仅是btr,因为这将是字节的非原子读取-修改-写入,甚至包含32 位。

使用您通常应该使用的字节或整数大小的锁,您甚至不需要locked 操作来解锁; release semantics are enough。 glibc 的pthread_spin_unlock 和我的解锁功能一样:一个简单的商店。

lock bts 不是必需的;xchglock cmpxchg 与普通锁一样好。)


第一次访问应该是原子 RMW

参见Does cmpxchg write destination cache line on failure? If not, is it better than xchg for spinlock? 上的讨论 - 如果第一次访问是只读的,CPU 可能只发送对该高速缓存行的共享请求。然后,如果它看到行解锁(希望常见的低争用情况),它必须发送一个 RFO(读取所有权)才能真正写入缓存行。所以这是非核心交易的两倍。

缺点是这将占用该缓存行的MESI 独占所有权,但真正重要的是拥有锁的线程可以有效地存储0,因此我们可以看到它已解锁。无论是只读还是 RMW,该核心都将失去对该行的独占所有权,并且必须 RFO 才能提交该解锁存储。

我认为,当多个线程排队等待已被占用的锁时,只读的首次访问只会优化内核之间的流量略少。优化这将是一件愚蠢的事情。

(Fastest inline-assembly spinlock 还测试了一个大规模竞争自旋锁的想法,其中多个线程除了试图获取锁之外什么都不做,结果很差。这个链接的答案提出了一些关于 xchg 全局锁定总线的错误声明 - 对齐 @ 987654351@s不这样做,只是一个缓存锁(Can num++ be atomic for 'int num'?),每个核心都可以doing a separate atomic RMW on a different cache line at the same time。)


但是,如果最初的尝试发现它被锁定,我们不想继续使用原子 RMW 对高速缓存行进行攻击。那是我们回退到只读的时候。 10 个线程都为同一个自旋锁发送垃圾邮件xchg 会使内存仲裁硬件非常忙碌。它可能会延迟解锁商店的可见性(因为该线程必须争夺该行的独占所有权),因此它直接适得其反。它也可以为其他内核提供一般内存。

PAUSE 也是必不可少的,以避免错误推测 CPU 的内存排序。仅当您正在读取的内存 被另一个内核修改时,您才退出循环。但是,在非竞争情况下,我们不想pause。在 Skylake 上,PAUSE 等待的时间要长得多,例如从 ~5 循环增加 ~100 个循环,因此您绝对应该将自旋循环与初始检查解锁分开。

我确信 Intel 和 AMD 的优化手册都谈到了这一点,请参阅 标签 wiki 和大量其他链接。


不够好?例如,我应该在 C 中使用 register 关键字吗?

register 在现代优化编译器中是毫无意义的提示,调试版本除外 (gcc -O0)。

【讨论】:

  • 我现在不能这么说,但感谢您的洞察力!证明此评论合理的问题: volatile 关键字会强制编译器使用 register 关键字吗?即使是这种情况,使用 register 关键字作为锁开始是否有任何好处/要点?
  • volatileregister 正好相反。这意味着每次引用该值时都必须从内存中重新读取,并且每次存储都必须按照程序顺序单独完成。无论如何,您希望register 在生成的 asm 中做什么?锁定实现没有任何意义,即使它做了任何事情。
  • 这只是我缺乏理解。我不确定一开始是如何制作锁的。从业余的角度来看,这似乎是有道理的,因为锁因其性质而有点特殊。它们必须快速并利用原子操作,这样我们才能拥有线程安全性和可重入性。对于像我这样的业余爱好者来说,为锁保留一个寄存器似乎是有道理的。但我也不太确定那部分。我在想“int register res asm("r0")=0;"
  • @user1235831: 1. 每个线程都有自己的架构状态(包括寄存器),所以锁的寄存器变量不能工作。阅读 Jeff Preshing 关于内存排序的博客文章,它们很棒。 2. 通过永久绑定寄存器来减慢其余代码的速度是一个糟糕的主意。大多数代码不会花太多时间在锁定上。 3. 锁定本质上是昂贵的。您更有可能从avoiding locking as much as possible through careful design 中受益。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-10-20
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多