多生产者多消费者问题的无锁队列实现

背景

代码根据论文 Implementing Lock-Free Queues 复现。
背景知识博客:左耳朵耗子博客 https://coolshell.cn/articles/8239.html
代码地址:https://github.com/zxwsbg/lock-free-queue
b站链接:https://www.bilibili.com/video/BV1q54y1Y71W/

背景介绍

生产者和消费者问题是操作系统中老生常谈的一个问题。针对不同的场景(单生产者-单消费者、单生产者-多消费者、多生产者-多消费者)有着不同的解决方法,本文研究的是多生产者多消费者的场景。

解决多生产者多消费者的常见方法是互斥锁+信号量,而互斥锁开销过大,在高并发常见下会有很大的性能影响。而采用无锁队列的方法可以避免锁的使用来达到减小开销的目的。

无锁队列的实现的要点就是 CAS 操作,后续在考虑到内存重新分配的时候会造成安全性问题(ABA问题),故引入了 DoubleCAS 或者其他内存分配机制来解决问题。
在这里插入图片描述

无锁队列实现

数据结构介绍

按照论文中提到的两种方法,选了一种较优的方法实现。
以单向链表的形式实现这个队列,每个节点的数据结构为

class QueueNode {
  public:
    int        val;
    QueueNode* next;
    QueueNode(int val) : val(val) {
        next = NULL;
    }
};

而对于一个无锁队列对象而言,包含以下部分

  • 数据:队列的最大长度、链表头结点、链表尾节点
  • 函数:初始化(构造函数)、入队、出队
class LockFreeQueue {
  public:
    LockFreeQueue();
    bool enqueue(int val);
    int  dequeue();
    ~LockFreeQueue();

  private:
    int        queue_size; // 暂时未使用,论文里并没有提及最大资源数
    QueueNode* tail;
    QueueNode* head;
};

其数据结构包含关系如下图所示
在这里插入图片描述

入队操作

底下我们以入队操作为例

bool LockFreeQueue::enqueue(int val)
{
    QueueNode* cur_node;
    QueueNode* add_node = new QueueNode(val);
    while (1) {
        cur_node = tail;
        if (__sync_bool_compare_and_swap(&(cur_node->next), NULL, add_node)) {
            break;
        }
        else {
            __sync_bool_compare_and_swap(&tail, cur_node, cur_node->next);
        }
    }
    __sync_bool_compare_and_swap(&tail, cur_node, add_node);
    return 1;
}

这里的 __sync_bool_compare_and_swap就是 GCC 提供的 CAS 算法。
第7行的CAS首先尝试将新加的节点放到链表末尾,这里有两种结果:

  1. 加入成功,那么退出循环进入倒数第三行
  2. 加入不成功,说明这时候有其他线程抢先往里面插入了一个节点,那么就把当前节点的位置更新为尾节点,再次进入循环直到能正确更新

到了最后一个CAS的时候,只需要进行一次置尾操作,并不需要循环,原因是:如果当前线程将节点已经加进去了的话,那么其他所有线程的操作都会失败,只有当前线程更新尾节点完成后,其他线程的第二个CAS操作才能成功。

这里有一个小trick,明明第6行每次循环时都会更新节点,为什么还需要第二个CAS操作呢?因为在极端情况下,一个线程已经完成了增加节点操作,在置尾操作(第三个CAS)之前突然挂了,这时候就导致其他所有线程全部不能更新。

在左耳朵耗子的博客中,对于上述问题的解决方法描述是选用了《Implementing Lock-Free Queues》论文中提到的第二个方法,该方法采用的是一开始在head,然后不断找next直到找尾节点(通过多个线程共用fetch来减少开销)。本文以论文中提到的第一个方法为例进行描述(因为我觉得它更优雅)。

出队操作

与入队操作类似。

int LockFreeQueue::dequeue()
{
    QueueNode* cur_node;
    int        val;
    while (1) {
        cur_node = head;
        if (cur_node->next == NULL) {
            return -1;
        }

        if (__sync_bool_compare_and_swap(&head, cur_node, cur_node->next)) {
            break;
        }
    }
    val = cur_node->next->val;
    delete cur_node;
    return val;
}

多生产者-多消费者模型

我们采用 C++ 的 Thread 库,模拟多个生产者和多个消费者的情况。多个生产者线程向无所队列中插入数据,多个消费者从无锁队列中取出数据。
在这里插入图片描述

