zmq无锁队列的浅解

最近看到一篇文章讲述到,每次学到一门新技术是,要多从如下方面不断反问自己:
这个技术是用来做什么的
优势是什么
为什么用它
它解决了什么问题
这个技术是怎么实现的(原理+底层)
今天总结无锁队列时,也主要从这些方面进行总结

前提准备的知识:
(缓存原理)局部性原理:在一段时间内,整个程序的执行仅限于程序的某一部分,相应地,程序访问的存储空间也局限于某个内存区域。主要分为两类:
时间局部性:如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某数据被访问,则不久之后该数据可能再次被访问。(缓存)
空间局部性:是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。


前言

首先解决第一个疑问,无锁队列使用来做什么的?

一、无锁队列的应用场景

当需要处理的数据非常多,比如行情数据,一秒处理非常多的数据的时候,可以考虑用无锁队列。无锁队列一般也会结合mutex + condition使用,如果数据量很小,比如一秒钟几百个、几千个消息,那就会有很多时间是没有消息需要处理的,消费线程就会休眠,等待唤醒;所以对于消息量很小的情况,无锁队列的吞吐量并不会有很大的提升,没有必要使用无锁队列。

二、无锁队列的优点

无锁队列的根本是CAS操作
原子操作相对于其他加锁的操作的优点,显而易见。
原子操作对于锁的比较请参考以前博文:
互斥锁、自旋锁、原子操作的原理、区别及应用场景

三、用无锁队列解决了什么问题

我们首先应该知道有锁队列会引起哪些问题?

. Cache损坏(Cache trashing)
Cache是⼀种速度更快 但容量更⼩的内存(也更加昂贵),当处理器要访问主存中的数据时,这些数据⾸先被拷⻉到Cache中,因为这 些数据在不久的将来可能⼜会被处理器访问。Cache misses对性能有⾮常⼤的影响,因为处理器访问 Cache中的数据将⽐直接访问主存快得多。
线程被频繁抢占产⽣的Cache损坏将导致应⽤程序性能下降。在线程间频繁切换的时候会导致 Cache 中数据的丢失;
. 在同步机制上的争抢队列
在⼀个负载较重的应⽤程序 中使⽤这样的阻塞队列来在线程之间传递消息会导致严重的争⽤问题。也就是说,任务将⼤量的时间(睡 眠,等待,唤醒)浪费在获得保护队列数据的互斥锁,⽽不是处理队列中的数据上。
非阻塞的机制使用了 CAS 的特殊操作,使得任务之间可以不争抢任何资源,然后在队列中预定的位置上,插入或者提取数据。

. 动态内存分配
在多线程系统中,当⼀个任务从堆中分配内存时,标准的内存分配机制会阻 塞所有与这个任务共享地址空间的其它任务(进程中的所有线程)。这样做的原因是让处理更简单,且它⼯作 得很好。两个线程不会被分配到⼀块相同的地址的内存,因为它们没办法同时执⾏分配请求。 显然线程频 繁分配内存会导致应⽤程序性能下降(必须注意,向标准队列或map插⼊数据的时候都会导致堆上的动态内存 分配)

⽆锁队列要解决的问题

  1. cache失效
  2. 竞争导致线程的切换
  3. 内存的频繁申请和释放

这3个问题,本质上都是由于线程切换带来的问题。无锁队列就是从这几个方面解决问题。

四、无锁队列的实现

一写一读无锁队列实现

zmq的无锁队列主要由yqueue和ypipe组成。yqueue负责队列的数据组织,用来设计队列。ypipe负责队列的操作,用来设计队列的写入时机、回滚以及 flush,首先我们来看 yqueue 的设计。

yqueue——无锁队列

数据结构逻辑
chunk机制

首先要理解清什么是chunk机制:
就是一次性分配一个可以容纳多个元素的大块。利用chunk可以减少分配内存的次数,减少节点分配的时间
(1)chunk机制,一次分配多个节点
(2)chunk机制,利用局部性原理,chunk数量基本上处于一个稳态,尽量减少chunk的分配和释放。(pare_chunk的回收chunk)

yqueue_t的实现,每次批量分配⼀批元素,减少内存的分配和释放 (解决不断动态内存分配的问题)。 yqueue_t内部由⼀个⼀个chunk组成,每个chunk保存N个元素。N不能太小,如果太小,就退化成链表方式了,就会有内存频分的申请和释放的问题。

