并发队列(concurrentqueue)源码详细剖析 [第一篇 - 结构介绍]

752 阅读7分钟

回来填坑了,希望能帮到其他人
项目中一共有三个文件

  • blockingconcurrentqueue.h: 阻塞并发队列.代码量 500行
  • concurrentqueue.h: 并发无锁队列.主要文件,代码量 3000行
  • lightweightsemaphore.h: 轻量级信号量.用于阻塞并发队列使用. 代码量 500行
    只要把concurrentqueue.h这个文件啃明白,剩下两个文件都是小菜一碟.

这个项目大量使用了c++11的原子操作,所以需要了解内存模型,以及std::memory_order_xxx这些的含义,还有掌握一丢丢模板技术
这个项目因为都将源码写入一个头文件中,对于我这种带有一点点强迫症的人,还有主要就是看代码太难读懂了.所以将代码拆分成多个文件,这样对理解代码更加有帮助.后续把拆分后的代码(并附上详细注释)上传到github,引用原项目链接.

拆分后的项目结构:
image.png

下面给出两个项目链接,喜欢该项目的同学希望能给原版作者一个star,写的非常不错.
原作者项目链接
我自己进行拆分后的项目链接
下面以我自己拆分后的项目讲解为主,因为原项目都在一个文件分析起来特别麻烦.

程序入口

#include "concurrent_queue_impl.h"
#include <iostream>
#include <thread>

using namespace std;

int main()
{
    ConcurrentQueue<int> conqueue;
    bool exit = false;
    
    std::thread t1([&]{
        for (int i = 0; i < 50; i++)
            conqueue.Enqueue(i);   // 插入元素
        cout << "t1 insert done!\n";
    });

    std::thread t2([&]{
        int ret = 0;
        while (conqueue.TryDequeue(ret))  // 尝试弹出元素,如果返回值为true表示成功
            cout << "t2: " << ret << "\n";
        exit = true;
    });
    
    t1.join();
    t2.join();

    while (!exit);
    return 0;
}

只需要引用concurrent_queue_impl.h就可以使用并发队列了.视角就从ConcurrentQueue类里面是怎么做,直接去看concurrent_queue_impl.h文件.
准备好,后面内容可能比较多,可能需要你花一段时间去消化,毕竟我自己分析也花费了大量时间.

ConcurrentQueue类分析

ConcurrentQueue类的定义在concurrent_queue.h中,然后实现在concurrent_queue_impl.h

ConcurrentQueue类的定义:

class ConcurrentQueue
{
public:
    // 构造、虚构函数
    // 不支持拷贝和赋值函数,设置为delete
public:
// 入队操作
    bool Enqueue(T const& item)
    
    template <typename It>
    bool EnqueueBulk(It item_first, size_t count); // 批量插入
    
    bool TryEnqueue(T const& item); // 尝试入队,有可能插入失败
    
    template <typename It>
    bool TryEnqueueBulk(It item_first, size_t count); // 尝试批量入队
    // ... 省略一些入队的其他版本函数
    
// 出队操作
    template <typename U>
    bool TryDequeue(U& item);  // 尝试出队
    
    template <typename It>
    size_t TryDequeueBulk(It item_first, size_t max); // 尝试批量出队
    
    // ... 省略一些出队的其他版本函数
private:
// 成员变量
    std::atomic<ProducerBase<T>*> producer_list_tail_; // 生产者列表尾部
    std::atomic<std::uint32_t> producer_count_;        // 生产者个数
    std::atomic<size_t> initial_block_pool_index_;     // 初始块池索引
    Block<T>* initial_block_pool_;                     // 初始块池
    size_t initial_block_pool_size_;                   // 初始块池大小
    
    FreeList<Block<T>> free_list_;                     // 空闲链表
    
    std::atomic<size_t> implicit_producer_hash_count_; // 隐式生产者哈希个数
    std::atomic<ImplicitProducerHash<T>*> implicit_producer_hash_; // 隐式生产者哈希,和下面的指向同样的值
    
