C语言 通过内联汇编锁定内存操作

omjgkv6w  于 2023-11-16  发布在  其他
关注(0)|答案(1)|浏览(138)

我是一个新手,所以我完全不知道你可能会面临什么样的问题,我甚至不确定我是否正确理解了术语“原子”。现在我正在尝试通过扩展组装围绕内存操作创建简单的原子锁。为什么?出于好奇心。我知道我在这里重新发明轮子,可能会过度简化整个过程。

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

  • 如果成功了,为什么?
  • 如果不管用,为什么?
  • 还不够好吗?比如我应该在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加载堆栈框架后,objdump生成:

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


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

tp5buhyn

tp5buhyn1#

我认为一个简单的自旋锁在x86上没有任何真正的主要/明显的性能问题,就像这样。(像Linux futex)旋转一段时间后,解锁将不得不检查是否需要通知任何服务员与另一个系统调用。这是重要的;你不想永远旋转浪费CPU时间(和能量/热量)什么也不做。但是**从概念上讲,这是互斥的自旋部分,在你采取回退路径之前。**这是如何实现light-weight locking的重要部分。(在调用内核之前只尝试获取一次锁是一个有效的选择,而不是根本旋转。
在内联asm中尽可能多地实现这些功能,或者最好使用C11 stdatomic,比如semaphore implementation。这是NASM语法。如果使用GNU C内联asm,请确保使用"memory" clobber来停止compile-time reordering of memory access。但不要使用内联asm;使用C _Atomic uint8_t或C++ std::atomic<uint8_t>.exchange(1, std::memory_order_acquire).store(0, std::memory_order_release),以及来自immintrin.h_mm_pause()

;;; 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位。
使用字节或int大小的锁,你甚至不需要lock ed操作来解锁;释放语义就足够了。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(Read For Ownership)才能真正写入该高速缓存线。因此,这是核外事务的两倍。
缺点是这将使MESI独占该缓存行的所有权,但真正重要的是拥有锁的线程可以有效地存储0,因此我们可以看到它被解锁。无论是只读还是RMW,该核心都将失去对该行的独占所有权,并且必须在提交该解锁存储之前进行RFO。
我认为当多个线程排队等待一个已经被占用的锁时,只读的第一次访问只会稍微减少核心之间的流量,这是一个愚蠢的优化。
(最快的内联汇编自旋锁也测试了大规模竞争自旋锁的想法,多个线程什么也不做,只是试图获取锁,结果很差。这个链接的答案做出了一些关于xchg全局锁定总线对齐的lock的错误声明-s不这样做,只是一个缓存锁(Is incrementing an int effectively atomic in specific cases?),并且每个核可以同时在 * 不同的 * 高速缓存行上进行单独的原子RMW。
但是,如果初始尝试发现它锁定,我们不想用原子RMW不断地攻击该高速缓存行。这时我们就退回到只读状态。10个线程都在为同一个自旋锁发送xchg垃圾邮件,这将使内存仲裁硬件非常忙碌。这可能会延迟解锁的存储的可见性(因为该线程必须争用该行的独占所有权),所以它直接起反作用。

PAUSE也是必不可少的,以避免CPU对内存排序的错误推测。只有当您正在阅读的内存 * 被另一个核心修改时,您才退出循环。然而,我们不希望在无竞争的情况下pause。在Skylake上,PAUSE等待的时间要长得多,比如从~5增加到~100个周期,所以你一定要把自旋循环和初始的解锁检查分开。

我敢肯定,英特尔和AMD的优化手册谈论这一点,看到x86标签维基和吨的其他链接。
还不够好吗?比如我应该在C中使用register关键字吗?
register在现代优化编译器中是一个无意义的提示,除了在调试版本(gcc -O0)中。

相关问题