Skip to content
团子云技术 Lite 1.048596
Go back

shared_ptr 引用计数的内存序:加用 relaxed,减用 acq_rel

shared_ptr 引用计数的内存序:加用 relaxed,减用 acq_rel

我最近在学习 C++ 并发的基础设施,顺手翻了一下 shared_ptr 引用计数的实现。有一个细节很有意思:引用计数的 +1 用的是 memory_order_relaxed,-1 却用的是 memory_order_acq_rel。为什么同一个计数器,加和减要用两种不同的内存序?我在与 AI 的一次对话里把这个问题层层追问了一遍,这篇文章就是那次讨论的整理。

先把问题摆出来

先看一段简化的控制块代码:

// 简化版控制块,参考 libstdc++ 的 _Sp_counted_base
void _M_add_ref_copy() {
    _M_use_count.fetch_add(1, std::memory_order_relaxed);   // +1 用 relaxed
}

void _M_release() noexcept {
    if (_M_use_count.fetch_sub(1, std::memory_order_acq_rel) == 1) {  // -1 用 acq_rel
        _M_dispose();   // 减到 0,析构对象
        _M_destroy();   // 释放控制块
    }
}

加计数是最便宜的操作,减计数却背着 acquire + release 两个内存序的负担。第一次看到这段代码,我的疑问和很多人一样:

要回答这些问题,得先把两个容易混淆的概念拆开。

原子性 ≠ 内存可见性

这是整篇文章最重要的一次概念澄清。

原子性(atomicity):对这个计数器这个数字本身的修改是原子的、不丢失的。两个线程同时操作一个值为 2 的计数器,一个 +1 一个 -1,结果必然是 3 和 2(顺序不确定),绝对不会出现”两个线程都读到 2,一个写回 3、另一个写回 2”这种互相覆盖的漏算。底层靠的是硬件缓存一致性协议(如 MESI)在 CPU 内部排队处理。

内存可见性/顺序(memory ordering):除了计数器之外的其他内存操作,要不要跟着这次原子操作一起建立 happens-before 关系。

relaxed 只提供前者,不提供后者。“relaxed 不同步其他线程”这句话里,“不同步”的宾语是其他内存数据;计数器本身在任何内存序下都永远算得对。

先吃一颗定心丸:用 relaxed 做 +1,计数绝对不会算错,也绝不可能被减到 -1 或提前减到 0。

为什么 +1 可以 relax

事实一:+1 永远不可能是”最后一击”

想想什么时候会给引用计数 +1:当你通过一个已经存在的 shared_ptr 做拷贝构造或赋值时。

这就意味着,你手里一定握着一个合法的副本。只要你这个副本还没析构(你还没放手),其他线程无论怎么销毁它们自己的副本,总计数都不可能减到 0。

既然不会降到 0,就不会触发析构,自然不需要 acquire-release 这种重量级的同步边界。你唯一要做的就是”让这个数字加 1,并且不要加错”——这正是 relaxed 的能力范围。它省去了所有不必要的内存屏障,开销最低。

事实二:+1 不”发布”任何数据

拷贝 shared_ptr 时,你并没有修改指针指向的对象内容,也没有向其他线程暴露任何新信息。打个比方:你走进图书馆借了一本书(+1),只要保证登记册上的”借出数量”准确无误就行,不需要强迫其他读者放下手中的事情来和你核对笔记。

所以 +1 的动作只是单纯地”领一张门票”。领门票不需要和别人的退票动作在时序上对齐,只要售票机不吞票、不算错数字,系统就完美运转。

那并发 +1 / -1 呢?

这是我当时追问最深的一层:假设 +1 和 -1 并发,虽然都是原子的,但 +1 用了 relaxed 不发布,它不会和后面的 -1 缺少时序同步吗?

答案是不会,而且原因藏在 C++ 对合法并发的前提里。并发 +1 和 -1 只可能出现在两种情况:

情况 A:操作的是不同的 shared_ptr 实例(合法并发)

线程 A 手里有 ptr_A,执行 auto ptr_C = ptr_A;(+1);线程 B 手里有指向同一个对象的 ptr_B,执行 ptr_B.reset()(-1)。

线程 A 只要还在执行 +1,就说明它还持有 ptr_A,这构成了绝对的保底:总计数在 +1 发生前至少是 2。B 的 -1 哪怕瞬间执行完毕,计数最多降到 1,绝不可能降到 0。既然不会到 0,就不会触发析构,也就不需要任何屏障去同步对象数据——数字本身算对(relaxed)就够了。

情况 B:操作的是同一个 shared_ptr 实例(未定义行为)

