C++ 多线程并发编程3 条件变量
忙等待与阻塞等待
互斥锁能够解决竞态条件的问题,但是它本不提供任何关于事件通知的机制。如果我们想利用mutex::try_unlock(或者另设一个全局标志位)+sleep()+死循环的忙等待(busy-waiting)方式,把是否能够解锁这一信息作为线程之间通信的机制,那么这种方式是相当低效的。
- 忙等待的线程会占用CPU核心来执行毫无意义的重复检查,消耗大量的资源,妨碍其他线程的任务调度
- 如果为了降低CPU消耗在循环中加入
sleep()等函数降低循环频率,那又会引入不必要的延迟
理想的方式应该是让等待消息的线程挂起阻塞而不消耗仍和CPU资源,只有在可能发生变化时再被唤醒,重新变成可调度的状态。
简单的说,当你点了外卖之后,你应该等外卖员的电话,而不是隔两分钟就看一眼外卖APP里的进度。
条件变量
C++11引入的条件变量condition_variable正是对阻塞等待的标准化实现,它提供了一种基于操作系统内核调度的、高效的阻塞式等待机制。
这里的“阻塞”并非简单的循环,而是通过一次系统调用,告知操作系统内核的调度器将当前线程的状态从“运行中”切换为“等待中”或“阻塞中”。这意味着该线程将被移出调度器的可运行队列,在等待期间,它不占用任何CPU时间。
1 | |
头文件<condition_variable>包含了与条件变量相关的类与函数
std::condition_variable条件变量类std::condition_variable_anystd::cv_status枚举类std::notify_all_at_thread_exit()
std::condition_variable
构造函数
1 | |
只提供默认构造函数,拷贝构造函数被禁用
wait()
1 | |
- 当前线程调用
wait()函数后,首先调用lck.unlock()对象释放锁,然后将当前线程阻塞 - 当线程被唤醒时,它会调用
lck.lock() - 对于第二种函数原型
相当于1
while (!pred()) wait(lck);- 只有当
pred条件为false时,调用wait()才会阻塞当前进程 - 只有当
pred为true且接收到其他线程的通知后,才会解除阻塞
- 只有当
wait_for()和wait_until()
1 | |
- 与
wait()的区别在于,wait_for()可以指定一个时间段,在当前线程收到通知或者指定的时间rel_time超时之前,该线程都会处于阻塞状态 - 有意思的是,两个函数重载的返回值类型不同。之所以这样,是因为它们服务于不同的使用场景,调用者对“等待结束”这件事的关注点也不同。
- 对于没有条件谓词的版本,调用者关心的是等待结束的原因,即等待结束是因为被通知,还是因为超时,而这正是枚举类型
std::cv_status存在的意义,它包含两个枚举值cv_status::timeout: 等待确实是因为时间到了才结束。cv_status::no_timeout: 在超时之前,等待就结束了(可能是被notify,也可能是虚假唤醒)。
- 对于有条件谓词的版本,调用者关心的是条件谓词是否被满足,但于此同时是否超时的优先级高于条件谓词的值,因此返回值对应的情况为
true: 在超时之前,pred返回了true。或者超时后pred为true,因为当超时发生事,wait_for函数会最后再调用一次pred函数。false: 超时发生时,pred仍然返回false。
- 对于没有条件谓词的版本,调用者关心的是等待结束的原因,即等待结束是因为被通知,还是因为超时,而这正是枚举类型
1 | |
与wait_for()的区别在于,wait_until()指定的是时间点而非时间段
notify_one()和notify_all()
notify_one()唤醒某个等待线程。如果当前没有等待线程,则该函数什么也不做,如果同时存在多个等待线程,则唤醒某个线程是不确定的notify_all()则是唤醒所有等待的线程
std::condition_variable_any
与std::condition_variable的区别在与,std::condition_variable_any的wait函数可以接收任意的lockable参数,而std::condition_variable只能接受 std::unique_lock<std::mutex>类型的参数
std::notify_all_at_thread_exit
1 | |
当调用该函数的线程退出时,所有在 cond 条件变量上等待的线程都会收到通知。
注意事项
虚假唤醒
虚假唤醒指的是,一个条件变量在被唤醒后得不到需要的数据。我们可以考虑这样的场景,有1个生产者线程不断生产数据放入一个容量为1的队列中,有多个消费者线程不断从这个列表中获取数据,没有数据时所有的消费者都调用wait函数阻塞线程,当生产者生产出数据后调用notify_all通知所有的线程,但是这些消费者线程苏醒后却只有一个线程可以抢夺到数据,这就导致其他线程明明已经被唤醒了,却得不到想要的数据。
虚假唤醒的解决方案也很简单,只需要在唤醒之间先判断是否满足线程唤醒所需的条件即可,即
1 | |
这也是wait函数带条件谓词的重载存在的原因
为什么条件变量一定要加锁
条件变量内部是通过判断和修改某个局部变量的值来决定线程的阻塞与唤醒状态,因此需要加锁防止数据竞争。
此外,如果线程A调用了wait但是没有进入阻塞,此时线程B却调用了notify,那么线程A是无法接收到这个notify的。如果加了锁,那么让线程A在阻塞前获取锁,阻塞后释放锁,线程B就可以根据这个锁是否释放来判断是否可以调用notify。