与那里所说的不同,我不认为try_lock 函数由于操作系统相关原因而失败:这种操作是非阻塞的,因此信号不能真正中断它。很可能它与如何在 CPU 级别实现此功能有关。毕竟,对于互斥体来说,无争议的情况通常是最有趣的。
互斥锁通常需要某种形式的原子比较交换
手术。 C++11 和 C11 引入了atomic_compare_exchange_strong 和atomic_compare_exchange_weak。后者允许虚假失败。
通过允许try_lock 虚假失败,实现可以使用atomic_compare_exchange_weak 来最大化性能并最小化代码大小。
例如,在 ARM64 上,原子操作通常使用独占加载 (LDXR) 和独占存储 (STRX) 指令实现。 LDXR 启动“监控”硬件,开始跟踪对内存区域的所有访问。 STRX 仅在 LDXR 和 STRX 指令之间没有访问该区域时才执行存储。因此,如果另一个线程访问该内存区域或两者之间存在 IRQ,则整个序列可能会错误地失败。
实际上,使用弱保证实现的try_lock 代码生成与使用强保证实现的代码并没有太大区别。
bool mutex_trylock_weak(atomic_int *mtx)
{
int old = 0;
return atomic_compare_exchange_weak(mtx, &old, 1);
}
bool mutex_trylock_strong(atomic_int *mtx)
{
int old = 0;
return atomic_compare_exchange_strong(mtx, &old, 1);
}
查看为 ARM64 生成的程序集:
mutex_trylock_weak:
sub sp, sp, #16
mov w1, 0
str wzr, [sp, 12]
ldaxr w3, [x0] ; exclusive load (acquire)
cmp w3, w1
bne .L3
mov w2, 1
stlxr w4, w2, [x0] ; exclusive store (release)
cmp w4, 0 ; the only difference is here
.L3:
cset w0, eq
add sp, sp, 16
ret
mutex_trylock_strong:
sub sp, sp, #16
mov w1, 0
mov w2, 1
str wzr, [sp, 12]
.L8:
ldaxr w3, [x0] ; exclusive load (acquire)
cmp w3, w1
bne .L9
stlxr w4, w2, [x0] ; exclusive store (release)
cbnz w4, .L8 ; the only difference is here
.L9:
cset w0, eq
add sp, sp, 16
ret
唯一的区别是“弱”版本消除了条件向后分支cbnz w4, .L8 并用cmp w4, 0 替换它。在没有分支预测信息的情况下,CPU 将后向条件分支预测为“将被采用”,因为它们被假定为循环的一部分——在这种情况下,这种假设是错误的,因为大部分时间都将获得锁定(低竞争被认为是最常见的情况)。
Imo 这是这些功能之间唯一的性能差异。 “强”版本在某些工作负载下,单条指令的分支误预测率基本上可以达到 100%。
顺便说一下,ARMv8.1引入了原子指令,所以两者没有区别,就像在x86_64上一样。使用-march=armv8.1-a 标志生成的代码:
sub sp, sp, #16
mov w1, 0
mov w2, 1
mov w3, w1
str wzr, [sp, 12]
casal w3, w2, [x0]
cmp w3, w1
cset w0, eq
add sp, sp, 16
ret
即使使用atomic_compare_exchange_strong,某些try_lock 函数也可能会失败,例如shared_mutex 中的try_lock_shared 可能需要增加读取器计数器,并且如果另一个读取器进入锁,则可能会失败。这种函数的“强”变体需要生成一个循环,因此可能会遭受类似的分支误判。
另一个小细节:如果 mutex 是用 C 编写的,一些编译器(如 Clang)可能会在 16 字节边界处对齐循环以提高其性能,使用填充的函数体膨胀。如果循环几乎总是运行一次,这是不必要的。
虚假失败的另一个原因是未能获取内部互斥锁(如果互斥锁是使用自旋锁和一些内核原语实现的)。理论上,在try_lock 的内核实现中可以得到相同的原理,尽管这似乎不合理。