【C++】手写线程安全队列:mutex + condition_variable 实现生产者消费者模型

前面已经分别介绍过互斥锁和条件变量。单独看这些知识点可能比较零散,而线程安全队列正好可以把它们串起来。

普通的 std::queue 并不是线程安全的:

复制代码
std::queue<int> tasks;

如果一个线程正在:

复制代码
tasks.push(10);

另一个线程同时:

复制代码
tasks.pop();

就可能发生数据竞争。

因此,我们希望封装一个这样的队列:

复制代码
ThreadSafeQueue<int> queue;

queue.push(100);

int value;
queue.wait_and_pop(value);

其中 push() 可以被生产者线程调用,wait_and_pop() 可以让消费者在没有数据时自动等待。

一、普通 queue 为什么不能直接多线程使用

先来看一个简单例子:

复制代码
#include <queue>
#include <thread>

std::queue<int> tasks;

void Producer()
{
    for (int i = 0; i < 1000; ++i)
    {
        tasks.push(i);
    }
}

void Consumer()
{
    while (!tasks.empty())
    {
        int value = tasks.front();
        tasks.pop();
    }
}

如果生产者和消费者同时运行:

复制代码
std::thread t1(Producer);
std::thread t2(Consumer);

它们可能同时访问:

复制代码
tasks

std::queue 本身不会自动加锁。

例如消费者执行:

复制代码
if (!tasks.empty())

刚判断队列不为空,生产者或其他消费者就可能修改队列。

更典型的问题是:

复制代码
if (!tasks.empty())
{
    int value = tasks.front();
    tasks.pop();
}

这三步并不是一个不可分割的整体:

复制代码
检查队列
    ↓
读取队首
    ↓
删除队首

因此必须使用互斥锁保护整个操作过程。

最简单的写法:

复制代码
std::mutex mutex;

void Push(int value)
{
    std::lock_guard<std::mutex> lock(mutex);
    tasks.push(value);
}

取数据时同样加锁:

复制代码
bool Pop(int& value)
{
    std::lock_guard<std::mutex> lock(mutex);

    if (tasks.empty())
    {
        return false;
    }

    value = tasks.front();
    tasks.pop();

    return true;
}

这样可以保证同一时刻只有一个线程修改队列。

不过还有一个问题:

队列为空时,消费者应该怎么办?

如果不停调用:

复制代码
while (!Pop(value))
{
}

线程会一直空转,占用 CPU。

所以还需要 condition_variable

二、先实现 push 和 try_pop

我们先搭建一个基础线程安全队列:

复制代码
#include <condition_variable>
#include <mutex>
#include <queue>

template<typename T>
class ThreadSafeQueue
{
public:
    void push(const T& value)
    {
        {
            std::lock_guard<std::mutex> lock(mutex_);
            queue_.push(value);
        }

        condition_.notify_one();
    }

    bool try_pop(T& value)
    {
        std::lock_guard<std::mutex> lock(mutex_);

        if (queue_.empty())
        {
            return false;
        }

        value = queue_.front();
        queue_.pop();

        return true;
    }

private:
    std::queue<T> queue_;
    std::mutex mutex_;
    std::condition_variable condition_;
};

先看:

复制代码
void push(const T& value)

内部首先:

复制代码
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(value);

保证多个生产者不能同时破坏队列内部结构。

加入数据以后:

复制代码
condition_.notify_one();

通知一个正在等待数据的消费者:

队列中已经有新数据了,可以起来检查了。

这里故意把 notify_one() 放在锁作用域外:

复制代码
{
    std::lock_guard<std::mutex> lock(mutex_);
    queue_.push(value);
}

condition_.notify_one();

而不是:

复制代码
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(value);
condition_.notify_one();

后一种通常也能保证正确性,但唤醒消费者以后,消费者还需要重新获取 mutex_

如果生产者此时仍然持有锁:

复制代码
消费者被唤醒
    ↓
想获取mutex
    ↓
生产者还没有释放
    ↓
消费者继续等待

所以通常先完成共享数据修改并释放锁,再进行通知会更自然。

try_pop() 则表示:

