__rte_ring_mp_do_enqueue 详细实现分析
该函数位于 lib/librte_ring/rte_ring.h 第423-508 行,是 DPDK ring 库中多生产者(Multi-Producer)入队的核心内部实现。相比 SP 版本,它必须在无锁的前提下处理多个lcore/线程同时入队的并发。它被 rte_ring_mp_enqueue_bulk(FIXED 语义)和 rte_ring_mp_enqueue_burst(VARIABLE 语义)复用。
一、函数签名与参数
rte_ring.h
static inlineint attribute((always_inline))
__rte_ring_mp_do_enqueue(structrte_ring *r, void * const *obj_table,
unsigned n, enum rte_ring_queue_behaviorbehavior)
{
uint32_t prod_head, prod_next;
uint32_t cons_tail, free_entries;
const unsigned max = n;
int success;
unsigned i, rep = 0;
uint32_t mask = r->prod.mask;
int ret;
- 参数与 SP 版本完全一致。
- 关键局部变量:
- prod_head / prod_next:本线程"预约"的入队区间起止(逻辑序号)
- cons_tail:入队时读到的消费者尾指针,用来计算剩余空间
- max:保存最初请求的 n,因为 CAS 循环里如果失败要重试,需要把 n 恢复成初始值
- success:rte_atomic32_cmpset 的返回值
- rep:自旋等待时的重复计数,用于触发 sched_yield()
- mask = r->prod.mask,即 size - 1
二、Ring 的并发模型(前置知识)
在 MP 场景下,每个生产者要走一个"两阶段提交"的过程,总共有 4 个索引参与:
|-----------|----------------------|
| 索引 | 语义 |
| prod.head | 预约指针:下一个可被生产者"抢占"的槽位 |
| prod.tail | 提交指针:数据已经写好、消费者可见的边界 |
| cons.head | 消费者预约指针 |
| cons.tail | 消费者提交指针(生产者用它计算空闲空间) |
MP 入队分三步:
- 抢 head:用 CAS 把 prod.head 从 prod_head 推进到 prod_next,成功即预约了 [prod_head, prod_next) 这段区间的独占写入权;
- 写数据:各生产者互不干扰地并行写各自区间;
- 提交 tail:必须按抢占顺序推进 prod.tail,否则会出现"后来者先提交、tail 越过一段未写完的数据"的破窗。
SP 版没有第 1、3 步的并发问题,直接单向推进即可;MP 版的复杂度基本都花在这两步上。
三、实现步骤逐段解析
- 快速返回 n == 0
rte_ring.h
/* Avoid theunnecessary cmpset operation below, which is also
* potentially harmful when n equals 0. */
if (n == 0)
return 0;
- 如果调用方请求 0 个,直接返回。
- 为什么专门加这个判断? 后面 prod_next = prod_head + n 在 n=0 时等于 prod_head,cmpset(&prod.head, prod_head, prod_head) 虽然逻辑上"成功",但仍是一次带锁的原子操作(x86 上是 lock cmpxchg),白白付出内存屏障和总线锁代价;而且成功后的自旋等待 while (prod.tail != prod_head) 语义也变得可疑。所以在入口先剪枝。
SP 版本没有这个特判,因为 SP 里根本不用 CAS,n=0 时几乎所有分支都被静态优化掉。
- CAS循环:抢占 head 指针
rte_ring.hLines 439-472
/* moveprod.head atomically */
do {
/* Reset n to the initial burst count */
n = max;
prod_head = r->prod.head;
cons_tail = r->cons.tail;
/* The subtraction is done between twounsigned 32bits value
*(the result is always modulo 32 bits even if we have
*prod_head > cons_tail). So 'free_entries' is always between 0
*and 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;
success =rte_atomic32_cmpset(&r->prod.head, prod_head,
prod_next);
} while(unlikely(success == 0));
这是整段代码最关键的部分,逐步拆解:
a. n = max;(循环内重置)每次 CAS 失败后要恢复原始请求数。因为在VARIABLE 分支下,n 可能已经被削减为 free_entries;而下一轮重试时空闲空间可能又变大了(其他消费者出队了),必须从最初值重新计算,才能"能装多少装多少"。
b. 快照 prod.head 和 cons.tail注意读的是 cons.tail(消费者已经真正腾出来的空间),而不是 cons.head(消费者只是预约但可能还没拷完)。用 cons.tail 才能保证生产者写入的区间和消费者读取的区间完全不重叠。
c. 计算 free_entries = mask + cons_tail - prod_head和 SP 版原理相同,靠无符号32-bit 环绕运算,结果落在 0, size-1。这里读到的两个值可能来自不同瞬间(其他 CPU 在改),但没关系------只要最终的 CAS 能通过,就意味着 prod_head 期间没变;而 cons_tail 单调递增,读到旧值只会低估可用空间,把入队量算少一点,不会导致覆盖未消费的数据(保守估计是安全的)。
d. 空间检查(FIXEDvs VARIABLE)与 SP 版逻辑相同:FIXED 不够就返回 -ENOBUFS;VARIABLE 空闲为 0 时返回 0,否则把 n 砍到 free_entries。注意这里的 return 直接从 do-while 中退出,是允许的,因为这时候还没有对任何共享状态做修改。
e. rte_atomic32_cmpset(&r->prod.head, prod_head,prod_next)核心的 CAS 语义是:
"如果 prod.head 当前仍等于我刚才读到的 prod_head,就把它原子地写成 prod_next,返回 1;否则不写,返回 0。"
- 成功:本线程独占预约了 [prod_head, prod_next),退出循环;
- 失败:说明有别的生产者也在抢,先它一步把 prod.head 推进了。此时重新读一遍 prod.head/cons.tail,重新计算空间,重试。
while(unlikely(success == 0)):通常竞争不激烈,一次 CAS 就成功,所以标为 unlikely。
为什么这个模式是无锁(lock-free) 而非无等待 (wait-free)?如果其他生产者不断插入,理论上本生产者可能被无限次抢占,但整个系统整体在进展------这是lock-free 的定义。DPDK 依赖"绑核 + 短临界区"来让这种理论最坏情况几乎不会发生。
- 写入对象数据
rte_ring.h
/* writeentries in ring */
ENQUEUE_PTRS();
rte_smp_wmb();
- ENQUEUE_PTRS() 宏内容与 SP 版完全一致(参考 348-369 行),用 prod_head 作为写入起点,4 路展开 + Duff 风格残余处理拷贝 n 个指针,必要时环绕。
- 为什么这一步可以无锁地并行? 因为每个生产者在第 2 步 CAS 成功后,[prod_head, prod_next) 是它独占的,不同生产者预约的区间彼此不重叠(CAS 保证 head 严格单调),所以数据写入不需要任何同步。
- rte_smp_wmb():写内存屏障。保证对象数据的 store 全部完成后,才对 prod.tail 的写入可见。否则消费者可能观察到 tail 已经前进,但读到的对象指针还是旧值(store-store 乱序)。x86 上 store-store 本身有序,rte_smp_wmb() 只是一个编译器屏障(asm volatile("" ::: "memory")),几乎零成本;在弱内存序架构如 ARM 上,则会展开成真正的 dmb ishst。
- 水位线检查与返回值组装
rte_ring.hLines 478-487
/* if weexceed 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);
}
与 SP 版语义完全一致:
- (mask + 1) - free_entries 是入队前 ring 内元素数,加上 n 是入队后总数;
- 超过用户设定的高水位 watermark:FIXED 返回 -EDQUOT(数据已入,提示预警),VARIABLE 返回 n | RTE_RING_QUOT_EXCEED,用最高位捎带越界标志;
- 未超:FIXED 返回 0,VARIABLE 返回 n。
- 注意 ret 先算好但先不返回,因为后面还需要提交 tail。
- 自旋等待前序生产者提交,然后提交自己的 tail
rte_ring.hLines 489-506
/*
*If there are other enqueues in progress that preceded us,
*we need to wait for them to complete
*/
while (unlikely(r->prod.tail !=prod_head)) {
rte_pause();
/* Set RTE_RING_PAUSE_REP_COUNT toavoid spin too long waiting
* for other thread finish. It gives pre-emptedthread a chance
* to proceed and finish with ring dequeueoperation. */
if (RTE_RING_PAUSE_REP_COUNT&&
++rep == RTE_RING_PAUSE_REP_COUNT) {
rep = 0;
sched_yield();
}
}
r->prod.tail = prod_next;
return ret;
}
这是 MP 相对 SP 最"多出来"的一段,也是理解 MPring 正确性的关键。
问题:为什么需要等待?CAS 步骤只保证多个生产者能依次抢到互不重叠的区间(即 prod.head 单调前进),但不同生产者写数据的完成时刻是不确定的:后面抢到区间的生产者可能拷贝得更快、先写完。如果它直接推进 prod.tail = prod_next,tail 就会跳过前面还没写完的那一段------消费者看到 tail 前进,读到的却是"还没写"的槽位,数据错乱。
解法:按 head 序列化 tail 提交每个生产者的 prod_head 恰好等于前一个生产者的prod_next(因为 CAS 保证 head 单调递增且无缝对接)。所以当且仅当 r->prod.tail == prod_head 时:
- 意味着所有比我先抢占 head 的生产者都已经把 tail 推到了我的起点;
- 现在轮到我把 tail 推到 prod_next,让消费者看到我这段数据。
于是循环 while (r->prod.tail != prod_head) 就是在等前面所有"排队者"完成。等到后,一句 r->prod.tail = prod_next; 就把接力棒交给下一个人。
rte_pause()在 x86 上编译成 PAUSE 指令。作用:
- 提示 CPU 当前处于 spin-wait 循环,降低推测执行的激进程度,避免流水线上堆积一堆错误的 load;
- 减少总线上的读带宽争用,让其他核心更快地把 tail 更新的 cache line 写入;
- 降低 SMT/HT 兄弟核的资源占用;
- 显著降低 spin 阶段的功耗。
sched_yield() 逃生阀由 RTE_RING_PAUSE_REP_COUNT(第132-135 行定义,默认为 0 即禁用)控制。如果启用,每自旋 REP_COUNT 次就主动让出 CPU 一次。设计目的注释里说得很清楚:
rte_ring.h
/* SetRTE_RING_PAUSE_REP_COUNT to avoid spin too long waiting
* for other thread finish. It gives pre-emptedthread a chance
* to proceed and finish with ring dequeueoperation. */
场景是:如果前面某个生产者恰好在写数据/推 tail 的中途被内核抢占(比如在非独占 lcore、或者启用了抢占内核),后面所有等待者都会跟着卡死自旋。让出 CPU 可以让被抢占者尽快被调度回来完成工作。DPDK 默认关闭这个机制,是因为典型部署里生产者都绑核且忙轮询,不会被抢占,sched_yield 反而是净负面。
四、与 SP 版的差异汇总
|-----------------|----------|----------------------------------------|
| 环节 | SP | MP |
| n == 0 特判 | 无 | 有(避免无谓的 lock cmpxchg) |
| head 计算与写入 | 一次读、直接赋值 | do { 读快照 → 算空间 → 校验 → CAS } while (失败) |
| VARIABLE 下 n 削减 | 一次 | 每轮 CAS 失败后要 n = max 重置再削减 |
| 数据写入 | 独占 | 各生产者在各自独占区间并行写 |
| 写屏障 | 需要 | 需要 |
| tail 提交 | 直接赋值 | 先自旋 while (tail != prod_head),再赋值 |
| 逃生策略 | 无 | rte_pause + 可选 sched_yield |
| 关键原子原语 | 无 | rte_atomic32_cmpset |
五、正确性论证要点
- 区间独占:CAS 保证 prod.head 严格单调、多生产者预约的区间互不重叠,数据写入零竞争。
- 消费者看到的数据 ≤ tail 已覆盖的完整数据:
-
- 生产者:数据先写,rte_smp_wmb() 后才推 prod.tail;
- 消费者:先读 prod.tail,rte_smp_rmb() 后才读数据(见 __rte_ring_mc_do_dequeue); 一对 release-acquire 型屏障保证 happens-before。
- tail 顺序提交:自旋等待条件 prod.tail == prod_head 相当于把"逻辑上先抢的 head 必须先提交 tail"这条偏序固化在实现里,消费者永远看不到跳过未写完区间的 tail。
- 32-bit 环绕安全:所有 head/tail 都是逻辑序号,减法在无符号模 下计算,只要 ring 容量远小于 (且限制在 RTE_RING_SZ_MASK = 0x0fffffff 之下)就不会产生歧义。
- 保守估计:free_entries 用 cons.tail(而非 cons.head)保证不会因消费者尚未拷完就误以为可写。
六、性能特征与调用链
- 无锁:所有同步靠 lock cmpxchg 和内存屏障,不使用互斥量。
- 无竞争路径极快:单个生产者且 n=32 这种最常见情形下,CAS 一次成功、自旋 while 一次不进入,整个函数只有一次原子操作 + 若干次 store,和 SP 版差距很小。
- 有竞争路径可扩展:线性数量的生产者只会带来线性数量的 CAS 重试,不会退化到指数级抢占。
调用链:
- rte_ring_mp_enqueue_bulk → __rte_ring_mp_do_enqueue(..., RTE_RING_QUEUE_FIXED)(第 780 行)
- rte_ring_mp_enqueue_burst → __rte_ring_mp_do_enqueue(..., RTE_RING_QUEUE_VARIABLE)(第 1141 行)
- rte_ring_mp_enqueue(单对象) → rte_ring_mp_enqueue_bulk(r, &obj, 1)(第 853 行)
- rte_ring_enqueue_bulk / rte_ring_enqueue_burst 根据 r->prod.sp_enqueue 分派到 SP 或 MP 版本
七、总结
__rte_ring_mp_do_enqueue 是一段典型的两阶段无锁并发算法实现:
- 阶段一(抢占):用 CAS 循环让多个生产者串行化地"分片",各自拿到 ring 的一段独占写入权。CAS 是唯一的原子操作,重试仅在真实竞争时发生。
- 阶段二(提交):用一个 spin-wait 把 tail 的推进顺序强制与 head 的抢占顺序一致,配合写屏障,让消费者看到的永远是完整、有序、连续的数据流。
它的巧妙之处在于:临界区极短(几乎只有 CAS 那一条指令),数据拷贝完全并行,提交阶段的等待时间正比于前序未完成生产者的数据写入时间而非"锁持有时间",因此在 DPDK 强调的高吞吐、多核lockless 场景下,能保持接近 SP 版本的性能,同时正确处理任意多生产者的并发。