原始队列存在的问题
原始队列的入队与出队伪代码:
struct Node{
void *data;
Node *next;
};
void Enqueue(Node *node){
m_Tail->next = node;
m_Tail = node;
}
Node* Dequeue(){
Node * res = m_Head;
m_Head = m_Head->next;
return res;
}
入队与出队皆有两步操作,如果多个线程同时进行读写,便可能会出现:完成了第一步操作后,其他线程修改了Head或Tail指针,导致结果无法预料。
例如下列情况,两个线程同时push:节点C和D都受到了影响
// 线程A 线程B 实际情况
A->B head = A tail = B
入队C tail->next = C A->B->C head = A tail = B
入队D tail->next = D A->B->D head = A tail = B
tail = D A->B->D head = A tail = D
tail = C A->B->D head = A tail = C
无锁队列
无锁队列是在不使用传统锁(如互斥锁或读写锁)的情况下,实现线程安全的队列操作。
无锁队列主要依赖于现代处理器提供的原子操作(Atomic Operations)和内存模型来保证操作的线程安全性。
1. 原子操作
- 原子性:原子操作是指不可中断的操作,即在执行过程中不会被其他线程打断,从而保证了操作的完整性。常见的原子操作包括原子的读、写、交换(Compare-and-Swap, CAS)、加载(Load)和存储(Store)等。
- Compare-and-Swap (CAS):这是实现无锁数据结构的核心操作。CAS操作包含三个参数:内存位置、期望值和新值。仅当内存位置的值与期望值相匹配时,才会将新值写入内存位置,并返回true;否则,不做任何修改并返回false。
2. 冲突解决
- 由于多个线程可能同时对队列做出修改,冲突在所难免。无锁队列通过循环重试机制来解决冲突。当一个线程发现其操作未成功时,它会重新读取状态并再次尝试操作,直至成功。
3. 内存模型
- 为了确保不同线程之间对共享数据的访问是一致的,无锁队列的设计必须严格遵循处理器的内存模型。这涉及到内存屏障(Memory Barriers)的使用,以确保原子操作的可见性和顺序性。
4. 队列操作步骤(务必配合下面的代码一起看)
- 插入(Push)操作:
- 创建新节点,并设置其数据字段。
- 读取当前尾指针。
- 尝试将新节点链接到当前尾节点的后面,使用CAS操作更新尾节点的next指针。
- 如果CAS失败(说明尾节点已经改变),则重复前两步。
- 如果CAS成功,尝试将尾指针更新为新节点(再次使用CAS),以确保后续入队操作能够正确地添加到队列末尾。
- 删除(Pop)操作:
- 读取当前头指针和头节点的下一个节点。
- 如果头节点的下一个节点为空,这是队列为空,无法出队。
- 否则,尝试将头指针更新为头节点的下一个节点(使用CAS)。
- 如果CAS成功,返回原头节点的数据字段,并删除原头节点。
5. 示例
#include <atomic>
#include <memory>
template <typename T>
class LockFreeLinkedListQueue {
private:
struct Node {
std::shared_ptr<T> data;
std::atomic<Node*> next;
Node(T new_data) : data(std::make_shared<T>(new_data)), next(nullptr) {}
};
std::atomic<Node*> head;
std::atomic<Node*> tail;
public:
LockFreeLinkedListQueue() : head(new Node(T())), tail(head.load()) {}
~LockFreeLinkedListQueue() {
Node* curr = head.load();
while (curr) {
Node* toDelete = curr;
curr = curr->next.load();
delete toDelete;
}
}
bool enqueue(T new_value) {
Node* new_node = new Node(new_value);
while (true) {
Node* old_tail = tail.load();
Node* next = old_tail->next.load();
if (old_tail == tail.load()) {//确保在上面两个读操作期间,队列没有被其他线程改变
if (next == nullptr) {
//链接新节点
if (old_tail->next.compare_exchange_strong(next, new_node)) {
// 更新尾指针
tail.compare_exchange_strong(old_tail, new_node);
return true;
}
} else {//其他线程已经插入了一个新节点
// 将tail更新
tail.compare_exchange_strong(old_tail, next);
}
}
}
return false;
}
bool dequeue(T& value) {
while (true) {
Node* old_head = head.load();
Node* next = old_head->next.load();
if (old_head == head.load()) {
// 注意这里是判断next
if (next == nullptr) {
return false; // Queue is empty
}
if (head.compare_exchange_strong(old_head, next)) {
value = *next->data;
delete old_head;
return true;
}
}
}
}
};
出队的例子
T2线程在compare_exchange_weak后,发现不一致,会重新进入循环。
T1线程 T2线程 实际情况
----- ----- A-B-C
oldHead = head.load(); A-B-C T1-oldHead:A
oldHead = head.load(); A-B-C T1-oldHead:A T2-oldHead:A
next = oldHead->next.load(); A-B-C T1-oldHead:A T2-oldHead:A T2-next:B
next = oldHead->next.load(); A-B-C T1-oldHead:A T1-next:B T2-oldHead:A T2-next:B
compare_exchange_weak B-C head=B T2-oldHead:A T2-next:B
compare_exchange_weak B-C (head 与T2-oldHead不一致)
..重新执行循环..
我们不能太快删除节点使用的内存,因为出队成功的节点可能还在被其他线程访问。比较好的方法是使用对象池来缓存节点,不够用的时候就申请新节点,每次出队使用完成后将旧节点放回池中等待下一次使用。