无锁队列

单生产者——单消费者模型

此种场景不需要加锁,定长的可以通过读指针和写指针进行控制队列操作,变长的通过读指针、写指针、结束指针控制操作。此模型基于linux内核提供的kfifo的实现。

本文分析的原代码版本: 2.6.24.4

kfifo的定义文件: kernel/kfifo.c

kfifo的头文件: include/linux/kfifo.h

源码链接:https://github.com/torvalds/linux/blob/master/lib/kfifo.c 

PS:最新版本的kfifo省略了多处内存屏障,解决隐患问题

kfifo概述

kfifo是内核里面的一个First In First Out数据结构,它采用环形循环队列的数据结构来实现;它提供一个无边界的字节流服务,最重要的一点是,它使用并行无锁编程技术,即当它用于只有一个入队线程和一个出队线程的场情时,两个线程可以并发操作,而不需要任何加锁行为,就可以保证kfifo的线程安全。
kfifo代码既然肩负着这么多特性,那我们先一敝它的代码:

struct kfifo {
    unsigned char *buffer;    /* the buffer holding the data */
    unsigned int size;    /* the size of the allocated buffer */
    unsigned int in;    /* data is added at offset (in % size) */
    unsigned int out;    /* data is extracted from off. (out % size) */
    spinlock_t *lock;    /* protects concurrent modifications */
};

这是kfifo的数据结构,kfifo主要提供了两个操作,__kfifo_put(入队操作)和__kfifo_get(出队操作)。 它的各个数据成员如下:

buffer: 用于存放数据的缓存

size: buffer空间的大小,在初化时,将它向上扩展成2的幂

lock: 如果使用不能保证任何时间最多只有一个读线程和写线程,需要使用该lock实施同步。

in, out: 和buffer一起构成一个循环队列。 in指向buffer中队头,而且out指向buffer中的队尾,它的结构如示图如下:

+--------------------------------------------------------------+
|            |<----------data---------->|                      |
+--------------------------------------------------------------+
             ^                          ^                      ^
             |                          |                      |
            out                        in                     size

当然,内核开发者使用了一种更好的技术处理了in, out和buffer的关系,我们将在下面进行详细分析。

kfifo功能描述

kfifo提供如下对外功能规格

  1. 只支持一个读者和一个读者并发操作
  2. 无阻塞的读写操作,如果空间不够,则返回实际访问空间

kfifo_alloc 分配kfifo内存和初始化工作

struct kfifo *kfifo_alloc(unsigned int size, gfp_t gfp_mask, spinlock_t *lock)
{
    unsigned char *buffer;
    struct kfifo *ret;

    /*
     * round up to the next power of 2, since our 'let the indices
     * wrap' tachnique works only in this case.
     */
    if (size & (size - 1)) {
        BUG_ON(size > 0x80000000);
        size = roundup_pow_of_two(size);
    }

    buffer = kmalloc(size, gfp_mask);
    if (!buffer)
        return ERR_PTR(-ENOMEM);

    ret = kfifo_init(buffer, size, gfp_mask, lock);

    if (IS_ERR(ret))
        kfree(buffer);

    return ret;
}

这里值得一提的是,kfifo->size的值总是在调用者传进来的size参数的基础上向2的幂扩展,这是内核一贯的做法。这样的好处不言而喻——对kfifo->size取模运算可以转化为与运算,如下:

kfifo->in % kfifo->size 可以转化为 kfifo->in & (kfifo->size – 1)

在kfifo_alloc函数中,使用size & (size – 1)来判断size 是否为2幂,如果条件为真,则表示size不是2的幂,然后调用roundup_pow_of_two将之向上扩展为2的幂。

这都是常用的技巧,只不过大家没有将它们结合起来使用而已,下面要分析的__kfifo_put和__kfifo_get则是将kfifo->size的特点发挥到了极致。

__kfifo_put和__kfifo_get巧妙的入队和出队

__kfifo_put是入队操作,它先将数据放入buffer里面,最后才修改in参数;__kfifo_get是出队操作,它先将数据从buffer中移走,最后才修改out。你会发现in和out两者各司其职。

下面是__kfifo_put和__kfifo_get的代码