chunk数据结构体定义
//  Individual memory chunk to hold N elements.
    // 链表结点称之为chunk_t
    struct chunk_t
    {
    
    
        T values[N]; //每个chunk_t可以容纳N个T类型的元素,以后就以一个chunk_t为单位申请内存
        chunk_t *prev;
        chunk_t *next;
    };

在这里插入图片描述
当队列空间不⾜时每次分配⼀个chunk_t,每个chunk_t能存储N个元素。 在数据出队列后,队列有多余空间的时候,回收的chunk也不是⻢上释放,⽽是根据局部性原理先回收到 spare_chunk⾥⾯,当再次需要分配chunk_t的时候从spare_chunk中获取。
注意:spare_chunk只保存一个chunk, 即只保存最新的要回收的chunk;如果spare_chunk现在保存了一个chunk A,如果现在有一个更新的chunk B需要回收,那么spare_chunk会更新为chunk B,chunk A会被释放;这个操作是通过cas完成的。

对于回收chunk的spare_chunk代码展示如下

注意:只有删除满一个chunk时才回收chunk

 if (++begin_pos == N) // 删除满一个chunk才回收chunk
        {
    
    
            chunk_t *o = begin_chunk;
            begin_chunk = begin_chunk->next;
            begin_chunk->prev = NULL;
            begin_pos = 0;

            //  'o' has been more recently used than spare_chunk,
            //  so for cache reasons we'll get rid of the spare and
            //  use 'o' as the spare.
            chunk_t *cs = spare_chunk.xchg(o); //由于局部性原理,总是保存最新的空闲块而释放先前的空闲快
            free(cs);
        }

对于回收chunk的spare_chunk的实现其实就是cas操作。具体代码在zmq的atomic_ptr.hpp文件中,主函数代码展示如下:

// This class encapsulates several atomic operations on pointers. template <typename T> 
class atomic_ptr_t 
{
    
    
public: 
	inline void set (T *ptr_); //⾮原⼦操作 
	inline T *xchg (T *val_); //原⼦操作,设置⼀个新的值,然后返回旧的 值 
	inline T *cas (T *cmp_, T *val_);//原⼦操作 
private: 
	volatile T *ptr;
}
  • set函数,把私有成员ptr指针设置成参数ptr_的值,不是一个原子操作,需要使用者确保执行set过程没有其他线程使用ptr的值。
  • xchg函数,把私有成员ptr指针设置成参数val_的值,并返回ptr设置之前的值。原子操作,线程安全。
  • cas函数,原子操作,线程安全,把私有成员ptr指针与参数cmp_指针比较:
    如果相等返回ptr的值,并把ptr更新为参数val_的值;
    如果不相等直接返回ptr值。
chunk数据的组织

chunk是通过链表进行组织的;
yqueue_t内部有三个chunk_t类型指针以及对应的索引位置:

//  Back position may point to invalid memory if the queue is empty,
    //  while begin & end positions are always valid. Begin position is
    //  accessed exclusively be queue reader (front/pop), while back and
    //  end positions are accessed exclusively by queue writer (back/push).
    chunk_t *begin_chunk; // 链表头结点
    int begin_pos;        // 起始点
    chunk_t *back_chunk;  // 队列中最后一个元素所在的链表结点
    int back_pos;         // 尾部
    chunk_t *end_chunk;   // 拿来扩容的,总是指向链表的最后一个结点
    int end_pos;

begin_chunk/begin_pos:begin_chunk指向第一个的chunk;begin_pos是队列第一个元素在当前chunk中的位置;

back_chunk/back_pos:back_chunk指向队列尾所在的chunk;back_pos是队列最后一个元素在当前chunk的位置;back_pos是指当前存储到的位置,占用到哪里的位置

end_chunk/end_pos: end_chunk指向最后一个chunk;end_chunk和back_chunk大部分情况是一致的(不一致的情况是分配新的chunk的时候);end_pos 大部分情况是 back_pos + 1; end_pos主要是用来判断是否要分配新的chunk。end_pos代表结束位置
此图就是不一致的情况,因为已有的chunk已经全部插入元素,back_pos已经指向最后一个位置,因此需要分配新的chunk
在这里插入图片描述

初始化chunk,创建队列

代码如下:
对变量类型进行初始化

    //  创建队列.
    inline yqueue_t()
    {
    
    
        begin_chunk = (chunk_t *)malloc(sizeof(chunk_t));
        alloc_assert(begin_chunk);
        begin_pos = 0;
        back_chunk = NULL; //back_chunk总是指向队列中最后一个元素所在的chunk,现在还没有元素,所以初始为空
        back_pos = 0;
        end_chunk = begin_chunk; //end_chunk总是指向链表的最后一个chunk
        end_pos = 0;
    }

