【C++并发编程】支持并发的阻塞队列的设计与实现

1,334 阅读3分钟

最近在系统学习C++ 并发编程,便想着开启C++ 并发编程这一系列的博客,记录所遇到的,觉得需要记录下来的知识点。 那么第一篇开始记录一下如何设计一个支持并发的阻塞队列(BlockQueue)

1 阻塞队列的定义

首先阻塞队列肯定是一个队列,是一种先进先出的数据结果,与一般的数据结构相比,多了阻塞两个字,对应的与普通队列不同的点如下:

  • 当队列时,从队列中取出元素会阻塞(先等着,直到队列中有元素为止)
  • 当队列时,往队列中增加元素会阻塞(队列有容量限制,不能无节制的往队列中增加元素) 阻塞队列常用来实现生产者消费者模型。

2 阻塞队列的C++实现

2.1 成员变量

用stl的queue容器来存放实际数据。结合mutexcondition变量来支持并发。

size_t capacity_;
std::queue<T> queue_;
std::mutex mutex_;  
std::condition_variable cond_; 

2.2 Push接口设计

此接口实现的是往阻塞队列尾部插入一个元素。当队列满时,插入动作阻塞中,直到队列中元素个数小于阻塞队列容量。

    1. 基于RAII的思想锁住mutex;
    1. 判断queue的size是否大于等于设定的capacity。当大于时,队列被阻塞,循环等待条件变量;
    1. 使用queue的原生push接口push数据。

实现代码如下:

void Push(const T &value)
{
    std::unique_lock<std::mutex> lock(mutex_);
    while (queue_.size() >= capacity_) {
        cond_.wait(lock);
    }
    assert(queue_.size() < capacity_);
    queue_.push(value);
    cond_.notify_one();
}

2.3 Take接口的实现

此接口是从阻塞队列头部取出一个元素。当队列为空时,阻塞等待,直到队列中有元素为止。

    1. 基于RAII的思想锁住mutex;
    1. 判断queue的size是否为空,为空时队列被阻塞,循环等待条件变量;
    1. 使用queue的原生pop接口获取头部一个元素。

实现代码如下:

T Take()
{
    std::unique_lock<std::mutex> lock(mutex_);
    while (queue_.size() == 0) {
        cond_.wait(lock);
    }
    assert(queue_.size() > 0);
    T value(std::move(queue_.front()));
    queue_.pop();
    cond_.notify_one();
    return value;
}

2.4 Size接口的实现

此接口是获取queue的实时大小,需要注意并发访问。

    1. 基于RAII的思想锁住mutex;
    1. 使用queue的原生size接口获取队列的大小。

实现代码如下:

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

2.5 完整实现代码

#ifndef BLOCKQUEUE_H_
#define BLOCKQUEUE_H_

#include <queue>
#include <mutex>
#include <condition_variable>
#include <cassert>
template <typename T>
class BlockQueue
{
public:
    BlockQueue(size_t capacity) 
        : capacity_(capacity),
        queue_(),
        mutex_(),
        cond_()
    {}
    
    void Push(T &&value)
    {
        std::unique_lock<std::mutex> lock(mutex_);
        while (queue_.size() >= capacity_) {
            cond_.wait(lock);
        }
        assert(queue_.size() < capacity_);
        queue_.push(value);
        cond_.notify_one();
    }
    
    void Push(const T &value)
    {
        std::unique_lock<std::mutex> lock(mutex_);
        while (queue_.size() >= capacity_) {
            cond_.wait(lock);
        }
        assert(queue_.size() < capacity_);
        queue_.push(value);
        cond_.notify_one();
    }

    T Take()
    {
        std::unique_lock<std::mutex> lock(mutex_);
        while (queue_.size() == 0) {
            cond_.wait(lock);
        }
        assert(queue_.size() > 0);
        T value(std::move(queue_.front()));
        queue_.pop();
        cond_.notify_one();
        return value;
    }

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

private:
    size_t capacity_;
    std::queue<T> queue_;
    std::mutex mutex_;  
    std::condition_variable cond_; 
};

#endif

3 基于阻塞队列实现一个生产者消费者模型

生产者消费者问题是多线程同步的经典问题,生产者消费者共享固定大小的缓冲区,生产者产生数据放入到缓冲区中,消费者从缓冲区中取出数据消耗掉。

本文实现一个简单的生产者消费者模型。共享缓冲区即采用前文设计的阻塞队列,生产者往队列中填入一个int数据,消费者取出队列中的头部数据。

3.1 生产者函数实现

实现代码如下:

void Producer()
{
    std::thread::id id = std::this_thread::get_id();
    while (true) {
        int value = rand();
        block_queue.Push(value);
        {
            std::lock_guard<std::mutex> lock(out_mutex);
            std::cout << "Thread id : " << id << " produces value : " << value << std::endl;
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
}

3.2 消费者函数实现

实现代码如下:

void Consumer()
{
    std::thread::id id = std::this_thread::get_id();
    while (true) {
        int value = block_queue.Take();
        {
            std::lock_guard<std::mutex> lock(out_mutex);
            std::cout << "Thread id : " << id << " consumes value : " << value << std::endl;  
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(500));
    }    
}

3.3 main函数以及运行效果

main函数如下:

int main()
{
    int producer_cnt = 2;
    int consumer_cnt = 5;
    std::vector<std::thread> producer_vec;
    std::vector<std::thread> consumer_vec;
    for (int i = 0; i < producer_cnt; ++i) {
        producer_vec.emplace_back(std::thread(Producer));
    }
    for (int i = 0; i < consumer_cnt; ++i) {
        consumer_vec.emplace_back(std::thread(Consumer));
    }
    for (auto &t : producer_vec) {
        t.join();
    }
    for (auto &t : consumer_vec) {
        t.join();
    }
    return 0;
}

输出结果如下:

Thread id : 0x16fc7f000 produces value : 282475249
Thread id : 0x16fbf3000 produces value : 16807
Thread id : 0x16feaf000 consumes value : 282475249
Thread id : 0x16fd97000 consumes value : 16807
Thread id : 0x16fbf3000 produces value : 984943658
Thread id : 0x16fe23000 consumes value : 984943658
Thread id : 0x16fc7f000 produces value : 1622650073
Thread id : 0x16fd0b000 consumes value : 1622650073
Thread id : 0x16fbf3000 produces value : 1144108930

注意 在调试输出的时候我发现有的时候会先print消费了x值,然后print出来生产了x值。造成这样的原因是多线程运行时,假设线程1是生产者线程,生产出x值,线程2是消费者,消费x值。线程1完成了往阻塞队列中push这一动作,但是,在没有获取到out_mutex之前,线程2已经消费了x值,并先获取到了out_mutex,那么就会先print出线程2消费x的log,然后才是线程1生产的log。

|   线程1                                           线程2
|   block_queue.Push(value);
|                                                  int value = block_queue.Take(); 
|                                                  std::lock_guard<std::mutex> lock(out_mutex);
|   std::lock_guard<std::mutex> lock(out_mutex);
V
线程并发过程

总之,生产者消费者消费数据的相对顺序是正确的(先生产的先消费),且队列中的元素数量始终小于容量,则该模型是正确的。

4 完整代码

完整代码链接如下,欢迎大家fork/star, 后续会持续更新C++并发编程的相关内容。 C++并发编程实例