unsigned int __kfifo_put(struct kfifo *fifo,
             unsigned char *buffer, unsigned int len)
{
    unsigned int l;

    len = min(len, fifo->size - fifo->in + fifo->out);

    /*
     * Ensure that we sample the fifo->out index -before- we
     * start putting bytes into the kfifo.
     */

    smp_mb();

    /* first put the data starting from fifo->in to buffer end */
    l = min(len, fifo->size - (fifo->in & (fifo->size - 1)));
    memcpy(fifo->buffer + (fifo->in & (fifo->size - 1)), buffer, l);

    /* then put the rest (if any) at the beginning of the buffer */
    memcpy(fifo->buffer, buffer + l, len - l);

    /*
     * Ensure that we add the bytes to the kfifo -before-
     * we update the fifo->in index.
     */

    smp_wmb();

    fifo->in += len;

    return len;
}

奇怪吗?代码完全是线性结构,没有任何if-else分支来判断是否有足够的空间存放数据。内核在这里的代码非常简洁,没有一行多余的代码。

l = min(len, fifo->size - (fifo->in & (fifo->size - 1)));

这个表达式计算当前写入的空间,换成人可理解的语言就是:

l = kfifo可写空间和预期写入空间的最小值

使用min宏来代if-else分支

__kfifo_get也应用了同样技巧,代码如下:

unsigned int __kfifo_get(struct kfifo *fifo,
             unsigned char *buffer, unsigned int len)
{
    unsigned int l;

    len = min(len, fifo->in - fifo->out);

    /*
     * Ensure that we sample the fifo->in index -before- we
     * start removing bytes from the kfifo.
     */

    smp_rmb();

    /* first get the data from fifo->out until the end of the buffer */
    l = min(len, fifo->size - (fifo->out & (fifo->size - 1)));
    memcpy(buffer, fifo->buffer + (fifo->out & (fifo->size - 1)), l);

    /* then get the rest (if any) from the beginning of the buffer */
    memcpy(buffer + l, fifo->buffer, len - l);

    /*
     * Ensure that we remove the bytes from the kfifo -before-
     * we update the fifo->out index.
     */

    smp_mb();

    fifo->out += len;

    return len;
}

认真读两遍吧,我也读了多次,每次总是有新发现,因为in, out和size的关系太巧妙了,竟然能利用上unsigned int回绕的特性。

原来,kfifo每次入队或出队,kfifo->in或kfifo->out只是简单地kfifo->in/kfifo->out += len,并没有对kfifo->size 进行取模运算。因此kfifo->in和kfifo->out总是一直增大,直到unsigned in最大值时,又会绕回到0这一起始端。但始终满足:

kfifo->in - kfifo->out <= kfifo->size

即使kfifo->in回绕到了0的那一端,这个性质仍然是保持的。

对于给定的kfifo:

数据空间长度为:kfifo->in - kfifo->out

而剩余空间(可写入空间)长度为:kfifo->size - (kfifo->in - kfifo->out)

尽管kfifo->in和kfofo->out一直超过kfifo->size进行增长,但它对应在kfifo->buffer空间的下标却是如下:

kfifo->in % kfifo->size (i.e. kfifo->in & (kfifo->size - 1))

kfifo->out % kfifo->size (i.e. kfifo->out & (kfifo->size - 1))

往kfifo里面写一块数据时,数据空间、写入空间和kfifo->size的关系如果满足:

kfifo->in % size + len > size

那就要做写拆分了,见下图:

                                                    kfifo_put(写)空间开始地址
                                                    |
                                                   \_/
                                                    |XXXXXXXXXX
XXXXXXXX|                                                    
+--------------------------------------------------------------+
|                        |<----------data---------->|          |
+--------------------------------------------------------------+
                         ^                          ^          ^
                         |                          |          |
                       out%size                   in%size     size
        ^
        |
      写空间结束地址                      

第一块当然是: [kfifo->in % kfifo->size, kfifo->size]
第二块当然是:[0, len - (kfifo->size - kfifo->in % kfifo->size)]

下面是代码,细细体味吧:

/* first put the data starting from fifo->in to buffer end */   
l = min(len, fifo->size - (fifo->in & (fifo->size - 1)));   
memcpy(fifo->buffer + (fifo->in & (fifo->size - 1)), buffer, l);   

/* then put the rest (if any) at the beginning of the buffer */   
memcpy(fifo->buffer, buffer + l, len - l);  

对于kfifo_get过程,也是类似的,请各位自行分析。

