一、引言

在 C++ 编程的世界里,多线程编程可是个非常重要的技术。它能让程序同时处理多个任务,大大提高程序的性能和响应速度。不过,多线程编程也带来了很多挑战,比如线程之间的同步问题。今天咱们就来聊聊其中一个很有用的工具——条件变量,看看它在 C++ 多线程编程里该怎么正确使用。

二、条件变量是什么

条件变量就像是一个协调员,它能让线程在某个条件满足的时候才继续执行,不满足的时候就先等着。打个比方,有两个线程,一个负责生产数据,一个负责消费数据。消费者线程得等生产者线程生产出数据之后才能开始工作,这时候条件变量就能发挥作用了,它可以让消费者线程在没有数据的时候先暂停,等有数据了再接着干。

在 C++ 里,条件变量定义在 <condition_variable> 头文件里,主要有两个类型:std::condition_variablestd::condition_variable_anystd::condition_variable 只能和 std::unique_lock<std::mutex> 一起用,而 std::condition_variable_any 可以和任何满足互斥锁要求的锁类型一起用。

三、条件变量的基本用法

下面咱们来看一个简单的例子,看看条件变量是怎么用的:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

// 生产者线程函数
void producer() {
    // 模拟一些工作
    std::this_thread::sleep_for(std::chrono::seconds(2));

    {
        std::lock_guard<std::mutex> lock(mtx);
        ready = true;  // 设置标志位,表示数据准备好了
    }
    cv.notify_one();  // 通知一个等待的线程
}

// 消费者线程函数
void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    // 等待条件变量通知,并且检查标志位
    cv.wait(lock, []{ return ready; });
    std::cout << "Consumer: Data is ready and processing..." << std::endl;
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);

    t1.join();
    t2.join();

    return 0;
}

在这个例子里,生产者线程先睡 2 秒钟,模拟生产数据的过程。然后它设置 ready 标志位为 true,表示数据准备好了,接着调用 cv.notify_one() 通知一个等待的线程。消费者线程用 std::unique_lock 锁住互斥锁,然后调用 cv.wait() 等待条件变量的通知。wait() 函数会自动释放锁,让其他线程可以访问共享资源。当收到通知并且 ready 标志位为 true 时,wait() 函数会重新获取锁,然后继续执行。

四、条件变量的应用场景

1. 生产者 - 消费者模型

这是条件变量最常见的应用场景之一。就像上面的例子一样,生产者线程负责生产数据,消费者线程负责消费数据。生产者生产完数据后通知消费者,消费者在没有数据的时候就等待。这样可以避免消费者线程一直循环检查是否有数据,节省了 CPU 资源。

2. 线程池

在线程池里,主线程会创建一定数量的工作线程。当有任务到来时,主线程把任务放到任务队列里,然后通知一个空闲的工作线程来处理。工作线程在没有任务的时候就等待,有任务了就开始工作。

下面是一个简单的线程池示例:

#include <iostream>
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <atomic>

class ThreadPool {
public:
    ThreadPool(size_t numThreads) : stop(false) {
        for (size_t i = 0; i < numThreads; ++i) {
            workers.emplace_back([this] {
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(this->queueMutex);
                        // 等待任务队列有任务或者线程池停止
                        this->condition.wait(lock, [this] { return this->stop ||!this->tasks.empty(); });
                        if (this->stop && this->tasks.empty())
                            return;
                        task = std::move(this->tasks.front());
                        this->tasks.pop();
                    }
                    task();  // 执行任务
                }
            });
        }
    }

    ~ThreadPool() {
        {
            std::unique_lock<std::mutex> lock(queueMutex);
            stop = true;
        }
        condition.notify_all();  // 通知所有线程停止
        for (std::thread &worker : workers) {
            worker.join();
        }
    }

    template<class F>
    void enqueue(F &&f) {
        {
            std::unique_lock<std::mutex> lock(queueMutex);
            if (stop)
                throw std::runtime_error("enqueue on stopped ThreadPool");
            tasks.emplace(std::forward<F>(f));
        }
        condition.notify_one();  // 通知一个线程有新任务
    }

private:
    std::vector<std::thread> workers;
    std::queue<std::function<void()>> tasks;
    std::mutex queueMutex;
    std::condition_variable condition;
    std::atomic<bool> stop;
};

// 示例任务函数
void task() {
    std::cout << "Task is being executed." << std::endl;
}

int main() {
    ThreadPool pool(4);

    // 向线程池添加任务
    for (int i = 0; i < 10; ++i) {
        pool.enqueue(task);
    }

    return 0;
}

五、条件变量的技术优缺点

优点

  • 节省 CPU 资源:条件变量可以让线程在不满足条件的时候进入等待状态,避免了线程的空转,节省了 CPU 资源。
  • 线程同步:它能很好地实现线程之间的同步,确保线程在合适的时机执行相应的操作。

缺点

  • 使用复杂:条件变量的使用需要和互斥锁配合,而且要注意锁的获取和释放顺序,容易出错。
  • 虚假唤醒:有时候线程会在没有收到通知的情况下从 wait() 函数返回,这就是虚假唤醒。所以在使用 wait() 函数时,一定要检查条件是否真的满足。

六、条件变量的注意事项

1. 虚假唤醒

正如前面提到的,虚假唤醒是一个需要注意的问题。为了避免虚假唤醒,我们在使用 wait() 函数时,一定要传入一个条件判断函数。比如:

cv.wait(lock, []{ return ready; });

这样即使发生了虚假唤醒,线程也会检查 ready 标志位,如果条件不满足,它会继续等待。

2. 锁的使用

在使用条件变量时,一定要正确使用互斥锁。wait() 函数会自动释放锁,当收到通知时会重新获取锁。所以在调用 wait() 函数之前,一定要先获取锁。

3. 通知的顺序

在通知线程时,要注意通知的顺序。如果在设置条件之前就通知,可能会导致等待的线程错过通知。所以一般要先设置条件,再通知。

七、文章总结

条件变量是 C++ 多线程编程里一个非常有用的工具,它能很好地解决线程之间的同步问题。通过合理使用条件变量,我们可以实现高效的生产者 - 消费者模型、线程池等。不过,使用条件变量也有一些需要注意的地方,比如虚假唤醒、锁的使用和通知的顺序等。只要我们掌握了这些要点,就能在多线程编程中正确使用条件变量,提高程序的性能和稳定性。