前述
在github上用于C++
的工业级的无锁队列比较少,最好的当属作者cameron314写的ConcurrentQueue
,本文作者也在一些项目中用到了这个开源库,但是在使用过程中遇到了两个问题:
- 内存堆积:程序内存总是驻留在峰值流量的内存,一旦释放
concurrentqueue
对象,内存又恢复原状; - 多个producers下产生无序消费(不满足FIFO):明明一个producer优先于另一个producer写入数据,但是只有一个consumer的情况下,后写入的先消费,并且一个producer比较活跃时,consumer也一直在消费这个producer的数据;
我相信很多使用这个开源库的同学也同样的面临着这两个问题的困扰,鉴于本文作者始终坚持不明真相不轻易下撤换结论(是否决策要替换这个开源库)的原则,且比较喜欢阅读源码实行拿来主义的风格,带着上述问题阅读了concurrentqueue
的源码,这里将整个源码进行走读,并且分析其非常优秀的工业级无锁实现原理以供有需之同学参考。
一个优秀的开源库被高star使用,一定满足了切合业务情况下非常方便的使用(大家都是懒人,秉持着这几年互联网发展之精神,好用先上线,细节不究,吸引流量为上的高端原则)。concurrentqueue
也是具有上述特点的:极致的好用,性能优异,运行稳定,下面是一段来源concurrentqueue
作者的使用参考示例,非常简单的使用方式:定义一个队列,然后多个producers写入数据,多个consumers读取数据:
ConcurrentQueue<int> q;
int dequeued[100] = { 0 };
std::thread threads[20];
// Producers
for (int i = 0; i != 10; ++i) {
threads[i] = std::thread([&](int i) {
int items[10];
for (int j = 0; j != 10; ++j) {
items[j] = i * 10 + j;
}
q.enqueue_bulk(items, 10);}, i);
}
// Consumers
for (int i = 10; i != 20; ++i) {
threads[i] = std::thread([&]() {
int items[20];
for (std::size_t count = q.try_dequeue_bulk(items, 20); count != 0; --count) {
++dequeued[items[count - 1]];
}});
}
// Wait for all threads
for (int i = 0; i != 20; ++i) {
threads[i].join();
}
// Collect any leftovers (could be some if e.g. consumers finish before producers)
int items[10];
std::size_t count;
while ((count = q.try_dequeue_bulk(items, 10)) != 0) {
for (std::size_t i = 0; i != count; ++i) {
++dequeued[items[i]];
}
}
// Make sure everything went in and came back out!
for (int i = 0; i != 100; ++i) {
assert(dequeued[i] == 1);
}
按照上文文样例使用代码,我们又多出三个问题:
- 一个队列,多个producers写入数据怎么不引发冲突?
- 一个队列,多个consumers读取数据不会引发崩溃?
- producers和consumers如何不影响?
初始化操作
那我们就按照示例代码来进行源码走读,首先作者定义了一个ConcurrentQueue<int>
的结构,其构造函数如下:
explicit ConcurrentQueue(size_t capacity = 32 * BLOCK_SIZE)
: producerListTail(nullptr), // 原子变量,生产者队列末尾指针
producerCount(0), // 原子变量生产者计数
initialBlockPoolIndex(0),
nextExplicitConsumerId(0),
globalExplicitConsumerOffset(0)
{
implicitProducerHashResizeInProgress.clear(std::memory_order_relaxed);
// 初始化隐式生产者的hash表
populate_initial_implicit_producer_hash();
// 初始化块列表,用于存储用户数据
populate_initial_block_list(capacity / BLOCK_SIZE + ((capacity & (BLOCK_SIZE - 1)) == 0 ? 0 : 1));
}
这里我们分析一下函数populate_initial_implicit_producer_hash()
,其主要作用是占据一块初始化地盘(如果一直没有超出就一直使用这里,如果超出了就动态扩展),源码如下:
哦,想起来了,这里concurrentqueue提供了隐式生产者和显式生产者的操作,本文这里只分析隐式生产者的逻辑,因为这个是99%用户在使用的,显示生产者逻辑在本文末尾说明。
inline void populate_initial_implicit_producer_hash()
{
// 首先判断使用者是否设置了不允许使用隐式生产者,大多数使用者不会更改这个默认的TRAITS设置,
// 有些同学虽然更改了一些TRAITS,但是一问并不了解参数的作用域,读完本文,你对这些参数就有数了
MOODYCAMEL_CONSTEXPR_IF (INITIAL_IMPLICIT_PRODUCER_HASH_SIZE == 0) {
return;
}
else {
// 初始化隐式生产者的hash计数为0;
implicitProducerHashCount.store(0, std::memory_order_relaxed);
// initialImplicitProducerHash这个是初始化占据,
auto hash = &initialImplicitProducerHash;
// 默认隐式生产者数量,default是32
hash->capacity = INITIAL_IMPLICIT_PRODUCER_HASH_SIZE;
// 将默认producer的hash队列头赋值给initialImplicitProducerHash的入口指针;
hash->entries = &initialImplicitProducerHashEntries[0];
for (size_t i = 0; i != INITIAL_IMPLICIT_PRODUCER_HASH_SIZE; ++i) {
// 将所有的producer的key初始化为非法值(这个key后续用于存储producer线程id)
initialImplicitProducerHashEntries[i].key.store(details::invalid_thread_id, std::memory_order_relaxed);
}
// hash在扩展时是单调后向扩展,初始化时没有扩展,所以prev指针置为空
hash->prev = nullptr;
// 原子设置因此hash的指针,也是后续每个producer查找自己的队列的入口
implicitProducerHash.store(hash, std::memory_order_relaxed);
}
}
注意:concurrentqueue的基础实现是原子操作和原子操作的内存序,这块作者在本文不细讲,偶尔穿插说明为何这么做。
队列的初始化接下去还有一块Block
的初始化,即初始化数据容器,即函数populate_initial_block_list()
,这块就不细讲了,纯粹申请内存操作。
多个producers之间如何无锁写入
接下去我们分析队列的enqueue
操作,怎么样的实现解决了多个producers之间无锁的写入,首先我们调用的enqueue
主要有两类实现,一个是不带token
的(即隐式调用,也就是上文案例显示的,也是本文主讲),另一个是带token
的(即显式调用,本文后面说明);那么针对不带token
的调用,支持拷贝和移动两种方式,多数情况我们使用concurrentqueue
写入的是数据指针,所以一般是拷贝,偶尔是对象体则使用移动方式即可(都行,自行选择,主要就是减少拷贝)。按照调用栈依次进入,我们最终进入:
template<AllocationMode canAlloc, typename U>
inline bool inner_enqueue(U&& element)
{
// 看到重点了,首先选择一个隐式的producer,然后在producer中执行enqueue
auto producer = get_or_add_implicit_producer();
return producer == nullptr ? false : producer->ConcurrentQueue::ImplicitProducer::template enqueue<canAlloc>(std::forward<U>(element));
}
按照上述代码实现,我们大概也能猜到隐式的写入动作是每个producer线程先从一个池子中选择一个特定的producer队列,然后在这个producer队列中进行数据写入,我们先分析get_or_add_implicit_producer()
,因为这个函数比较重要,也比较长,我们分段解析一下:
auto id = details::thread_id(); // 计算producer的线程id
auto hashedId = details::hash_thread_id(id); // 通过id计算得到唯一hash值(尽量减少碰撞)
auto mainHash = implicitProducerHash.load(std::memory_order_acquire);
assert(mainHash != nullptr); // implicitProducerHash在上文已经初始化好了
for (auto hash = mainHash; hash != nullptr; hash = hash->prev){
auto index = hashedId;
while (true) {
index &= hash->capacity - 1u; // hash取余,这里capacity要求2的幂次且为无符号整型
auto probedKey = hash->entries[index].key.load(std::memory_order_relaxed);
if (probedKey == id) { // 按照取余的index找到对应的entry,如果key与线程id一致就找到了
auto value = hash->entries[index].value;
if (hash != mainHash) {
// 当找到之后,producer所在位置不是当前mainHash就复制到当前mainHash的位置,
// 同样是取余方式,只是capacity已经发生变化,取余的结果有差异
index = hashedId;
while (true) {
index &= mainHash->capacity - 1u;
auto empty = details::invalid_thread_id;
#ifdef MOODYCAMEL_CPP11_THREAD_LOCAL_SUPPORTED
auto reusable = details::invalid_thread_id2;
if (mainHash->entries[index].key.compare_exchange_strong(empty, id, std::memory_order_seq_cst, std::memory_order_relaxed) || mainHash->entries[index].key.compare_exchange_strong(reusable, id, std::memory_order_seq_cst, std::memory_order_relaxed)) {
#else
if (mainHash->entries[index].key.compare_exchange_strong(empty, id, std::memory_order_seq_cst, std::memory_order_relaxed)) {
#endif
mainHash->entries[index].value = value;
break;
}
++index;
// 上述采用原子变量的比较拷贝,如果发现不为空就找index的下一个,直到找到空位为止;
// 所以这里对线程id的hash需要有一个非常好的散列值,防止碰撞;
}
}
return value;
}
if (probedKey == details::invalid_thread_id) {
// 如果找到的位置与当前线程id不一致,且还是一个空的值,就说明当前的producer线程是第一次进
// 入,需要新建一个
break; // Not in this hash table
}
++index; // 同样的找index取余的依次下一个进行判断
}
}
很显然,单纯看上面的代码,很难理解一些操作,比如为啥有找到producer之后进行mainHash的拷贝动作,那么继续下面的代码:
// 假设两个producers的进入,如果implicitProducerHashCount为0,因为变量为原子变量则一个producer的newCount为1,运行后implicitProducerHashCount为1,另一个线程拿到的newCount为2,implicitProducerHashCount为2;
auto newCount = 1 + implicitProducerHashCount.fetch_add(1, std::memory_order_relaxed);
while (true) {
// producer判断拿到的newCount是否大于mainHash容量的一半了,同时是否拿到锁;
// 这里通过原子布尔变量处理自旋锁,且对该自旋锁要求使用读取屏障,即所有后续的
// 写入都不能重排到前面,主要是保障下文释放锁的操作不能重排到这之前,防止多个
// producers一起进入
if (newCount >= (mainHash->capacity >> 1) && !implicitProducerHashResizeInProgress.test_and_set(std::memory_order_acquire)) {
mainHash = implicitProducerHash.load(std::memory_order_acquire);
if (newCount >= (mainHash->capacity >> 1)) { // 进入后,为保险再次判断是否大于一半
size_t newCapacity = mainHash->capacity << 1;// 新capacity直接扩大一倍
while (newCount >= (newCapacity >> 1)) {
newCapacity <<= 1;//如果还不够,继续扩大,万一一次进来成千上万个producers呢是吧
}
// 申请 header + newCapacity的KVP大小
auto raw = static_cast<char*>((Traits::malloc)(sizeof(ImplicitProducerHash) + std::alignment_of<ImplicitProducerKVP>::value - 1 + sizeof(ImplicitProducerKVP) * newCapacity));
if (raw == nullptr) { // 失败没啥好说的
implicitProducerHashCount.fetch_sub(1, std::memory_order_relaxed);
implicitProducerHashResizeInProgress.clear(std::memory_order_relaxed);
return nullptr;
}
auto newHash = new (raw) ImplicitProducerHash; // 头部空间占位
newHash->capacity = static_cast<size_t>(newCapacity);
// 偏移头部位置并对其后的位置占位为entry的数组,数组头部指针给entry*
newHash->entries = reinterpret_cast<ImplicitProducerKVP*>(details::align_for<ImplicitProducerKVP>(raw + sizeof(ImplicitProducerHash)));
for (size_t i = 0; i != newCapacity; ++i) {
new (newHash->entries + i) ImplicitProducerKVP;// 每个数组位置占位为entry
newHash->entries[i].key.store(details::invalid_thread_id, std::memory_order_relaxed);
}
newHash->prev = mainHash; // 新空间的hash的pre指针指向原来的mainHash
// 注意这里采用写入屏障,也就是说所有排在后面的读操作一定读取到新的值,
// 也是为其他正在自旋的producer的更新mainHash做好内存保障;
implicitProducerHash.store(newHash, std::memory_order_release);
implicitProducerHashResizeInProgress.clear(std::memory_order_release);
mainHash = newHash; // 更换mainHash为新的newHash后释放锁;
} else {
// 如果进来发现已经扩展好了,直接释放锁
implicitProducerHashResizeInProgress.clear(std::memory_order_release);
}
}
// 这个while循环未完,下文继续
这里一部分代码通过原子布尔变量及内存屏障的使用实现了一把自旋锁
,确保新增数据不会产生多个producers同时操作的情况,并且多个producers是可预见的等待时间是非常小的,就几行代码的运行时间,因此自旋等待即可,第一个巧妙的无锁实现
。
当我们看到所有并发的producers其中之一对整个存储producers对象空间扩展后,其他的producer逐个获得自旋锁(进入自旋锁临界区后还会再次判断当前计数大于mainHash
的1/2
,确保正确)或者直接判断接下来的获得的mainHash
是否已经小于对象存储空间的3/4
,继续看代码:
// 接上文继续
// 如果新的producer计数小于对象空间的3/4
if (newCount < (mainHash->capacity >> 1) + (mainHash->capacity >> 2)) {
auto producer = static_cast<ImplicitProducer*>(recycle_or_create_producer(false));
if (producer == nullptr) { // 等效申请producer对象失败,没啥好说的
implicitProducerHashCount.fetch_sub(1, std::memory_order_relaxed);
return nullptr;
}
#ifdef MOODYCAMEL_CPP11_THREAD_LOCAL_SUPPORTED // 为简化理解,假设不支持,忽略掉
producer->threadExitListener.callback = &ConcurrentQueue::implicit_producer_thread_exited_callback;
producer->threadExitListener.userData = producer;
details::ThreadExitNotifier::subscribe(&producer->threadExitListener);
#endif
auto index = hashedId;
while (true) {
index &= mainHash->capacity - 1u; // 还是一样,在新的空间中用容量取余hash找位置
auto empty = details::invalid_thread_id;
// 如果是非法值则compare_exchange
#ifdef MOODYCAMEL_CPP11_THREAD_LOCAL_SUPPORTED
auto reusable = details::invalid_thread_id2;
if (mainHash->entries[index].key.compare_exchange_strong(reusable, id, std::memory_order_seq_cst, std::memory_order_relaxed)) {
implicitProducerHashCount.fetch_sub(1, std::memory_order_relaxed); // already counted as a used slot
mainHash->entries[index].value = producer;
break;
}
#endif
if (mainHash->entries[index].key.compare_exchange_strong(empty, id, std::memory_order_seq_cst, std::memory_order_relaxed)) { // 占空间位置替换操作
mainHash->entries[index].value = producer;
break;
}
++index; // 如果hash碰撞被占用,则取下一个
}
return producer;//这里返回producer对象,所以任何进入新申请空间的线程都从这里返回;
}
// 没有获取到自旋锁,且判断了当前producer线程计数还是大于对象空间mainHash的3/4,
// 则原子获取一下mainHash然后再继续上面的步骤,直到满足其中一个条件之一,
// 从return producer处返回,这里需要用acquire,也就是当前线程获取mainHash时,
// 排在后面的写必须不能重排到这个之前,如下一步循环进入自旋锁空间后,会有替换操作,
// 那么这个替换写入操作不能重排
mainHash = implicitProducerHash.load(std::memory_order_acquire);
}
到这里我们可能对第一块代码还是似懂非懂的状态,我们将这个操作画个结构图来说明一下这个原理,这样我们能够更加清晰一些,如下图所示:
mainHash
就是原子变量implicitProducerHash
,他总是指向最新扩展的那个空间(空间申请和占位这块上文已经讲了),前面的空间用pre
指针处理,整个代码步骤如下:
- 当有producer线程进来写入数据时,producer线程先用线程id的hash取余找到对应占位,判断其key是否与线程id一致,不一致,就找下一个占位,循环,直到找到key与当前线程id一致;
- 如果找到key的值为非法值,则跳出循环,通过pre找前一个hash空间
- 如果遍历完当前的所有hash空间都没有找,那么这是一个新的线程producer第一次进来,需要走创建流程;如果找到了,但是呢是通过
pre
指针找到的,那么需要把这个producer挪到mainHash
中来(防止下一次进来还需要再进行往前遍历操作,减少消耗),同样通过hashId
取余当前mainHash
的容量找到占位,如果碰撞了,那么就找下一个,一定存在key为非法的空间用于占用,因为容量到1/2
就会直接扩展,最差也会小于当前容量的3/4
;
到这里,大家应该都清楚了,其实concurrentqueue
的隐式生产者数据写入时都是用自己的producer对象(对象里面有自己的队列,下文会说明),采用了下面的3个方式达到了类似threadlocal
的效果(当然不能使用threadlocal
,要不然下文消费如何做呢是吧?),解决了多个producers之间写入数据会冲突的问题,也就是多个producers之间是无锁的,且都是O(1)的操作:
- 2的幂次-1方式取余找占位,性能高,增长也是2的幂次,这样保持取余能力;
- 采用预申请空间方式按照取余的index直接索引,也是高性能O(1);
- 通过原子自旋锁解决空间增长只会依次发生,不会冲突;
有一个比较隐晦但必须特别注意的地方,就是线程Id的hash值碰撞问题,读完本文,你会发现,其实多个producers如果发生了hash碰撞,那么碰撞的producer每次写入数据就会比较难受,这块在代码中也有体现,大家可以看下作者计算hash的函数details::hash_thread_id(id)
,也可以参考一下作者选择的hash算法代码:SMHasher,对整数进行hash来说是又快又很少发生碰撞。
producer写入数据过程
concurentqueue
是在内存足够的情况下尽力保证数据写入成功的,所以就是产生了本文开头提到的内存堆积的问题,当然这里面也是有很多原子操作,因为producer写入的数据,还有消费线程要来进行消费,那么如何解决呢我们来看看原理,通过get_or_add_implicit_producer()
我们已经找到了一个隐式producer,那么我们来看看隐式producer的enqueue
写入数据的代码,首先我们需要看看隐式producer构造做了哪些操作,其中之一构造函数调用了new_block_index()
:
auto prev = blockIndex.load(std::memory_order_relaxed);
size_t prevCapacity = prev == nullptr ? 0 : prev->capacity; // 新进来必然是nullptr
// 刚进来nextBlockIndexCapacity 是Traits的初始值,默认是32
auto entryCount = prev == nullptr ? nextBlockIndexCapacity : prevCapacity;
auto raw = static_cast<char*>((Traits::malloc)(sizeof(BlockIndexHeader) +
std::alignment_of<BlockIndexEntry>::value - 1 + sizeof(BlockIndexEntry) * entryCount + std::alignment_of<BlockIndexEntry*>::value - 1 + sizeof(BlockIndexEntry*) * nextBlockIndexCapacity)); // 申请空间大小注意一下
if (raw == nullptr) { // 失败有啥好说的
return false;
}
auto header = new (raw) BlockIndexHeader; // 头部占位为Header
// 去除Header对其的空间占用为entry的数组
auto entries = reinterpret_cast<BlockIndexEntry*>(details::align_for<BlockIndexEntry>
(raw + sizeof(BlockIndexHeader)));
// 去除Header及entry数组对其后占用为entry指针的数组
auto index = reinterpret_cast<BlockIndexEntry**>(details::align_for<BlockIndexEntry*>(
reinterpret_cast<char*>(entries) + sizeof(BlockIndexEntry) * entryCount));
if (prev != nullptr) { // 因为新进来为空没啥说的,下文在扩展空间时回头过来看
// 假设你回过来看这里,tail是有值的,我们假设了之前的capacity为32,且tail为29
auto prevTail = prev->tail.load(std::memory_order_relaxed);
auto prevPos = prevTail;
size_t i = 0;
do {
prevPos = (prevPos + 1) & (prev->capacity - 1);
index[i++] = prev->index[prevPos];
} while (prevPos != prevTail);
// 上面这个循环就是把前一个entry块的index从30的位置开始循环复制到29,总共32个位置,啥意思,
// 就是30的位置复制给新entry块的index0,31复制给index1,0复制给index2,…,29复制给index31,
// 那么下面的循环,index32为新块的entry0位置,index33为新块的entry1位置,所以这里为啥设置了
// 二级指针index**,就是利用二级指针重复利用所有申请过的entry空间,重新返回到下文;
assert(i == prevCapacity);
}
for (size_t i = 0; i != entryCount; ++i) {
new (entries + i) BlockIndexEntry;
// 将新空间的entry的key都置为invalid
entries[i].key.store(INVALID_BLOCK_BASE, std::memory_order_relaxed);
index[prevCapacity + i] = entries + i; // 将entry的地址赋值给 从prevCapacity(与i一样)的下一个开始的index二级指针
}
header->prev = prev;
header->entries = entries;
header->index = index;
header->capacity = nextBlockIndexCapacity;
//上面的赋值没啥说的,这里看下tail的初始值,新进来时上文假设nextBlockIndexCapacity为32,
// 那么tail的值是((0-1) & (32 - 1))= 31,记住这里;
header->tail.store((prevCapacity - 1) & (nextBlockIndexCapacity - 1), std::memory_order_relaxed);
// 最后是原子赋值操作,这里都是单线程producer会进来,所以不需要考虑原子序;
blockIndex.store(header, std::memory_order_release);
nextBlockIndexCapacity <<= 1; // 为下一次扩展做准备,变成64了
return true;
看完了隐式生产者的构造,我们基本能看到整个数据结构如下图所示:
但是还是有两块地方有疑问:
- 当不是第一次进入时,
prev
指针不为空; - tail第一次进入时
prevCapacity
是0,扩展进入时就不是0了;
留着疑问,我们再来看enqueue
操作的代码:
inline bool enqueue(U&& element){
// 第一次producer进来,tailIndex为0
index_t currentTailIndex = this->tailIndex.load(std::memory_order_relaxed);
// 那么newTailIndex就是1
index_t newTailIndex = 1 + currentTailIndex;
// BLOCK_SIZE默认32,也是用户填的必须2的幂次,因为要取余,第一次进来0&31==0,进入if
if ((currentTailIndex & static_cast<index_t>(BLOCK_SIZE - 1)) == 0) {
// headIndex是消费的位置,代表已经消费到哪里了
auto head = this->headIndex.load(std::memory_order_relaxed);
// 所以最后写入的位置一定是大于等于headIndex
assert(!details::circular_less_than<index_t>(currentTailIndex, head));
// 下文我们仔细讲讲circular_less_than,是无锁循环队列的灵魂之一
if (!details::circular_less_than<index_t>(head, currentTailIndex + BLOCK_SIZE) || (MAX_SUBQUEUE_SIZE != details::const_numeric_max<size_t>::value && (MAX_SUBQUEUE_SIZE == 0 || MAX_SUBQUEUE_SIZE - BLOCK_SIZE < currentTailIndex - head))) {
return false;
}
#ifdef MCDBGQ_NOLOCKFREE_IMPLICITPRODBLOCKINDEX
debug::DebugLock lock(mutex);
#endif
// 查找一个空的entry,entry用于存储Block,Block用于存储写入的元素
BlockIndexEntry* idxEntry;
if (!insert_block_index_entry<allocMode>(idxEntry, currentTailIndex)) {
return false;
}
// 申请一个Block,先查预留pool,再查后续申请没有释放到OS,在freelist,最后不够再申请新的
// 注意就是这里如果采用比较老的版本,新申请的Block都只会放入freelist,不会还给OS,后面版本有优化,只保留初始化的空间
auto newBlock = this->parent->ConcurrentQueue::template requisition_block
<allocMode>();
if (newBlock == nullptr) { // 申请失败没啥说的
rewind_block_index_tail();
idxEntry->value.store(nullptr, std::memory_order_relaxed);
return false;
}
#ifdef MCDBGQ_TRACKMEM
newBlock->owner = this;
#endif
newBlock->ConcurrentQueue::Block::template reset_empty<implicit_context>();
MOODYCAMEL_CONSTEXPR_IF (!MOODYCAMEL_NOEXCEPT_CTOR(T, U,
new (static_cast<T*>(nullptr)) T(std::forward<U>(element)))) {
// May throw, try to insert now before we publish the fact that we have this new block
MOODYCAMEL_TRY {
new ((*newBlock)[currentTailIndex]) T(std::forward<U>(element));// 占位
}MOODYCAMEL_CATCH (...) {
rewind_block_index_tail();
idxEntry->value.store(nullptr, std::memory_order_relaxed);
this->parent->add_block_to_free_list(newBlock);
MOODYCAMEL_RETHROW;
}
}
// 替换最新的Block,下次在Block内写入即可,所以BLOCK_SIZE大的时候,
// 可以减少这块代码的进入,假如设计成128,比32大4倍,就可以少进入这里四次;
idxEntry->value.store(newBlock, std::memory_order_relaxed);
this->tailBlock = newBlock;
MOODYCAMEL_CONSTEXPR_IF (!MOODYCAMEL_NOEXCEPT_CTOR(T, U,
new (static_cast<T*>(nullptr)) T(std::forward<U>(element)))) {
this->tailIndex.store(newTailIndex, std::memory_order_release);
return true;
}
}
// 如果最新的Block没有用完,就直接写入即可;
new ((*this->tailBlock)[currentTailIndex]) T(std::forward<U>(element));
this->tailIndex.store(newTailIndex, std::memory_order_release);
return true;
}
这里我们倒是应该看下,如果producer的队列一直没有消费,但是生产一直继续,如果超出了队列,应该怎么办,看看insert_block_index_entry
函数的实现,concurrentqueue
在内存足够的时候一直在尽力增涨队列防止写入队列失败:
// 就生产者自己在自己的队列中写入,因此只需原子,无序内存序;
auto localBlockIndex = blockIndex.load(std::memory_order_relaxed);
// 不可能为空,即使第一次进来,也是先初始化好的,在构造producer队列时就已经new_block_index了
if (localBlockIndex == nullptr) {
return false; // this can happen if new_block_index failed in the constructor
}
// 上文new_block_index中假设capacity为32,初始值为31,所以这里(31 + 1)&(32 - 1)= 0,那么下一个是(0 + 1)&(32 - 1)= 1, 依次2,3,4…,直到31往复循环,(2的幂次在无锁循环队列中是灵魂存在)
size_t newTail = (localBlockIndex->tail.load(std::memory_order_relaxed) + 1) &
(localBlockIndex->capacity - 1);
idxEntry = localBlockIndex->index[newTail];
// 判断如果非法或者为空说明该位置没有用或者已经被消费掉,可以覆盖处理,如果不是,重新new_block_index
if (idxEntry->key.load(std::memory_order_relaxed) == INVALID_BLOCK_BASE ||
idxEntry->value.load(std::memory_order_relaxed) == nullptr) {
idxEntry->key.store(blockStartIndex, std::memory_order_relaxed);
localBlockIndex->tail.store(newTail, std::memory_order_release);
return true;
}
// No room in the old block index, try to allocate another one!
MOODYCAMEL_CONSTEXPR_IF (allocMode == CannotAlloc) {
return false;
} else if (!new_block_index()) { // 我们回到上文的new_block_index
return false;// 扩展失败返回false
} else {
// 扩展成功,则与上面逻辑一致
localBlockIndex = blockIndex.load(std::memory_order_relaxed);
newTail = (localBlockIndex->tail.load(std::memory_order_relaxed) + 1) &
(localBlockIndex->capacity - 1);
idxEntry = localBlockIndex->index[newTail];
assert(idxEntry->key.load(std::memory_order_relaxed) == INVALID_BLOCK_BASE);
idxEntry->key.store(blockStartIndex, std::memory_order_relaxed);
localBlockIndex->tail.store(newTail, std::memory_order_release);
return true;
}
那么很显然按照整个entry的逻辑看,就是通过最后一个entry位置中的Block
,看是否塞满数据(即BLOCK_SIZE
),如果是则重新获取下一个entry,如果循环entry已经满了(判断是否满很简单,就是下一个entry是空或者非法即可),则通过2倍
空间扩展方式扩展,并且拷贝之前的entry到新的entry,我们可以看下图的整个数据结构:
很明显,这里刚开始看new_block_index
时,我也疑惑为啥要二级指针index
做个代理,看到上图的结构,也就非常清晰了,每次扩展时都会将前面的index位置留给前一个的index,也就是图中红色线直接指向的entry位置,虽然图中位置都是从0开始(事实上不是,是随机的
),代码中已说明,按照消费最前沿开始循环拷贝的,以前一个capacity
为32为例,如果写入满的时候tail
为29,则最新消费到前面的是30,则复制30,31, 0, 1,…,29,到新的capacity
为64的前32个位置,这里2的幂次又展示强大的取余能力,最新blockIndex
被替换后,多个consumers时,有些会取到64的blockIndex
,有些在替换时已经取到32的blockIndex
,竟然没有任何冲突,下文继续看看consumer的无锁消费如何实现就明白了(其实就是2的幂次-1
取余的效果)。
多个consumers如何无锁消费
在讲consumer消费时,我们还得插一个问题,因为每个entry并不是存储1个元素,而是允许使用者配置2的幂次
个元素,假设我们配置了BLOCK_SIZE
为8,那么对于64个blockIndex
,循环队列长度是64 * 8 = 512,所以我们单独拿出来看看,进入enqueue
之前,每个producer都保存了一个tailIndex
一直增长的无符号整型数(size_t
,非常大的整型)的原子变量,假设队列一直在写入消费,此时tailIndex
变成了513,会怎么处理呢(下文解释时为简化采用uint8_t
)?
好的,进入到dequeue
环节了,dequeue
环节中也有一些非常好的无锁编程实践实现,我们来看看代码:
// 首先原子获取写入的tailIndex(即已经写入了多少个元素)
index_t tail = this->tailIndex.load(std::memory_order_relaxed);
// 这里作者通过原子变量巧妙的实现了一把乐观锁,一开始读时咯噔一下,看完下面才豁然开朗,一开始值为0;
index_t overcommit = this->dequeueOvercommit.load(std::memory_order_relaxed);
// 判断optimistic与overcommit的差值是否小于写入的元素值tailIndex
if (details::circular_less_than<index_t>(this->dequeueOptimisticCount.load(
std::memory_order_relaxed) - overcommit, tail)) {
// 这里写入一个内存屏障,所有后续的排在读取后面的写入动作都按照代码顺序处理,不能重排;
// 防止读的时候出错,因为如果有一个元素被写入到队列,那么如果有多个consumer都会一同
// 进入进来,必须保证读写内存序,因为这里的乐观锁就必须保证读取的数据是不一致的
std::atomic_thread_fence(std::memory_order_acquire);
// 乐观计数先fetch后add,如果第一个consumer的第一次进来,获取的值为0,optimistic值变为1;
index_t myDequeueCount = this->dequeueOptimisticCount.fetch_add(1,
std::memory_order_relaxed);
// 进入乐观锁区域内,重新在内存屏障后重新获取一次tail,防止变化(事实上tail只会增,也无所谓);
tail = this->tailIndex.load(std::memory_order_acquire);
// 重新判断optimistic-overcommit的值是否小于tail的值,如果是则进入,这里我们在下文
// 解析一下,这是多个consumers之间的乐观锁,即如果producer只写入了一个值,但是两个
// consumer只有一个能进入if,因为不同的consumer进入时因为fetch_add原因,获取的值
// 一定都不一样;
if ((details::likely)(details::circular_less_than<index_t>(myDequeueCount -
overcommit, tail))) {
// 进入后fet_add方式获取消费的长度值(headIndex)
index_t index = this->headIndex.fetch_add(1, std::memory_order_acq_rel);
// 按照headIndex值获取到对应的entry,即存储Block的位置;
auto entry = get_block_index_entry_for_index(index);
// 获取Block,为何这里是原子变量?Block本身这块内存producer可能在用于判断是否已经写
// 满,也就是与consumer竞争而已,因为consumer可能正在在进行读取动作,所以这里只需要
// 原子操作,无关内存序,所以作者用了relax的内存序
auto block = entry->value.load(std::memory_order_relaxed);
// 接下去就是pop元素,如果const进来的就拷贝,如果移动进来就移动出去
auto& el = *((*block)[index]);
// 如果下面显示调用析构不抛异常就直接调用,如果可能会,就在离开作用域时析构
if (!MOODYCAMEL_NOEXCEPT_ASSIGN(T, T&&, element = std::move(el))) {
#ifdef MCDBGQ_NOLOCKFREE_IMPLICITPRODBLOCKINDEX
debug::DebugLock lock(producer->mutex);
#endif
struct Guard {
Block* block;
index_t index;
BlockIndexEntry* entry;
ConcurrentQueue* parent;
~Guard(){
(*block)[index]->~T();
if (block->ConcurrentQueue::Block::template set_empty
<implicit_context>(index)) {
entry->value.store(nullptr, std::memory_order_relaxed);
parent->add_block_to_free_list(block);
}
}
} guard = { block, index, entry, this->parent };
element = std::move(el); // NOLINT
}else {
element = std::move(el); // NOLINT
el.~T(); // NOLINT
if (block->ConcurrentQueue::Block::template set_empty
<implicit_context>(index)) {
{
#ifdef MCDBGQ_NOLOCKFREE_IMPLICITPRODBLOCKINDEX
debug::DebugLock lock(mutex);
#endif
// Add the block back into the global free pool (and remove from block index)
entry->value.store(nullptr, std::memory_order_relaxed);
}
// 作者已经在freelist时做了内存优化,即释放了非初始化申请的block内存
this->parent->add_block_to_free_list(block);//releases the above store
}
}
return true;
}else {
// 如果消费者线程进入后,但是没有消费到元素,则出去前overCommit也是加1操作,
// 也就是optimistic-overcommit的差值才是真正消费的数据长度;
this->dequeueOvercommit.fetch_add(1, std::memory_order_release);
}
}
// 如果队列没有数据,直接返回false
return false;
按照上文的描述,作者通过使用原子变量dequeueOptimisticCount
和dequeueOvercommit
的差值来控制多个consumers的无锁消费,无论consumer进入消费函数是否消费到数据,dequeueOptimisticCount
一定加1,但是dequeueOvercommit
只在没有消费到数据时才加1
。注意,这里dequeueOptimisticCount
采用fet_add
且acquire
的内存序保证多个consumers同时进入时值是不一样的,这样他们两者的差值一定不一样;
那么这里无锁循环队列
最传神的是这个函数:
template<typename T>
static inline bool circular_less_than(T a, T b){
// 必须是无符号整型数据
static_assert(std::is_integral<T>::value && !std::numeric_limits<T>::is_signed,
"circular_less_than is intended to be used only with unsigned integer types”);
// 如果a小于b,那么a与b的差值一定大于最大值的一半,例如T为uint8_t,占一个字节,即 1<< 7 = 128,
// 这个函数是vector队列变成循环队列的传神之笔,没有这个函数,这个无锁队列就无法完成
return static_cast<T>(a - b) > static_cast<T>(static_cast<T>(1) <<
(static_cast<T>(sizeof(T) * CHAR_BIT - 1)));
}
假如上文队列长度就是8,且BLOCK_SIZE
为1,则dequeueOptimisticCount
这个size_t
的值对于uint8_t
的队列,如果其值为257,那么取余后对于uint8_t
是溢出值得到的数是1,而此时如果dequeueOvercommit
为6(即consumer有6次是失败的),那么这个队列被循环写入读取了31
次了,已经在循环第32
次,其中有6
次消费失败,那么已经消费了251
个元素,假如tail
已经写入到255
个元素了,那么按照上述函数(1 - 6)- 255 = (-4)> (1<<(8 - 1))
,其中-5
的无符号值是251
,-4
的无符号值252
大于128
,这里本文作者把这个留给阅读者自己再仔细思考一下,这是数组用于循环队列的灵魂操作;
总结
按照上文讲的整个生产和消费逻辑,我们再来一张图用于补充:
tailIndex
用于累计写入的元素,headIndex
用于消费的元素(这两者结合屏蔽了生产者和消费者同时处理同一块内存的风险,也即生产者与消费者之间无锁处理),作者又额外在生产侧采用类似threadlocal
方式为每个producer线程创建循环队列屏蔽生产者县城竞争,在consumer线程中加入乐观锁(即dequeueOptimisticCount
与dequeueOvercommit
的差值,其实与headIndex
一样大小)来屏蔽多个消费者之间的竞争
这个工业级的无锁循环队列concurrentqueue
,通过C++11
之后的原子操作和二进制无符号的原理实现了很多优秀的设计:
- 2的幂次取余+数组索引+循环比较,一套组合拳让循环队列具备O(1)的算法性能;
- 原子布尔变量的自旋锁的多线程空间扩展能力,(既有类似threadlocal的效果又能实现数据内存区为一个连续数组);
- 原子变量的乐观锁(这个确实很巧妙的实现无锁的“锁”);
C++
的无锁队列的设计最初由Cliff Click博士为java
设计,参考视频: 来自youtobe的视频。那么回过头来我们开头本文的两个问题该如何解决:
-
内存堆积如何解决:
- 按照分析,如果不做任何内存改动,我们希望在你创建
concurrentqueue<T, Trait>
时,最好T
是一个指针,本文作者使用时,当时传入了一个std::function<void()>
对象,并且还是lambda
+capture
,一个元素占用了32
个字节,按照Block
申请大小每次都是1064
字节才存入32
个元素(32*32+40
,Block
本身占用40
字节,32
个元素空间),但是当我把T
变成std::function<void()>*
,占用8
个字节,且把BLOCK_SIZE
提高到64
,占用552
字节就能存64
个元素,提高内存利用率,高并发内存降了一半;当然涉及到OS
的内存碎片或者内存人质等问题不再这里考虑; - 提高消费速度,增加消费者,用
CPU
换内存;因为增加消费了,队列可以短下来,减少队列满扩展频率; - 作者在
Trait
提供了malloc
和free
函数的重载,那么可以自己设计内存池,自己管理内存,防止内存过大,当然这样可能存在producer因为申请内存失败而返回失败,需要做好失败重试的机制,高端局都是这么玩的,如果不想玩得深,上面两步也基本上解决95%
以上问题;
- 按照分析,如果不做任何内存改动,我们希望在你创建
-
无序消费如何解决:
- 说句实话,在隐式多生产者模型中很难做到消费者按照多个生产者先后写入队列的顺序进行消费(作者提供了显示生产者模型,也就是
token
,但是这个做法做不到线程安全
,即多个线程使用同一个token
需要加锁,而隐式生产的消费是按照生产者队列取末尾存在数据的3
个队列选择数据最多的消费),那么无锁的情况下最好的方式就是一对一生产消费; - 加锁,毕竟这个无锁队列还解决了循环队列的痛点是吧,生产者加把锁,但是使用上还是方便的;
- 业务上自行保证,比如写入的数据消费时按照hash到特定线程消费,也就是在一对一队列前面进行一次业务分配;
- 说句实话,在隐式多生产者模型中很难做到消费者按照多个生产者先后写入队列的顺序进行消费(作者提供了显示生产者模型,也就是