kfifo_get和kfifo_put无锁并发操作

计算机科学家已经证明,当只有一个读经程和一个写线程并发操作时,不需要任何额外的锁,就可以确保是线程安全的,也即kfifo使用了无锁编程技术,以提高kernel的并发。

kfifo使用in和out两个指针来描述写入和读取游标,对于写入操作,只更新in指针,而读取操作,只更新out指针,可谓井水不犯河水,示意图如下:

                                               |<--写入-->|
+--------------------------------------------------------------+
|                        |<----------data----->|               |
+--------------------------------------------------------------+
                         |<--读取-->|
                         ^                     ^               ^
                         |                     |               |
                        out                   in              size

为了避免读者看到写者预计写入,但实际没有写入数据的空间,写者必须保证以下的写入顺序:

  1. 往[kfifo->in, kfifo->in + len]空间写入数据
  2. 更新kfifo->in指针为 kfifo->in + len

在操作1完成时,读者是还没有看到写入的信息的,因为kfifo->in没有变化,认为读者还没有开始写操作,只有更新kfifo->in之后,读者才能看到。

那么如何保证1必须在2之前完成,秘密就是使用内存屏障:smp_mb(),smp_rmb(), smp_wmb(),来保证对方观察到的内存操作顺序。(最小版本已省略)

总结

读完kfifo代码,令我想起那首诗“众里寻他千百度,默然回首,那人正在灯火阑珊处”。不知你是否和我一样,总想追求简洁,高质量和可读性的代码,当用尽各种方法,江郞才尽之时,才发现Linux kernel里面的代码就是我们寻找和学习的对象。

(一)多对多(一)模型

关于CAS等原子操作

在开始说无锁队列之前,我们需要知道一个很重要的技术就是CAS操作——Compare & Set,或是 Compare & Swap,现在几乎所有的CPU指令都支持CAS的原子操作,X86下对应的是 CMPXCHG 汇编指令。有了这个原子操作,我们就可以用其来实现各种无锁(lock free)的数据结构。

这个操作用C语言来描述就是下面这个样子:(代码来自Wikipedia的Compare And Swap词条)意思就是说,看一看内存*reg里的值是不是oldval,如果是的话,则对其赋值newval

int compare_and_swap (int* reg, int oldval, int newval)
{
  int old_reg_val = *reg;
  if (old_reg_val == oldval) {
     *reg = newval;
  }
  return old_reg_val;
}

我们可以看到,old_reg_val 总是返回,于是,我们可以在 compare_and_swap 操作之后对其进行测试,以查看它是否与 oldval相匹配,因为它可能有所不同,这意味着另一个并发线程已成功地竞争到 compare_and_swap 并成功将 reg 值从 oldval 更改为别的值了。

这个操作可以变种为返回bool值的形式(返回 bool值的好处在于,可以调用者知道有没有更新成功):

bool compare_and_swap (int *addr, int oldval, int newval)
{
  if ( *addr != oldval ) {
      return false;
  }
  *addr = newval;
  return true;
}

与CAS相似的还有下面的原子操作:(这些东西大家自己看Wikipedia,也没什么复杂的)

注:在实际的C/C++程序中,CAS的各种实现版本如下:

1)GCC的CAS

GCC4.1+版本中支持CAS的原子操作(完整的原子操作可参看 GCC Atomic Builtins

bool __sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...)

2)Windows的CAS

在Windows下,你可以使用下面的Windows API来完成CAS:(完整的Windows原子操作可参看MSDN的InterLocked Functions

 InterlockedCompareExchange ( __inout LONG volatile *Target,
                                 __in LONG Exchange,
                                 __in LONG Comperand);

3) C++11中的CAS

