std::mutex::try_lock 虚假失败

std::mutex::try_lock spuriously fail?

本文关键字:失败 lock mutex try std      更新时间:2023-10-16

也许我对std::mutex::try_lock有误解:

允许此函数虚假失败并返回 false,即使互斥锁当前未被任何其他线程锁定。

这意味着如果没有一个线程在该mutex上锁定,当我尝试try_lock时,它可能会返回false?出于什么目的?

如果它被锁定,try_lock返回的功能不是false,或者如果没有人锁定它,true吗?不太确定我的非母语英语是否在愚弄我......

这意味着如果没有一个线程具有该互斥锁,当我尝试try_lock时,它可能会返回 false?

是的,这正是它所说的。

如果

它被锁定,try_lock返回 false 的功能不是假的,如果没有人锁定它,则返回 true 的函数不是真的吗?

不,try_lock的功能是尝试锁定互斥锁。

但是,它可能会失败的方式不止一种:

  1. 互斥锁已经在其他地方锁定(这是您正在考虑的那个)
  2. 某些特定于平台的功能会中断或阻止锁定尝试,并将控制权返回给可以决定是否重试的调用方。

POSIX-ish平台上的常见情况,并从POSIX线程继承,是信号被传递到当前线程(并由信号处理程序处理),中断锁定尝试。

在其他平台上可能还有其他特定于平台的原因,但行为是相同的。

根据您的评论,我会写(引用您的话):

std::unique_lock<std::mutex> lock(m, std::defer_lock); // m being a mutex
...
if (lock.try_lock()) {
... // "DO something if nobody has a lock"
} else {
... // "GO AHEAD"
}

请注意,lock.try_lock()有效地调用m.try_lock(),因此它也容易出现虚假失败。但我不太关心这个问题。IMO,在实践中,虚假故障/唤醒非常罕见(正如Useless指出的那样,在Linux上,它们可能在信号传递时发生)。

有关虚假问题的更多信息,请参阅例如:https://en.wikipedia.org/wiki/Spurious_wakeup 或为什么pthread_cond_wait有虚假唤醒?。

更新

如果你真的想消除try_lock的杂散失败,你可以使用一些原子标志,例如:

// shared by threads:
std::mutex m;  
std::atomic<bool> flag{false};
// within threads:
std::unique_lock<std::mutex> lock(m, std::defer_lock); // m being a mutex
...
while (true) {
lock.try_lock();
if (lock.owns_lock()) {
flag = true;
... // "DO something if nobody has a lock"    
flag = false;
break;
} else if (flag == true) {
... // "GO AHEAD"
break;
}
}

它可能会被重写成更好的形式,我没有检查。另外,请注意,flag不会通过 RAII 自动取消设置,一些范围保护在这里可能很有用。

更新 2

如果您不需要mutex的阻塞功能,请使用std::atomic_flag

std::atomic_flag lock = ATOMIC_FLAG_INIT;
// within threads:
if (lock.test_and_set()) {
... // "DO something if nobody has a lock"    
lock.clear();
} else {
... // "GO AHEAD"
}

只是,再一次,通过一些 RAII 机制清除标志会更好。

与那里所说的不同,我认为try_lock函数没有任何理由由于与操作系统相关的原因而失败:这样的操作是非阻塞的,所以信号不能真正中断它。它很可能与如何在 CPU 级别实现此功能有关。毕竟,对于互斥锁来说,无争议的情况通常是最有趣的情况。

互斥锁通常需要某种形式的原子比较交换 操作。C++11 和 C11 介绍了atomic_compare_exchange_strongatomic_compare_exchange_weak。后者被允许虚假地失败。

通过允许try_lock虚假失败,允许实现使用atomic_compare_exchange_weak来最大化性能和最小化代码大小。

例如,在ARM64上,原子操作通常使用独占加载(LDXR)和独占存储(STRX)指令来实现。LDXR启动"监视器"硬件,该硬件开始跟踪对内存区域的所有访问。 仅当在LDXRSTRX指令之间未访问该区域时,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

某些try_lock函数即使在使用atomic_compare_exchange_strong时也可能失败,例如,shared_mutextry_lock_shared可能需要增加读取器计数器,如果另一个读取器进入锁定,则可能会失败。这种函数的"强"变体需要生成一个循环,因此可能会遭受类似的分支错误预测。

另一个小细节:如果互斥体是用 C 语言编写的,一些编译器(如 Clang)可能会在 16 字节边界对齐循环以提高其性能,使函数体膨胀。如果循环几乎总是运行一次,则不需要这样做。


虚假故障的另一个原因是无法获取内部互斥锁(如果使用自旋锁和某些内核原语实现互斥锁)。理论上,在try_lock的内核实现中也可以获得相同的原理,尽管这似乎并不合理。

在论文《C++并发内存模型基础》第3节中,已经明确解释了为什么该标准允许try_lock的虚假故障。简而言之,它被指定为使try_lock的语义与C++记忆模型中的种族定义保持一致。

如果对try_lock()的调用返回 true,则调用成功锁定锁。如果它返回 false,如果没有。仅此而已。是的,当没有其他人拥有锁时,该函数可以返回 false。False表示尝试锁定未成功;它不会告诉你为什么它失败了。