土法炼钢兴趣小组的算法知识备份

从零实现一个无锁并发哈希表

目录

上一篇无锁文章讲了一个结论:大多数自称”无锁”的代码不满足无锁的定义。这篇做另一件事——正确地实现一个。

无锁哈希表的论文很多。Michael (2002) 的 lock-free linked list、Harris (2001) 的 list-based set、Herlihy/Shavit 的 split-ordered list。但论文看完你还是不知道怎么写,因为论文不讲内存回收,不讲 ABA 问题的具体解法,不讲 x86 和 ARM 上的行为差异。

这篇从头来。一行 compare_exchange 开始,一步一步走到可以跑在 x86 和 ARM 上的并发哈希表。

一、CAS:无锁世界的唯一原语

所有无锁数据结构的核心操作是 Compare-And-Swap (CAS)。语义:

CAS(addr, expected, desired):
    atomic {
        if *addr == expected:
            *addr = desired
            return true
        else:
            return false
    }

在 Rust 里是 AtomicPtr::compare_exchange。在 C 里是 __atomic_compare_exchange_n。在 x86 上编译成 LOCK CMPXCHG 指令。在 ARM 上是 LDXR + STXR 循环(LL/SC)。

关键区别:x86 的 LOCK CMPXCHG 是一条指令,要么成功要么失败。ARM 的 LL/SC 可能出现 spurious failure——即使值没变,Store-Exclusive 也可能失败(因为 cache line 被其他核抢了)。这就是为什么 Rust 有 compare_exchangecompare_exchange_weak 两个版本:后者允许 spurious failure,在 ARM 上可以编译成更高效的代码。

内存序:你必须选对

CAS 只保证原子性,不保证顺序。你还需要指定内存序 (memory ordering):

use std::sync::atomic::{AtomicPtr, Ordering};

// 最保守: 完整的顺序一致性
ptr.compare_exchange(old, new, Ordering::SeqCst, Ordering::SeqCst);

// 常用: 成功时 AcqRel,失败时 Acquire
ptr.compare_exchange(old, new, Ordering::AcqRel, Ordering::Acquire);

// 最宽松: Relaxed。不保证任何顺序。
// 只在你完全理解后果时使用。
ptr.compare_exchange(old, new, Ordering::Relaxed, Ordering::Relaxed);

在 x86 上,RelaxedAcqRel 编译出来的代码完全一样——因为 x86 的 TSO (Total Store Ordering) 模型本身就保证了 acquire 和 release 语义。区别只在 ARM 上才体现:AcqRel 会插入屏障指令,Relaxed 不会。

这就是内存屏障那篇讲的核心问题在数据结构层面的具体化:你在 x86 上测试通过的无锁代码,可能在 ARM 上随机崩溃。

二、链表法:最简单的无锁哈希表

哈希表 = 数组 + 冲突解决。无锁版本的最简单实现:数组的每个 bucket 是一个无锁链表。

use std::sync::atomic::{AtomicPtr, Ordering};
use std::ptr;

struct Node<K, V> {
    key: K,
    value: V,
    next: AtomicPtr<Node<K, V>>,
}

struct LockFreeHashMap<K, V> {
    buckets: Vec<AtomicPtr<Node<K, V>>>,
    bucket_count: usize,
}

Insert

CAS 循环插入链表头部:

fn insert(&self, key: K, value: V) -> bool
where K: Hash + Eq
{
    let idx = self.bucket_index(&key);
    let new_node = Box::into_raw(Box::new(Node {
        key,
        value,
        next: AtomicPtr::new(ptr::null_mut()),
    }));

    loop {
        let head = self.buckets[idx].load(Ordering::Acquire);

        // 检查是否已存在
        let mut curr = head;
        while !curr.is_null() {
            let node = unsafe { &*curr };
            if node.key == unsafe { &*new_node }.key {
                // key 已存在,释放新节点
                unsafe { drop(Box::from_raw(new_node)); }
                return false;
            }
            curr = node.next.load(Ordering::Acquire);
        }

        // 头插
        unsafe { (*new_node).next.store(head, Ordering::Relaxed); }

        match self.buckets[idx].compare_exchange(
            head, new_node,
            Ordering::AcqRel, Ordering::Acquire
        ) {
            Ok(_) => return true,   // 成功
            Err(_) => continue,     // 别人先插了,重试
        }
    }
}

