首先,我们先来认识一下条件变量。
条件变量是一种同步原语,通常用于在多线程编程中,使一个线程在特定条件满足之前等待,同时允许其他线程在该条件发生更改时通知等待的线程。
1. “等待”:当条件不满足时(例如,某个资源还未准备好),线程可以选择等待。调用条件变量的 wait
方法会将线程置于阻塞状态,并释放已获取的互斥锁,让其他线程有机会修改条件。
2. “通知”:当条件发生变化时(例如,资源已经准备好),线程可以通过条件变量来通知其他等待这个条件的线程。这可以通过 notify_one
(唤醒一个等待线程)或 notify_all
(唤醒所有等待线程)方法实现。
3. “重检”:当被通知并从 wait
返回时,线程应重新检查条件以确定其是否真正满足。这是因为存在所谓的"虚假唤醒",即线程可能在条件实际满足之前被唤醒。
例如,我们可以使用条件变量来同步一个生产者线程和一个消费者线程。生产者线程负责生成数据并将其放入缓冲区,消费者线程则从缓冲区中取出数据进行处理。用条件变量可以使消费者线程在缓冲区为空(即数据不足)时等待,而在生产者线程向缓冲区添加数据后,消费者线程会被唤醒并开始处理新数据。
条件变量通常与互斥锁一起使用,以确保线程在检查条件和决定等待之间不会被打断,即这两个操作是原子的。同时,在修改条件(比如修改共享数据)时,一般也会使用互斥锁来保证数据一致性。
- 条件变量的要点:
- 检查所等待的条件:在调用
wait
方法之前,我们通常会在一个循环中检查所等待的条件。循环的目的是防止虚假唤醒,即wait
由于未知原因返回但条件并未满足。 - 使用正确的锁:在调用
wait
,notify_one
或notify_all
时,我们需要使用一个 unique_lock 来保护条件变量。在调用wait
时,锁会被释放,使其他线程有机会修改条件。等wait
返回时,锁会被重新获得。 - 避免死锁:在使用条件变量时,我们需要留意不要引入死锁。例如,如果两个线程都在等待对方发送信号,但它们都无法发送信号(例如,因为它们都在等待对方释放某个资源),那么就会发生死锁。
- 避免滞后唤醒:如果
signal
先于wait
发生,则该信号会丢失。为了防止这种情况,我们可以在修改条件的同时调用notify_one
或notify_all
。这样,如果有其他线程正在等待,它们将立即被唤醒。如果没有线程正在等待,那么这个通知将被忽视。 - 注意
notify_all
和notify_one
的区别:notify_all
会唤醒所有等待的线程,而notify_one
只唤醒一个。如果多个线程都在等待同一个条件,那么notify_all
可能更合适。
- 检查所等待的条件:在调用
这里我们就以消费者和生产者为例,进行代码上的深入讲解。
对于一般的生产者-消费者模型,生产者会产生数据供消费者使用。生产者需要在准备好数据的时候通知消费者,消费者通过判断是否已经有数据来决定要不要消费数据。
生产者和消费者的实现如下所示:
#include<bits/stdc++.h>
#include<mutex>
#include<unistd.h>
using namespace std;
vector<int> buffer;
mutex mtx;
bool dataIsReady = false;
void producer(){while (true) {sleep(2);std::unique_lock<std::mutex> lk(mtx);//随机生成10个数字for (int i = 0; i < 10; i++) {buffer.push_back(rand()%20);}dataIsReady = true;}
}
void consumer(){while (true) {sleep(2);std::unique_lock<std::mutex> lk(mtx);if (!dataIsReady) continue;//输出10个数字之后清空数组for (int i = 0; i < buffer.size(); i++) {if (i) putchar(' ');cout << buffer[i];}cout << endl;buffer.clear();dataIsReady = false;}
}
int main(){srand(time(NULL));thread produc(producer);thread consum(consumer);produc.join();consum.join();return 0;
}
std::unique_lock<std::mutex> lk(mtx);
的作用是创建一个互斥锁的锁对象 lk,并在创建时自动锁定 mtx。这意味着在 lk 的作用域内,互斥锁 mtx 被锁定,确保同一时刻只有一个线程可以访问被保护的资源(如 buffer)。
有两个好处:
-
自动解锁:
std::unique_lock 是一个 RAII(Resource Acquisition Is Initialization)类,意味着它会在作用域结束时自动释放锁。当 lk 超出其作用域时(如函数结束或块结束),lk 的析构函数会被调用,从而自动解锁 mtx。
-
避免手动解锁:
这种设计避免了手动解锁可能带来的错误,比如忘记解锁或在解锁后再次访问共享资源。使用 std::unique_lock 可以使代码更加安全和易于维护。
这种写法可以正常工作,但是存在一个问题:每次consumer线程都会循环判断数据是否准备好,这个过程中线程不会让出资源,因此循环判断会带来不必要的开销。正因如此,才需要学习条件变量。
// condition_variable::notify_all
#include <iostream> // std::cout
#include <thread> // std::thread
#include <mutex> // std::mutex, std::unique_lock
#include <condition_variable> // std::condition_variablestd::mutex mtx;
std::condition_variable cv;
bool ready = false;void print_id (int id) {std::unique_lock<std::mutex> lck(mtx);while (!ready) cv.wait(lck);// ...std::cout << "thread " << id << '\n';
}void go() {//修改ready标记,并通知打印线程工作std::unique_lock<std::mutex> lck(mtx);ready = true;cv.notify_all();
}int main (){std::thread threads[10];// 创建10个线程,每个线程当ready标记为真时打印自己的id号for (int i=0; i<10; ++i)threads[i] = std::thread(print_id,i);std::cout << "10 threads ready to race...\n";go(); // go!for (auto& th : threads) th.join();return 0;
}
父女水果问题:
问题描述:
父亲、母亲分别向一个果盘中放置一个水果。父亲放置苹果,母亲放置橘子。儿子专门等待果盘中的苹果。女儿专门等待果盘中的橘子。当果盘中准备好水果以后,儿子和女儿分别根据自己的需要拿走水果。
这其实也是一个生产者和消费者的模型,使用条件变量实现如下:
#include<bits/stdc++.h>
#include<mutex>
#include<unistd.h>
using namespace std;
mutex plate_mtx; //盘子互斥量
condition_variable plate_cv; //条件通知
//三个条件标记
bool appleIsReady = false; //苹果已准备好
bool orangeIsReady = false; //橘子已准备好
bool plateIsEmpty = true; //盘子已经空了void father() {int cnt = 1;while (true) {sleep(1);unique_lock<mutex> lck(plate_mtx);plate_cv.wait(lck, []{return plateIsEmpty;});//如果盘子不空,则准备苹果cout << "[Father] : I prepared my " << cnt;switch (cnt) {case 1 : cout << "st ";break;case 2 : cout << "nd ";break;case 3 : cout << "rd ";break;default: cout << "th ";break;}cnt++;cout << "apple." << endl;//修改盘子标记和苹果标记plateIsEmpty = false;appleIsReady = true;plate_cv.notify_all();}
}
void mother() {int cnt = 1;while (true) {sleep(1);unique_lock<mutex> lck(plate_mtx);plate_cv.wait(lck, []{return plateIsEmpty;});//如果盘子不空则准备橘子cout << "\t[Mother] : I prepared my " << cnt;switch (cnt) {case 1 : cout << "st ";break;case 2 : cout << "nd ";break;case 3 : cout << "rd ";break;default: cout << "th ";break;}cnt++;cout << "orange." << endl;//修改盘子标记和橘子标记plateIsEmpty = false;orangeIsReady = true;plate_cv.notify_all();}
}
void son() {while (true) {sleep(1);unique_lock<mutex> lck(plate_mtx);plate_cv.wait(lck, []{return !plateIsEmpty && appleIsReady;});//当盘子不空且苹果已经准备好的情况下拿苹果,并修改标记cout << "\t\t[Son] : I get an apple! Thank you, dad!" << endl;plateIsEmpty = true;appleIsReady = false;plate_cv.notify_all();}
}
void daughter() {while (true) {sleep(1);unique_lock<mutex> lck(plate_mtx);plate_cv.wait(lck, []{return !plateIsEmpty && orangeIsReady;});//当盘子不空且橘子已经准备好的情况下拿橘子,并修改标记cout << "\t\t\t[daughter] : I get an orange! Thank you, mom!" << endl;plateIsEmpty = true;orangeIsReady = false;plate_cv.notify_all();}
}int main() {//创建线程thread father_thread(father);thread mother_thread(mother);thread son_thread(son);thread daughter_thread(daughter);//join所有线程father_thread.join();mother_thread.join();son_thread.join();daughter_thread.join();return 0;
}
在这个代码中,因为每个线程中我们都设置了sleep,所以看起来他可能是按父亲-儿子-母亲-女儿这样的顺序进行的。如果将sleep删去的话,那其实在盘子为空的时候,父亲进程和母亲进程是有相同的概率实现的。