测试部分的代码实现在 lockfreequeue_test.cpp 文件中。以生产者为例,其代码如下所示。

int            thread_number;
int            task_number; // 每个线程需要入队/出队的资源个数
LockFreeQueue* lfq;

void produce(int offset)
{
    // 算上偏移量,保证不会出现重复
    for (int i = task_number * offset; i < task_number * (offset + 1); i++) { 
        printf("produce %d\n", i);
        lfq->enqueue(i);
    }
}
int main(int argc, char** argv)
{
    lfq = new LockFreeQueue;
    std::vector<std::thread> thread_vector1;

    for (int i = 0; i < thread_number; i++) {
        thread_vector1.push_back(std::thread(produce, i));
    }

    for (auto& thr1 : thread_vector1) {
        thr1.join();
    }
}

正确性检测

对于结果,首先做正确性检验,具体实现在 check.py 文件中,对于produce,要求之前没有生产过该资源;对于consume,要求之前生产过并且没有消费过该资源。

ABA 问题

在高并发场景下会出现该问题。如上述情况,以默认值参数运行后经常不能通过正确性检测。原因根据我的判断,是因为ABA问题:当我们做CAS的之前,如果head的那块内存被回收并被重用了,而重用的内存又被EnQueue()进来了。而判断的方法也是个小 trick ,只需要将deque操作中释放内存的步骤注释掉,那么就不会出现内存重用的问题,这样做后也确实可以通过正确性测试。这个问题在论文中也提到了并给出了相应解决方法,但其需要自己实现一套内存分配系统(麻烦)。于是本文参考另一篇论文Simple, Fast, and Practical Non-Blocking and Blocking Concurrent Queue Algorithms
,利用Double-CAS来解决此问题,每访问一个节点,都用引用计数的方式给其+1,Double-CAS则可以一次既判断原始的节点值,又判断引用计数值,保证其未被置换出去过。这一块的具体代码实现可见github项目代码的fix_aba_problem分支。

对比检验

为了对比实验,写了个几乎差不多的正常的多生产者多消费者单链表实现(互斥锁+信号量)。经过测试,时间几乎没差(并发场景不够)。


现存问题

  1. 如何模拟高并发
  • 6
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论
基于无锁队列的单生产者消费者模型可以使用C++11中的atomic和memory_order来实现。以下是一个简单的实现示例: ``` #include <atomic> #include <memory> template<typename T> class LockFreeQueue { public: LockFreeQueue() { Node* node = new Node; tail_.store(node, std::memory_order_relaxed); head_.store(node, std::memory_order_relaxed); } ~LockFreeQueue() { T value; while (pop(value)); Node* node = head_.load(std::memory_order_relaxed); delete node; } void push(const T& value) { Node* node = new Node(value); Node* prev_tail = tail_.exchange(node, std::memory_order_acq_rel); prev_tail->next.store(node, std::memory_order_release); } bool pop(T& value) { Node* head = head_.load(std::memory_order_relaxed); Node* next = head->next.load(std::memory_order_acquire); if (!next) { return false; } value = next->value; head_.store(next, std::memory_order_release); delete head; return true; } private: struct Node { Node() : next(nullptr) {} Node(const T& value) : value(value), next(nullptr) {} T value; std::atomic<Node*> next; }; std::atomic<Node*> head_; std::atomic<Node*> tail_; }; ``` 在构造函数中,我们创建了一个节点,并将头指针和尾指针都指向该节点。在push操作中,我们创建一个新节点,并使用std::atomic::exchange函数原子地将尾指针指向新节点,并返回原来的尾节点。然后将原来的尾节点的next指针指向新节点。在pop操作中,我们先取出头节点的next指针,如果为nullptr,则说明队列为空,返回false。否则,我们将头指针指向next节点,并返回next节点的值。最后,我们删除原来的头节点。 需要注意的是,在push操作中,我们使用std::memory_order_acq_rel来保证对tail指针的读写操作都是原子的,并且保证任何其他线程在读取tail指针之前都已经完成了对前一个节点的更新操作。在pop操作中,我们使用std::memory_order_acquire来保证对next指针的读取是原子的,并且保证任何其他线程在更新next指针之前都已经完成了对头节点的更新操作。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

总想玩世不恭

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值