欢迎来到博主的专栏:c++杂谈
博主ID:代码小豪
相信大家都发现了一个问题,线程的运行是不可控的,换句话说就是我们无法保证线程执行任务的先后顺序,比如我们创建两个线程,让这两个线程对一个共享的数据进行修改操作,那么谁先修改,谁后修改,这些都是不可控的,但是有时候我们会需要线程执行操作是需要有个先后顺序,比如我们先让线程A将任务完成,接着再启用线程B。此时我们就需要用到条件变量了。
条件变量本身是对线程的一个控制,或者说是限制,首先我们要有一个明确的条件,比如线程A要满足一个条件,才能去执行任务,若线程A不满足,则使其阻塞,直到条件满足后再启用。这么说有点抽象,我们后面会结合例子。
condition_variable
首先条件变量是线程间通信的核心同步原语,本质是等待/通知机制。我们以下课的例子为例,下课后大家都会离开教室,但是如果在上课时候直接离开教室,那么很显然记个早退是不可避免的,记得多了就挂科了。那么在上课的时候,大家是不能离开教室的,此时就是阻塞在一个阶段,那么什么时候可以下课呢?就是当下课铃响了之后,满足下课铃声响了这个条件,大家才能执行离开教室这个行为。在这个过程中,下课铃声就是一个条件,不满足条件的话,就只能阻塞在教室上课,满足条件,才能执行离开教室这一行为。
一般在多线程中,使用条件变量需要满足三大要素,第一点、共享条件,即条件对所有的线程都是共享的,这样才能满足一个线程对条件进行修改后,其他的线程可以看到条件被改变了。第二点、互斥锁,因为共享条件可以被任意线程修改, 在检查条件和进入等待之间,其他线程可能修改条件,导致判断失效。加锁确保检查条件到进入等待的原子性。第三点,条件变量,没有条件变量当然是用不了条件的啦(笑),实际上条件变量会提供等待和唤醒这两个功能,这是线程同步的核心原理。
不同平台下对于条件变量的使用方式不同,比如linux中使用的是pthread_condition_t,而windows使用的又有所不同,而c++库封装了对应操作系统系统调用,在不同的平台下都适用,这是使用C++并发库的优势之一。
而在C++中,条件变量被封装在<condition_variable>库中,其类型如下:
class condition_variable;
condition_variable只有默认构造函数,不支持拷贝或移动构造
default (1)
condition_variable();
copy [deleted] (2)
condition_variable (const condition_variable&) = delete;
条件变量的核心在于调停和唤醒,与调停相关的函数有三个。而唤醒相关的有两个。
其中wait_for和wait_until是与时间相关,即让线程在规定范围内等待唤醒,超时自动唤醒。有时候需要结合cv_status
使用,cv_staus是一个定义在<conditon_variable>的枚举类型,用来说明此次唤醒线程,是由于条件满足,还是超时唤醒的。比如你需要线程对于唤醒有两种方案时使用,超时唤醒执行其中一种方案,非超时唤醒使用另一种方案。这两个函数用的很少,重点放在wait身上。
前面不是提了吗?一般条件变量会结合锁一起用,在linux中,条件变量可以也要求加锁使用,而condition_variable也强制要求使用者调用wait时添加一个unique_lock。
unconditional (1)
void wait (unique_lock<mutex>& lck);
predicate (2)
template <class Predicate>
void wait (unique_lock<mutex>& lck, Predicate pred);
也就是说,如果想要用条件变量,首先手中要持有锁,即一个线程互斥的场景下,保证共享条件使用是正确的。
以下是唤醒相关的函数。其中notify_one表示唤醒一个线程,notify_all表示唤醒所有的线程。
void notify_one() noexcept;
void notify_all() noexcept;
关于使用方法和原理,我们先来看看一个案例,加深对条件变量的理解。
使用场景
首先,条件变量一定是用于线程根据一个共享条件下进行同步的场景下,如果没有这方面的需求,是不需要使用条件变量的。所以我们需要构造出一个共享条件。
比如有一个很经典的并发场景:现有一个线程A,线程B,令线程A、B交替打印连续的数,线程A负责打印奇数,线程B负责打印偶数。为了方便查看,我们令数字的范围为[0,100)。
那么这里就引入我们的第一个要素了,即共享条件,既然需要打印0~100的数,我们就创建一个全局的int计数器,这样两个线程都能共享这个变量。每个线程执行完打印的操作,就要让计数器++。为了避免线程之间进行竞争,需要添加一个mutex。
#include <thread>
#include <iostream>
#include <mutex>
#include <condition_variable>
int n = 0; //共享条件
std::condition_variable cv;//条件变量
void threadArun(std::mutex& mtx) {//打印奇数
while (n < 100)
{
std::unique_lock<std::mutex> lck(mtx);
while (n % 2 == 0) { //若n为偶数,条件不满足,调用wait进行等待
cv.wait(lck);
}
if (n >= 100) exit(0);
std::cout << "Thrad A:" << n << std::endl;
n++;
cv.notify_one(); //当n为奇数,并且++后,将会变成偶数,此时唤醒Thread B
}
}
void threadBrun(std::mutex& mtx) {//打印偶数
while (n < 100)
{
std::unique_lock<std::mutex> lck(mtx);
while (n % 2 == 1) { //若n为奇数,条件不满足,调用wait进行等待
cv.wait(lck);
}
if (n >= 100) exit(0);
std::cout << "Thrad B:" << n << std::endl;
n++;
cv.notify_one(); //当n为偶数,并且++后,将会变成奇数,此时唤醒Thread B
}
}
int main() {
std::mutex mtx; //互斥锁
std::thread th1(threadArun,ref(mtx));
std::thread th2(threadBrun,ref(mtx));
th1.join();
th2.join();
return 0;
}
条件是否满足由程序员设定,一般我们判断条件变量时,都会用循环检查的方式,这是因为线程会存在虚假唤醒的问题,添加循环的方式以避免线程被条件不满足时被唤醒的问题,关于虚假唤醒,我们后面再说。
条件变量的使用原理
当线程使用条件变量时,主要的行为分为两种,一种是等待,使用wait来实现,一种是唤醒,使用notify实现。一般线程会在条件不满足时陷入等待而挂起,等到条件满足时,由另一个线程进行唤醒。
每一个条件变量都维护着一条等待队列,所有调用wait()的线程都会被移交在等待队列中。
但是有一个问题,使用条件变量的时候是需要处于加锁的环境下的,那么也就是说,线程此时是具有锁的,所以此时调用wait(),线程被挂起,但是锁是未归还的。那么若是有其他线程需要执行这段临界区岂不是无法使用?因此,当线程调用wait时,一定会将锁释放。这一点可以在手册中看到。
因为在进入wait的时候,会自动给线程解锁,因此此时其他的线程也是有可能调用wait()的。为了避免多个线程竞争等待队列,通常wait当中还会有一个内部的锁。保证wait本身的原子性。那么wait的整体流程是怎么做的呢?
- 1、获取内部锁
- 2、释放当前线程持有的锁(并非是所有的锁,而是用户上传的那个)
- 3、将线程挂入等待队列(wait_queue)
- 4、让线程进入挂起状态(系统调用)
下面的伪代码很好的解释了wait的整个操作:
// wait() 伪代码实现
void condition_variable::wait(unique_lock& lock) {
// 原子操作开始
internal_lock(); // 锁定条件变量内部状态
lock.unlock(); // 释放用户传入的互斥锁
add_to_wait_queue(); // 将线程加入等待队列
internal_unlock(); // 解锁内部状态
// 原子操作结束
block_thread(); // 线程进入阻塞状态(OS级休眠)
lock.lock(); // 唤醒后自动重新获取互斥锁
}
那么线程在进入等待之前将锁释放了,那么被唤醒的时候不就处于无锁的状态了吗?那么这就会导致线程竞争的发生,因此在唤醒时,也会申请锁,以保证整个任务的原子性。
那么唤醒会发生什么呢?首先要确定一点,唤醒谁?谁在等待?很显然是等待队列中的线程在等待,因此在使用notify时,其实是在等待队列里挑一个线程唤醒,虽然队列是FIFO的,但是实际上并不能保证先等待的线程,先被唤醒。这取决于OS的调度机制。
所以唤醒操作的流程如下:先判断等待队列是否为空,为空就什么都不做,不为空,就选择一个线程唤醒,并且将唤醒的线程从等待队列里删除。
考虑到有多个线程被唤醒的情况,因此notify的操作也是存在竞争的,因为等待队列本质上也是一个共享资源。在notify也需要有一个内部锁。
那么还有一点,被唤醒的线程,一定满足了条件了吗?比如在中午的最后一节课上,你想赶紧下课去吃饭,于是你选择了睡觉,那么如果你被叫醒了,一定是下课了才醒吗?不一定,因为叫醒你的可能是老师。而不是下课铃,所以被唤醒的线程,不一定满足了条件。所以一般我们会用循环判断的方式,保证线程一定是在满足条件的情况下继续执行。
另一方面,OS中还会存在虚假唤醒。虚假唤醒(Spurious Wakeup)是指等待在条件变量上的线程未被显式通知却意外唤醒的现象。其根本原因在于系统优化与硬件限制。这里是关于操作系统方面的知识,博主就不展开讲了。总之,虚假唤醒的概率非常非常低,但是我们依旧要保证不会因为虚假唤醒影响了程序的正确性,因此加上一个循环判断是非常有必要的。
std::unique_lock lock(mtx);
while (!condition) { // while 而非 if
cv.wait(lock);
}
而且c++为我们提供了简化版的wait。
std::unique_lock lock(mtx);
cv,wait(mtx,[]()->bool {return condition});
那么这是什么意思呢?这个用法叫做带条件的wait。
template <class Predicate>
void wait (unique_lock<mutex>& lck, Predicate pred)
pred要求是一个函数对象,而且返回一个bool值,如果pred的范围值为true,就不会进入wait。如果pred为false,则进入wait。当线程被唤醒时,pred必须为true才会退出wait的状态,否则将会继续进入wait。
下面的例子是一个简化版的生产者-消费者模型,只有一个生产者和一个消费者,其中主线程负责生产,而线程负责消费。
std::mutex mtx;
std::condition_variable cv;
int cargo = 0;
bool shipment_available() { return cargo != 0; }
void consume(int n) {
for (int i = 0; i < n; ++i) {
std::unique_lock<std::mutex> lck(mtx);
cv.wait(lck, shipment_available);
// consume:
std::cout << cargo << '\n';
cargo = 0;
}
}
int main()
{
std::thread consumer_thread(consume, 10);
// produce 10 items when needed:
for (int i = 0; i < 10; ++i) {
while (shipment_available()) std::this_thread::yield();
std::unique_lock<std::mutex> lck(mtx);//unique_lock创建时调用mtx.lock()
cargo = i + 1;
cv.notify_one();
//unique_lock的生命周期到了,相当于调用mtx.unlock()
}
consumer_thread.join();
return 0;
}