条件变量 —C++17 多线程
C++标准库提供了条件变量的两种实现:std::condition_variable
和std::condition_variable_any
。它们都在标准库的头文件<condition_variable>
内声明。两者都需配合互斥,方能提供妥当的同步操作。std::condition_variable
仅限于与std::mutex
一起使用;然而,只要某一类型符合成为互斥的最低标准,足以充当互斥,std::condition_variable_any
即可与之配合使用,因此它的后缀是“_any”。由于std::condition_variable_any
更加通用,它可能产生额外开销,涉及其性能、自身的体积或系统资源等,因此std::condition_variable
应予优先采用,除非有必要令程序更灵活
#pragma once
std::mutex mut;
std::queue<data_chunk> data_queue; ⇽-- - ①
std::condition_variable data_cond;
void data_preparation_thread() // 由线程乙运行
{while (more_data_to_prepare()){data_chunk const data = prepare_data();{std::lock_guard<std::mutex> lk(mut);data_queue.push(data); ⇽-- - ②}data_cond.notify_one(); ⇽-- - ③}
}
void data_processing_thread() // 由线程甲运行
{while (true){std::unique_lock<std::mutex> lk(mut); ⇽-- - ④data_cond.wait(lk, [] {return !data_queue.empty(); }); ⇽-- - ⑤data_chunk data = data_queue.front();data_queue.pop();lk.unlock(); ⇽-- - ⑥process(data);if (is_last_chunk(data))break;}
}
首先,我们使用std::queue
队列在两个线程之间传递数据①。一旦线程乙准备好数据,就使用std::lock_guard
锁住互斥以保护队列,并压入数据②。然后,线程乙调用std::condition_variable
实例的成员函数notify_one()
,通知线程甲③(如果它确实正等待着)。请注意,我们特地使用一个较小的代码块,放置压入数据的代码,目的是在解锁互斥后通知条件变量。若线程甲立刻觉醒,也无须等待互斥解锁,从而不会被阻塞。
同时,线程甲等待接收处理数据。这次,它先对互斥加锁,但使用的是std::unique_lock
而非std::lock_guard
④(我们很快会明白缘由)。线程甲在std::condition_variable实例上调用wait(),传入锁对象和一个lambda函数,后者用于表达需要等待成立的条件⑤。
本例中,[]{return !data_queue.empty();}
是一个简单的lambda函数,它检查容器data_queue
是否为空。若否,则说明已有数据备妥,存放在队列中等待处理。
接着,wait()在内部调用传入的lambda函数,判断条件是否成立:若成立(lambda函数返回true),则wait()返回;否则(lambda函数返回false),wait()
解锁互斥,并令线程进入阻塞状态或等待状态。线程乙将数据准备好后,即调用notify_one()
通知条件变量,线程甲随之从休眠中觉醒(阻塞解除),重新在互斥上获取锁,再次查验条件:若条件成立,则从wait()
函数返回,而互斥仍被锁住;若条件不成立,则线程甲解锁互斥,并继续等待。我们舍弃std::lock_guard
而采用std::unique_lock
,原因就在这里:线程甲在等待期间,必须解锁互斥,而结束等待之后,必须重新加锁,但std::lock_guard
无法提供这种灵活性。假设线程甲在休眠的时候,互斥依然被锁住,那么即使线程乙备妥了数据,也不能锁住互斥,无法将其添加到队列中。结果线程甲所等待的条件永远不能成立,它将无止境地等下去。
等待终止的条件判定需要查验队列是否非空⑤,为此,我们使用了简单的lambda函数。其实,也可以向wait()传递普通函数或可调用对象。本例只进行简单判定,实际上条件判定的函数有可能更加复杂,因而我们需事先另行写出。
那么,该判定函数就可以被直接传入,无须用lambda表达式包装。在wait()的调用期间,条件变量可以多次查验给定的条件,次数不受限制;在查验时,互斥总会被锁住;另外,当且仅当传入的判定函数返回true时(它判定条件成立),wait()才会立即返回。如果线程甲重新获得互斥,并且查验条件,而这一行为却不是直接响应线程乙的通知,则称之为伪唤醒(spurious wake)
。
按照C++标准的规定,这种伪唤醒出现的数量和频率都不确定。故此,若判定函数有副作用1,则不建议选取它来查验条件。倘若读者真的要这么做,就有可能多次产生副作用,所以必须准备好应对方法。譬如,每次被调用时,判定函数就顺带提高所属线程的优先级,该提升动作即产生的副作用。结果,多次伪唤醒可“意外地”令线程优先级变得非常高。
参考《C++并发编程实战(第2版)》