3. 无锁单链表(Lock-Free Singly Linked List):插入、删除、查找的ABA问题与解决方案

无锁单链表是并发编程中基础且关键的数据结构。在DPDK等高性能网络场景中,多个核(lcore)同时操作同一个链表是常态。本章深入剖析无锁单链表的插入、删除、查找操作,重点攻克并发环境下最棘手的ABA问题,并给出工程级的解决方案。

3.1 无锁单链表的核心操作与CAS原语

无锁编程的核心依赖是比较并交换(Compare-And-Swap, CAS)原子操作。在x86架构上对应 CMPXCHG 指令,在DPDK中通常通过 rte_atomic32_cmpset() 或C11的 __atomic_compare_exchange_n 实现。

关键概念: CAS(&p, old, new) 原子地执行:如果 *p == old,则将 *p 赋值为 new 并返回真;否则返回假。

无锁单链表的基本节点结构如下:

struct lf_node {
    void *data;               // 用户数据指针
    struct lf_node *next;     // 下一个节点指针(原子操作)
};

3.1.1 插入操作(Insert)

在无锁链表中插入节点,通常采用头插法或指定位置插入。以头插法为例,其核心逻辑是:

  1. 新节点 new_node 的 next 指向当前头节点 head。
  2. 使用CAS将 head 原子地更新为 new_node。
  3. 如果CAS失败(说明其他线程修改了 head),则重试步骤1。
void lf_list_push(struct lf_node **head, struct lf_node *new_node) {
    struct lf_node *old_head;
    do {
        old_head = *head;
        new_node->next = old_head;  // 步骤1
    } while (!rte_atomic32_cmpset((uint32_t *)head,
                                   (uint32_t)old_head,
                                   (uint32_t)new_node)); // 步骤2
}
工程提示: 在DPDK中,节点内存通常从mempool中分配,避免动态内存分配带来的性能抖动和内存碎片。

3.1.2 删除操作(Delete)

删除操作更为复杂。假设我们要删除节点 A,其前驱节点为 prev。直观的做法是:

  1. 读取 prev->next,确认其指向 A。
  2. 读取 A->next,得到 B。
  3. 使用CAS将 prev->next 从 A 更新为 B。

然而,这个看似简单的流程隐藏着巨大的风险——ABA问题。

3.2 ABA问题的本质与危害

ABA问题是无锁数据结构中最经典、最隐蔽的并发陷阱。

ABA问题定义: 线程T1读取共享变量 p 的值为 A;在T1执行CAS之前,其他线程将 p 从 A 改为 B,再改回 A;T1的CAS检查 p 的值仍然是 A,于是CAS成功,但此时 p 指向的内存状态已经发生了根本变化,导致数据结构损坏。

在单链表的删除场景中,ABA问题的具体表现如下:

时间 线程T1(删除节点A) 线程T2(并发操作) 链表状态
t1 读取 prev->next = A — ... → prev → A → B → ...
t2 读取 A->next = B — ... → prev → A → B → ...
t3 — 删除A,释放A的内存 ... → prev → B → ...
t4 — 插入新节点C,恰好分配到A的地址 ... → prev → C → D → ...
t5 CAS(prev->next, A, B) 成功! — ... → prev → B → ... (C和D丢失!)

在t5时刻,T1的CAS认为 prev->next 仍然是 A(实际上地址相同,但内容已变),于是将其更新为 B,导致节点C和D从链表中丢失,造成内存泄漏或野指针。

ABA问题在无锁单链表中的发生过程 初始: prev A B t3: prev B A被删除,内存释放 t4: prev C D 新节点C恰好分配到A的地址 t5: prev B CAS成功!但C和D丢失!

3.3 ABA问题的解决方案

解决ABA问题的主流方案有三种:标记指针(Tagged Pointer)、双宽CAS(DCAS)和危险指针(Hazard Pointer)。在DPDK环境中,最常用的是标记指针方案。

3.3.1 方案一:标记指针(Tagged Pointer)

核心思想:在指针的高位或低位附加一个版本号/标记,每次修改指针时同时递增标记。即使地址被复用,标记不同也能被CAS检测到。

// 将指针和标记打包在一个uint64_t中(x86-64用户空间地址高16位空闲)
union tagged_ptr {
    struct {
        uint64_t ptr  : 48;  // 指针(48位虚拟地址)
        uint64_t tag  : 16;  // 标记(16位版本号)
    } fields;
    uint64_t raw;            // 用于CAS操作的完整64位值
};

struct lf_node_tagged {
    void *data;
    union tagged_ptr next;   // 原子操作next.raw
};

插入操作使用标记指针:

void lf_list_push_tagged(union tagged_ptr *head, struct lf_node_tagged *new_node) {
    union tagged_ptr old_head;
    do {
        old_head.raw = head->raw;  // 原子读取
        new_node->next.raw = old_head.raw;
        // 新节点的next标记继承旧头节点的标记(或置0)
    } while (!rte_atomic64_cmpset(&head->raw,
                                   old_head.raw,
                                   (uint64_t)(uintptr_t)new_node | (old_head.fields.tag + 1)));
    // 头节点标记+1,即使地址相同,标记不同也会导致CAS失败
}
优势: 实现简单,仅需64位CAS,无额外内存开销。
局限: 标记位有限(16位),极端高并发下可能溢出;需要确保指针和标记的位域划分与硬件架构匹配。

