指针里的计数器:利用 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::mutex 或 std::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。
- 返回加法前的旧值(低 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 的类型系统强制了所有权不变式。关键要点:
- 高位上的
fetch_add同时充当锁获取和指针读取。因为地址占据低 48 位,对高 16 位做加法不会破坏地址(溢出前)。 - Wait-Free 属性来自单次原子操作:没有自旋循环,没有额外同步。
权衡与约束
既然这个技巧这么有效,为什么不成为标准做法?
架构依赖:这种方法强依赖于当前 x86_64 的 48 位虚拟地址空间。在 32 位系统或未来 57 位地址空间扩展(如 Intel 5 级分页)的情况下,这个假设就失效了。标准库必须优先考虑跨平台可移植性。
计数溢出:16 位最多存储 65535 个引用。在极高并发的场景下,这个上限可能被突破。实际的工业级实现通常包含一个"刷新"机制,定期将高位计数转移到内存控制块的引用计数中,或者在饱和时使用 fallback 策略。
对齐约束:安全的位操作要求指针满足对齐保证(如 8 字节对齐),以确保低位保持未使用状态,从而避免算术操作导致的数据破坏。
总结
这个设计体现了系统编程的典型权衡:为了在特定工业场景中实现极致性能,放弃通用性和标准安全性。当标准同步原语(互斥锁、CAS)成为某些系统的瓶颈时,利用硬件特性(如 x86_64 的地址布局)可以达到额外的性能收益。代价同样明显:代码不可移植、不符合标准安全要求、紧耦合于硬件实现细节。对于合适的工作负载,收益却是实实在在的:单次原子操作完成指针获取,同时保证 Wait-Free 属性。