尝试取出一个数据,如果当前没有数据就立即返回。

复制代码
bool try_pop(T& value)
{
    std::lock_guard<std::mutex> lock(mutex_);

    if (queue_.empty())
    {
        return false;
    }

    value = queue_.front();
    queue_.pop();

    return true;
}

使用:

复制代码
int value;

if (queue.try_pop(value))
{
    std::cout << value << '\n';
}
else
{
    std::cout << "队列为空\n";
}

它不会等待。

因此 try_pop() 比较适合:

复制代码
有任务就处理
没任务就去做其他事情

而线程池工作线程通常需要的是:

复制代码
没有任务就睡眠
有任务再醒来

这就需要 wait_and_pop()

三、wait_and_pop 为什么需要 unique_lock

实现:

复制代码
void wait_and_pop(T& value)
{
    std::unique_lock<std::mutex> lock(mutex_);

    condition_.wait(lock, [this]() { return !queue_.empty(); });

    value = queue_.front();
    queue_.pop();
}

这里最重要的一句是:

复制代码
condition_.wait(lock, [this]() { return !queue_.empty(); });

它表示:

只要队列为空,就继续等待;队列不为空以后,才继续向下执行。

大致等价于:

复制代码
while (queue_.empty())
{
    condition_.wait(lock);
}

假设队列为空:

复制代码
消费者获得mutex_
    ↓
发现queue_为空
    ↓
wait释放mutex_
    ↓
消费者睡眠

之后生产者:

复制代码
queue.push(100);

内部执行:

复制代码
获得mutex_
    ↓
queue_.push(100)
    ↓
释放mutex_
    ↓
notify_one()

消费者被唤醒:

复制代码
重新获得mutex_
    ↓
再次检查queue_.empty()
    ↓
发现不为空
    ↓
取出数据

这里必须使用:

复制代码
std::unique_lock<std::mutex>

而不是:

复制代码
std::lock_guard<std::mutex>

因为 wait() 睡眠时需要临时:

复制代码
unlock

醒来以后还需要:

复制代码
lock

unique_lock 支持这种灵活控制。

因此:

复制代码
std::unique_lock<std::mutex> lock(mutex_);
condition_.wait(lock, [this]() { return !queue_.empty(); });

是线程安全队列中非常经典的一种写法。

四、完整线程安全队列实现

把几个接口组合起来:

复制代码
#include <condition_variable>
#include <mutex>
#include <queue>

template<typename T>
class ThreadSafeQueue
{
public:
    ThreadSafeQueue() = default;

    void push(const T& value)
    {
        {
            std::lock_guard<std::mutex> lock(mutex_);
            queue_.push(value);
        }

        condition_.notify_one();
    }

    void push(T&& value)
    {
        {
            std::lock_guard<std::mutex> lock(mutex_);
            queue_.push(std::move(value));
        }

        condition_.notify_one();
    }

    bool try_pop(T& value)
    {
        std::lock_guard<std::mutex> lock(mutex_);

        if (queue_.empty())
        {
            return false;
        }

        value = std::move(queue_.front());
        queue_.pop();

        return true;
    }

    void wait_and_pop(T& value)
    {
        std::unique_lock<std::mutex> lock(mutex_);

        condition_.wait(lock, [this]() { return !queue_.empty(); });

        value = std::move(queue_.front());
        queue_.pop();
    }

    bool empty() const
    {
        std::lock_guard<std::mutex> lock(mutex_);
        return queue_.empty();
    }

    size_t size() const
    {
        std::lock_guard<std::mutex> lock(mutex_);
        return queue_.size();
    }

private:
    std::queue<T> queue_;
    mutable std::mutex mutex_;
    std::condition_variable condition_;
};

这里同时提供了两个 push()

复制代码
void push(const T& value);
void push(T&& value);

第一个处理左值:

复制代码
int value = 100;
queue.push(value);

第二个支持移动:

复制代码
queue.push(100);

或者:

复制代码
std::string str = "hello";
queue.push(std::move(str));

取数据时也使用:

复制代码
value = std::move(queue_.front());