3.3.2 方案二:双宽CAS(Double-Word CAS, DCAS)

某些架构(如x86的 CMPXCHG16B)支持对16字节(128位)进行原子CAS操作。可以将指针和标记作为两个独立的64位字段,一起进行CAS。

struct lf_node_dcas {
    void *data;
    struct lf_node_dcas *next;  // 8字节指针
    uint64_t tag;               // 8字节标记
} __attribute__((aligned(16))); // 16字节对齐

// 使用CMPXCHG16B原子更新 (next, tag) 对
// 伪代码:DCAS(&node->next, &node->tag, old_next, old_tag, new_next, new_tag)
工程提示: DCAS在x86-64上需要 CMPXCHG16B 指令支持(所有主流Intel/AMD处理器均支持)。但DCAS的ABA防护能力与标记指针等价,实现复杂度更高,因此在DPDK中不如标记指针常用。

3.3.3 方案三:危险指针(Hazard Pointer, HP)

危险指针是一种延迟回收机制,确保一个节点在被任何线程引用时不会被释放。每个线程维护一个危险指针列表,指向其当前正在访问的节点。在释放节点前,检查所有线程的危险指针列表。

步骤 操作 说明
1 线程T1读取节点A的地址 将A的地址写入T1的危险指针槽
2 线程T2尝试删除节点A 检查所有危险指针,发现T1正在引用A
3 T2将A从链表中逻辑删除 将A放入待释放列表,但不立即释放
4 T1完成操作,清除危险指针 通知T2可以安全释放A
5 T2回收A的内存 确认无任何线程引用A
注意: 危险指针方案虽然能彻底解决ABA问题,但实现复杂,需要线程间协调和内存回收的全局视图。在DPDK的无等待(Wait-Free)场景中,标记指针通常是更轻量的选择。

3.4 查找操作与无锁遍历

无锁单链表的查找(Search)相对简单,因为查找是只读操作,不涉及CAS。但需要注意内存一致性问题:

struct lf_node *lf_list_search(struct lf_node *head, void *key,
                               int (*cmp)(void *, void *)) {
    struct lf_node *curr = head;
    while (curr != NULL) {
        // 使用原子加载确保读取到最新的next指针
        if (cmp(curr->data, key) == 0) {
            return curr;
        }
        // 使用rte_smp_rmb()或atomic_load确保可见性
        curr = (struct lf_node *)rte_atomic32_read((uint32_t *)&curr->next);
    }
    return NULL;
}
关键点: 在DPDK多核环境中,即使查找是只读的,也必须使用原子加载(如 rte_atomic32_read 或C11的 atomic_load_explicit)来保证跨核的可见性。否则,一个核上的插入操作可能对另一个核的查找不可见。

3.5 工程选型对比

在DPDK项目中,选择哪种ABA解决方案取决于具体场景:

方案 实现复杂度 性能开销 ABA防护能力 DPDK适用场景
标记指针 低 极低(仅一次64位CAS) 强(标记溢出前) 通用场景,DPDK推荐
双宽CAS 中 低(一次128位CAS) 强(64位标记) 需要超大标记空间的场景
危险指针 高 中(需扫描全局列表) 完全防护 内存回收严格控制的场景
ABA问题解决方案架构对比 标记指针方案 64位:指针(48位) + 标记(16位) 每次CAS操作同时递增标记 地址复用时标记不同→CAS失败 ★ DPDK首选方案 双宽CAS方案 128位:指针(64位) + 标记(64位) 使用CMPXCHG16B指令 指针和标记作为原子对更新 需要16字节对齐 危险指针方案 延迟内存回收机制 每个线程维护危险指针列表 引用计数确保安全释放 完全防护ABA

3.6 实践建议与避坑指南

  • 优先使用标记指针: 在DPDK中,标记指针方案在性能和实现复杂度之间取得了最佳平衡。DPDK的 rte_ring 和 rte_hash 内部也采用了类似思想。
  • 注意内存序(Memory Order): 无锁操作必须配合适当的内存屏障。在DPDK中,rte_atomic32_cmpset 默认提供完整的内存屏障(相当于 memory_order_seq_cst),在性能敏感路径上可考虑使用 rte_atomic32_cmpset_relaxed 配合显式屏障。
  • 避免在删除后立即释放内存: 即使使用标记指针,也建议将删除的节点放入一个待回收列表,延迟一个宽限期(Grace Period)后再释放。DPDK的 rte_rcu_qsbr 库提供了现成的RCU机制。
  • 测试覆盖ABA场景: 编写单元测试时,构造多线程交替插入/删除同一地址的场景,使用压力测试工具(如 rte_test)验证ABA防护的有效性。
DPDK实战提示: 在DPDK 21.11+版本中,rte_atomic.h 已标记为遗留接口,推荐使用C11原子操作(__atomic_compare_exchange_n)。但底层原理完全一致,标记指针方案依然适用。