C++11中的STL中的atomic类的函数可以让你跨平台。(完整的C++11的原子操作可参看 Atomic Operation Library

template< class T >
bool atomic_compare_exchange_weak( std::atomic* obj,
                                   T* expected, T desired );
template< class T >
bool atomic_compare_exchange_weak( volatile std::atomic* obj,
                                   T* expected, T desired );

队列数据定长

下面的代码主要参考于两篇论文:

(注:下面的代码并不完全与这篇论文相同)

初始化一个队列的代码很简,初始化一个dummy结点(注:在链表操作中,使用一个dummy结点,可以少掉很多边界条件的判断),如下所示:

InitQueue(Q)
{
    node = new node()
    node->next = NULL;
    Q->head = Q->tail = node;
}

我们先来看一下进队列用CAS实现的方式,基本上来说就是链表的两步操作:

  1. 第一步,把tail指针的next指向要加入的结点。 tail->next = p;
  2. 第二步,把tail指针移到队尾。 tail = p;
EnQueue(Q, data) //进队列
{
    //准备新加入的结点数据
    n = new node();
    n->value = data;
    n->next = NULL;

    do {
        p = Q->tail; //取链表尾指针的快照
    } while( CAS(p->next, NULL, n) != TRUE); 
    //while条件注释:如果没有把结点链在尾指针上,再试

    CAS(Q->tail, p, n); //置尾结点 tail = n;
}

我们可以看到,程序中的那个 do-while 的 Retry-Loop 中的 CAS 操作:如果 p->nextNULL,那么,把新结点 n 加到队尾。如果不成功,则重新再来一次!

就是说,很有可能我在准备在队列尾加入结点时,别的线程已经加成功了,于是tail指针就变了,于是我的CAS返回了false,于是程序再试,直到试成功为止。这个很像我们的抢电话热线的不停重播的情况。

但是你会看到,为什么我们的“置尾结点”的操作(第13行)不判断是否成功,因为:

  1. 如果有一个线程T1,它的while中的CAS如果成功的话,那么其它所有的 随后线程的CAS都会失败,然后就会再循环,
  2. 此时,如果T1 线程还没有更新tail指针,其它的线程继续失败,因为tail->next不是NULL了。
  3. 直到T1线程更新完 tail 指针,于是其它的线程中的某个线程就可以得到新的 tail 指针,继续往下走了。
  4. 所以,只要线程能从 while 循环中退出来,意味着,它已经“独占”了,tail 指针必然可以被更新。

这里有一个潜在的问题——如果T1线程在用CAS更新tail指针的之前,线程停掉或是挂掉了,那么其它线程就进入死循环了。下面是改良版的EnQueue()

EnQueue(Q, data) //进队列改良版 v1
{
    n = new node();
    n->value = data;
    n->next = NULL;

    p = Q->tail;
    oldp = p
    do {
        while (p->next != NULL)
            p = p->next;
    } while( CAS(p.next, NULL, n) != TRUE); //如果没有把结点链在尾上,再试

    CAS(Q->tail, oldp, n); //置尾结点
}

我们让每个线程,自己fetch 指针 p 到链表尾。但是这样的fetch会很影响性能。而且,如果一个线程不断的EnQueue,会导致所有的其它线程都去 fetch 他们的 p 指针到队尾,能不能不要所有的线程都干同一个事?这样可以节省整体的时间?

比如:直接 fetch Q->tail 到队尾?因为,所有的线程都共享着 Q->tail,所以,一旦有人动了它后,相当于其它的线程也跟着动了,于是,我们的代码可以改进成如下的实现:

EnQueue(Q, data) //进队列改良版 v2 
{
    n = new node();
    n->value = data;
    n->next = NULL;

    while(TRUE) {
        //先取一下尾指针和尾指针的next
        tail = Q->tail;
        next = tail->next;

        //如果尾指针已经被移动了,则重新开始
        if ( tail != Q->tail ) continue;

        //如果尾指针的 next 不为NULL,则 fetch 全局尾指针到next
        if ( next != NULL ) {
            CAS(Q->tail, tail, next);
            continue;
        }

        //如果加入结点成功,则退出
        if ( CAS(tail->next, next, n) == TRUE ) break;
    }
    CAS(Q->tail, tail, n); //置尾结点
}

上述的代码还是很清楚的,相信你一定能看懂,而且,这也是 Java 中的 ConcurrentLinkedQueue 的实现逻辑,当然,我上面的这个版本比 Java 的好一点,因为没有 if 嵌套,嘿嘿。

好了,我们解决了EnQueue,我们再来看看DeQueue的代码:(很简单,我就不解释了)

DeQueue(Q) //出队列
{
    do{
        p = Q->head;
        if (p->next == NULL){
            return ERR_EMPTY_QUEUE;
        }
    while( CAS(Q->head, p, p->next) != TRUE );
    return p->next->value;
}

我们可以看到,DeQueue的代码操作的是 head->next,而不是 head 本身。这样考虑是因为一个边界条件,我们需要一个dummy的头指针来解决链表中如果只有一个元素,headtail 都指向同一个结点的问题,这样 EnQueueDeQueue 要互相排斥了

但是,如果 headtail 都指向同一个结点,这意味着队列为空,应该返回 ERR_EMPTY_QUEUE,但是,在判断 p->next == NULL 时,另外一个EnQueue操作做了一半,此时的 p->next 不为 NULL了,但是 tail 指针还差最后一步,没有更新到新加的结点,这个时候就会出现,在 EnQueue 并没有完成的时候, DeQueue 已经把新增加的结点给取走了,此时,队列为空,但是,head 与 tail 并没有指向同一个结点。如下所示:

image

虽然,EnQueue的函数会把 tail 指针置对,但是,这种情况可能还是会导致一些并发问题,所以,严谨来说,我们需要避免这种情况。于是,我们需要加入更多的判断条件,还确保这个问题。下面是相关的改进代码:

DeQueue(Q) //出队列,改进版
{
    while(TRUE) {
        //取出头指针,尾指针,和第一个元素的指针
        head = Q->head;
        tail = Q->tail;
        next = head->next;

        // Q->head 指针已移动,重新取 head指针
        if ( head != Q->head ) continue;
        
        // 如果是空队列
        if ( head == tail && next == NULL ) {
            return ERR_EMPTY_QUEUE;
        }
        
        //如果 tail 指针落后了
        if ( head == tail && next == NULL ) {
            CAS(Q->tail, tail, next);
            continue;
        }

        //移动 head 指针成功后,取出数据
        if ( CAS( Q->head, head, next) == TRUE){
            value = next->value;
            break;
        }
    }
    free(head); //释放老的dummy结点
    return value;
}

上面这段代码的逻辑和 Java 的 ConcurrentLinkedQueuepoll 方法很一致了。也是《Simple, Fast, and Practical Non-Blocking and Blocking ConcurrentQueue Algorithms》这篇论文中的实现。

解决ABA的问题

论文《Implementing Lock-Free Queues》给出一这么一个方法——使用结点内存引用计数refcnt!(论文《Simple, Fast, and Practical Non-Blocking and Blocking ConcurrentQueue Algorithms》中的实现方法也基本上是一样的,用到的是增加一个计数,可以理解为版本号)

SafeRead(q)
{
    loop:
        p = q->next;
        if (p == NULL){
            return p;
        }

        Fetch&Add(p->refcnt, 1);

        if (p == q->next){
            return p;
        }else{
            Release(p);
        }
    goto loop;
}

其中的 Fetch&Add和Release分是是加引用计数和减引用计数,都是原子操作,这样就可以阻止内存被回收了。

小结

1)无锁队列主要是通过CAS、FAA这些原子操作,和Retry-Loop实现。

2)对于Retry-Loop,我个人感觉其实和锁什么什么两样。只是这种“锁”的粒度变小了,主要是“锁”HEAD和TAIL这两个关键资源。而不是整个数据结构。

