__rte_ring_mp_do_enqueue 详细实现分析(dpdk-16.04 )

__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 入队分三步:

  1. 抢 head:用 CAS 把 prod.head 从 prod_head 推进到 prod_next,成功即预约了 [prod_head, prod_next) 这段区间的独占写入权;
  2. 写数据:各生产者互不干扰地并行写各自区间;
  3. 提交 tail:必须按抢占顺序推进 prod.tail,否则会出现"后来者先提交、tail 越过一段未写完的数据"的破窗。

SP 版没有第 1、3 步的并发问题,直接单向推进即可;MP 版的复杂度基本都花在这两步上。

三、实现步骤逐段解析

  1. 快速返回 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 时几乎所有分支都被静态优化掉。

  1. 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 依赖"绑核 + 短临界区"来让这种理论最坏情况几乎不会发生。

  1. 写入对象数据

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。
  1. 水位线检查与返回值组装

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。
  1. 自旋等待前序生产者提交,然后提交自己的 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 |

五、正确性论证要点

  1. 区间独占:CAS 保证 prod.head 严格单调、多生产者预约的区间互不重叠,数据写入零竞争。
  2. 消费者看到的数据 ≤ tail 已覆盖的完整数据:
    • 生产者:数据先写,rte_smp_wmb() 后才推 prod.tail;
    • 消费者:先读 prod.tail,rte_smp_rmb() 后才读数据(见 __rte_ring_mc_do_dequeue); 一对 release-acquire 型屏障保证 happens-before。
  1. tail 顺序提交:自旋等待条件 prod.tail == prod_head 相当于把"逻辑上先抢的 head 必须先提交 tail"这条偏序固化在实现里,消费者永远看不到跳过未写完区间的 tail。
  2. 32-bit 环绕安全:所有 head/tail 都是逻辑序号,减法在无符号模 下计算,只要 ring 容量远小于 (且限制在 RTE_RING_SZ_MASK = 0x0fffffff 之下)就不会产生歧义。
  3. 保守估计: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 版本的性能,同时正确处理任意多生产者的并发。

相关推荐
Mr.HeBoYan2 个月前
一次持续三天才出现的丢包故障——深入解析 DPDK Memory Ordering、rte_ring 与 CPU Memory Barrier (下)
linux·网络·算法·架构·dpdk
Mr.HeBoYan2 个月前
DPDK为什么越来越少使用rte_ring?——从Lock-Free到Run-to-Completion,重新理解现代DPDK架构演进(上)
linux·网络·算法·性能优化·架构·dpdk
故事还在继续吗4 个月前
DPDK 教程(一):Hugepage、绑核、dpdk-devbind 与跑通 testpmd
dpdk
故事还在继续吗4 个月前
DPDK 内存与子系统
dpdk
故事还在继续吗4 个月前
DPDK 教程(二):mbuf、mempool、ethdev 的数据路径
dpdk
故事还在继续吗4 个月前
DPDK 教程(三):多队列 + RSS + 多 worker 的最小转发 / Echo
算法·哈希算法·dpdk
故事还在继续吗4 个月前
DPDK免锁队列
开发语言·dpdk
优秀是一种习惯啊5 个月前
DPDK 学习第一天
网络·dpdk
Qinti_mm6 个月前
DPDK:解锁CDN推流与日志发送的极致性能
dpdk