设置条件变量监控标志不带锁,是否有效?

Setting condition variable monitor flag without a lock, is it valid?

提问人:Amir Kirsh 提问时间:9/8/2023 更新时间:9/13/2023 访问量:63

问:

以下代码使用条件变量和 monitor 标志来同步主线程和 thread2 之间的操作:

int main() {
    std::mutex m;
    std::condition_variable cv;
    std::atomic<bool> ready = false;
    std::thread thread2 = std::thread([&](){
        std::unique_lock<std::mutex> l(m);
        cv.wait(l, [&ready]{return ready.load();});
        std::cout << "Hello from thread2\n"; // 3 should print after 1
    });
    std::cout << "Hello from main thread\n"; // 1 we want this to be 1st
    ready = true; // 2, store to an atomic bool, without a lock, is it OK?
    cv.notify_one();
    thread2.join();
    std::cout << "Goodbye from main thread\n";
} 

在上面的代码中,我们同时使用 monitor 标志,因此对该标志的读取和写入不会产生数据争用(对于大多数(如果不是所有平台)来说都不是问题,但仍然是 UB“按书本”),并避免对标有 1 和 2 的行进行重新排序(原子变量的默认存储是保证在此线程中存储之前发生的所有事情都是可见的副作用在为此变量执行加载的线程中)。atomic<bool>readymemory_order_seq_cst

但是,该代码不会锁定标志(原子)的修改和对 的调用。readynotify_one

这篇 SO 帖子中可以清楚地看出,将调用保留到没有锁是可以的,它甚至可能更有效,因为我们不希望 thread2 在调用后处于唤醒状态,然后看到它应该等待锁定并被 os-scheduler 发送休眠, 直到锁被释放。notify_onenotify_one

但是,目前尚不清楚标志的修改是否应在锁定的范围内进行(使用用于读取的相同范围),还是使用就足够了?readymutexatomic<bool>

C++ 多线程 同步等待 条件变量

评论


答:

2赞 Amir Kirsh 9/8/2023 #1

标志的更新必须使用用于读取的相同互斥锁锁定ready

(然后布尔值可能变成一个简单的布尔值,而不是 )。atomic<bool>

根据 cppreference

即使共享变量是原子变量,也必须在拥有互斥锁时对其进行修改,以便将修改正确发布到等待线程。

这篇博文很好地解释了为什么需要锁,以及为什么使用原子是不够的。类似的解释可以在这篇 SO 文章(在相关的类似场景中)和这篇额外的 SO 文章中找到,该文章列出了即使使用原子变量也使用锁的原因。一个非常相似的问题已经在这里和这里进行了讨论和解释


问题

如果没有锁,我们可能会陷入以下竞争条件:

  1. Thread2 检查标志,它是 false,它计划开始等待条件变量(通过调用基本操作),但它仍然在此调用之前。readycv.wait(lock)
  2. 主线程将 flag 设置为 并且足够快,可以在 thread2 尚未等待条件变量时调用。readytruecv.notify_one()
  3. Thread2 现在调用并永久挂起,因为通知已经发送并且“丢失”。cv.wait(lock)

让我们来证明竞争条件

为了证明未锁定时的争用条件是真实的,我们可以在 thread2 中添加一个模拟有效计时场景的睡眠:

std::thread thread2 = std::thread([&](){
    std::unique_lock<std::mutex> l(m);
    cv.wait(l, [&ready]{
        auto r = ready.load();
        std::this_thread::sleep_for(20ms); // timing that causes missing the event
        return r;
    });

添加这个睡眠实际上使 thread2 挂起,QED:锁定标志的修改是绝对必要的,这不仅仅是理论上的。ready


解决方案:锁定!

以下版本通过锁定标志的修改解决了这个问题,我们现在不需要标志是:readyatomic

int main() {
    std::mutex m;
    std::condition_variable cv;
    bool ready = false;
    std::thread thread2 = std::thread([&](){
        std::unique_lock<std::mutex> l(m);
        cv.wait(l, [&ready]{ return ready; });
        std::cout << "Hello from thread2\n";
    });
    std::cout << "Hello from main thread\n";
    // synchronization block
    {
        std::lock_guard<std::mutex> l(m);
        ready = true;
    }
    cv.notify_one();
    thread2.join();
    std::cout << "Goodbye from main thread\n";
}

它如何解决上面介绍的比赛?

当锁归 thread2 所有时,主线程无法设置为。由于锁归 thread2 所有,直到它在调用释放(仅当等待开始时,锁才会被释放),因此在 thread2 开始等待条件变量之前,主线程将无法修改标志。因此,当 thread2 已处于等待状态时,可以保证对 main to 的调用发生。readytruecv.wait(lock)readycv.notify_one()


关于等待conditional_variable的用法的说明,以及设置为:第一个必须使用(这是需要在内部调用的 API),第二个可以同时使用两者,但我们选择更简单的方法(另请参阅:std::unique_lock<std::mutex> 或 std::lock_guard<std::互斥>?)。unique_locklock_guardreadytrueunique_lockwaitunlocklock_guard


等待超时

需要注意的是,我们可能更喜欢等待超时(一般来说,最好选择超时等待,以避免死锁,并对线程状态有更好的可追溯性)。如果我们等待超时,我们可能会决定放弃锁定并返回 ,如下所示atomic<bool>

int main() {
    std::mutex m;
    std::condition_variable cv;
    std::atomic<bool> ready = false;
    std::thread thread2 = std::thread([&](){
        std::unique_lock<std::mutex> l(m);
        // adding a timeout
        while(!cv.wait_for(l, 100ms, [&ready]{ return ready.load(); }));
        std::cout << "Hello from thread2\n";
    });
    std::cout << "Hello from main thread\n";
    ready = true; // no lock, cv.wait_for prevents us from hanging
    cv.notify_one();
    thread2.join();
    std::cout << "Goodbye from main thread\n";
}

当然,此解决方案存在计时问题,因为我们可能会等待额外的时间(超时持续时间)让 thread2 进行操作,但如果操作的优先级不高,这可能是一个有效的解决方案。