4. DPDK:同步与互斥

68 阅读7分钟

1. 前言

这世界上有两种让人每天都感到压力的事: 一种是需求又改了,一种是 CPU 又加核了。

单核年代的程序就像一个人吃火锅,想怎么夹就怎么夹,没有人跟你抢。多核时代就不一样了,变成八个人围着一锅肥牛啃,每个人都伸筷子、都想先吃,稍不注意就会互相碰手、掉菜互殴,最终一锅火锅变成了抢食修罗场。

CPU 核心越多,性能收益越大吗? 理论上是的,但是现实中,大家都在抢同一份数据,当场变成“多核互殴”。 原本计划让程序飞起来,结果锁一上,所有核乖乖排队,性能瞬间回到了单线程,甚至更惨。

DPDK 的做法是,它告诉我们: 网络包处理这种活儿,别搞成大家一起等锁喝西北风,能不等待就不等待,能无锁就无锁,能减小争用就减小争用。

它的哲学很简单: 与其让多核排队,不如让数据分家与其让线程互相谦让,不如自己动动脑子实现无锁。

多核本该是快乐老家,只是我们还没学会如何安置这些热情过度的“室友们”。 接下来,我们一起看看 DPDK 在同步与互斥这件事上,用了哪些实战技巧,让 CPU 不再打架,让性能真正飞起来。

2. 原子操作

想象有个共享变量,它就像办公室冰箱里唯一那瓶可乐。每个人都想拿,谁都不想等。

传统做法是上锁:“大家排队,一个个来。” 但锁的代价有点像加班费,能省则省。

原子操作更像是: 抢可乐这个动作,只需一瞬,在物理层面不可分割 。别人还没反应过来,可乐已经被你拿到嘴边了。

没有抢占,没有插队,也不需要锁。这就是“原子”的含义:
整个操作要么全部完成,要么完全不发生,中间状态对外不可见。

2.1 CPU如何做到原子操作

在硬件层面,CPU 提供了一些特殊指令,比如:CAS(Compare-And-Swap),它们保证某个操作序列在总线上一次性完成,任何其他核心都插不了手。

CAS(addr, expect, new):
    if *addr == expect:
        *addr = new
        return SUCCESS
    else:
        return FAIL

CAS(Compare-And-Swap)原理可以简单理解成:如果变量当前值等于我预期的,就改成新值,否则啥也不做。 这个能力直接由 CPU 提供硬件原子指令,无论多少核心盯着这一个变量,抢到就算你牛。

更深一点点,从硬件层面来看需要至少这几个点来保证:

  1. 缓存一致性协议(MESI / MOESI)保证这个变量在某个时刻只能被一个核心改
  2. CPU 内部以总线锁/缓存行锁方式保障原子性

2.2 DPDK 中的原子操作

DPDK 给不同宽度的整数都定义了类型与操作,比如:

类型描述
rte_atomic16_t16bit 原子变量
rte_atomic32_t32bit 原子变量
rte_atomic64_t64bit 原子变量

基本用法:

#include <rte_atomic.h>

static rte_atomic32_t pkt_count;

void init_counter() {
    rte_atomic32_init(&pkt_count);
}

void process_packet() {
    // 原子自增
    rte_atomic32_inc(&pkt_count);
}

uint32_t get_packet_count() {
    return rte_atomic32_read(&pkt_count);
}

// CAS
if (rte_atomic32_cmpset(&flag, 0, 1)) {
    // 我抢到了,其他核心都失败
    do_something();
}

2.3 原子操作适用场景

原子操作最适合这些:

  1. 简单共享变量的并发更新 :例如统计收到了多少包、丢了多少包 (非常经典,高速路径中大量使用)
  2. 状态抢占:线程 A 和 B 谁先设置成功就执行,另一个不做事(用 CAS)
  3. 少量、轻微的共享数据修改

不太适合这些情况:

  1. 复杂数据结构共享
  2. 高频大规模累加(如百万 FPS 环境)
  3. 跨 NUMA 节点访问

3. 读写锁

在多核处理场景里,读写冲突是常态。数据通常是读多写少,例如配置更新、统计值查看、路由规则查询等。如果依旧使用互斥锁,所有读线程必须排队等写线程释放锁,性能迅速崩塌。读写锁专门为这种情况设计:读者与读者之间可以共享锁,多个 CPU 并行读取;写者需要独占锁,写期间会阻塞所有读者。 当写线程极少时,这种模式能显著提升并发吞吐。DPDK 提供 rte_rwlock 读写锁,接口保持精简实用,不做复杂的优先级调度。典型用法如下

