【问题标题】:std::mutex::try_lock spuriously fail?std::mutex::try_lock 虚假失败?
【发布时间】:2018-06-26 13:26:21
【问题描述】:

也许I'm misunderstanding 关于std::mutex::try_lock

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

这意味着如果没有一个线程锁定该mutex,当我尝试try_lock可以返回false?目的是什么?

try_lock的功能不就是如果被锁了就返回falseORtrue如果没人锁吗?不太确定我的非母语英语是否在愚弄我......

【问题讨论】:

  • 这与允许 std::condition_variable::wait 虚假唤醒的原因相同,即使条件尚未设置 - 它允许操作系统更多地优化一些常见情况
  • @YSC 我真的没有时间写一个完整的答案 :( 但是 condition_variable 是一个我知道在某处的问题...

标签: c++ locking mutex


【解决方案1】:

在论文 Foundations of the C++ Concurrency Memory Model 第 3 节中,已经清楚地解释了为什么该标准允许 try_lock 的虚假故障。简而言之,就是指定try_lock的语义与C++内存模型中race的定义一致。

【讨论】:

    【解决方案2】:

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

    互斥锁通常需要某种形式的原子比较交换 手术。 C++11 和 C11 引入了atomic_compare_exchange_strongatomic_compare_exchange_weak。后者允许虚假失败。

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

    例如,在 ARM64 上,原子操作通常使用独占加载 (LDXR) 和独占存储 (STRX) 指令实现。 LDXR 启动“监控”硬件,开始跟踪对内存区域的所有访问。 STRX 仅在 LDXRSTRX 指令之间没有访问该区域时才执行存储。因此,如果另一个线程访问该内存区域或两者之间存在 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 的内核实现中可以得到相同的原理,尽管这似乎不合理。

    【讨论】:

    • “这样的操作是非阻塞的,所以信号不能真正中断它” 嗯,除非操作是完全原子的,那么它们当然可以。每个操作都是“阻塞”,只是程度的衡量
    • @LightnessRacesinOrbit 如果一切都阻塞,那么没有任何东西阻塞。内核中任何合理的try_lock 实现只需要等待几个自旋锁。这些等待不会被信号中断。甚至 posix 也没有列出 EINTR 的锁定/定时锁定功能。
    • 我只与您在引用段落中的声明有关,而不是与 POSIX 系统上的 try_lock 相关
    • @LightnessRacesinOrbit Ofc 你可以通过一些建立在套接字之上的消息传递机制来实现try_lock,这是非常可中断的(这就是Wine 实际上所做的) ,但我认为这样的实施“不合理”。没有理由使编程接口过于复杂。接口应尽可能少,“不合理”的实现应隐藏任何不必要的细节。
    • 不幸的是,“应该”和“必须”并不总是一致的。
    【解决方案3】:

    根据你的 cmets,我会写(引用你的话):

    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_wakeupWhy does pthread_cond_wait have spurious wakeups?

    更新

    如果你真的想消除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 机制清除标志会更好。

    【讨论】:

    • 我没有看到你的任何代码,但你问:“我需要一个解决方案,如果没有人有锁,我会做一些事情。否则继续(即不锁定线程,只是忽略 if 语句)否则。try-lock 似乎不是正确的方法。你有什么建议?”。这就是我的建议。此外,它使用std::unique_lock,这比直接使用互斥锁要好,因为它支持通过 RAII 技术保证解锁。但主要信息是,在这种情况下,我根本不会关心虚假失败。
    • @markzzz 我更新了处理虚假失败的答案。
    【解决方案4】:

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

    【讨论】:

    • 但是如果没有人锁定它,为什么要the attempt to lock shouldn't succeed?听起来像锁定失败:D
    • 这样设计的原因很严重。
    • @markzzz 我的答案中有链接,可以解释它们。但是,我还提出了另一个更简单的解决方案,它适用于您不需要 mutex 的阻塞功能(这似乎是您的情况)的情况。请参阅我的答案的第二次更新。
    • 确实对我来说似乎是一个很大的抽象泄漏,但事实就是这样
    【解决方案5】:

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

    是的,这正是它所说的。

    try_lock的函数是不是锁了就返回false,没有人锁就返回true?

    不,try_lock 的作用是尝试锁定互斥体。

    但是,失败的方式不止一种:

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

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

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

    【讨论】:

    • 这意味着我的部分代码“随机”将/不会被执行,即使没有其他线程正在使用锁:O
    • 不,这意味着如果您的代码不是为了应对这种完全正常的情况而设计的,那么它是静态不正确且编写不佳
    • @markzzz 这就是为什么你测试 try 的结果,而不是假设你有它。
    • 取决于还有谁在争夺锁,以及它所保护的内容,但听起来应该有自己的问题
    • @markzzz:可能值得退后一步,重新审视你试图解决的根本问题,因为这似乎是一个弱策略来解决它。少关注你认为正确的一种方法,多关注其他方法可能是什么。让程序行为依赖于时间和线程交互,就像这样对我来说听起来像是一个巨大的抽象泄漏。线程应该做什么?
    猜你喜欢
    • 2016-02-27
    • 2016-05-21
    • 1970-01-01
    • 1970-01-01
    • 2011-03-21
    • 2017-03-28
    • 2013-12-29
    • 2015-08-24
    • 2011-12-10
    相关资源
    最近更新 更多