队列数据变长

队列数据变长的参考intel dpdk提供的rte_ring的实现。

此部分请结合intel dpdk源码去阅读,源码可以去http://dpdk.org/dev 网页中下载;更多官方文档请访问http://dpdk.org

参考文档:intel-dpdk-programmers-guide.pdf ,请去intel官网下载http://www.intel.com/content/dam/www/public/us/en/documents/guides/intel-dpdk-programmers-guide.pdf
本部分基于intel dpdk 的源码1.3.1 版本进行讲解;

摘要

intel dpdk 提供了一套ring 队列管理代码,支持单生产者产品入列,单消费者产品出列;多名生产者产品入列,多产品消费这产品出列操作;

我们以app/test/test_ring.c文件中的代码进行讲解,test_ring_basic_ex()函数完成一个基本功能测试函数;

1、ring的创建

    rp = rte_ring_create("test_ring_basic_ex", RING_SIZE, SOCKET_ID_ANY,
            RING_F_SP_ENQ | RING_F_SC_DEQ);

调用rte_ring_create函数去创建一个ring,
第一参数"test_ring_basic_ex"是这个ring的名字,

第二个参数RING_SIZE是ring的大小;

第三个参数是在哪个socket id上创建 ,这指定的是任意;

第四个参数是指定此ring支持单入单出;

我看一下rte_ring_create函数主要完成了哪些操作;

