提问者:小点点

通过内联程序集锁定内存操作


我对低级的东西不熟悉,所以我完全忘记了你在那里可能会面临什么样的问题,我甚至不确定我是否理解“原子”这个术语。现在我正试图通过扩展汇编围绕内存操作制作简单的原子锁。为什么?出于好奇。我知道我在这里重新发明轮子,可能过度简化了整个过程。

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

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

我只是想做…

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

代码:

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的堆栈帧后,Objdump生成:

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

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


共1个答案

匿名用户

我认为一个简单的自旋锁在x86上没有任何真正重大/明显的性能问题是这样的。当然,一个真正的实现会在旋转一段时间后使用一个系统调用(比如Linuxfutex),解锁必须检查它是否需要用另一个系统调用通知任何服务员。这很重要;你不想永远旋转浪费CPU时间(和精力/热量)什么都不做。但从概念上讲,这是自旋锁在你走回退路径之前的旋转部分。这是轻量级锁定如何实现的一个重要部分。(在调用内核之前只尝试获取一次锁将是一个有效的选择,而不是完全旋转。)

在内联asm中实现尽可能多的内容,或者最好使用C11stdatomic,就像这个信号量实现一样。这是NASM语法。在GNU C中,确保使用“内存”阻塞器来停止编译时对内存访问的重新排序(TTAS一致性问题?)

;;; 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或其他东西中得到)。获取/释放足以保护临界区(因此得名)。

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

使用通常应该使用的字节或整型锁,您甚至不需要locked操作即可解锁;发布语义学就足够了。

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

请参阅cmpxchg在失败时写入目标缓存行吗?如果不是,它是否比xchg更适合自旋锁?-如果第一次访问是只读的,CPU可能会为该缓存行发送一个共享请求。然后,如果它看到该行未锁定(希望常见的低竞争情况),它将不得不发送一个RFO(读取所有权)才能真正写入缓存行。因此,这是非核心事务的两倍。

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

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

(最快的内联汇编自旋锁也测试了一个大规模竞争自旋锁的想法,多个线程除了试图获取锁之外什么也不做,结果很差。这个链接的答案提出了一些关于xchg全局锁定总线的错误说法-对齐的lock不这样做,只是一个缓存锁(num可以是'int num'的原子吗?),每个内核可以同时在不同的缓存线上执行单独的原子RMW。)

但是,如果最初的尝试发现它锁定了,我们不想继续用原子RMW敲打缓存行。这时我们又回到了只读。10个线程都为同一个自旋锁发送xchg将使内存仲裁硬件非常繁忙。这可能会延迟解锁存储的可见性(因为该线程必须争夺该行的独占所有权),因此它直接适得其反。它也可能通常用于其他内核的内存。

PAUSE也是必不可少的,以避免CPU对内存排序的错误猜测。只有当您正在读取的内存被另一个内核修改时,您才会退出循环。但是,我们不希望在无竞争的情况下暂停。在Skylake上,PAUSE等待的时间要长得多,比如从~5上升到~100个周期,所以您绝对应该将自旋循环与初始解锁检查分开。

我相信英特尔和AMD的优化手册会讨论这个问题,请参阅x86标签wiki和大量其他链接。

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

寄存器在现代优化编译器中是一个毫无意义的提示,除了在调试构建中(gcc-O0)。