    // 初始隐式生产者哈希,也就是下面的array
    ImplicitProducerHash<T> initial_implicit_producer_hash_; 
    
    // 初始隐式生产者哈希条目,默认32大小(key、value形式)
    std::array<ImplicitProducerKVP<T>, kInitalImplicitProducerHashSize>
        initial_implicit_producer_hash_entries_; 
    
    // 用于标记隐式生产者哈希大小正在进行调整
    std::atomic_flag implicit_producer_hash_resize_in_progress_;
    std::atomic<uint32_t> next_explicit_consumer_id_; // 下一个显式消费者id
    std::atomic<uint32_t> global_explicit_consumer_offset_; // 全局显式消费者偏移值
};

浏览上面的成员变量和成员函数,知道个大概.会发现,它整了implcit_produerexplicit_producer.,我称为隐式生产者,简写IP显式生产者,简写EP,后面就直接用这俩称呼.
没错,入队和出队操作实际都是调用这俩的相应成员函数,实际值就保存在这俩类维护的内部数据结构. 也就是给用户提供了两种使用方式.

上面数据成员主要定义了块池隐式生产者哈希表.
块池

initial_block_pool_主要是用来存储我们将入队的元素,它底层就是一个数组,通过索引存储具体的元素

隐式生产者哈希表

用于存储隐式生产者,入队和出队都是通过这个进行的.

构造函数

默认提供了两个构造函数

image.png

第一个构造函数

template <typename T>
ConcurrentQueue<T>::ConcurrentQueue(size_t capacity)
    : producer_list_tail_(nullptr),
    producer_count_(0),
    initial_block_pool_index_(0),
    next_explicit_consumer_id_(0),
    global_explicit_consumer_offset_(0)
{
    implicit_producer_hash_resize_in_progress_.clear(std::memory_order_relaxed);
    PopulateInitialImplicitProducerHash();  // 初始化隐式哈希表

    // (capacity & (kBlockSize - 1)) == 0 ? 0 : 1) 
    // 主要是获取capacity / kBlockSize如果有余数就多分配一块
    PopulateInitialBlockList(capacity /  kBlockSize + 
        ((capacity & (kBlockSize - 1)) == 0 ? 0 : 1));  
}

第9行是一个atomic_flag,用于多线程中分配隐式哈希表大小的标志.这一行将它设置为初始值
第10行初始化隐式哈希表
第11行初始化块池

看一下这两个函数是如何初始化的.

PopulateInitialImplicitProducerHash

template <typename T>
void ConcurrentQueue<T>::PopulateInitialImplicitProducerHash()
{
    // kInitialImplicitProducerHashSize代表哈希表大小,默认32
    if (kInitialImplicitProducerHashSize == 0)    
        return;
    else
    {
        // 设置哈希表大小为0
        implicit_producer_hash_count_.store(0, std::memory_order_relaxed);
        auto hash = &initial_implicit_producer_hash_;
        hash->capacity_ = kInitialImplicitProducerHashSize; // 设置哈希表的大小,默认32
        // 指向ConcurrentQueue的成员std::array,默认为32大小
        hash->entries_ = &initial_implicit_producer_hash_entries_[0]; 
        for (size_t i = 0; i != kInitialImplicitProducerHashSize; i++)
            // key_的含义是指向线程id,这里设置为初始值
            initial_implicit_producer_hash_entries_[i].key_.store(kInvalidThreadId, std::memory_order_relaxed); 

        hash->prev_ = nullptr; // 哈希表不够也会创建新的,使用prev_和上一个哈希表连接起来
        implicit_producer_hash_.store(hash, std::memory_order_relaxed);
    }
}

image.png 隐式哈希表其实就是这个std::array,我们将哈希表的的一些成员指向它.
看上图,其中涉及到了两个数据结构,ImplicitProducerHashImplicitProducerKVP.

ImplicitProducerHash源码