rte_rwlock_write_lock(RTE_EAL_TAILQ_RWLOCK);

执行读写锁的加锁操作;

mz = rte_memzone_reserve(mz_name, ring_size, socket_id, mz_flags);

预留一部分内存空间给ring,其大小就是RING_SIZE个sizeof(struct rte_ring)的尺寸;

        r = mz->addr;
 
        /* init the ring structure */
        memset(r, 0, sizeof(*r));
        rte_snprintf(r->name, sizeof(r->name), "%s", name);
        r->flags = flags;
        r->prod.watermark = count;
        r->prod.sp_enqueue = !!(flags & RING_F_SP_ENQ);
        r->cons.sc_dequeue = !!(flags & RING_F_SC_DEQ);
        r->prod.size = r->cons.size = count;
        r->prod.mask = r->cons.mask = count-1;
        r->prod.head = r->cons.head = 0;
        r->prod.tail = r->cons.tail = 0;
 
        TAILQ_INSERT_TAIL(ring_list, r, next);

将获取到的虚拟地址给了ring,然后初始化她,prod 代表生成者,cons代表消费者;

生产者最大可以生产count个,其取模的掩码是 count-1; 目前是0个产品,所以将生产者的头和消费者头都设置为0;其尾也设置未0;

rte_rwlock_write_unlock(RTE_EAL_TAILQ_RWLOCK);

执行读写锁的写锁解锁操作;

2、ring的单生产者产品入列

rte_ring_enqueue(rp, obj[i])

ring的单个入列;

__rte_ring_sp_do_enqueue

最终会调用到上面这个函数,进行单次入列,我们看一下它的实现;

prod_head = r->prod.head;
cons_tail = r->cons.tail;

暂时将生产者的头索引和消费者的尾部索引交给临时变量;

free_entries = mask + cons_tail - prod_head;

计算还有多少剩余的存储空间;

prod_next = prod_head + n;
r->prod.head = prod_next;

如果有足够的剩余空间,我们先将临时变量prod_next 进行后移,同事将生产者的头索引后移n个;

    /* write entries in ring */
    for (i = 0; likely(i < n); i++)
        r->ring[(prod_head + i) & mask] = obj_table[i];
    rte_wmb();

执行写操作,将目标进行入队操作,它并没有任何大数据量的内存拷贝操作,只是进行指针的赋值操作,因此dpdk的内存操作很快,应该算是零拷贝;

r->prod.tail = prod_next;

成功写入之后,将生产者的尾部索引赋值为prox_next ,也就是将其往后挪到n个索引;我们成功插入了n个产品;目前是单个操作,索引目前n=1;

3、ring的单消费者产品出列

rte_ring_dequeue(rp, &obj[i]);

同样出队也包含了好几层的调用,最终定位到__rte_ring_sc_do_dequeue函数;

    cons_head = r->cons.head;
    prod_tail = r->prod.tail;

先将消费者的头索引和生产者的头索引赋值给临时变量;

entries = prod_tail - cons_head;

计算目前ring中有多少产品;

    cons_next = cons_head + n;
    r->cons.head = cons_next;

如果有足够的产品,就将临时变量cons_next往后挪到n个值,指向你想取出几个产品的位置;同时将消费者的头索引往后挪到n个;这目前n=1;因为是单个取出;

    /* copy in table */
    rte_rmb();
    for (i = 0; likely(i < n); i++) {
        obj_table[i] = r->ring[(cons_head + i) & mask];
    }

执行读取操作,同样没有任何的大的数据量拷贝,只是进行指针的赋值;

 r->cons.tail = cons_next;

最后将消费者的尾部索引也像后挪动n个,最终等于消费者的头索引;

4、ring的多生产者产品入列

多生产者入列的实现是在 __rte_ring_mp_do_enqueue()函数中;在dpdk/lib/librte_ring/rte_ring.h 文件中定义;其实这个函数和单入列函数很相似;

    /* move prod.head atomically */
    do {
        /* Reset n to the initial burst count */
        n = max;
.................
 
        prod_next = prod_head + n;
        success = rte_atomic32_cmpset(&r->prod.head, prod_head,
                          prod_next);
    } while (unlikely(success == 0));

在单生产者中时将生产者的头部和消费者的尾部直接赋值给临时变量,去求剩余存储空间;最后将生产者的头索引往后移动n个,

