dpdk介绍系列之ring

DPDK所提供的ring本质上是一个FIFO的无锁队列,支持单生产者/单消费者/多生产者/多消费者等多种操作模式,同时也支持burst模式来进行以上操作。物理上它是一个数组,需要在定义时就指定好大小(队列是没有大小限制的),在逻辑上可以看成是一个环形队列。

和队列相比,具备如下优势

  • 更快:仅仅需要一个CAS指令
  • 简单:相比标准的linux无锁队列,实现和使用都相对简单
  • 支持批量入队/出队操做
逻辑上ring看起来如下图:
dpdk介绍系列之ring_第1张图片

rte_ring 的属性

可以看下结构体的定义:

struct rte_ring {
	TAILQ_ENTRY(rte_ring) next;      /**< Next in list. */

	//ring的唯一标示,不可能同时有两个相同name的ring存在
	char name[RTE_RING_NAMESIZE];    /**< Name of the ring. */
	int flags;                       /**< Flags supplied at creation. */

	/** Ring producer status. */
	struct prod {
		uint32_t watermark;      /**< Maximum items before EDQUOT. */
		uint32_t sp_enqueue;     /**< True, if single producer. */
		uint32_t size;           /**< Size of ring. */
		uint32_t mask;           /**< Mask (size-1) of ring. */
		volatile uint32_t head;  /**< Producer head. */
		volatile uint32_t tail;  /**< Producer tail. */
	} prod __rte_cache_aligned;

	/** Ring consumer status. */
	struct cons {
		uint32_t sc_dequeue;     /**< True, if single consumer. */
		uint32_t size;           /**< Size of the ring. */
		uint32_t mask;           /**< Mask (size-1) of ring. */
		volatile uint32_t head;  /**< Consumer head. */
		volatile uint32_t tail;  /**< Consumer tail. */
#ifdef RTE_RING_SPLIT_PROD_CONS
	/*这个属性就是要求gcc在编译的时候,把cons/prod结构都单独分配到一个cache行,为什么这样做?
	 因为如果没有这些的话,这两个结构在内存上是连续的,编译器不会把他们分配到不同cache 行,而
	一般上这两个结构是要被不同的核访问的,如果连续的话这两个核就会产生伪共享问题。*/
	} cons __rte_cache_aligned;
#else
	} cons;
#endif

#ifdef RTE_LIBRTE_RING_DEBUG
	struct rte_ring_debug_stats stats[RTE_MAX_LCORE];
#endif

	void * ring[0] __rte_cache_aligned; /**< Memory space of ring starts here.
	 	 	 	 	 	 	 	 	 	 * not volatile so need to be careful
	 	 	 	 	 	 	 	 	 	 * about compiler re-ordering */
};

在使用这个结构的时候,一般是将1个核作为生产者,向这个ring队列里面添加数据;另一个core或者多个core 作为消费者从这个ring队列中获取数据。生产者核访问上面的prod结构,消费者访问cons结构。

另外,在struct cons/prod中都有字段size和mask,这两个都是指ring的大小,但是由于这两个结构通常被两个核访问,单独存放可以提高访问性能,也算是内存优化的一个例子。

在这两个结构中的有head和tail索引,为什么要放两个索引呢?主要还是考虑多生产者/多消费者使用ring时,会涉及到多线程编程以及锁冲突的问题。通过这四个索引,DPDK提供了巧妙的优化机制来提升自身的性能。

对rte_ring的操作

考虑到入队和出队的原理类似,就选取入队来研究下。

函数调用关系如下:rte_ring_enqueue_bulk->rte_ring_sp_enqueue_bulk(rte_ring_mp_enqueue_bulk)->__rte_ring_sp_do_enqueue(__rte_ring_mp_do_enqueue),其中sp的情况比较简单,不再介绍;重点分析下mp的情况,这里面会涉及到多线程写无锁队列的实现技巧,以及x86 cpu的一些特殊优化指令

废话不多少,直接上代码:

