C++ 互斥锁、条件变量和信号量的作用、区别和使用场景
本文介绍C++ 中互斥锁、条件变量和信号量的作用、区别和使用场景。
这三者都是用于多线程编程中同步和协调线程行为的工具,但它们的侧重点和用途各不相同。
1. 互斥锁
核心作用: 实现互斥,保护共享资源,确保同一时间只有一个线程可以访问该资源。
- 是什么? 互斥锁就像一个房间的钥匙。一次只有一个线程能拿到这把钥匙。拿到钥匙的线程可以进入“房间”(即访问共享数据),其他线程必须等待,直到钥匙被放回。
- 解决的问题: 防止数据竞争。当多个线程同时读写同一块数据时,如果没有保护,会导致数据不一致、程序崩溃等未定义行为。
- C++ 标准库中的类:
std::mutex,std::recursive_mutex,std::shared_mutex(C++17) 等。 - 关键操作:
lock(): 获取锁。如果锁已被其他线程持有,则当前线程阻塞。unlock(): 释放锁。try_lock(): 尝试获取锁,不阻塞,立即返回成功或失败。
- 最佳实践: 使用
std::lock_guard或std::unique_lock这类 RAII 包装器,在作用域结束时自动释放锁,避免忘记解锁。
简单示例:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex g_mutex;
int g_counter = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(g_mutex); // 构造时加锁,析构时自动解锁
++g_counter; // 这个操作是“原子的”,受保护的
}
}
int main() {
std::thread t1(increment); // 创建一个新线程,并在该线程中执行 increment 函数
std::thread t2(increment);
t1.join(); // 表示主线程会等待 t1 线程执行完毕后再继续
t2.join();
std::cout << "Final counter value: " << g_counter << std::endl; // 保证是 200000
return 0;
}
2. 条件变量
核心作用: 实现线程间的等待和通知机制,用于一个线程等待某个条件成立,而另一个线程在条件成立时通知它。
- 是什么? 条件变量提供了一个场所,让线程可以在这里等待某个特定的事件发生(即“条件”满足)。它本身不管理互斥,需要与互斥锁配合使用。
- 解决的问题: 忙等待 的效率问题。如果没有条件变量,线程可能需要不断地循环检查某个条件是否满足,这会浪费 CPU 资源。条件变量允许线程在条件不满足时主动休眠,直到被其他线程唤醒。
- C++ 标准库中的类:
std::condition_variable,std::condition_variable_any。 - 关键操作:
wait(std::unique_lock& lock, Predicate pred): 原子地解锁并进入等待状态,直到被notify_*唤醒。被唤醒后,它会重新获取锁,并检查条件pred是否满足(防止虚假唤醒)。notify_one(): 唤醒一个正在等待的线程。notify_all(): 唤醒所有正在等待的线程。
- 经典模式: 生产者-消费者模型。
简单示例(生产者-消费者):
/*
1.等待时的行为
当执行 cv.wait(lock, [] { return data_queue.size() < MAX_SIZE; }); 时:
• 如果 data_queue.size() < MAX_SIZE 为真(队列未满),lambda表达式返回 true,wait 立即返回,线程继续执行后续代码。
• 如果为假(队列已满),wait 会自动释放锁,让其他线程(如消费者)有机会消费队列数据,然后当前线程进入等待状态。
2.被唤醒后的行为
• 当其他线程调用 cv.notify_one() 或 cv.notify_all() 唤醒等待的线程时,wait 会重新获得锁,并再次执行 lambda 表达式判断条件。
• 只有当 lambda 返回 true(即队列有空间),线程才会真正从 wait 返回,继续生产数据。
3.原始代码的问题(一个条件变量)
• 当调用 notify_one() 时,系统随机唤醒一个等待的线程。
• 可能唤醒同类型的线程(生产者唤醒生产者,或消费者唤醒消费者)。
• 导致同一类型的线程连续执行多次。
*/
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue;
const int MAX_SIZE = 10;
void producer() {
for (int i = 0; i < 20; ++i) {
std::unique_lock<std::mutex> lock(mtx);
// 如果队列满了,就等待消费者消费
cv.wait(lock, [] { return data_queue.size() < MAX_SIZE; });
data_queue.push(i);
std::cout << "Produced: " << i << std::endl;
lock.unlock();
cv.notify_one(); // 通知消费者
}
}
void consumer() {
for (int i = 0; i < 20; ++i) {
std::unique_lock<std::mutex> lock(mtx);
// 如果队列空了,就等待生产者生产
cv.wait(lock, [] { return !data_queue.empty(); });
int data = data_queue.front();
data_queue.pop();
std::cout << "Consumed: " << data << std::endl;
lock.unlock();
cv.notify_one(); // 通知生产者
}
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
运行结果:
Produced: 0 Produced: 1 Produced: 2 Produced: 3 Produced: 4 Produced: 5 Produced: 6 Produced: 7 Produced: 8 Produced: 9 Consumed: 0 Consumed: 1 Consumed: 2 Consumed: 3 Consumed: 4 Consumed: 5 Consumed: 6 Consumed: 7 Consumed: 8 Consumed: 9 Produced: 10 Produced: 11 Produced: 12 Produced: 13 Produced: 14 Produced: 15 Produced: 16 Produced: 17 Produced: 18 Produced: 19 Consumed: 10 Consumed: 11 Consumed: 12 Consumed: 13 Consumed: 14 Consumed: 15 Consumed: 16 Consumed: 17 Consumed: 18 Consumed: 19
并不是Produced: 0 Consumed: 0 Produced: 1 Consumed: 1 .... 交替出现,是因为:
- 线程调度机制:生产者线程在获取锁后,只要队列不满就会连续生产,直到队列满才会等待。
- 队列容量限制:MAX_SIZE = 10 意味着生产者可以连续生产10个元素而不被阻塞。
- 锁的持有时间:生产者在生产过程中持有着锁,消费者无法介入。
// 生产者执行流程:
1. 获取锁
2. 检查队列是否满 → 不满就继续
3. 生产数据
4. 释放锁,通知消费者
5. 立即重新获取锁,继续生产...
// 在队列满之前,生产者会连续运行
如何实现交替执行?
如果要实现真正的交替执行(生产一个消费一个),可以
方案1:减小队列容量
const int MAX_SIZE = 1; // 每次只能存1个元素
方案2:修改等待条件
void producer() {
for (int i = 0; i < 20; ++i) {
std::unique_lock<std::mutex> lock(mtx);
// 等待队列不满
cv.wait(lock, [] { return data_queue.size() < MAX_SIZE; });
data_queue.push(i);
std::cout << "Produced: " << i << std::endl;
lock.unlock();
cv.notify_one();
std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 让出CPU
}
}
方案3:使用两个条件变量(推荐)
std::condition_variable cv_producer, cv_consumer;
void producer() {
for (int i = 0; i < 20; ++i) {
std::unique_lock<std::mutex> lock(mtx);
cv_producer.wait(lock, [] { return data_queue.size() < MAX_SIZE; });
data_queue.push(i);
std::cout << "Produced: " << i << std::endl;
lock.unlock();
cv_consumer.notify_one(); // 专门通知消费者
}
}
void consumer() {
for (int i = 0; i < 20; ++i) {
std::unique_lock<std::mutex> lock(mtx);
cv_consumer.wait(lock, [] { return !data_queue.empty(); });
int data = data_queue.front();
data_queue.pop();
std::cout << "Consumed: " << data << std::endl;
lock.unlock();
cv_producer.notify_one(); // 专门通知生产者
}
}
3. 信号量
核心作用: 控制对一定数量的共享资源的访问。它维护一个计数器,表示可用资源的数量。
- 是什么? 信号量可以被看作是一个计数器,代表可用资源的数量。线程通过
acquire(或wait) 来请求一个资源(计数器减一),如果计数器为0,则线程阻塞。线程通过release(或signal) 来释放一个资源(计数器加一)。 - 解决的问题: 允许多个线程(但数量有限)同时访问一个资源池。例如,限制同时运行的数据库连接数。
- C++ 中的情况: 在 C++20 之前,标准库中没有信号量,通常使用
std::mutex和std::condition_variable自己实现,或者使用操作系统原生 API(如sem_t)。C++20 引入了std::counting_semaphore和std::binary_semaphore。 - 关键操作:
acquire(): 获取一个资源(P 操作),计数器减一。如果计数器为0则阻塞。release(): 释放一个资源(V 操作),计数器加一。
- 特殊类型: 二进制信号量 (
std::binary_semaphore),其计数器只有 0 和 1。它在功能上类似于互斥锁,但语义不同:互斥锁强调“谁加锁谁解锁”,而信号量没有这个限制(可以在一个线程获取,在另一个线程释放)。
C++20 信号量示例:
#include <iostream>
#include <thread>
#include <semaphore>
std::counting_semaphore<3> sem(3); // 最多允许3个线程同时访问
void worker(int id) {
sem.acquire(); // 请求一个资源
std::cout << "Thread " << id << " is working..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟工作
std::cout << "Thread " << id << " is done." << std::endl;
sem.release(); // 释放一个资源
}
int main() {
std::thread threads[10];
for (int i = 0; i < 10; ++i) {
threads[i] = std::thread(worker, i);
}
for (auto& t : threads) {
t.join();
}
return 0;
}
// 输出会显示,最多同时有3个线程在“工作”。
总结与对比
| 特性 | 互斥锁 | 条件变量 | 信号量 |
|---|---|---|---|
| 核心目的 | 互斥,保护共享数据 | 协调,线程等待与通知 | 控制,限制并发访问数量 |
| 资源管理 | 二元状态(锁定/未锁定) | 不直接管理资源,依赖外部条件 | 计数器,管理多个资源 |
| 配合使用 | 通常单独使用 | 必须与互斥锁一起使用 | 可以单独使用 |
| C++标准 | C++11 (std::mutex) |
C++11 (std::condition_variable) |
C++20 (std::counting_semaphore) |
| 典型场景 | 保护一个全局变量、一个数据结构 | 生产者-消费者、等待任务完成 | 连接池、限制并行任务数 |
简单关系概括:
- 互斥锁是基础,解决了“不能同时进”的问题。
- 条件变量建立在互斥锁之上,解决了“我等你忙完通知我”的问题,避免了傻等。
- 信号量是一个更通用的计数器,可以用于实现互斥锁(二进制信号量)和更复杂的资源控制。
在实际开发中,根据你的具体需求来选择最合适的工具。如果需要严格保护一块数据,用互斥锁;如果一个线程需要等待某个条件成立,用条件变量;如果需要限制对某类资源(数量大于1)的并发访问数,用信号量。
浙公网安备 33010602011771号