《C++ Concurrency in Action》笔记14 condition_variable

有时候,你不仅仅需要保护数据,还要在线程之间做同步执行。一个线程可能需要等待一个线程完成一个任务后再继续执行。例如:一个线程等待一个时间发生或者一个条件达成。尽快通过周期判断一个标志或者一些保存在共享数据中的类似的东西可能达到目的,但这并不是理想的做法。在线程间进行同步操作是如此的寻常,C++标准库提供了灵活的手段:condition variables和futures。

假如你正坐在旅行的火车上,一种确保不坐过站的方式就是整夜不睡并时刻注意火车到哪了。肯定不会错过站,但是你将因为时刻保持警惕而疲惫不堪。另一种办法是对到站时间做出预测,将闹钟设置稍微提前一点,然后去睡觉。这个主意无疑是更好的,你肯定不会睡过站,顶多火车晚点了,导致你过早醒来。但也有可能你的闹钟坏了,导致你睡过站。最好的办法就是,让别人或者别的什么东西保证无论是什么时候,只要火车到站就叫醒你。

上面说的情况与多线程有什么关系呢?如果一个线程需要等待另一个线程完成一个任务,那么它有几个选项。首先,它可以一直不间断的检测一个标志量,这个标志量处于mutex保护状态,另一个线程完成任务就去设置这个标志量。这无疑是一种浪费,这两个线程的操作彼此冲突,一个读,一个写。检查状态量的线程浪费昂贵的资源去不断的锁定mutex,以至于任何其他线程都要进入等待状态。这就仿佛是你整夜不睡与火车司机交谈,他不得不开得慢一些,因为你导致他分心,所以火车要晚点到达。类似的,等待线程由于过多占用了宝贵的资源,间接导致延长了等待的时间。

第二种方案是使用小周期检测,并使用 std::this_thread::sleep_for()函数:

bool flag;
std::mutex m;
void wait_for_flag()
{
	std::unique_lock lk(m);
	while (!flag)
	{
		lk.unlock();
		std::this_thread::sleep_for(std::chrono::milliseconds(100));
		lk.lock();
	}
}
这是一种改进,因为函数在sleep时没有浪费宝贵的处理时间,但是它很难确定一个恰好的sleep周期。太短会浪费资源,太长会导致判断不及时。这可能在程序中导致操作不及时,甚至在一些实时系统中会导致丢帧。

第三种方法,也是推荐的方法是使用C++标准库提供的手段去等待事件本身。等待由另一个线程触发的事件的最基本的机制是条件变量(condition variable)。条件变量与另外的事件或条件关联,可以被一个或多个线程等待达成。但一个线程设置条件达成后,它会唤醒(notify)所有正在等待这个条件的线程继续工作。

C++标准库支持两种条件变量:std::condition_variable和std::condition_variable_any。它们都声明在 头文件中。它们都需要使用mutex一起工作。condition_variable只能与std::unique_lock一起使用,而另一个则可以与任何满足最小接口的类似mutex的对象工作。因为condition_variable_any是更具一般性,它在内存占用、执行效率以及资源占用上要不如condition_variable。如果不是想要更多的灵活性的话,建议使用condition_variable。示例:

std::mutex mut;
std::queue 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 lk(mut);
		data_queue.push(data);
		data_cond.notify_one();
	}
}
void data_processing_thread()
{
	while (true)
	{
		std::unique_lock lk(mut);
		data_cond.wait(lk, [] {return !data_queue.empty(); });//如果lambda表达式正确的话,可以确保不出假醒(spurious wake)
		data_chunk data = data_queue.front();
		data_queue.pop();
		lk.unlock();
		process(data);
		if (is_last_chunk(data))
			break;
	}
}
data_preparation_thread()中使用lock_guard函数锁定mutex,然后向queue中添加数据,最后执行data_cond.notify_one(),以通知正在等待这个条件变量的线程。data_proccessing_thread()函数负责处理队列中的数据,它使用了一个 std::unique_lock来锁定mutex,并将lk传给了条件变量的wait函数作为第一个参数,然后使用了一个lambda表达式来判断queue中是否真的有数据。接下来,wait函数会执行lambda表达式来判断其值是否为真,如果为假,则使用unique_lock的unlock函数解锁,将线程置于阻塞或者睡眠的状态。当data_cond被另一个线程中调用的notify_one()通知后,这个线程将被唤醒,并再次请求锁定mutex,再次检查lambda表达式的结果,如果lambda返回真,则wait函数返回后,mutex已经被本线程锁定,继续往下执行。如果lambda返回假,则再次执行unlock,并继续睡眠。所以,为了反复lock以及unlock,这里必须使用uniuqe_lock而不是lock_gurad。data_preparation_thread()中的while循环是为了多次处理数据,如果没有while,则只能处理一次数据就会退出子线程。

这个例子使用了lambda作为wait函数的条件判断,其实这里也可以使用任何函数或可调用对象。在调用wait的过程中,一个条件变量可能多次访问判断条件,并且保证此时mutex处于锁定状态。只有条件满足,wait函数才会立即返回。

当一个睡眠线程重新锁定mutex并检查条件函数时,如果这次检查并不是直接响应另外一个线程的通知,那么这种情况就叫做假醒(spurious wake)。因为假醒的次数和次序是未定义的行为,一旦发生就会去执行条件判断函数。所以,一个具有副作用的函数不应该作为wait的条件判断函数。另外,这个条件函数必须与所要检查的条件变量在意义上保持严格的一致性。比如,这里的lambda表达式通过判断队列是否为空来判断条件是否达成,与条件变量data_cond所要出的目的是一致的。

如果有多个线程都在等待同一个条件,那么当notify_one执行后,并不能确定是哪个线程获取了控制权甚至也不能确定是否有线程响应了这次通知。

有时候,多个线程都在等待一个对象的初始化完成,当对象初始化完成后,他们都需要立刻得到通知以便做下一步操作。虽然在前面的章节中已经介绍了2种方法可以解决这个问题(once_flag/call_once、局部static变量),但是还有一种情况,多个线程等待共享数据被更新,比如周期初始化(什么应用场景?),以便做下一步操作。这种情况下,修改数据的线程可以使用notify_all() (而不是notify_one)来通知所有正在等待的线程去执行wait()函数,检查条件是否达成。

最后要说的是,一旦wait()函数检查条件达成,那么就会继续执行之后的语句,再也不会重新检查条件了,也许条件变量并不是线程同步的最好选择。

——请继续查看关于futures的讲解。

你可能感兴趣的:(C++11,STL,多线程)