SharedPtr 控制块如何保留析构类型

阅读 stl1weekend 的 SharedPtr 实现时,我注意到:SharedPtr<Base> 最终释放的对象不一定按 Base* 删除。构造时传入的指针类型会进入控制块的模板实参,控制块保存的删除器据此处理对象。

构造时保留传入指针的类型

假设 Derived 公开继承 Base:

SharedPtr<Base> p(new Derived);

p 的模板实参是 Base,但表达式 new Derived 的类型是 Derived*。源码中接收裸指针的构造函数另有一个模板参数 _Yp:

template <class _Yp,
          std::enable_if_t<std::is_convertible_v<_Yp *, _Tp *>, int> = 0>
explicit SharedPtr(_Yp *__ptr)
    : _M_ptr(__ptr),
      _M_owner(new _SpCounterImpl<_Yp, DefaultDeleter<_Yp>>(__ptr)) {
    // ...
}

在这个例子中,_Tp 是 Base,而 _Yp 从传入的 Derived* 推导为 Derived。因此,供 SharedPtr<Base> 访问的 _M_ptr 是 Base*,实际创建的控制块却是 _SpCounterImpl<Derived, DefaultDeleter<Derived>>,它自己保存的是 Derived*。虽然 _M_owner 以控制块基类指针保存,实际控制块仍保有这个具体类型及删除器。

删除器决定最终析构方式

控制块析构时调用它保存的删除器;默认删除器对所保存的指针执行 delete。相关代码摘录如下:

// SharedPtr.hpp
template <class _Tp, class _Deleter>
struct _SpCounterImpl final : _SpCounter {
    _Tp *_M_ptr;
    [[no_unique_address]] _Deleter _M_deleter;
 
    ~_SpCounterImpl() noexcept override {
        _M_deleter(_M_ptr);
    }
};
 
// UniquePtr.hpp
template <class _Tp>
struct DefaultDeleter {
    void operator()(_Tp *p) const {
        delete p;
    }
};

对于前面的例子,删除器是 DefaultDeleter<Derived>,拿到的是控制块中保存的 Derived*,所以执行的是对 Derived* 的 delete:调用 Derived 的析构函数,然后析构其 Base 子对象。即使 Base 的析构函数不是虚函数,这个具体例子也能正确析构,因为删除表达式使用的是 Derived*。

非虚析构函数与基类子对象

既然删除器调用的是 Derived 的析构函数,而 Base 的析构函数又不是虚函数,Derived 中的 Base 子对象会不会漏掉?不会。C++ 工作草案的 class.dtor 规定:析构函数体执行完毕后,会自动析构该类的非静态成员、非虚直接基类;如果当前类是最派生类,还会析构虚基类。基类和成员按构造完成顺序的逆序析构。

析构函数体与子对象的销毁

需要区分析构函数体和整个对象的析构过程:~Derived() { ... } 中花括号里的语句只是用户编写的函数体。执行完函数体后,语言规则还会自动销毁成员和基类子对象;这部分并不需要写进花括号。对于类类型的成员,销毁它会调用该成员类型的析构函数,并递归地销毁它自己的成员和基类。

例如,下面用标准库 std::shared_ptr 展示可观察的顺序:

#include <iostream>
#include <memory>
 
struct Member {
    ~Member() { std::cout << "Member\n"; }
};
 
struct Base {
    ~Base() { std::cout << "Base\n"; } // 非虚析构函数
};
 
struct Derived : Base {
    Member member;
    ~Derived() { std::cout << "Derived 函数体\n"; }
};
 
int main() {
    std::shared_ptr<Base> p(new Derived);
} // 最后一个所有者离开作用域

输出依次是:

Derived 函数体
Member
Base

首先执行 ~Derived() 的函数体,此时 member 和 Base 子对象还在;接着自动销毁 member,调用 ~Member();再自动析构基类子对象,调用 ~Base()。因此,尽管示例中的 ~Derived() 只打印一行,整个析构过程还包含对成员和基类的清理。多个成员按声明顺序的逆序销毁;一般规则见 class.dtor。

