通过内联汇编锁定内存操作
Locks around memory manipulation via inline assembly
我是低级别的新手,所以我完全不知道你在那里可能会遇到什么样的问题,我什至不确定我是否理解 "atomic" 这个词。现在我正在尝试通过扩展程序集围绕内存操作进行简单的原子锁。为什么?为了好奇。我知道我正在重新发明轮子,可能会过度简化整个过程。
问题?
我在此处提供的代码是否实现了使内存操作既线程安全又可重入的目标?
- 如果有效,为什么?
- 如果不起作用,为什么?
- 不够好?例如,我应该在 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)
此外,考虑到上面的反汇编;我可以假设我正在进行原子操作,因为它只有一条指令吗?
我认为在 x86 上没有任何真正主要/明显的性能问题的简单自旋锁就是这样的。当然,真正的实现会使用系统调用(例如 Linux futex
) after spinning for a while, and unlocking would have to check if it needs to notify any waiters with another system call. This is important; you don't want to spin forever wasting CPU time (and energy / heat) doing nothing. But conceptually this is the spin part of a spinlock before you take the fallback path. It's an important piece of how light-weight locking 实现。(在调用内核之前只尝试获取一次锁是一个有效的选择,而不是根本旋转。)
尽可能多地在内联 asm 中实现这些,或者最好使用 C11 stdatomic
,像这样 . This is NASM syntax. In GNU C, make sure you use a "memory"
clobber to stop compile-time reordering of memory access (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。您可以在 bt
或 test
上旋转。要解锁,您需要 lock btr
,而不仅仅是 btr
,因为这将是字节的非原子读取-修改-写入,甚至是包含 32 位的字节。
使用像您通常应该使用的字节或整数大小的锁,您甚至不需要 lock
ed 操作来解锁; release semantics are enough. glibc's pthread_spin_unlock
和我的解锁功能一样:一个简单的商店。
(lock bts
不是必需的;xchg
或 lock cmpxchg
与普通锁一样好。)
第一次访问应该是原子 RMW
参见关于 的讨论 - 如果第一次访问是只读的,CPU 可能只发送对该缓存行的共享请求。然后,如果它看到该行已解锁(希望常见的低争用情况),它必须发送一个 RFO(读取所有权)才能真正写入缓存行。所以这是核外交易的两倍。
缺点是这将 MESI 独占该缓存行,但真正重要的是拥有锁的线程可以有效地存储 0
因此我们可以看到它已解锁.无论哪种方式,只读或 RMW,该核心都将失去对该行的独占所有权,并且在提交该解锁存储之前必须进行 RFO。
我认为当多个线程排队等待已占用的锁时,只读优先访问只会优化核心之间的流量。这将是一个愚蠢的优化。
(Fastest inline-assembly spinlock also tested the idea for a massively contended spinlock with multiple threads doing nothing but trying to take the lock, with poor results. That linked answer makes some incorrect claims about xchg
globally locking a bus - aligned lock
s don't do that, just a cache lock (), and each core can be 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 的优化手册讨论了这一点,请参阅 x86 标签 wiki 和大量其他链接。
Not good enough? Should I for example make use of the register keyword in C?
register
在现代优化编译器中是一个无意义的提示,调试版本除外 (gcc -O0
)。
我是低级别的新手,所以我完全不知道你在那里可能会遇到什么样的问题,我什至不确定我是否理解 "atomic" 这个词。现在我正在尝试通过扩展程序集围绕内存操作进行简单的原子锁。为什么?为了好奇。我知道我正在重新发明轮子,可能会过度简化整个过程。
问题? 我在此处提供的代码是否实现了使内存操作既线程安全又可重入的目标?
- 如果有效,为什么?
- 如果不起作用,为什么?
- 不够好?例如,我应该在 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)
此外,考虑到上面的反汇编;我可以假设我正在进行原子操作,因为它只有一条指令吗?
我认为在 x86 上没有任何真正主要/明显的性能问题的简单自旋锁就是这样的。当然,真正的实现会使用系统调用(例如 Linux futex
) after spinning for a while, and unlocking would have to check if it needs to notify any waiters with another system call. This is important; you don't want to spin forever wasting CPU time (and energy / heat) doing nothing. But conceptually this is the spin part of a spinlock before you take the fallback path. It's an important piece of how light-weight locking 实现。(在调用内核之前只尝试获取一次锁是一个有效的选择,而不是根本旋转。)
尽可能多地在内联 asm 中实现这些,或者最好使用 C11 stdatomic
,像这样 "memory"
clobber to stop compile-time reordering of memory access (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。您可以在 bt
或 test
上旋转。要解锁,您需要 lock btr
,而不仅仅是 btr
,因为这将是字节的非原子读取-修改-写入,甚至是包含 32 位的字节。
使用像您通常应该使用的字节或整数大小的锁,您甚至不需要 lock
ed 操作来解锁; release semantics are enough. glibc's pthread_spin_unlock
和我的解锁功能一样:一个简单的商店。
(lock bts
不是必需的;xchg
或 lock cmpxchg
与普通锁一样好。)
第一次访问应该是原子 RMW
参见关于
缺点是这将 MESI 独占该缓存行,但真正重要的是拥有锁的线程可以有效地存储 0
因此我们可以看到它已解锁.无论哪种方式,只读或 RMW,该核心都将失去对该行的独占所有权,并且在提交该解锁存储之前必须进行 RFO。
我认为当多个线程排队等待已占用的锁时,只读优先访问只会优化核心之间的流量。这将是一个愚蠢的优化。
(Fastest inline-assembly spinlock also tested the idea for a massively contended spinlock with multiple threads doing nothing but trying to take the lock, with poor results. That linked answer makes some incorrect claims about xchg
globally locking a bus - aligned lock
s don't do that, just a cache lock (
但是,如果最初的尝试发现它锁定了,我们不想用原子 RMW 继续敲打缓存行。那是我们退回到只读的时候。 10 个线程都为同一个自旋锁发送垃圾邮件 xchg
会使内存仲裁硬件非常繁忙。它可能会延迟解锁商店的可见性(因为该线程必须争夺该行的独占所有权),因此它会直接适得其反。它也可能一般内存一般用于其他核心。
PAUSE
也是必不可少的,以避免 CPU 对内存排序的错误推测。仅当您正在读取的内存 被另一个内核修改 时,您才退出循环。但是,我们不想 pause
在无竞争的情况下。在 Skylake 上,PAUSE
等待的时间更长,比如从 ~5 上升到 ~100 个周期,所以你绝对应该将自旋循环与初始检查解锁分开。
我确定 Intel 和 AMD 的优化手册讨论了这一点,请参阅 x86 标签 wiki 和大量其他链接。
Not good enough? Should I for example make use of the register keyword in C?
register
在现代优化编译器中是一个无意义的提示,调试版本除外 (gcc -O0
)。