【问题标题】:C++ atomics: how to allow only a single thread to access a function?C++ atomics:如何只允许一个线程访问一个函数?
【发布时间】:2021-03-01 04:25:36
【问题描述】:

我想编写一个一次只能由一个线程访问的函数。我不需要忙等待,如果另一个线程已经在运行它,那么残酷的“拒绝”就足够了。到目前为止,这是我想出的:

std::atomic<bool> busy (false);

bool func()
{
    if (m_busy.exchange(true) == true)
        return false;  

    // ... do stuff ...

    m_busy.exchange(false);
    return true;
}
  1. 原子交换的逻辑是否正确?
  2. 将两个原子操作标记为std::memory_order_acq_rel是否正确?据我了解,轻松订购 (std::memory_order_relaxed) 不足以防止重新订购。

【问题讨论】:

  • 曾经需要一个线程来等待吗?或者如果无法获得锁,每个 线程是否会访问它?
  • @DavidSchwartz b) 无法获得锁的线程还有其他事情要做。
  • 仅供参考:您尝试实现的东西有一个名称。它被称为mutex。此外,有时称为 advisory lock, 或简称为 lock,,受互斥锁保护的代码行有时称为 critical section。 “互斥”是“互斥”的合成词。
  • 另请注意:您试图阻止多个线程同时进入同一个函数的想法会分散注意力。很少有多少线程同时进入同一个函数。 真正重要的是,有多少线程同时访问相同的数据。当只有一个函数可以访问数据时,很容易混淆这两种想法。当有多个函数访问数据时,您可能会遇到麻烦,除非所有函数都锁定同一个互斥体。

标签: c++ multithreading lock-free stdatomic mutual-exclusion


【解决方案1】:

您的原子交换实现可能会起作用。但是尝试在没有锁的情况下进行线程安全编程总是充满问题并且通常更难维护。

除非需要提高性能,否则您只需要使用try_lock() 方法的std::mutex,例如:

std::mutex mtx;

bool func()
{
    // making use of std::unique_lock so if the code throws an
    // exception, the std::mutex will still get unlocked correctly...

    std::unique_lock<std::mutex> lck(mtx, std::try_to_lock);
    bool gotLock = lck.owns_lock();

    if (gotLock)
    {
        // do stuff
    }

    return gotLock;
}

【讨论】:

  • 我很想使用 try-lock,不幸的是我处于 100% 无锁场景中:)
  • 你能详细说明为什么不能使用锁吗?
  • @Ignorant:您的代码在逻辑上与此等效,并且很可能与性能等效。如果您从不使用mtx.lock() 或其他任何东西,那么您永远不会真正阻塞,即等待锁定,因此您的代码在进度保证意义上仍然可能是lock-free。 “无锁”不是的意思是“避免任何名称中带有'锁'的东西”。使用 std::atomic 的手动自旋锁不会是无锁的,而仅使用 std::mutex is 的非阻塞功能仍然是无锁的(并且可能是无等待的)。
  • 一些 std::mutex 实现在非竞争和失败的 trylock 情况下可能比简单的原子标志做更多的工作,但如果以这种方式使用,任何好的实现都应该避免进行任何系统调用。 (@selbie:互斥锁应该是 static 本地变量,而不是全局变量。)
  • @selbie 请注意,如果您的代码示例在do stuff 部分中引发异常,则互斥锁将无法正确解锁。考虑将std::unique_lockstd::try_to_lock 策略一起使用,这样如果互斥锁成功锁定,即使抛出异常,它也总是会解锁。 bool func() { std::unique_lock&lt;std::mutex&gt; lck(mtx, std::try_to_lock); bool gotLock = lck.owns_lock(); if (gotLock) { ... } return gotLock; }
【解决方案2】:

您的代码在我看来是正确的,只要您通过退出、不返回或抛出异常离开临界区。

您可以使用release 商店解锁; RMW(如交换)是不必要的。初始交换只需要acquire。 (但确实需要像 exchangecompare_exchange_strong 这样的原子 RMW)

请注意,ISO C++ 表示获取std::mutex 是“获取”操作,而释放是“释放”操作,因为这是保持获取和释放之间包含关键部分的最低要求。


您的算法与自旋锁完全一样,但如果锁已被占用,则无需重试。 (即只是一个 try_lock)。所有关于锁定的必要内存顺序的推理也适用于此。 您实现的内容在逻辑上等同于 @selbie 的答案中的 try_lock / unlock 并且很可能也等同于性能。如果你从不使用mtx.lock() 或其他什么,你永远不会真正阻塞,即等待另一个线程做某事,所以你的代码在进度保证意义上仍然可能是lock-free

使用atomic&lt;bool&gt; 滚动你自己的可能很好;在这里使用std::mutex 没有任何好处;您希望它只为尝试锁定和解锁而执行此操作。这当然是可能的(有一些额外的函数调用开销),但一些实现可能会做更多的事情。除此之外,您没有使用任何功能。 std::mutex 给您的一件好事是让您感到舒适,因为它知道它安全且正确地实现了 try_lockunlock。但是,如果您了解锁定和获取/释放,那么您自己就很容易做到这一点。

不滚动您自己的锁定的通常性能原因是 mutex 将针对操作系统和典型硬件进行调整,例如指数退避、x86 pause 指令同时旋转几次,然后回退到系统称呼。并通过系统调用(如 Linux futex)进行高效唤醒。所有这些都只对阻塞行为有益。 .try_lock 让所有这些都未使用,如果你从来没有任何线程休眠,那么unlock 永远不会有任何其他线程要通知。


使用std::mutex 有一个优点:您可以使用 RAII,而无需滚动您自己的包装类。 std::unique_lockstd::try_to_lock 策略将执行此操作。这将使您的函数异常安全,确保在退出之前始终解锁,如果它获得了锁。

【讨论】:

  • 所以如果我理解正确的话,只有一个线程可以进入临界区这一事实使得算法无锁,即使使用互斥锁也是如此。如果允许多个线程(互斥体将完成触发睡眠系统调用等工作),则无锁会丢失。
  • @Ignorant:无锁是进度保证,请参阅我的回答中的 wiki 链接。您已经在这里拉伸它,因为如果另一个线程进入临界区然后碰巧休眠或死亡,则没有其他线程可以在关键部分中的任何内容上取得进展。在重试之间做一些 else 不会使受保护的工作本身无锁。但是,如果它不是那么简单,并且当函数返回 false 时调用者实际上仍然可以为同一个整体任务贡献进度,那么它可以说是无锁算法的一部分。
  • @Ignorant:要无锁,你必须避免休眠以等待其他线程执行某些操作(这不是充分条件,例如自旋锁不是无锁的)。但我不明白为什么允许多个线程进入关键部分会使其非无锁。
  • 我很确定无锁计数信号量是可能的,如果超过限制,它会失败而不是休眠。例如atomic&lt;int&gt; slots_left=3 并使用 compare_exchange_weak 获取,如果当前值非零,则递减。您需要一个 CAS 重试循环,它是无锁但不是无等待的,因为当另一个线程将计数从 2 更改为 1 时,您不想因争用而出现虚假失败,例如:还有一个留给该线程占用的插槽。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2023-01-16
  • 2016-12-31
  • 1970-01-01
  • 2016-10-01
  • 1970-01-01
  • 2012-01-05
相关资源
最近更新 更多