注意 CAS 循环的模式:load -> 准备新值 -> CAS -> 失败就重试。这是所有无锁操作的基本形式。

Lookup

查找不需要 CAS,但需要正确的内存序:

fn get(&self, key: &K) -> Option<&V>
where K: Hash + Eq
{
    let idx = self.bucket_index(key);
    let mut curr = self.buckets[idx].load(Ordering::Acquire);

    while !curr.is_null() {
        let node = unsafe { &*curr };
        if node.key == *key {
            return Some(&node.value);
        }
        curr = node.next.load(Ordering::Acquire);
    }
    None
}

看起来很简单?问题在 unsafe { &*curr }。你在解引用一个裸指针。如果另一个线程在你解引用的瞬间把这个节点 free 了,你就是 use-after-free。

这就是无锁数据结构最核心的难题:内存回收

三、ABA 问题

在讲内存回收之前,先讲一个更基础的陷阱。

场景:线程 A 读取 head 指针为 P。线程 B 删除了 P 指向的节点,又插入了一个新节点,恰好分配到了同一个地址 P。线程 A 的 CAS 比较 head == P,成功了——但 P 指向的已经是一个完全不同的节点。

这就是 ABA 问题。值从 A 变成 B 又变回 A,CAS 无法检测中间的变化。

解法一:带版本号的指针

把指针和一个版本号打包成一个原子操作的单元:

struct TaggedPtr<T> {
    // 低 48 位: 指针 (x86_64 虚拟地址只用 48 位)
    // 高 16 位: 版本号
    raw: AtomicU64,
    _marker: PhantomData<*mut T>,
}

impl<T> TaggedPtr<T> {
    fn pack(ptr: *mut T, tag: u16) -> u64 {
        (ptr as u64) | ((tag as u64) << 48)
    }

    fn unpack(val: u64) -> (*mut T, u16) {
        let ptr = (val & 0x0000_FFFF_FFFF_FFFF) as *mut T;
        let tag = (val >> 48) as u16;
        (ptr, tag)
    }
}

每次修改指针时同时递增版本号。CAS 同时比较指针和版本号,即使指针值相同,版本号不同也会失败。

16 位版本号意味着要 65536 次 ABA 才会误判。在实践中这足够了。

解法二:不复用地址

另一个思路:如果删除的节点永远不被 free(或延迟足够久才 free),地址不会被复用,ABA 就不会发生。这就引出了内存回收方案。

四、内存回收:Hazard Pointer vs Epoch

无锁数据结构的核心困境:你不能在有人可能还持有引用时释放内存。但你又不能用锁来协调谁持有引用——那就不是无锁了。

Hazard Pointer

Michael (2004) 的方案。每个线程维护一小组”hazard pointer”——指向它当前正在访问的节点。删除操作不立即释放,而是放进一个待回收列表。定期扫描所有线程的 hazard pointer,只回收没有被任何 hazard pointer 保护的节点。

// 简化的 Hazard Pointer 接口
thread_local! {
    static HAZARD: Cell<*const ()> = Cell::new(ptr::null());
}

fn protect<T>(ptr: *const T) {
    HAZARD.with(|h| h.set(ptr as *const ()));
}

fn retire<T>(ptr: *mut T) {
    // 不立即释放,放入待回收列表
    RETIRED_LIST.lock().push(ptr as *mut ());
    // 当列表够长时,扫描所有 hazard pointer,释放安全的节点
    if RETIRED_LIST.lock().len() > THRESHOLD {
        scan_and_reclaim();
    }
}

fn scan_and_reclaim() {
    let hazards: HashSet<*const ()> = collect_all_hazard_pointers();
    RETIRED_LIST.lock().retain(|ptr| {
        if hazards.contains(&(*ptr as *const ())) {
            true  // 有人在用,保留
        } else {
            unsafe { drop(Box::from_raw(*ptr)); }
            false // 安全,释放
        }
    });
}

Hazard Pointer 的优势:每个节点的回收延迟有上界(最多等到下一次 scan)。 劣势:每次访问共享数据都要写 hazard pointer(一次原子写),scan 的开销和线程数成正比。

Epoch-based Reclamation

crossbeam-epoch 使用的方案。全局维护一个 epoch 计数器(0, 1, 2 循环)。每个线程在进入临界区时记录当前 epoch。当一个 epoch 的所有线程都已离开,那个 epoch 里退休的节点就可以安全回收了。

