队列基础,一对一无锁
队列就是一个先进先出的数据结构,一般使用链表实现,如下图:
数据从Head进入链表,从Tail出链表。我们会发现,只要链表一直不是空的,那么Head指针和Tail指针是完全无关的,也就是读写之间不加锁的线程安全比较容易实现,我们只要保证链表从初始化开始Head和Tail就无关即可,那么一对一的生产者和消费者无锁队列就可以十分简单地实现,显然Tail或者Head直接指向的内存我们不用来存储数据,是最合理的。
上述两种方式都可以,我们采用第一种展开讲述。在队列创建时,我们让Head和Tail共同指向一个不存储数据的内存,从此这两个指针就可以毫不相干了,后续生产消费中,Tail总是预留一个不存储数据的内存节点,而Head则只需要完成自己的指向操作。以下是相关代码:
template <typename T>
class T1V1Queue : NonCopyable {
public:
using ElementType = T;
T1V1Queue() : mSize(0) { mHead = mTail = new TNode; }
~T1V1Queue() {
while (mTail != nullptr) {
TNode* pNode = mTail;
mTail = pNode->NextNode;
delete pNode;
}
}
bool Dequeue(ElementType& OutItem) {
TNode* pPop = mTail->NextNode;
if (pPop == nullptr) return false;
OutItem = std::move(pPop->Item);
TNode* pOldTail = mTail;
mTail = pPop;
mTail->Item = ElementType();
delete pOldTail;
mSize--;
return true;
}
bool Enqueue(const ElementType& InItem) {
TNode* pNewNode = new (std::nothrow) TNode(InItem);
if (pNewNode == nullptr) return false;
TNode* OldHead = mHead;
mHead = pNewNode;
std::atomic_thread_fence(std::memory_order_seq_cst);
OldHead->NextNode = pNewNode;
mSize++;
return true;
}
FORCEINLINE bool IsEmpty() const { return mSize == 0; }
ElementType* Peek() {
if (mTail->NextNode == nullptr) return nullptr;
return &(mTail->NextNode->Item);
}
FORCEINLINE const ElementType* Peek() const { return const_cast<T1V1Queue*>(this)->Peek(); }
bool Pop() {
TNode* pPop = mTail->NextNode;
if (pPop == nullptr) return false;
TNode* pOldTail = mTail;
mTail = pPop;
mTail->Item = ElementType();
delete pOldTail;
mSize--;
return true;
}
void Empty() {
while (Pop())
;
}
uint64_t GetSize() const { return mSize; }
private:
struct TNode {
TNode* volatile NextNode;
ElementType Item;
TNode() : NextNode(nullptr) {}
explicit TNode(const ElementType& InItem) : NextNode(nullptr), Item(InItem) {}
explicit TNode(ElementType& InItem) : NextNode(nullptr), Item(std::move(InItem)) {}
};
TNode* volatile mHead;
TNode* mTail;
std::atomic_uint64_t mSize;
};
生产者之间无锁
为了实现多生产者之间的无锁操作,我们需要对上述的代码进行一定的修改,引入原子变量std::atomic,需要把Head指针改为: std::atomic<TNode*> mHead。
指针的修改的原子操作,我们需要使用std::atomic_compare_exchange_weak(或者std::atomic_compare_exchange_strong),下面的代码可以保证多线程的情况下,链表可以保证正确性:
bool Enqueue(const ElementType& InItem) {
TNode* pNewNode = new (std::nothrow) TNode(InItem);
if (pNewNode == nullptr) return false;
TNode* pCurrHead = mHead;
while (!std::atomic_compare_exchange_weak(&mHead, &pCurrHead, pNewNode))
;
std::atomic_thread_fence(std::memory_order_seq_cst);
pCurrHead->NextNode = pNewNode;
mSize++;
return true;
}
std::atomic_compare_exchange_weak进行比较和赋值的原子操作,当前线程先获取了一个Head的当前值,然后原子地比较和修改Head的指向,成功则修改Head指向,失败则pCurrHead被赋值为新的mHead,然后再重复上述原子操作,直至成功。std::atomic_thread_fence(std::memory_order_seq_cst);是一个内存屏障,可以保证屏障前后的操作不被重排。
消费者之间的无锁
完全无锁队列就差消费者之间的无锁了,这一步我们很自然想到,可以对Tail指针使用与Head相同的处理,但是这里我们会遇到一个难题,因消费过程我们需要释放内存,否则就会内存泄漏,从上面可以看到,我们实际上并不是直接消费Tail->Item,而是消费Tail->NextNode->Item,在多线程消费的情况下,Tail->NexNode极有可能被其他线程销毁,导致程序崩溃。
那么采用Head指向无数据节点会不会好点?不管哪种方式,我们移动Tail指针都是如下的代码:
TNode* pTail = mTail;
do
{
if (pTail->NextNode == nullptr) return false;
} while (!std::atomic_compare_exchange_weak(&mTail, &pTail, pTail->NextNode));
pTail指向的内存随时被其它线程delete。因此在消费的过程中我们不能随便delete尾部的内存,否则整个消费过程就会程序崩溃。我们需要进行一个延迟删除的操作,从而保证队列的指针在消费者消费的时候是正常的。
延迟删除我们需要先标记链表上被删除的元素,然后再找一个适当的时机销毁这些被标记的元素,目前可以轻易想到的是时间上的延迟和预留足够的数量两种方式,其中时间上的延迟需要对被标记的元素记录一个时间戳,真正执行删除操作时与当前时间进行比较,基于时间的延迟删除,在队列吞吐量特别大时,会占用较多的内存;基于预留特定数量的删除在内存使用上会好一些,但风险上会比时间延迟要大一些。
删除操作我们可以非常容易原子性地交给某个消费者线程,只需要对一个原子变量执行++操作判断是否是1
uint64_t uCurrSize = --mSize;
uint64_t uNeedDelNum = mListSize.load() - uCurrSize;
if (uNeedDelNum > 256) {
if (++mUniqDelFlag == 1) {
while (mDelTail->NextNode != nullptr && mDelTail->NextNode->BeDeleted && uNeedDelNum > 256) {
TNode* pDel = mDelTail;
mDelTail = mDelTail->NextNode;
mListSize--;
uNeedDelNum--;
delete pDel;
}
}
--mUniqDelFlag;
}
我们始终保护性地保留了256个元素,并且我们是按照顺序delete,遇到没被标记删除的我们就停止。