template <typename T>
struct ImplicitProducerHash
{
    ImplicitProducerHash()
        : capacity_(0), entries_(nullptr), prev_(nullptr) { }
    ~ImplicitProducerHash() {}
	size_t capacity_;                   // 哈希的容量
	ImplicitProducerKVP<T>* entries_;   // 条目
	ImplicitProducerHash* prev_;        // 指向上一次已分配的隐式生产者哈希
};

entries_就是指向std::array这个数组.我们在使用的时候都需要通过这个来进行访问哈希表

ImplicitProducerKVP源码

template <typename T>
struct ImplicitProducerKVP
{
    ImplicitProducerKVP();
    ~ImplicitProducerKVP() {}
    ImplicitProducerKVP(ImplicitProducerKVP&& other) noexcept;
    ImplicitProducerKVP& operator=(ImplicitProducerKVP&& other) noexcept;
    void Swap(ImplicitProducerKVP& other) noexcept;

    std::atomic<ThreadId_t> key_;   // 存储线程id的地址
    ImplicitProducer<T>* value_;    // 存储这个线程对应着的隐式生产者
};

一个隐式哈希表的底层项就是存储线程对应的id和这个线程对应的隐式生产者

PopulateInitialBlockList

template <typename T>
void ConcurrentQueue<T>::PopulateInitialBlockList(size_t block_count)
{
    initial_block_pool_size_ = block_count; // 设置块池大小,默认也是32
    if (initial_block_pool_size_ == 0)
    {
        initial_block_pool_ = nullptr;
        return;
    }

    // 创建块池,大小为block_count
    initial_block_pool_ = CreateArray<Block<T>>(block_count);
    if (initial_block_pool_ == nullptr)
        initial_block_pool_size_ = 0;
    
    for (size_t i = 0; i < initial_block_pool_size_; i++)
        initial_block_pool_[i].dynamically_allocated_ = false;
}

上面的代码很简单,主要就是分配一块指定字节的连续内存,用于存储块. 那么看一下Block数据结构的定义

Block

template <typename T>
struct Block 
{
    Block();

    // ... 一些操作函数

// 下面是两个访问具体block的函数,可以通过下标进行访问
    T* operator[](index_t index) noexcept;
    T const* operator[](index_t index) const noexcept;
public:
    Block* next_;   // 指向下一个Block
    std::atomic<size_t> elements_completely_dequeued_;  // 用完空间的block个数. [隐式生产者会用到]

    // 标志位数组,遍历查找是否有空闲的block(!!!效率较低) [显式生产者会用到]
    std::atomic<bool> empty_flags_[kBlockSize <= kExplicitBlockEmptyCounterThreshold ? kBlockSize : 1];
    std::atomic<std::uint32_t> free_list_refs_;     // 
    std::atomic<Block<T>*> free_list_next_;         // 指向下一个空闲的块链表
    bool dynamically_allocated_;                    // 是否是动态分配
private:
    // 存储队列中的元素, 元素类型为char,大小是 sizeof(T) * KBlockSize
    alignas(alignof(T)) typename Identify<char[sizeof(T) * kBlockSize]>::type elements;
};

每一个Block内部都有一个数组,用于存储具体元素的值. 它的大小根据模板类型的大小*32(默认块大小).
前面说过,并发队列提供隐式生产者和显式生产者.所以,Block内部也定义了关于这两种方式的一些成员.
比如,对于隐式生产者,使用原子计数维护空闲还没有使用的空间.
对于显式生产者,使用原子标志位数组,通过遍历的方式查找哪一个空间还没用(效率较低些)

初始化后的块池和隐式生产者哈希表如下图所示

块池 image.png

隐式生产者哈希表 image.png


构造函数初始化了隐式生产者哈希表和块池,剩下就是该分析怎么入队和出队了.这个就涉及到了隐式生产者和显式生产者内部是入队和出队的.

留在下一篇,点击下面的链接跳转到下一篇文章
第二篇,隐式生产者的入队和出队源码分析