【问题标题】:waiting on worker thread using std::atomic flag and std::condition_variable使用 std::atomic 标志和 std::condition_variable 等待工作线程
【发布时间】:2020-03-28 15:30:01
【问题描述】:

这是一个 C++17 sn-p,其中一个线程等待另一个线程到达某个阶段:

std::condition_variable  cv;
std::atomic<bool>        ready_flag{false};
std::mutex               m;


// thread 1
... // start a thread, then wait for it to reach certain stage
auto lock = std::unique_lock(m);
cv.wait(lock, [&]{ return ready_flag.load(std::memory_order_acquire); });


// thread 2
... // modify state, etc
ready_flag.store(true, std::memory_order_release);
std::lock_guard{m};   // NOTE: this is lock immediately followed by unlock
cv.notify_all();

据我了解,这是使用原子标志和条件变量来实现目标的有效方法。例如这里不需要使用std::memory_order_seq_cst

是否可以进一步放宽此代码?例如:

  • 也许在ready_flag.load() 中使用std::memory_order_relaxed
  • 也许使用std::atomic_thread_fence() 而不是std::lock_guard{m};

【问题讨论】:

  • 我认为你根本不需要 std::atomic 因为你在锁里面。但是,您需要在锁内设置标志
  • 您是否衡量了自己的表现并发现了瓶颈?
  • @bertubezz,由于与此问题无关的某些原因,我需要标志是原子的。
  • @jtbandes,目的是尽可能放宽逻辑,同时保持 C++ 内存模型的约束。性能无关紧要
  • @bertubezz 在线程 2 中,ready_flag 在锁外设置为 true

标签: c++ multithreading c++17


【解决方案1】:

std:atomicstd:condition_variable 的组合使用是非常规的,应该避免, 但是,如果您在代码审查中遇到此行为并需要决定是否需要补丁,那么分析行为可能会很有趣。

我认为有2个问题:

  1. 由于ready_flag 不受std:mutex 保护,因此您不能保证一旦waitnotify_one 唤醒,线程1 将观察到更新的值。 如果在线程 2 中存储到 ready_flag 被平台延迟,线程 1 可能会看到旧值(false)并再次输入 wait(可能导致死锁)。
    延迟存储是否可能取决于您的平台。在诸如X86 之类的强排序平台上,您可能是安全的,但同样不能保证 C++ 标准。
    另请注意,使用更强的内存排序在这里没有帮助。

  2. 假设商店没有延迟,一旦 wait 唤醒,ready_flag 加载 true
    这一次,根据您使用的内存顺序,线程 2 中对 ready_flag 的存储与线程 1 中的加载同步,线程 1 现在可以安全地访问线程 2 写入的修改状态。

    但是,这只适用于一次。您无法重置ready_flag 并再次写入共享状态。这将引入数据竞争,因为现在两个线程都可以不同步地访问共享状态

是否可以进一步放宽这段代码

因为您正在修改锁之外的共享状态,所以需要在ready_flag 上释放/获取排序以进行同步。

要使其成为可移植的解决方案,请访问共享状态和ready_flag,同时受互斥锁保护(ready_flag 可以是普通的bool)。 这就是该机制的设计用途。

std::condition_variable  cv;
bool                     ready_flag{false}; // not atomic
std::mutex               m;


// thread 1
... // start a thread, then wait for it to reach certain stage
auto lock = std::unique_lock(m);
cv.wait(lock, [&] { return ready_flag; });
ready_flag = false;
// access shared state


// thread 2
auto lock = std::unique_lock(m);
... // modify state, etc
ready_flag = true;
lock.unlock();  // optimization
cv.notify_one();

在调用notify_one 之前解锁互斥锁是一种优化。详情请见this question

【讨论】:

  • 问题中的代码是可移植的、有效的代码,前提是没有其他写入ready_flag
  • @LWimsey -- 我不认为你在第 1 点上是正确的。互斥锁+解锁可防止您提到的死锁。 Wrt 进一步放松——我几乎是肯定的 load() cv.wait() 可以完全放松,但不是 100% :)——因此这篇文章。
  • @C.M.我在标准中找不到任何东西让我相信 T2 的商店不能延迟。或者,换句话说,如果它死锁(根据我的回答中的#1),违反了标准的哪一部分?
  • @C.M. & AnthonyWilliams,我想我在我正在寻找的标准中找到了这句话。 N4713, 33.5.4 说:实现应该表现得好像 notify_one、notify_all 的所有执行以及 wait、wait_for 和 wait_until 执行的每个部分都以与“发生在之前”一致的单个未指定总顺序执行顺序。。看起来它不能死锁。
【解决方案2】:

首先:此代码确实有效。在 notify_one 调用之前的 lock_guard 确保等待线程在唤醒时将看到正确的 ready_flag 值,无论这是由于虚假唤醒还是由于对 notify_one 的调用。

其次:如果对ready_flag 的唯一访问是此处显示的那些,那么使用atomic 是多余的。将写入移动到写入器线程上lock_guard 范围内的ready_flag,并使用更简单、更传统的模式。

如果您坚持这种模式,那么您是否可以使用memory_order_relaxed 取决于您需要的排序语义。

如果设置ready_flag 的线程也写入将由读取器线程读取的其他对象,那么您需要获取/释放语义以确保数据正确可见:读取器线程可能会锁定互斥体并看到ready_flag 的新值写入器线程锁定互斥体之前,在这种情况下,互斥体本身将不提供排序保证。

如果设置ready_flag的线程没有接触到其他数据,或者该数据被另一个互斥锁或其他同步机制保护,那么你可以在任何地方使用memory_order_relaxed,因为它只是@的值您关心的 987654334@ 本身,而不是任何其他写入的顺序。

atomic_thread_fence 在任何情况下都无法使用此代码。如果您使用的是条件变量,则需要 lock_guard{m}

【讨论】:

  • 代码被简化,在现实生活中ready_flag 是更复杂的结构(如非阻塞单链表),它被填充在复杂的逻辑中,我现在不想唤醒其他线程它还被填充(实际上我只想在以前的列表为空时唤醒它们)。代码可以容忍虚假唤醒,如果处理线程过早地抓取元素 - 它很好并且得到了照顾。事实上,slist 可以由其他线程填充(在我的情况下不会发生)
  • 无论如何,我的观点是设置“标志”和唤醒其他人是两个动作,它们之间可能有很大的距离(或者我不想在标志时被互斥锁阻塞已设置,或者我希望其他线程能够在不锁定的情况下检查标志等)。 Wrt 放松,我对在 cv.wait() 中使用完全放松的负载感觉很好,但不是 100% 确定...
猜你喜欢
  • 2020-11-01
  • 2020-11-22
  • 2014-01-25
  • 1970-01-01
  • 2015-01-28
  • 1970-01-01
  • 2021-09-02
  • 2014-03-12
  • 2021-04-09
相关资源
最近更新 更多