链表的进阶:无锁链表(Lock-Free Linked List)简介

多线程编程里,链表是个让人又爱又恨的数据结构。爱它,是因为插入删除灵活;恨它,是因为一旦多个线程同时操作,各种诡异问题就冒出来了。传统的做法是加锁——mutex、spinlock 轮着上。但锁这东西,用多了性能下降,用少了数据错乱。

那有没有不用锁也能保证线程安全的办法?有,这就是无锁链表。说白了,就是利用 CPU 提供的原子操作,让多个线程同时读写链表时,不会出现数据竞争。

为什么需要无锁链表?

我在一个高并发消息队列项目里遇到过这个问题。当时用互斥锁保护链表,压测到 8 个线程时,性能曲线就开始往下掉。锁竞争太激烈了,大部分时间线程都在等锁,而不是真正干活。

无锁方案的好处很明显:

  • 没有锁的开销——不需要获取/释放锁,省掉了系统调用和上下文切换
  • 天然避免死锁——没有锁,自然不会有死锁问题
  • 更好的扩展性——线程数增加时,性能不会因为锁竞争而急剧下降

当然,代价是编程复杂度上升。你想想看,要保证多个线程同时读写一个链表还不乱,这逻辑得有多绕。

无锁编程的核心:CAS 操作

无锁编程的基石是 CAS(Compare-And-Swap)。这是一个 CPU 级别的原子指令,它的语义很简单:

// 伪代码:如果 *ptr == old_val,就把 *ptr 改成 new_val,返回 true
bool CAS(void** ptr, void* old_val, void* new_val) {
    if (*ptr == old_val) {
        *ptr = new_val;
        return true;
    }
    return false;
}

在 C11 标准里,我们可以用 stdatomic.h 里的 atomic_compare_exchange_strong。我个人习惯用这个,因为它语义清晰,不容易踩坑。

核心思想:无锁链表的所有操作,最终都归结为对指针的 CAS 操作。你修改指针时,先检查它是不是还是你之前看到的值,如果是,就更新;如果不是,说明别的线程已经改了,你得重试。

无锁链表的插入操作

先看最简单的场景:在链表头部插入一个节点。传统做法是:

new_node->next = head;
head = new_node;

但在多线程下,这两行代码之间可能被其他线程打断。无锁版本是这样写的:

void lock_free_push(Node* new_node, Node** head) {
    do {
        new_node->next = *head;  // 1. 先让新节点指向当前头
    } while (!CAS(head, new_node->next, new_node));  // 2. 原子地更新头指针
}

这里有个细节:如果 CAS 失败,说明 *head 已经被别的线程改了。这时候 new_node->next 还是指向旧值,但 *head 已经变了。所以循环里重新读取 *head 赋值给 new_node->next,再试一次。

小技巧:我写无锁代码时,习惯把 CAS 操作放在一个 do-while 循环里。这样逻辑清晰,而且编译器优化起来也方便。

无锁链表的删除操作

删除比插入麻烦得多。为什么?因为当你删除一个节点时,别的线程可能正在访问它。这就是经典的 ABA 问题。

举个例子:线程 A 要删除节点 X,它先读到了 X 的 next 指针。但还没来得及操作,线程 B 把 X 删了,又新建了一个节点 Y,恰好 Y 的地址和 X 一样。线程 A 继续操作时,以为 X 还在,实际上它操作的是 Y——数据就乱了。

解决 ABA 问题的常用方法是使用带标记的指针(Tagged Pointer):

// 用 64 位整数的低 48 位存地址,高 16 位存版本号
typedef struct {
    uintptr_t ptr : 48;
    uint16_t tag : 16;
} tagged_ptr_t;

每次修改指针时,版本号加 1。这样即使地址复用,版本号对不上,CAS 也会失败。

注意:带标记指针的实现依赖平台。x86-64 架构下,用户空间地址只用到了低 48 位,高 16 位可以自由使用。但 ARM 架构下情况不同,需要查手册确认。

无锁链表的遍历

遍历相对简单,因为只读不写。但有个坑:你读到某个节点的 next 指针时,这个节点可能已经被别的线程删了。

解决办法是使用危险指针(Hazard Pointer)机制。每个线程维护一个列表,记录它当前正在访问的节点。删除节点前,先检查有没有线程正在访问它,如果有,就延迟删除。

// 伪代码:遍历时标记当前节点
hazard_ptr_set(current);  // 告诉其他线程:我在用这个节点
if (current->value == target) {
    // 找到了,处理逻辑
}
hazard_ptr_clear();  // 用完了,释放标记

我曾经在一个项目中因为没处理好危险指针,导致内存泄漏。排查了两天才发现,是删除线程没等所有读者线程释放引用就回收了节点。嗯,这里要注意:无锁编程里,内存回收比数据同步更难。

无锁链表的性能特征

操作 加锁版本 无锁版本 说明
插入(头部) O(1),但锁竞争时退化 O(1),CAS 重试次数少 无锁版本在低竞争下更快
删除(已知节点) O(1),需持有锁 O(1),需处理 ABA 无锁版本实现更复杂
遍历 O(n),读锁可共享 O(n),需危险指针保护 读多写少场景下无锁优势明显
内存开销 低,只需锁变量 较高,需要标记位/危险指针 无锁版本空间换时间

什么时候该用无锁链表?

说实话,不是所有场景都适合无锁。我个人的经验是:

  • 读多写少——比如配置表、路由表,大部分时间在查,偶尔更新
  • 写操作简单——只在头部或尾部插入,不涉及复杂的中间节点操作
  • 线程数多——超过 4 个线程竞争时,无锁的优势开始显现
  • 实时性要求高——不能容忍锁导致的优先级反转或不确定延迟

反过来,如果链表操作复杂(比如双向链表、需要同时修改多个指针),或者线程数很少,那老老实实用锁反而更省心。

无锁链表的核心逻辑图

下面这张图展示了无锁链表插入操作的核心流程:

无锁链表插入操作流程 步骤1:读取 head 步骤2:new->next = head 步骤3:CAS (head, old, new) 失败 → 重试 插入成功,返回 核心:CAS 失败时自动重试,直到成功为止 整个过程不需要锁,多个线程可以同时执行

避坑指南

最后,分享几个我踩过的坑:

  • 不要用 volatile 替代原子操作——volatile 只保证编译器不优化,不保证 CPU 指令的原子性
  • 注意内存序——C11 的 atomic 操作默认是 memory_order_seq_cst,性能有损耗。读多写少的场景可以降级为 memory_order_acquire/release
  • 测试要跑在多核机器上——单核下无锁代码看起来没问题,一上多核就暴露问题
  • 先保证正确性,再优化性能——无锁代码调试困难,先写对再改快

我曾经在一个项目里,把无锁链表的内存回收写错了,导致线上服务运行 72 小时后内存暴涨。排查时发现是危险指针的释放顺序反了——先清除了标记,才释放节点,中间刚好被另一个线程插进来访问。这种时序问题,靠 log 几乎查不出来,最后是用 ThreadSanitizer 跑出来的。

无锁链表是个好东西,但用之前得想清楚:你真的需要它吗?如果加锁就能满足性能要求,别折腾。如果确实需要,那就做好心理准备——调试无锁代码的时间,通常是写代码时间的 5 倍以上。


公众号:蓝海资料掘金营,微信 deep3321