再区分析构对象和释放对象占用的存储空间:对这个由普通 new Derived 创建的对象执行 delete,既会进行上述析构,也会调用释放函数归还存储空间。单独显式调用 p->~Derived() 会进行对象和子对象的析构,却不会自动归还 new 分配的存储空间;这种写法用于手动管理生命周期,不能再对同一对象直接 delete,否则会重复析构。原项目中 SharedPtr<Base> p(new Derived) 的默认删除器使用的是对 Derived* 的 delete,因此完成这两件事。

虚析构函数关系到通过基类指针删除派生对象时能否正确处理动态类型,不决定进入 ~Derived() 后是否析构基类子对象。工作草案 expr.delete 规定,对于普通的单对象 delete,当静态类型与动态类型不同时,通常要求静态类型是具有虚析构函数的基类,否则行为未定义;该规则对 destroying operator delete 有例外。

传入指针的静态类型

类型推导依据的是传给构造函数的表达式的静态类型,并不是运行时自动探测对象的动态类型:

Base* raw = new Derived;
SharedPtr<Base> p(raw); // _Yp 推导为 Base

这里控制块使用 DefaultDeleter<Base>,即通过 Base* 执行 delete。如果 Base 的析构函数是虚函数,会通过虚析构调用到 Derived 的析构函数;如果它不是虚函数,那么在通常的删除方式下通过这个 Base* 删除 Derived 对象是未定义行为。控制块不能从 Base* 推导回原来的 Derived*。

上面的结论针对默认删除器。构造时也可以传入自定义删除器,此时控制块保存该删除器,最终如何处理对象由它决定。自定义删除器需要与实际资源及其生命周期相匹配。

引用计数的内存顺序:为什么不能只看原子性

控制块不仅决定如何销毁对象,也决定何时销毁对象。一个常见问题是:shared_ptr 不保护被管理对象的并发读写,那引用计数为什么不全部使用 memory_order_relaxed?

先假设我们希望满足什么保证

假设 D 是触发删除器调用的那个 shared_ptr 析构操作,D' 是任何其他与它共享所有权的 shared_ptr 析构操作,那么我们希望:

D' strongly happens-before 最终删除器的调用。

这是 ISO C++ 邮件讨论中提出的措辞思路,不是这里引用的标准现有条款;该讨论建议向 LWG 确认标准文字是否已经充分表达这一预期。下面先把它当作设计目标,以理解为什么实现需要同步。reset()、赋值等也可能释放所有权;完整规范需要覆盖这些路径,不能直接把上面的析构措辞视作完整标准表述。

这个假设如何保护此前对对象的访问?如果线程 A 使用对象,然后释放其 shared_ptr,而线程 B 的最后一个所有者调用删除器:

A 访问对象
   ──sequenced-before──> A 释放自己的 shared_ptr(D')
   ──假设的 strongly happens-before──> B 调用最终删除器

在这个假设下,通过 happens-before 的传递性,A 的访问先于最终删除器。我们需要实现的跨线程关系正是中间这一环:只让计数原子地增减,无法自动建立它。同时,这个目标不保证持有者之间的普通并发读写互相有序。

从一个析构读取成员的例子思考

struct Wobble {
    int x = 0;
    ~Wobble() { std::cout << x << '\n'; }
};
 
auto p = std::make_shared<Wobble>();
auto q = p;
// 假定 p 和 q 已安全地交给不同线程,且之后分别只由各自线程操作。
 
// 线程 A                    // 线程 B
p->x = 42;                   q.reset();
p.reset();

假设 A 的 reset() 先将计数从 2 减到 1,B 的 reset() 再将其从 1 减到 0,并在 B 中执行析构函数。为了让 A 对 x 的写入与 B 的析构读取不构成数据竞争,需要建立 A 写 x happens-before B 析构时读 x。如果 B 先释放,则 A 自己最后释放并析构,写入与析构处于同一线程。

仅有计数的原子修改顺序不够:即便 B 的原子减一读取了 A 写出的计数值,两个操作若都为 relaxed,它们本身也不建立跨线程的 synchronizes-with。这是针对这种具体原子计数实现的分析;标准对 shared_ptr 的措辞和实现方式的关系见下文。

常见的计数实现

操作常见选择推理
从已有强引用复制,计数加一relaxed有效的源强引用保证控制块存活;加一只需原子更新计数。对象及指针的跨线程发布须另有同步。
释放强引用,计数减一acq_rel非最后释放者通过 release 发布此前操作;最后释放者通过 acquire 在销毁前取得所需同步。
查询计数relaxed只取得计数快照,不用它同步对象内容。

例如,把 stl1weekend 实现中 _M_decref() 的 relaxed 换成下面的 acq_rel,可修正这一具体计数模型中的最终销毁同步问题:

void _M_decref() noexcept {
    if (_M_refcnt.fetch_sub(1, std::memory_order_acq_rel) == 1) {
        delete this;
    }
}

对于上述两线程例子,A 的 release 减一写出计数 1,B 的 acquire 减一读取该值,于是建立如下关系:

A 写 x
  ──sequenced-before──> A 的 release 减一
  ──synchronizes-with─> B 的 acquire 减一
  ──sequenced-before──> B 析构时读 x

由 happens-before 的传递性,A 的写入先行于 B 的析构读取。多个持有者时,连续原子读改写构成的 release sequence 使最终 acquire 可与此前的 release 配合。LLVM libc++ 的 shared_count.h 使用 relaxed 增加和 acq_rel 减少引用计数;其内部计数表示与上面的简化实现不同。

也可以每次用 release 减一,仅在归零时做 acquire fence:

void _M_decref() noexcept {
    if (_M_refcnt.fetch_sub(1, std::memory_order_release) == 1) {
        std::atomic_thread_fence(std::memory_order_acquire);
        delete this;
    }
}

这里 fence 依赖前面的原子读改写读取到其他 release 操作或其释放序列中的值来建立同步,不是 fence 单独创造同步。以上只是强引用计数的示意;它不能修复该教学实现的其他问题,也不包含 weak_ptr::lock() 所需的非零时才能增加强计数的操作。

怎样判断是否需要同步

单看“内存操作能否跨过引用计数修改”容易混淆抽象机的顺序与硬件重排。可以先画三个关系:

  1. 找冲突操作:例如一个线程写对象成员,另一个线程的析构函数读取同一成员。
  2. 画线程内顺序:同一线程中,对象访问 sequenced-before 后续的 reset();即使计数操作是 relaxed,已有的 sequenced-before 仍然成立。
  3. 找跨线程同步边:若最后的析构发生在另一个线程,还需要某种同步将两侧连起来。单纯的 relaxed 计数修改顺序不是这样的同步边。

所有权不是对象读写锁

两个线程分别持有 shared_ptr,不能因此并发写入同一个普通 int 成员;之后的引用释放不会倒过来消除数据竞争。不同线程同时操作同一个 shared_ptr 实例且至少一方修改它,也需要同步;不同的 shared_ptr 实例可以共享所有权并独立操作。

use_count() 同样不是同步协议。工作草案对它注明 “Synchronization: None”,并指出 use_count() == 1 不意味着此前通过已销毁的 shared_ptr 进行的访问已经完成。更强的原子顺序也不能让一个已经销毁的控制块复活;不能从不受保护的裸控制块指针安全地直接加引用。

标准措辞与实现:邮件讨论的边界

C++ 工作草案明确描述了共享所有权和最后所有者负责销毁资源;析构条款描述了何时调用删除器或 delete。但不能把“所有持有者释放前的访问 happens-before 最终销毁”当作这些条款中直接写出的原文。

在 2025 年的 ISO C++ std-discussion 讨论中,发帖者质疑标准是否明确规定了这样的顺序。后续讨论以类似上面的 Wobble 例子说明问题,并提出用其他共享所有者的释放与最终删除器调用之间的先行关系来表达预期;讨论后段认可 release/acquire 等实现思路,并建议提交 LWG issue。邮件是参与者的分析和提议,并非委员会正式裁定;此处未确认后续 issue 或裁决。标准规定可观察行为,不要求所有实现使用某一种具体内存序。

参考文献