在这里插入图片描述
end_chunk总是指向最后分配的chunk,刚分配出来的chunk,end_pos也总是为0。
back_chunk需要chunk有元素插⼊的时候才指向对应的chunk。

插入元素
	//  Returns reference to the back element of the queue.
    //  If the queue is empty, behaviour is undefined.
    // 返回队列尾部元素的引用,调用者可以通过该引用更新元素,结合push实现插入操作。
    // 如果队列为空,该函数是不允许被调用。
    inline T &back() // 返回的是引用,是个左值,调用者可以通过其修改容器的值
    {
    
    
        return back_chunk->values[back_pos];
    }
//  Adds an element to the back end of the queue.
    inline void push()
    {
    
    
        back_chunk = end_chunk;
        back_pos = end_pos; //

        if (++end_pos != N) //end_pos!=N表明这个chunk节点还没有满
            return;

        chunk_t *sc = spare_chunk.xchg(NULL); // 为什么设置为NULL? 因为如果把之前值取出来了则没有spare chunk了,所以设置为NULL
        if (sc)                               // 如果有spare chunk则继续复用它
        {
    
    
            end_chunk->next = sc;
            sc->prev = end_chunk;
        }
        else // 没有则重新分配
        {
    
    
            // static int s_cout = 0;
            // printf("s_cout:%d\n", ++s_cout);
            end_chunk->next = (chunk_t *)malloc(sizeof(chunk_t)); // 分配一个chunk
            alloc_assert(end_chunk->next);
            end_chunk->next->prev = end_chunk;  
        }
        end_chunk = end_chunk->next;
        end_pos = 0;
    }

back()函数返回队列back_chunk->values[back_pos]代表队列尾可写元素,写⼊元素时则是更新back_pos位置的元素, 要确保元素真正⽣效,还需要调⽤push函数更新back_pos的位置,避免下次更新的时候⼜是更新当前 back_pos位置对应的元素。
重点push函数更新back_pos和end_pos的位置。并且判断末尾的那个chunk是否已经用完,用完了就再分配个chunk。再分配的chunk,先看spare_chunk中是否存在个原来删掉的chunk,如果有拿来直接用,避免了chunk的分配。如果spare_chunk中不存在chunk了,再重新分配。(局部性原理)

联合使用back和push函数向队列中插入元素

  //  Place the value to the queue, add new terminator element.
        queue.back() = value_;
        queue.push();
删除元素(删满一个chunk,spare_chunk进行回收)
//  Returns reference to the front element of the queue.
    //  If the queue is empty, behaviour is undefined.
    // 返回队列头部元素的引用,调用者可以通过该引用更新元素,结合pop实现出队列操作。
    inline T &front() // 返回的是引用,是个左值,调用者可以通过其修改容器的值
    {
    
    
        return begin_chunk->values[begin_pos];
    }
//  Removes an element from the front end of the queue.
    inline void pop()
    {
    
    
        if (++begin_pos == N) // 删除满一个chunk才回收chunk
        {
    
    
            chunk_t *o = begin_chunk;
            begin_chunk = begin_chunk->next;
            begin_chunk->prev = NULL;
            begin_pos = 0;

            //  'o' has been more recently used than spare_chunk,
            //  so for cache reasons we'll get rid of the spare and
            //  use 'o' as the spare.
            chunk_t *cs = spare_chunk.xchg(o); //由于局部性原理,总是保存最新的空闲块而释放先前的空闲快
            free(cs);
        }
    }

front()函数begin_chunk->values[begin_pos]代表队列头可读元素, 读取队列头元素即是读取begin_pos位置 的元素;
pop函数()函数使begin_pos的位置++,并判断是否完全移除了一整个chunk,如果移除了一整个chunk,则让spare_chunk进行回收,否则直接返回。
对pop函数中移除完整个chunk,进行回收的步骤解读:
1.begin_chunk更新指向下一个chunk,begin_pos=0;
2.把已经读完的chunk回收到spare_chunk,释放掉老的chunk,只保留最新回收的chunk

队列的销毁
//  销毁队列.
    inline ~yqueue_t()
    {
    
    
        while (true)
        {
    
    
            if (begin_chunk == end_chunk)
            {
    
    
                free(begin_chunk);
                break;
            }
            chunk_t *o = begin_chunk;
            begin_chunk = begin_chunk->next;
            free(o);
        }

        chunk_t *sc = spare_chunk.xchg(NULL);
        free(sc);
    }