但在多生产者中,要判断这个头部是否和其他的生产者发出竞争,

    success = rte_atomic32_cmpset(&r->prod.head, prod_head,
                      prod_next);

是否有其他生产者修改了prod.head,所以这要重新判断一下prod.head是否还等于prod_head,如果等于,就将其往后移动n个,也就是将prod_next值赋值给prod.head;

如果不等于,就会失败,就需要进入do while循环再次循环一次;重新刷新一下prod_head和prod_next 以及prod.head的值 ;

    /* write entries in ring */
    for (i = 0; likely(i < n); i++)
        r->ring[(prod_head + i) & mask] = obj_table[i];
    rte_wmb();

执行产品写入操作;

写入操作完成之后,如是单生产者应该是直接修改生产者尾部索引,将其往后顺延n个,但目前是多生产者操作;是怎样实现的呢?

    /*
     * If there are other enqueues in progress that preceeded us,
     * we need to wait for them to complete
     */
    while (unlikely(r->prod.tail != prod_head))
        rte_pause();
 
    r->prod.tail = prod_next;

这也先进行判断,判断当前的生产者尾部索引是否还等于,存储在临时变量中的生产者头索引,
如果不等于,说明,有其他的线程还在执行,而且应该是在它之前进行存储,还没来得及更新prod.tail;等其他的生产者更新tail后,就会使得prod.tailprod_head;
之后再更新,prod.tail 往后挪动n个,最好实现 prod.tail
prod.headprod_nextprod_head+n;

5、ring的多消费者产品出列

多个消费者同时取产品是在__rte_ring_mc_do_dequeue()函数中实现;定义在dpdk/lib/librte_ring/rte_ring.h文件中;

    /* move cons.head atomically */
    do {
        /* Restore n as it may change every loop */
        n = max;
 
        cons_head = r->cons.head;
        prod_tail = r->prod.tail;
...................
 
        cons_next = cons_head + n;
        success = rte_atomic32_cmpset(&r->cons.head, cons_head,
                          cons_next);
    } while (unlikely(success == 0));

和多生产者一样,在外面多包含了一次do while循环,防止多消费者操作发生竞争;
在循环中先将消费者的头索引和生产者的为索引赋值给临时变量;让后判断有多少剩余的产品在循环队列,

如有n个产品,就将临时变量cons_next 往后挪动n个,然后判断目前的消费者头索引是否还等于刚才的保存在临时变量cons_head 中的值,如相等,说明没有发生竞争,就将cons_next赋值给

消费者的头索引 r->cons.head,如不相等,就需要重新做一次do while循环;

    /* copy in table */
    rte_rmb();
    for (i = 0; likely(i < n); i++) {
        obj_table[i] = r->ring[(cons_head + i) & mask];
    }

在成功更新消费者头索引后,执行读取产品操作,这并没有大的数据拷贝操作,只是进行指针的重新赋值操作;

    /*
     * If there are other dequeues in progress that preceded us,
     * we need to wait for them to complete
     */
    while (unlikely(r->cons.tail != cons_head))
        rte_pause();
 
    __RING_STAT_ADD(r, deq_success, n);
    r->cons.tail = cons_next;

读取完成后,就要更新消费者的尾部索引;
为了避免竞争,就要判是否有其他的消费者在更新消费者尾部索引;如果目前的消费者尾部索引不等于刚才保存的在临时变量cons_head 的值,就要等待其他消费者修改这个尾部索引;

如相等,机可以将当前消费者的尾部索引往后挪动n个索引值了,

实现 r->cons.tail=r->cons.head=cons_next=cons_head+n;

6、ring的其他判定函数

rte_ring_lookup("test_ring_basic_ex")

验证以test_ring_basic_ex 为名的ring是否创建成功;

rte_ring_empty(rp)

判断ring是否为空;

rte_ring_full(rp)

判断ring是否已经满;

rte_ring_free_count(rp)

判断当前ring还有多少剩余存储空间;

参考文章:

https://blog.csdn.net/linyt/article/details/53355355

https://coolshell.cn/articles/8239.html#关于CAS等原子操作

https://blog.csdn.net/lucky52529/article/details/101162787

https://blog.csdn.net/linzhaolover/article/details/9771329

posted @ 2021-09-02 17:16  DWVictor  阅读(1386)  评论(0编辑  收藏  举报