← Back to Blog
EN中文

指针里的计数器:利用 64 位地址空间实现 Wait-Free 原子共享指针

在并发编程的深水区,我们总是在寻找那把“完美的钥匙”:既要像 std::shared_ptr 那样自动管理内存,又要能像原子变量一样在多线程间自由交换,最好还是 Wait-Free 的。

标准的 C++ std::shared_ptr 虽然名字里带“shared”,但它本身(即持有指针和控制块指针的那个对象)并不是线程安全的。多线程读写同一个 shared_ptr 对象会导致数据竞争。虽然 C++20 引入了 std::atomic<std::shared_ptr>,但其内部实现往往依赖自旋锁或复杂的 CAS 循环,在高并发竞争下性能难以达到极致。

今天我们来拆解某工业级分布式基础库中的一个核心设计:利用 x86_64 指针的高 16 位存储引用计数,实现真正的 Wait-Free 原子指针获取。

为什么 shared_ptr 还不够?

std::shared_ptr 的引用计数(控制块内部)是原子的,这没错。但是,当你从一个线程读取 shared_ptr,而另一个线程正在赋值(修改)它时,问题就来了。

shared_ptr 本质上是两个指针:

  1. 指向对象的指针。
  2. 指向引用计数控制块的指针。

修改 shared_ptr 不是原子操作,它需要更新这两个指针。如果读操作夹在两次写入之间,你可能会得到一个“新对象的指针”搭配“旧对象的控制块”,或者反之。这就是经典的 Race Condition。

解决这个问题的传统方法是加锁(std::mutex)或者使用 std::atomic_load/store(本质上也是锁或复杂的原子操作)。但在极致性能要求的场景下,锁意味着等待,意味着上下文切换,意味着延时抖动。

指针标记(Pointer Tagging):榨干 64 位

在 x86_64 架构下,虚拟地址虽然是 64 位的,但实际上只使用了低 48 位(Canonical Form)。这意味着指针的高 16 位通常是全 0 或全 1。

这 16 位闲置空间,就是性能优化的金矿。

如果我们把这 16 位利用起来,存储一个“本地引用计数”或“操作标记”,会发生什么?

核心魔法:一次原子加法

假设我们将 64 位的原子变量布局设计如下:

  • 高 16 位:引用计数(或者叫“获取计数”)。
  • 低 48 位:真实的内存地址。

当我们需要获取这个指针时,我们不做 CAS(Compare-And-Swap),也不加锁。我们只需要做一次 fetch_add

// 伪代码
let old_value = atomic_ptr.fetch_add(1 << 48, Ordering::SeqCst);
let ptr = old_value & 0x0000_FFFF_FFFF_FFFF;

这一行代码完成了两件事:

  1. 原子地将高位的计数加 1。
  2. 原子地获取了加 1 之前的旧值(其中包含原始指针)。

因为是原子操作,哪怕有一万个线程同时执行这行代码,它们各自拿到的 ptr 都是一致的,且高位的计数会被正确累加。没有任何线程需要等待,没有任何循环重试。这就是 Wait-Free

深度重构:Rust 演示

为了演示这个机制,我用 Rust 重构了一个简化版本。Rust 的类型系统能让我们更清晰地看到所有权和生命周期的流转。

use std::sync::atomic::{AtomicU64, Ordering};
use std::marker::PhantomData;

/// 这是一个演示用的 Wait-Free 原子指针结构
/// 核心思想:利用 AtomicU64 同时存储 [16位计数 | 48位地址]
pub struct WaitFreeAtomicPtr<T> {
    inner: AtomicU64,
    _marker: PhantomData<T>,
}

const ADDR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF;
const COUNT_SHIFT: u32 = 48;
const COUNT_INC: u64 = 1 << COUNT_SHIFT;

impl<T> WaitFreeAtomicPtr<T> {
    pub fn new(data: T) -> Self {
        let boxed = Box::new(data);
        let ptr = Box::into_raw(boxed) as u64;
        // 初始化:高位计数设为 1,低位为指针
        Self {
            inner: AtomicU64::new(COUNT_INC | ptr),
            _marker: PhantomData,
        }
    }

    /// Wait-Free 获取指针
    pub fn acquire(&self) -> SharedGuard<T> {
        // 魔法时刻:原子增高位,取回旧指针
        // 不需要 CAS 循环,不需要锁
        let prev = self.inner.fetch_add(COUNT_INC, Ordering::SeqCst);
        let ptr = (prev & ADDR_MASK) as *mut T;
        
        SharedGuard {
            ptr,
            parent: &self.inner,
        }
    }
}

pub struct SharedGuard<'a, T> {
    ptr: *mut T,
    parent: &'a AtomicU64,
}

impl<'a, T> Drop for SharedGuard<'a, T> {
    fn drop(&mut self) {
        // 离开作用域时,原子减高位
        self.parent.fetch_sub(COUNT_INC, Ordering::SeqCst);
    }
}

关键点解析

  1. fetch_add 的妙用:这是整个设计的灵魂。通常我们用 fetch_add 做计数器,但在这里,它同时扮演了“读锁”和“数据获取”的角色。由于地址存储在低位,加法操作如果不溢出到低位(高位加法不会影响低位),就能保证地址的完整性。
  2. Wait-Free 属性:无论有多少线程竞争,每个线程执行 acquire 都能在有限步骤内完成,不依赖其他线程的状态。这比 Lock-Free(保证系统整体有进展)更强,是并发编程的最高境界。

权衡与代价 (Trade-offs)

既然这么好,为什么 std::shared_ptr 不这么做?

  1. 架构依赖性:这种技巧强依赖于 x86_64 的 48 位虚拟地址实现。如果是在 32 位系统,或者未来地址空间扩展到 64 位全用(如 Intel 的 5-level paging 扩展了 57 位),这种假设就会失效。标准库必须考虑可移植性。
  2. 计数溢出:16 位只能存储 65535 个引用。对于大规模并发系统,这个数字可能不够用。实际的工业级实现通常会有一个机制:当高位计数达到阈值时,将其“刷入”真正的内存控制块引用计数中,或者使用更复杂的各种 fallback 策略。
  3. 内存对齐:为了安全地利用位操作,通常需要保证指针指向的内存有特定的对齐(比如至少 8 字节对齐),否则低位可能包含有效信息被误操作。

总结

这个设计展示了系统编程中的一种极致思维:当标准工具(如 mutexCAS)成为瓶颈时,我们要敢于打破抽象,利用硬件的底层特性(如地址布局)来换取性能。

虽然它不具备通用的可移植性,但在特定的工业场景下,这种“方寸之间的舞蹈”——在 64 位整数里腾挪跌宕——正是高性能中间件的魅力所在。