彻底搞懂 C++ 锁、条件变量和生产者-消费者模式
文章目录
- 彻底搞懂 C++ 锁、条件变量和生产者-消费者模式
- 一、锁(mutex)到底是什么
- 二、两种锁的区别
- 三、条件变量(condition_variable)
- 不用条件变量——轮询,浪费 CPU
- 用条件变量——数据没到就睡,到了自动醒
- wait 到底做了什么
- notify_one 和 notify_all
- 为什么 wait 需要一个条件 lambda
- 四、生产者-消费者模式
- 最简单版本
- 为什么两个消费者交替取数据
- 关键细节
- 五、我项目里的 BlockingQueue
- 六、常见面试问题速答
- Q: lock_guard 和 unique_lock 的区别?
- Q: 条件变量为什么必须配合 while 或 lambda?
- Q: 为什么 notify 放在 unlock 后面?
- Q: 为什么 push 加锁了,pop 的 wait 还能解锁?
- 七、我记住的关键规则
彻底搞懂 C++ 锁、条件变量和生产者-消费者模式
一、锁(mutex)到底是什么
锁不是代码层面的东西,是约定。你和室友住一间房,门上挂一把锁——谁进去谁锁门,出来再开。另一个人想进去?门口等着。
#include<mutex>std::mutex mtx;// 一把锁void写日志(conststd::string&msg){mtx.lock();// 锁门// 往文件里写日志(这个操作不能同时被两个线程做)mtx.unlock();// 开门}如果两个线程同时往同一个文件写日志,内容会交叠错乱。加锁后,同一时刻只有一个线程在写。
但手动lock()/unlock()容易出事——写日志写到一半抛异常了,unlock()没执行,锁永远开不了,所有人卡死。所以 C++ 提供了管家:
void写日志(conststd::string&msg){std::lock_guard<std::mutex>lock(mtx);// 构造 = 立刻加锁// 写日志...}// lock 析构 = 自动解锁,就算中间抛异常也一定解锁lock_guard = 构造加锁,离开花括号自动解锁。永远用它,不要手动调 lock/unlock。
二、两种锁的区别
| lock_guard | unique_lock | |
|---|---|---|
| 加锁时机 | 构造时自动 | 构造时自动 |
| 解锁时机 | 离开{}自动 | 离开{}自动,也可以手动unlock() |
| 能临时解锁再重新加锁吗 | ❌ | ✅ |
| 能配合条件变量吗 | ❌ | ✅ |
| 用在哪 | 简单临界区 | 条件变量 / 需要灵活控制的场景 |
// lock_guard:省心,绝大多数场景用它{std::lock_guard<std::mutex>lock(mtx);// 操作共享数据}// unique_lock:灵活,条件变量必须用它{std::unique_lock<std::mutex>lock(mtx);// 操作共享数据lock.unlock();// 提前解锁,lock_guard 做不到// 可以做其他不涉及共享数据的操作lock.lock();// 重新加锁}三、条件变量(condition_variable)
锁解决了"同时只能一个人访问"的问题,但解决不了"数据还没到,先睡会儿"的问题。
不用条件变量——轮询,浪费 CPU
while(queue_.empty()){std::this_thread::sleep_for(std::chrono::milliseconds(10));// 每隔 10ms 检查一次,CPU 空转,反应慢}autotask=queue_.front();queue_.pop();用条件变量——数据没到就睡,到了自动醒
std::unique_lock<std::mutex>lock(mtx);cv.wait(lock,[&]{return!queue_.empty();});// 空了就睡,0% CPUautotask=queue_.front();queue_.pop();wait 到底做了什么
cv.wait(lock,[&]{return!queue_.empty();});① 检查条件 !queue_.empty() → ② true → 直接返回,什么都不发生 ③ false → 解锁 lock → 线程挂起(睡觉,0% CPU) ④ 另一个线程调 cv.notify_one() 叫醒我 ⑤ 重新拿锁 → 再检查条件 → true 就取数据关键:睡觉前解锁,醒了重新拿锁。如果睡觉时还抱着锁不放,别人怎么往队列里放东西?这就是死锁。
notify_one 和 notify_all
cv.notify_one();// 叫醒一个正在 wait 的线程cv.notify_all();// 叫醒所有正在 wait 的线程为什么 wait 需要一个条件 lambda
因为虚假唤醒——操作系统偶尔会莫名其妙叫醒线程(极少见,但标准允许)。还有一个原因是:你通知了一个线程,但这期间另一个线程可能先醒、先把数据取走了。所以醒过来第一件事必须是检查条件,不满足条件继续睡。
四、生产者-消费者模式
这是多线程编程里最常见的模式。一个或多个线程生产数据放到队列里,一个或多个线程从队列里取数据处理。
最简单版本
#include<condition_variable>#include<iostream>#include<mutex>#include<queue>#include<thread>#include<chrono>std::mutex mtx;std::condition_variable cv;std::queue<int>q;boolrunning=true;voidconsumer(intid){while(running||!q.empty()){std::unique_lock<std::mutex>lock(mtx);// 队列空且还在生产 → 睡觉等cv.wait(lock,[]{return!q.empty()||!running;});// running=false 且队列空 → 退出if(!running&&q.empty())break;intval=q.front();q.pop();lock.unlock();// 数据到手就解锁,不拖std::cout<<"消费者"<<id<<" 取走: "<<val<<std::endl;std::this_thread::sleep_for(std::chrono::milliseconds(200));}}voidproducer(){for(inti=1;i<=10;i++){{std::lock_guard<std::mutex>lock(mtx);q.push(i);std::cout<<"生产者 放入: "<<i<<std::endl;}// 离开 {} 自动解锁cv.notify_one();// 叫醒一个消费者std::this_thread::sleep_for(std::chrono::milliseconds(100));}running=false;cv.notify_all();// 叫醒所有人:"下班了,别睡了"}intmain(){std::threadc1(consumer,1);std::threadc2(consumer,2);std::threadp(producer);c1.join();// 等线程结束c2.join();p.join();std::cout<<"全部结束"<<std::endl;return0;}运行结果大概是:
生产者 放入: 1 消费者 1 取走: 1 生产者 放入: 2 消费者 2 取走: 2 生产者 放入: 3 消费者 1 取走: 3 ... 全部结束为什么两个消费者交替取数据
因为sleep_for(200ms)让取完数据的消费者暂时不继续抢锁,另一个消费者有时间拿到锁。去掉 sleep 就是纯抢锁,谁抢到谁拿,就不一定交替了。
关键细节
{std::lock_guard<std::mutex>lock(mtx);q.push(i);}// ← 先解锁cv.notify_one();// ← 再通知,不要抱着锁通知为什么解锁后才通知?如果先通知再解锁——被叫醒的线程立刻尝试拿锁,但锁还没解,又得等。多此一举。解锁后通知,人家醒了直接能拿到锁。
五、我项目里的 BlockingQueue
项目里把这些东西封装成了一个类:
template<typenameT>classBlockingQueue{public:voidpush(T item){{std::lock_guard<std::mutex>lock(mutex_);queue_.push(std::move(item));}cv_.notify_one();// 解锁后通知}Tpop(){std::unique_lock<std::mutex>lock(mutex_);// 用 unique_lockcv_.wait(lock,[&]{return!queue_.empty();});// 空了就睡T item=std::move(queue_.front());queue_.pop();returnitem;}private:std::mutex mutex_;std::condition_variable cv_;std::queue<T>queue_;};用法简单:
// 生产者(odud 的 IPC 接收线程):tx_queue_.push(OduSerialTask{...});// 消费者(odud 的串口发送线程):OduSerialTask task=tx_queue_.pop();// 空了自动睡,不占 CPU六、常见面试问题速答
Q: lock_guard 和 unique_lock 的区别?
A: lock_guard 只有构造加锁 + 析构解锁,不能手动控制。unique_lock 可以临时 unlock 再 lock,条件变量的 wait 必须用 unique_lock。大部分场景用 lock_guard 就够了。
Q: 条件变量为什么必须配合 while 或 lambda?
A: 因为虚假唤醒。线程可能被操作系统莫名其妙叫醒,也可能被 notify 但数据被另一个线程抢了。醒过来必须重新检查条件,不满足继续睡。
Q: 为什么 notify 放在 unlock 后面?
A: 先 unlock 再 notify——被叫醒的线程直接能拿到锁,不用再等。反过来先 notify 再 unlock,被叫醒的线程发现锁还没解,又要等,浪费一次上下文切换。
Q: 为什么 push 加锁了,pop 的 wait 还能解锁?
A: wait 内部做的事是:临时解锁 → 挂起 → 被叫醒 → 重新加锁。push 在 wait 解锁期间可以拿到锁、放数据。这正是条件变量的核心机制。
七、我记住的关键规则
- 任何共享数据被多线程读写 → 加锁。用
lock_guard,别手写 lock/unlock。 - 加锁后不做耗时操作。拿数据就解锁,别抱着锁做 IO。
- 生产者-消费者 = 队列 + 锁 + 条件变量。生产者 push + notify,消费者 pop + wait。
- wait 必须传条件判断。要么 lambda,要么 while 套 wait。防止虚假唤醒。
- 先解锁再 notify。不抱着锁通知别人。