避免某些较大对象发生不必要的复制。

另外:

复制代码
bool empty() const

const 成员函数,但里面需要锁住:

复制代码
mutex_

所以互斥锁声明为:

复制代码
mutable std::mutex mutex_;

mutable 表示:

即使当前对象是 const,这个成员仍然允许修改。

而加锁和解锁本身会修改 mutex 的内部状态,所以需要 mutable

五、生产者消费者完整示例

下面创建两个生产者和两个消费者。

复制代码
#include <iostream>
#include <thread>

ThreadSafeQueue<int> queue;

void Producer(int start)
{
    for (int i = 0; i < 5; ++i)
    {
        int value = start + i;
        queue.push(value);

        std::cout << "生产:" << value << '\n';
    }
}

void Consumer()
{
    for (int i = 0; i < 5; ++i)
    {
        int value;

        queue.wait_and_pop(value);

        std::cout << "消费:" << value << '\n';
    }
}

int main()
{
    std::thread producer1(Producer, 100);
    std::thread producer2(Producer, 200);

    std::thread consumer1(Consumer);
    std::thread consumer2(Consumer);

    producer1.join();
    producer2.join();

    consumer1.join();
    consumer2.join();

    return 0;
}

生产者不断:

复制代码
queue.push(value);

消费者不断:

复制代码
queue.wait_and_pop(value);

如果队列中存在数据:

复制代码
消费者直接取出

如果队列为空:

复制代码
消费者进入wait
    ↓
释放mutex
    ↓
进入睡眠

生产者加入任务:

复制代码
push数据
    ↓
notify_one
    ↓
消费者被唤醒

这就是最基本的生产者---消费者模型。

整个结构其实已经非常接近线程池中的任务队列:

复制代码
外部线程
    ↓
提交任务
    ↓
ThreadSafeQueue
    ↓
condition_variable通知
    ↓
Worker线程醒来
    ↓
wait_and_pop取任务
    ↓
执行任务

如果把:

复制代码
ThreadSafeQueue<int>

改成:

复制代码
ThreadSafeQueue<std::function<void()>>

队列里面保存的就不再是数字,而是一个个真正可以执行的任务:

复制代码
ThreadSafeQueue<std::function<void()>> tasks;

提交任务:

复制代码
tasks.push([]() { std::cout << "执行任务\n"; });

工作线程:

复制代码
std::function<void()> task;
tasks.wait_and_pop(task);
task();

这样就已经搭出了一个简化线程池最核心的结构:

复制代码
任务
 ↓
线程安全队列
 ↓
condition_variable
 ↓
Worker
 ↓
执行task()

这一篇最需要掌握的是:

复制代码
std::queue本身不保证线程安全;
mutex负责保护队列内部数据;
try_pop没有数据时立即返回;
wait_and_pop没有数据时让线程睡眠;
condition_variable负责通知等待线程;
wait需要unique_lock,因为等待期间必须释放mutex;
push完成数据修改后再notify_one;
线程安全队列是生产者消费者模型和线程池的重要基础。

0voice · GitHub

相关推荐
崖边看雾1 小时前
同步上下文管理器规则
开发语言·数据库
名字还没想好☜10 小时前
Python f-string 进阶:数字格式化、对齐填充、调试 = 号与嵌套表达式
开发语言·数据库·python·字符串格式化·f-string
·薯条大王10 小时前
经济实惠玩云服务器|一台云服务器多人共用,子账号配置教程
java·linux·运维·服务器·汇编·c++·python
一壶浊酒..13 小时前
Scrape Webpack练习
开发语言·javascript·webpack
命运之光14 小时前
【C语言完整代码】就诊信息管理系统
java·c语言·开发语言
命运之光14 小时前
【C语言完整代码】图书管理系统:图书馆场景下的增删改查实战
c语言·开发语言
猿长大人14 小时前
C# | Serilog 新手入门
开发语言·c#·.net·log
hansang_IR15 小时前
【题解】LC:倍增 / 区间并查集(Range Parallel Unionfind)
c++·算法·并查集
东东最爱敲键盘15 小时前
day7.c++继承
c++