用户层使用无锁zmq的无锁队列的读和写

这里为了让对zmq的无锁队列的读和写执行操作更加容易理解。我们这里可以先看下用户层面对读写的方式。
写端方式1:
一写一刷新

yqueue.write(count, false);
yqueue.flush();

写端方式2:
批量写入

yqueue.write(count, true);
yqueue.write(count, true);
yqueue.write(count, false);
yqueue.flush();

写端方式3:
条件变量+互斥锁

yqueue.write(count, false); 
if(!yqueue.flush())
 {
    
    
     // printf("notify_one\n");
     std::unique_lock<std::mutex> lock(ypipe_mutex_);
     ypipe_cond_.notify_one();
  }
方式选择和性能对比

在这里插入图片描述

ypipe-----yqueue的封装

yqueue 负责元素内存的分配与释放,入队以及出队列;ypipe 负责 yqueue 读写指针的变化。
ypipe 是在 yqueue_t 的基础上再构建一个单读单写的无锁队列。

类型定义
//  Allocation-efficient queue to store pipe items.
    //  Front of the queue points to the first prefetched item, back of
    //  the pipe points to last un-flushed item. Front is used only by
    //  reader thread, while back is used only by writer thread.
    yqueue_t<T, N> queue;

    //  Points to the first un-flushed item. This variable is used
    //  exclusively by writer thread.
    T *w; //指向第一个未刷新的元素,只被写线程使用

    //  Points to the first un-prefetched item. This variable is used
    //  exclusively by reader thread.
    T *r; //指向第一个还没预提取的元素,只被读线程使用

    //  Points to the first item to be flushed in the future.
    T *f; //指向下一轮要被刷新的一批元素中的第一个

    //  The single point of contention between writer and reader thread.
    //  Points past the last flushed item. If it is NULL,
    //  reader is asleep. This pointer should be always accessed using
    //  atomic operations.
    atomic_ptr_t<T> c; //读写线程共享的指针,指向每一轮刷新的起点(看代码的时候会详细说)。当c为空时,表示读线程睡眠(只会在读线程中被设置为空)

主要变量:
T *w;//指向第⼀个未刷新的元素,只被写线程使⽤
T *r;//指向第⼀个还没预提取的元素,只被读线程使⽤
T *f;//指向下⼀轮要被刷新的⼀批元素中的第⼀个
atomic_ptr_t c;//读写线程共享的指针,指向每⼀轮刷新的起点。当c为 空时,表示读线程睡眠(只会在读线程中被设置为空) 正常读写的情况下c的值其实是一直等于w的,但当出现队列没有数据的情况下调用一次read后c的值才会变成NULL

初始化
	inline ypipe_t()
    {
    
    
        //  Insert terminator element into the queue.
        queue.push(); //yqueue_t的尾指针加1,开始back_chunk为空,现在back_chunk指向第一个chunk_t块的第一个位置

        //  Let all the pointers to point to the terminator.
        //  (unless pipe is dead, in which case c is set to NULL).
        r = w = f = &queue.back(); //就是让r、w、f、c四个指针都指向这个end迭代器
        c.set(&queue.back());
    }

在这里插入图片描述
在这里插入图片描述
都指向同一个位置。
这里同时也解决了一个疑惑:在插入元素时,先back(),再push()逻辑顺序不同。原来是在插入元素之前,已经初始化,是end_pos的位置+1了

write()写函数
//  Write an item to the pipe.  Don't flush it yet. If incomplete is
    //  set to true the item is assumed to be continued by items
    //  subsequently written to the pipe. Incomplete items are neverflushed down the stream.
    // 写入数据,incomplete参数表示写入是否还没完成,在没完成的时候不会修改flush指针,即这部分数据不会让读线程看到。
    inline void write(const T &value_, bool incomplete_)
    {
    
    
        //  Place the value to the queue, add new terminator element.
        queue.back() = value_;
        queue.push();

        //  Move the "flush up to here" poiter.
        if (!incomplete_)
        {
    
    
            f = &queue.back(); // 记录要刷新的位置
            // printf("1 f:%p, w:%p\n", f, w);
        }
        else
        {
    
    
            //  printf("0 f:%p, w:%p\n", f, w);
        }
    }

为此我们在用户层展示下批量写的调用过程:
yqueue.write(count, true);
yqueue.write(count, false);
更新f(下⼀轮要被刷新的⼀批元素中的第⼀个)的位置指向
在这里插入图片描述