全局只有一个 shared_ptr<int> g_ptr;,线程 A 执行 auto local = g_ptr;,线程 B 同时执行 g_ptr.reset();。这是数据竞争,属于未定义行为——崩溃的直接原因是并发读写同一个普通变量(指针本身被撕裂了),跟 relaxed 没有任何关系。这种情况必须加锁,或者用 C++20 的 std::atomic<std::shared_ptr>。

所以那个”少了同步点”的担心,前提本身就不成立:合法的并发 +1/-1 只发生在情况 A,而情况 A 里 +1 根本不需要和 -1 建立时序关系——总有人保底,计数掉不到 0。

为什么 -1 必须用 acq_rel

当 shared_ptr 被析构时,计数 -1。减到 0 的那一刻被称为”最后一击”(last hit)——它要负责调用析构函数、释放内存。这是最危险的时刻。

acq_rel 是 Release(发布)和 Acquire(获取)的组合,分别防范两类灾难:

Release:把本线程的写”发布”出去

假设线程 A 和线程 B 共同持有一个对象(计数为 2):

  1. 线程 A 修改了对象数据:obj->value = 100;
  2. 线程 A 的 shared_ptr 析构,计数减为 1

如果 -1 不用 release,线程 A 对 obj->value 的修改可能还停留在它所在 CPU 的高速缓存里,没有刷到主存。release 像一个盖章确认:在我交出所有权之前,我对这个对象的所有修改都已”发布”出去。

Acquire:析构前必须看到所有人的写

接着上面的场景,线程 B 的 shared_ptr 析构,计数减为 0,线程 B 执行”最后一击”调用 delete。

如果不用 acquire,线程 B 在执行析构时可能根本看不到线程 A 刚才写进去的 value = 100——在对象状态不一致、甚至底层内存撕裂的情况下强行析构,后果是灾难性的崩溃。acquire 确保执行减法并发现结果为 0 的那个线程,在动手析构之前,强制把其他线程之前 release 的所有改动同步到本核心。

完整生命周期的推演

把一次典型的生命周期串起来看,同步链条是怎么建立的:

  1. 创建:引用计数为 1
  2. 线程 A 拷贝给线程 B(+1,relaxed):A 不发布数据,没关系,此时没人析构对象
  3. 线程 A 修改对象:obj->value = 999;
  4. 线程 A 析构它的指针(-1,acq_rel):注意,虽然它不是最后一个走的,它依然执行了 Release——把 value = 999 这个修改”发布”出去
  5. 线程 B 析构它的指针(-1,acq_rel):它是最后一个,减到 0,执行 Acquire——强行”获取”之前所有 Release 发布的数据
  6. 销毁:线程 B 能完整看到 value = 999,安全地调用 delete

也就是说,建立时序同步边界(happens-before)的责任,全部交给了 -1 这条路径。每一次 -1 都带着 Release 发布自己线程的写,最后那个减到 0 的线程带着 Acquire 收集所有前序发布——两层合起来就是 acq_rel。+1 在这条同步链条里不承担任何职责。

会议室比喻

把引用计数想象成会议室的人数统计:

动作语义需要的同步
原子性门口的计数器永远算得清,几百人同时进出也不漏一人CPU 和标准库白送
+1(进门,relaxed)我进门只需要把大屏上的人数 +1,不管屋里其他人在聊什么不需要屏障
-1(出门,acq_rel)我是最后一个人时,要负责关灯锁门打扫(析构)关灯前必须看清白板上所有人留的字(Acquire),并确保我的操作别人也看得见(Release)

关键在于:+1 永远不可能是”关灯”的那个人——你手里还握着一个副本,你就是保底。所以 +1 可以放心 relax。

两句直觉收束

如果只记两句话:

  1. 加计数不发布任何东西——你只是多拿了一个引用,没有写共享数据需要让别人看到,relaxed 只保证原子性,最便宜。
  2. 减计数要建立销毁的安全边界——-1 可能是最后一击,销毁前必须确保其他线程此前对对象的写全部可见;Release 保证本线程的写先完成,Acquire 保证看到别人的写。

受限于笔者的背景和水平,内存序的精确形式化定义(比如 C++ 内存模型的 happens-before 形式化推导)本文没有展开,只是把”加不发布、减要建立边界”这条工程直觉掰开了。有兴趣的读者可以继续深挖 cppreference 上 memory_order 的规范描述,或者直接读 libstdc++ 里 _Sp_counted_base 的实现。


Share this post on:

Previous Post
shared_ptr 线程安全的三层与四层:从 enable_shared_from_this 说起
Next Post
std::vector<bool>:C++ 标准库埋得最深的一颗雷