文章 · 2026-02-25

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

标准的 C++ std::shared_ptr 本身(作为持有指针和控制块指针的对象)并不是线程安全的。shared_ptr 包含两个指针:指向对象的指针和指向引用计数控制块的指针。修改 shared_ptr 需要写入两个字。如果读操作与写操作并发,会导致竞态条件:读线程可能看到新对象指针搭配旧控制块,或反之。C++20 的 std::atomic<std::shared_ptr> 通过同步原语避免这一问题,但内部通常依赖自旋锁或复杂的 CAS 循环。在高竞争场景下,这些机制引入等待和延时抖动。

本文探讨一个工业级方案:利用 x86_64 地址空间的结构特性,实现真正 Wait-Free 的原子指针交换。方法是将引用计数存储在 64 位指针的高 16 位,通过单次原子加法完成指针获取。

为什么 shared_ptr 还不够?

shared_ptr 的引用计数(控制块内部)确实是原子的。但是,shared_ptr 对象本身包含两个字。更新 shared_ptr 需要分别写入两个指针。如果读操作穿插在写序列中间,读者看到的就是不一致的状态——新对象指针配搭旧控制块指针,或反之。这是经典的竞态条件。

std::mutexstd::atomic_load 通过同步来解决问题,但代价是等待、上下文切换和高吞吐量场景下的延时波动。

指针标记(Pointer Tagging):利用 64 位布局

在 x86_64 架构下,虚拟地址虽然是 64 位,但实际只使用了低 48 位(规范形式)。高 16 位是闲置的——通常全 0 或符号扩展。

这些闲置位可以存储每线程或每次获取的元数据。常见做法是将本地引用计数嵌入高 16 位,同时保持真实地址在低 48 位。

通过原子加法获取

核心机制很直接:不用 CAS 循环或锁,只用一次 fetch_add 在高位上获取指针:

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

这一条原子指令做了两件事:

  1. 原子地将高位计数加 1。
  2. 返回加法前的旧值(低 48 位包含有效指针)。

因为是单次指令,每个线程——无论竞争多么激烈——都能在有限步骤内得到一致的指针和正确递增的计数。没有线程等待,没有重试。这就是 Wait-Free 属性:每次操作都在有限步骤内完成,不依赖其他线程的进展。

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);
    }
}

Rust 的类型系统强制了所有权不变式。关键要点:

  1. 高位上的 fetch_add 同时充当锁获取和指针读取。因为地址占据低 48 位,对高 16 位做加法不会破坏地址(溢出前)。
  2. Wait-Free 属性来自单次原子操作:没有自旋循环,没有额外同步。

权衡与约束

既然这个技巧这么有效,为什么不成为标准做法?

架构依赖:这种方法强依赖于当前 x86_64 的 48 位虚拟地址空间。在 32 位系统或未来 57 位地址空间扩展(如 Intel 5 级分页)的情况下,这个假设就失效了。标准库必须优先考虑跨平台可移植性。

计数溢出:16 位最多存储 65535 个引用。在极高并发的场景下,这个上限可能被突破。实际的工业级实现通常包含一个"刷新"机制,定期将高位计数转移到内存控制块的引用计数中,或者在饱和时使用 fallback 策略。

对齐约束:安全的位操作要求指针满足对齐保证(如 8 字节对齐),以确保低位保持未使用状态,从而避免算术操作导致的数据破坏。

总结

这个设计体现了系统编程的典型权衡:为了在特定工业场景中实现极致性能,放弃通用性和标准安全性。当标准同步原语(互斥锁、CAS)成为某些系统的瓶颈时,利用硬件特性(如 x86_64 的地址布局)可以达到额外的性能收益。代价同样明显:代码不可移植、不符合标准安全要求、紧耦合于硬件实现细节。对于合适的工作负载,收益却是实实在在的:单次原子操作完成指针获取,同时保证 Wait-Free 属性。

© 2026 Yuxu Ge ·