flush函数和check_read函数

存在cas互斥

//  Flush all the completed items into the pipe. Returns false if
    //  the reader thread is sleeping. In that case, caller is obliged to
    //  wake the reader up before using the pipe again.
    // 刷新所有已经完成的数据到管道,返回false意味着读线程在休眠,在这种情况下调用者需要唤醒读线程。
    // 批量刷新的机制, 写入批量后唤醒读线程;
    // 反悔机制 unwrite
    inline bool flush()
    {
    
    
        //  If there are no un-flushed items, do nothing.
        if (w == f) // 不需要刷新,即是还没有新元素加入
            return true;

        //  Try to set 'c' to 'f'.
        // read时如果没有数据可以读取则c的值会被置为NULL
        if (c.cas(w, f) != w) // 尝试将c设置为f,即是准备更新w的位置
        {
    
    

            //  Compare-and-swap was unseccessful because 'c' is NULL.
            //  This means that the reader is asleep. Therefore we don't
            //  care about thread-safeness and update c in non-atomic
            //  manner. We'll return false to let the caller know
            //  that reader is sleeping.
            c.set(f); // 更新为新的f位置
            w = f;
            return false; //线程看到flush返回false之后会发送一个消息给读线程,这需要写业务去做处理
        }
        else  // 读端还有数据可读取
        {
    
    
            //  Reader is alive. Nothing special to do now. Just move
            //  the 'first un-flushed item' pointer to 'f'.
            w = f;             // 更新f的位置
            return true;
        }
    }
//  Check whether item is available for reading.
    // 这里面有两个点,一个是检查是否有数据可读,一个是预取
    inline bool check_read()
    {
    
    
        //  Was the value prefetched already? If so, return.
        if (&queue.front() != r && r) //判断是否在前几次调用read函数时已经预取数据了return true;
            return true;

        //  There's no prefetched value, so let us prefetch more values.
        //  Prefetching is to simply retrieve the
        //  pointer from c in atomic fashion. If there are no
        //  items to prefetch, set c to NULL (using compare-and-swap).
        // 两种情况
        // 1. 如果c值和queue.front(), 返回c值并将c值置为NULL,此时没有数据可读
        // 2. 如果c值和queue.front(), 返回c值,此时可能有数据度的去
        r = c.cas(&queue.front(), NULL); //尝试预取数据

        //  If there are no elements prefetched, exit.
        //  During pipe's lifetime r should never be NULL, however,
        //  it can happen during pipe shutdown when items are being deallocated.
        if (&queue.front() == r || !r) //判断是否成功预取数据
            return false;

        //  There was at least one value prefetched.
        return true;
    }

在这里插入图片描述
在这里插入图片描述

相对于write和read就比较简单了

不做赘述了。

用户对读写端线程调用的解释

用户要注意的问题,当数据被读端读完后,读端该怎么办。以及读完数据后,读端线程又该怎么办。
1.对于读端读完数据,读端线程该怎么办?
使用条件变量+锁进行等待,休眠
2.那又有数据来了,读端线程又该怎么办?
已知当队列中没有数据的时候,读端处于休眠状态。当有数据来的时候,需要唤醒读端线程。
相对其他的消息队列,每来个数据,都进行notify,这样线程频繁的切换,性能明显比较低。
如果检测到再notify,效率才高。
3.那写端是怎么检测到读端处于休眠状态的?
在这里插入图片描述
写端在进行flush刷新的时候,如果返回false,说明读端处于等待唤醒的状态,就可以进行notify。
这都要归功于读写线程共享的指针c
通过对c值进行cas操作,写端就可以判断读端是否处于等待唤醒的状态。
r = c.cas(&queue.front(), NULL); //尝试预取数据 c和front相等,表示没有数据可读,cas操作会将c值设置为NULL,此时读线程被设置成条件等待
if (c.cas(w, f) != w) // 尝试将c设置为f,即是准备更新w的位置 读端没有数据可读,c值被设置为空,并且读线程也处于等待唤醒状态,c和w不相等,flush函数返回false,写线程发送notify。

在这里插入图片描述在这里插入图片描述

推荐一个零声学院免费公开课程,个人觉得老师讲得不错,
分享给大家:[Linux,Nginx,ZeroMQ,MySQL,Redis,
fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,
TCP/IP,协程,DPDK等技术内容,点击立即学习:服务器课程

猜你喜欢

转载自blog.csdn.net/weixin_52259848/article/details/125887549
今日推荐