#include <rte_rwlock.h>

static rte_rwlock_t rwlock = RTE_RWLOCK_INITIALIZER;
static int shared_data = 0;

void reader(void)
{
    rte_rwlock_read_lock(&rwlock);
    int tmp = shared_data;   // 并发读取
    rte_rwlock_read_unlock(&rwlock);
}

void writer(void)
{
    rte_rwlock_write_lock(&rwlock);
    shared_data++;  // 独占写操作
    rte_rwlock_write_unlock(&rwlock);
}

其核心原理是:锁内部存在一个状态计数,读锁通过原子加减计数器来表示当前有多少读者持锁,如果计数为正值表示正在读,此时写者无法获取锁;写锁通过 CAS(Compare-And-Swap)尝试把计数设置为一个特殊值或等待计数清零,成功后独占访问。由于锁本身是依靠原子指令实现,所有读写的同步序列都依赖 CPU 的内存屏障指令,保证数据一致性和可见性。

读写锁虽然看似完美,却需要注意几个现实问题:

  1. 如果读线程持续涌入,写者可能长时间等不到机会,甚至出现写者饥饿。
  2. 在网络数据平面中,这种场景经常触发,尤其是在统计数据读取频繁而规则更新稀少时,会拖延配置生效。DPDK 的 RW 锁实现并不会主动打破这种饥饿,所以对实时性要求高的写操作依旧建议避免使用 RW 锁。
  3. 在性能极限场景下,更推荐采用 RCU(Read-Copy-Update)、无锁 ring、每核数据分片等方式减少共享。

DPDK 提供的 rte_rwlock 简洁、易用,让我们在读多写少场景下能轻松提升并发效率,不过仍需结合业务特点谨慎使用。如果正在为共享统计、配置查询、控制平面数据同步设计方案,那么读写锁可以作为出发点,但绝非终点。下一步可以探索 RCU 或 per-core 数据等更激进的无锁并发策略。

4. 自旋锁

多线程访问共享资源时,如果让线程因为等待锁而睡眠与唤醒,成本太高,尤其在 DPDK 这类低延迟环境里更是不可接受。

自旋锁的策略就是:抢不到锁不睡觉,原地空转等待,直到锁被释放再第一时间抢到。 它依靠 CPU 的原子交换指令去独占某个标志位,从而保证只有一个线程能进入临界区。由于线程一直在忙等待,自旋锁只有在临界区非常短 的情况下才能展现优势,否则会浪费大量 CPU 周期。

DPDK 中使用自旋锁非常简单:通过 rte_spinlock_t 定义锁,用 rte_spinlock_lockrte_spinlock_unlock 完成加解锁。如果担心阻塞过久,也可以用 rte_spinlock_trylock 尝试获取锁,失败立刻返回。例如多个核向同一队列写入数据时,可以这样写:

#include <rte_spinlock.h>

static rte_spinlock_t q_lock = RTE_SPINLOCK_INITIALIZER;
static struct rte_mbuf *queue[MAX_Q];
static int q_head;

void enqueue(struct rte_mbuf *m) {
    rte_spinlock_lock(&q_lock);

    queue[q_head++] = m;
    if (q_head >= MAX_Q)
        q_head = 0;

    rte_spinlock_unlock(&q_lock);
}

硬件层面,自旋锁背后依靠原子操作实现互斥。x86 架构使用 LOCK XCHG 指令,ARM 使用 LDXR/STXR 指令对共享变量完成原子的“检查并设定”操作,同时缓存一致性协议(如 MESI/MOESI)保证只有一个核心能修改对应的缓存行。这也意味着,高争用场景下同一个锁所在的缓存行会在各核之间频繁来回转移,开销不小。因此自旋锁虽轻量,却更需要合理使用。

它与其它同步机制之间的对比其实很明确:原子操作更轻,不进入临界区更不阻塞,适合简单计数和状态更新;读写锁允许多个读者并发,比自旋锁在读多写少场景效果更好;自旋锁则适合临界区非常短、并发条件紧张且必须互斥的共享逻辑。换句话说,能用原子就不用锁,能用读写锁就不要让所有读线程排队等一个自旋锁。