C++ 互斥锁、条件变量和信号量的作用、区别和使用场景

本文介绍C++ 中互斥锁、条件变量和信号量的作用、区别和使用场景。
这三者都是用于多线程编程中同步协调线程行为的工具,但它们的侧重点和用途各不相同。


1. 互斥锁

核心作用: 实现互斥,保护共享资源,确保同一时间只有一个线程可以访问该资源。

  • 是什么? 互斥锁就像一个房间的钥匙。一次只有一个线程能拿到这把钥匙。拿到钥匙的线程可以进入“房间”(即访问共享数据),其他线程必须等待,直到钥匙被放回。
  • 解决的问题: 防止数据竞争。当多个线程同时读写同一块数据时,如果没有保护,会导致数据不一致、程序崩溃等未定义行为。
  • C++ 标准库中的类: std::mutex, std::recursive_mutex, std::shared_mutex (C++17) 等。
  • 关键操作:
    • lock(): 获取锁。如果锁已被其他线程持有,则当前线程阻塞。
    • unlock(): 释放锁。
    • try_lock(): 尝试获取锁,不阻塞,立即返回成功或失败。
  • 最佳实践: 使用 std::lock_guardstd::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 .... 交替出现,是因为:

  1. 线程调度机制:生产者线程在获取锁后,只要队列不满就会连续生产,直到队列满才会等待。
  2. 队列容量限制:MAX_SIZE = 10 意味着生产者可以连续生产10个元素而不被阻塞。
  3. 锁的持有时间:生产者在生产过程中持有着锁,消费者无法介入。
// 生产者执行流程:
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::mutexstd::condition_variable 自己实现,或者使用操作系统原生 API(如 sem_t)。C++20 引入了 std::counting_semaphorestd::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)的并发访问数,用信号量

posted on 2026-01-03 10:36  四季萌芽V  阅读(19)  评论(0)    收藏  举报

导航