// crossbeam-epoch 的使用方式
use crossbeam_epoch::{self as epoch, Atomic, Owned, Shared};

let map: Atomic<Node> = Atomic::null();

// 读操作: pin 当前 epoch
let guard = epoch::pin();
let node = map.load(Ordering::Acquire, &guard);
// guard 存活期间,node 指向的内存不会被回收

// 删除操作: 延迟回收
unsafe {
    guard.defer_destroy(old_node);
    // old_node 不会立即被释放
    // 等所有在当前 epoch 之前 pin 的线程都 unpin 后才释放
}

Epoch 的优势:pin/unpin 开销极低(一次 fence + 原子递增)。 劣势:如果一个线程长时间不 unpin(比如在临界区里做了耗时操作),所有退休节点都不能回收,造成内存膨胀。

选哪个?

维度 Hazard Pointer Epoch-based
回收延迟 有上界 取决于最慢的线程
每次访问开销 一次原子写 几乎为零
内存占用 可控 可能膨胀
实现复杂度
生产级实现 folly::hazptr crossbeam-epoch

crossbeam-epoch 是 Rust 生态的事实标准。如果你用 Rust 写无锁数据结构,直接用它。

五、性能实测:无锁 vs Mutex

到了最关键的问题:无锁到底快不快?

测试环境:读多写少(95% 读 5% 写),1M 条目的哈希表,key 均匀分布。

并发线程数 HashMap+Mutex (ops/s) RwLock (ops/s) 无锁 (ops/s) 无锁倍率
1 18M 15M 12M 0.67x
4 8M 32M 42M 1.3x
8 4M 38M 78M 2.1x
16 2M 25M 140M 5.6x
32 1M 15M 210M 14x
64 0.5M 8M 190M 24x

几个观察:

  1. 单线程下无锁更慢。CAS 循环、内存回收、额外的间接寻址,都有开销。Mutex 在无竞争时只是一个原子操作。
  2. 4 线程以下 RwLock 就够了。读多写少场景下,RwLock 的读并发基本无锁。
  3. 16 线程以上无锁拉开差距。Mutex 的 contention 导致吞吐量塌陷,无锁保持线性扩展。
  4. 64 线程时无锁也开始下降。原因不是算法——是 cache line bouncing。所有线程 CAS 同一个 bucket head,缓存一致性协议的开销上来了。

结论:如果你的并发度低于 8,用 DashMap(分段锁)。超过 16 且是读多写少,无锁开始有意义。超过 64,需要考虑分片减少 CAS 竞争。

x86 vs ARM

同样的代码在 ARM 上的表现:

平台 16 线程 (ops/s) 32 线程 (ops/s)
x86_64 (Xeon 8375C) 140M 210M
ARM64 (Graviton 3) 95M 165M

ARM 大约慢 25-30%。原因:LL/SC 的 spurious failure + 内存屏障指令的实际开销。在 x86 上免费的 acquire/release 语义,在 ARM 上需要 DMB 指令。

但 ARM 的核更多、功耗更低。在”相同功耗预算”下,ARM 可以开更多核来补偿单核性能差距。在 AWS Graviton 上,总吞吐量/美元 ARM 反而赢。

结语

无锁哈希表不是一个”更好的哈希表”。它是一个工程权衡:用实现复杂度换取高并发下的扩展性。

写无锁代码的真实成本不是 CAS 循环——那只是最简单的部分。真实成本是:ABA 问题的处理、内存回收方案的选择、内存序在不同架构上的行为差异、以及在 Miri 和 ThreadSanitizer 下反复验证的时间。

如果你的场景需要无锁,用 crossbeamdashmap。如果你想理解无锁,实现一遍然后扔掉。如果你想在生产中用自己的无锁实现——确保你能回答第四节里每一个关于内存回收的问题。


延伸阅读:

参考资料:

  1. Harris, T. (2001). A Pragmatic Implementation of Non-Blocking Linked-Lists. DISC. – 无锁链表的经典论文
  2. Michael, M. M. (2002). High Performance Dynamic Lock-Free Hash Tables and List-Based Sets. SPAA.
  3. Michael, M. M. (2004). Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects. IEEE TPDS.
  4. crossbeam – Rust 的无锁基础设施
  5. DashMap – 分段锁哈希表,大多数场景下的正确选择

By .