static inline int __attribute__((always_inline))
__rte_ring_mp_do_enqueue(struct rte_ring *r, void * const *obj_table,
			 unsigned n, enum rte_ring_queue_behavior behavior)
{
	uint32_t prod_head, prod_next;
	uint32_t cons_tail, free_entries;
	const unsigned max = n;
	int success;
	unsigned i;
	uint32_t mask = r->prod.mask;
	int ret;

	/* move prod.head atomically */
	do {
		/* Reset n to the initial burst count */
		n = max;

		prod_head = r->prod.head;
		cons_tail = r->cons.tail;
		
		 /*在这里dpdk提供的索引计算方法,能保证即使prod_head > cons_tail,
		  *取模求得的值也始终落在0~size(ring)-1范围内
		  */
		free_entries = (mask + cons_tail - prod_head);

		/* check that we have enough room in ring */
		if (unlikely(n > free_entries)) {
			if (behavior == RTE_RING_QUEUE_FIXED) {
				__RING_STAT_ADD(r, enq_fail, n);
				return -ENOBUFS;
			}
			else {
				/* No free entry available */
				if (unlikely(free_entries == 0)) {
					__RING_STAT_ADD(r, enq_fail, n);
					return 0;
				}

				n = free_entries;
			}
		}

		prod_next = prod_head + n;
		/*这里使用CAS指令来移动r->prod.head,去掉了锁操作,也算优化
		*点
		*/
		success = rte_atomic32_cmpset(&r->prod.head, prod_head,
					      prod_next);
	} while (unlikely(success == 0));

	/* write entries in ring */
	ENQUEUE_PTRS();
	/*COMPILER_BARRIER 是一个宏定义,它的作用就是确保上面的ENQUEUE_PTRS
	*宏处理在下面r->prod.tail = prod_next;之前执行?大家可能会问,
	*ENQUEUE_PTRS怎么可能会跑到r->prod.tail = prod_next之后执行?
	*其实是有可能的,GCC为了提高性能,它会优化代码,它可能会调整代码的
	*执行顺序,把后面的指令放到前面,GCC这些编译器是依据单核情况实现,
	*所以这种情况下,程序员必须介入,上面这条指令,就是告诉编译器不允许
	*调整这个指令顺序。
	*/
	rte_compiler_barrier();

	/* if we exceed the watermark */
	if (unlikely(((mask + 1) - free_entries + n) > r->prod.watermark)) {
		ret = (behavior == RTE_RING_QUEUE_FIXED) ? -EDQUOT :
				(int)(n | RTE_RING_QUOT_EXCEED);
		__RING_STAT_ADD(r, enq_quota, n);
	}
	else {
		ret = (behavior == RTE_RING_QUEUE_FIXED) ? 0 : n;
		__RING_STAT_ADD(r, enq_success, 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;
	return ret;
}

其中ENQUEUE_PTRS的实现:

#define ENQUEUE_PTRS() do { \
	const uint32_t size = r->prod.size; \
	//这里idx每次循环加4,也是针对CPU的特殊优化
	//至于为什么是4个交替写,这块我还没怎么理解
	uint32_t idx = prod_head & mask; \
	if (likely(idx + n < size)) { \
		for (i = 0; i < (n & ((~(unsigned)0x3))); i+=4, idx+=4) { \
			r->ring[idx] = obj_table[i]; \
			r->ring[idx+1] = obj_table[i+1]; \
			r->ring[idx+2] = obj_table[i+2]; \
			r->ring[idx+3] = obj_table[i+3]; \
		} \
		switch (n & 0x3) { \
			case 3: r->ring[idx++] = obj_table[i++]; \
			case 2: r->ring[idx++] = obj_table[i++]; \
			case 1: r->ring[idx++] = obj_table[i++]; \
		} \
	} else { \
		for (i = 0; idx < size; i++, idx++)\
			r->ring[idx] = obj_table[i]; \
		for (idx = 0; i < n; i++, idx++) \
			r->ring[idx] = obj_table[i]; \
	} \
} while(0)

代码中使用rte_compiler_barrier这个东西有好有坏。好处是编译器有更大的自主权去做深度优化,坏处就是对人的要求比较高,必须得了解底层这些东西。所以说,编译器只能优化单核单线程程序不会出现上面的问题,但是多线程(不管是多核/还是单核)就不是编译器能完成的事情了。

代码中四个索引变量是比较重要的:

  • prod.head/prod. tail 表示生产者,他们表示入队列线程要新加入数据加到那个数组索引号上去,他们的差别后边讲,先认为他们是相等的即可。
  • cons.head/ cons. tail 表示消费者,从队列上取数据时,队列头的位置。他们的差别后边讲,先认为他们是相等的即可。
  • prod.head/prod. Tail == cons.head/ cons. tail:初始化时就是这种状态,头尾指针相等表示队列无数据
  • prod.head/prod. Tail > cons.head/ cons. Tail 或者 prod.head/prod. Tail < cons.head/ cons. Tail&& prod.head/prod. Tail +1 < cons.head/ cons. Tail:上面两种情况表示队列里面有数据
  • prod.head/prod. Tail < cons.head/ cons. Tail&& prod.head/prod. Tail +1 == cons.head/ cons. Tail:表示这个队列满了。

逻辑示意图

还是只研究多生产者入队,分为如下步骤:

  1. 在所有核上,将ring->prod_head和ring->cons_tail拷贝到本地变量中。prod_next本地变量指向prod_head的下一个元素,或者多个元素(bulk enqueue情况下);如果ring中空间不够,直接报错退出
  2. 在每个核上,修改ring->prod_head指向本地变量prod_next。这个动作的完成需要使用CAS指令,该指令自动完成下述动作:
    • 如果ring->prod_head和本地变量的prod_head不相等,CAS操作失败,重新执行
    • 如果ring->prod_head和本地变量的prod_head相等,CAS操作成功,继续             
    • 这样如果两个核同时更新该索引,一次就能保证只有一个能成功,另一个失败后会自动尝试继续比较,再第一次添加的基础上继续更新添加。当CAS都更新成功后,core 1添加obj4, core 2添加obj5
  3. 当CAS都更新成功后,core 1添加obj4, core 2添加obj5
  4. 每个核都尝试更新ring->prod_tail. 比较ring->prod_tail是否等于本地prod_head,只有true的core能够执行,第一次是core 1成功
  5. 当core 1更新成功后,core 2继续判断,此时应该也可以更新了,完成更新。
dpdk介绍系列之ring_第2张图片
dpdk介绍系列之ring_第3张图片
dpdk介绍系列之ring_第4张图片 dpdk介绍系列之ring_第5张图片 dpdk介绍系列之ring_第6张图片

ok,介绍完毕。

你可能感兴趣的:(dpdk研究)