互斥锁与条件变量:优雅的线程同步
目录
引言
在多线程编程的世界里,我们经常会遇到这样的场景:多个线程同时操作一个共享变量(比如全局计数器),结果数据乱了;或者一个线程需要等待另一个线程完成某项任务后才能继续执行。
如果处理不好,你的程序可能会出现数据竞争、CPU 占用率飙升,甚至直接卡死(死锁)。今天,我们就来深入聊聊解决这些问题的两把“钥匙”——互斥锁(Mutex)和条件变量(Condition Variable)。
为什么我们需要互斥锁?
想象一下,你和你的同事共用一个记事本记账。如果你们同时拿起笔,在同一行写数字,结果会怎样?字迹重叠,账目混乱。在计算机中,这就是竞态条件(Race Condition)。
互斥锁(Mutex)就像是一把“厕所钥匙”。
- 加锁(Lock):谁拿到了钥匙,谁就能进去上厕所(访问共享资源)。
- 解锁(Unlock):上完厕所出来,把钥匙挂回去,下一个人才能进去。
- 阻塞:如果钥匙被别人拿走了,你就得在门口等着,啥也干不了。
在 C++ 中,我们通常这样使用:
#include <mutex> std::mutex mtx; // 定义一把锁 int counter = 0; // 共享资源 void safeIncrement() { mtx.lock(); // 1. 进门,锁门 counter++; // 2. 安全地修改数据 mtx.unlock(); // 3. 出门,解锁 }最佳实践:RAII 锁守卫
手动
lock和unlock有个风险:如果在临界区代码中发生了异常或提前 return,忘记解锁会导致死锁。
现代 C++ 推荐使用std::lock_guard或std::unique_lock,它们利用 RAII 机制,在对象销毁时自动解锁:void safeIncrement() { std::lock_guard<std::mutex> lock(mtx); // 作用域结束自动解锁 counter++; }
互斥锁的局限性:
互斥锁只能保证“互斥访问”,但它无法解决“等待条件”的问题。比如,消费者线程想从队列里取数据,但队列是空的。如果只用互斥锁,消费者只能不断地加锁、检查、解锁、再加锁……这种“忙等待(Busy Waiting)”会把 CPU 跑满,效率极低。
条件变量:让线程学会“睡觉”
为了解决“忙等待”,我们需要一种机制:当条件不满足时,线程主动去睡觉(释放 CPU);当条件满足时,另一个线程把它叫醒。 这就是条件变量(Condition Variable)。
条件变量通常与互斥锁配合使用,它主要包含两个动作:
- 等待(Wait):如果条件不满足,就释放锁并进入睡眠。
- 通知(Notify):改变条件后,唤醒正在睡眠的线程。
为什么条件变量必须配合互斥锁?
很多初学者会问:“为什么
wait函数必须传入一个互斥锁?直接等不行吗?”这主要是为了解决原子性和竞态条件的问题。
想象一个没有锁保护的糟糕场景:
- 消费者线程检查队列,发现是空的(准备去睡觉)。
- 就在这一瞬间! 生产者线程往队列里放入了数据,并发送了“唤醒信号”。
- 消费者线程没收到信号(因为它还没睡着),接着调用了
wait()进入死睡。- 结果:数据已经在队列里了,但消费者永远在等下一个信号,程序卡死。
加上互斥锁后的标准流程(原子操作):
cond.wait(lock)这个函数内部其实做了三件连贯的事:
- 释放锁:让其他线程(生产者)有机会获取锁并修改数据。
- 阻塞等待:线程进入睡眠状态,不占用 CPU。
- 被唤醒后重新加锁:当被
notify唤醒时,它会自动尝试重新获取锁,成功后才从wait函数返回。这种机制保证了“检查条件”和“进入等待”这两个动作的原子性,彻底杜绝了信号丢失。
实战演练:生产者-消费者模型
#include <iostream> #include <queue> #include <thread> #include <mutex> #include <condition_variable> std::queue<int> q; // 共享的任务队列 std::mutex mtx; // 互斥锁 std::condition_variable cv; // 条件变量 const int MAX_SIZE = 10; // 队列最大容量 bool finished = false; // 结束标志 // 生产者线程 void producer() { for (int i = 0; i < 20; ++i) { std::unique_lock<std::mutex> lock(mtx); // 如果队列满了,就等待消费者取走数据 cv.wait(lock, []{ return q.size() < MAX_SIZE; }); q.push(i); std::cout << "生产了: " << i << " (当前队列大小: " << q.size() << ")\n"; lock.unlock(); // 通知前解锁是个好习惯,减少锁竞争 cv.notify_all(); // 唤醒所有等待的线程(包括消费者) std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟耗时 } // 生产结束,通知消费者退出 { std::lock_guard<std::mutex> lock(mtx); finished = true; } cv.notify_all(); } // 消费者线程 void consumer() { while (true) { std::unique_lock<std::mutex> lock(mtx); // 等待队列非空,或者生产结束 //因为 finished 为 true 时队列可能还有未消费完的数据,需要消费完再退出 cv.wait(lock, []{ return !q.empty() || finished; }); // 如果队列空了且生产已结束,退出循环 if (q.empty() && finished) break; int val = q.front(); q.pop(); std::cout << "消费了: " << val << " (当前队列大小: " << q.size() << ")\n"; lock.unlock(); cv.notify_all(); // 唤醒生产者(因为队列有空位了) std::this_thread::sleep_for(std::chrono::milliseconds(150)); } } int main() { std::thread t1(producer); std::thread t2(consumer); t1.join(); t2.join(); return 0; }
常见误区与最佳实践
在实战中,有几个细节如果不注意,很容易写出 Bug:
警惕“虚假唤醒(Spurious Wakeup)”
操作系统底层可能会在没有收到notify的情况下意外唤醒线程。
- 错误写法:
if (condition) cv.wait(lock);- 正确写法:
while (!condition) cv.wait(lock);
或者使用 C++ 提供的带谓词的版本:cv.wait(lock, []{ return condition; });,它内部就是一个 while 循环。notify_one() 还是 notify_all()?
- notify_one():只唤醒一个等待线程。适用于资源只有一个(如队列里只有一个任务),唤醒多了也是浪费。
- notify_all():唤醒所有等待线程。适用于状态发生全局改变(如程序退出、资源批量就绪)。
锁的粒度控制
不要把耗时的操作(如 I/O、复杂计算)放在锁里面。锁住的代码越少,并发性能越高。
总结
互斥锁和条件变量是多线程同步的“黄金搭档”。
- 互斥锁负责保护共享数据,确保同一时间只有一个线程能修改它。
- 条件变量负责线程间的通信,让线程在条件不满足时“优雅地睡觉”,条件满足时“及时地工作”。
掌握这两个工具,你就掌握了并发编程的半壁江山。建议大家把上面的生产者-消费者代码敲一遍,体会一下锁的获取与释放、线程的阻塞与唤醒,相信你会有更深的理解!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)