Day 05|WeakPtr 与循环引用:用“双计数器 + AddRefLock”安全打破引用环

4 阅读7分钟

目标:用 7 天时间,从“最简引用计数”迭代到接近 Boost shared_ptr 的控制块架构:默认删除器、自定义删除器、线程安全、weak_ptr、make_shared、最终工程化。

Day05 只做一件事:引入 WeakPtr,并用它解决 SharedPtr 的循环引用问题(同时把 Day04 预埋的 AddRefLock() 真正用起来,跑通 lock() 的并发闭环)。


1. 今日目标

在 Day04 线程安全控制块基础上,补齐 weak_ptr 体系:

  • 新增 WeakCount:只管理弱引用计数(弱引用生命周期)
  • 实现 WeakPtr<T>Lock()/expired()/use_count()/Reset()/Swap()
  • 新增“从弱提升为强”的构造路径:SharedCount(WeakCount) + SharedPtr(WeakPtr, tag)
  • 用真实案例验证循环引用被打破:双向链表 / 父子关系 / 缓存
  • 单测闭环:功能 + 多线程 lock 竞争 + 循环引用实战

核心收获:WeakPtr 的价值 = “观察但不拥有”;lock() 的价值 = “安全地临时拥有”。


2. 今日设计:WeakPtr 的语义与控制块生命周期

2.1 为什么 SharedPtr 会被循环引用卡死?

SharedPtr 的释放条件只有一个:强引用计数归零
当对象之间用 SharedPtr 形成强引用环(A→B,B→A),即便外部都退出了,环内仍互相持有,强计数永远 > 0 → 析构永远不会发生。

2.2 WeakPtr 如何打破循环?

WeakPtr 的语义是:不拥有对象,不增加强计数
你把“反向边/回指针/缓存”改成 WeakPtr,就不会阻止对象释放:

  • 双向链表:next 强、prev
  • 父子关系:父强持有子、子弱指向父
  • 缓存:map 存 weak,不阻止对象释放

2.3 双计数器

控制块里两套计数(Day04 已实现原子化):

  • use_count_:强引用计数 → 决定对象生命周期
  • weak_count_:弱引用计数(WeakPtr 数量 + 隐式 +1)→ 决定控制块生命周期

关键规则(这也是为什么 weak_count 要隐式 +1):

  • use_count_ == 0Dispose() 删除对象
  • 删除对象后要释放隐式弱引用:WeakRelease()
  • weak_count_ == 0Destroy() 删除控制块

3. 关键实现

关键文件:

  • include/shared_count.h(新增 WeakCount + SharedCount(WeakCount)
  • include/my_weak_ptr.h(新增 WeakPtr<T>
  • include/my_shared_ptr.h(新增从 WeakPtr 构造的 nothrow 路径)

下面代码片段都是你当前“Google 风格化”后的写法,只摘 Day05 关键新增点


3.1 新增:WeakCount(弱引用计数管理器)

它的职责非常单一:持有控制块指针 + RAII 管 weak_count

// include/shared_count.h
class WeakCount {
 public:
  WeakCount() noexcept : control_block_(nullptr) {}

  // WeakPtr 从 SharedPtr 来:共享控制块,只增加 weak_count
  explicit WeakCount(const SharedCount& shared) noexcept;

  WeakCount(const WeakCount& other) noexcept : control_block_(other.control_block_) {
    if (control_block_ != nullptr) control_block_->WeakAddRef();
  }

  WeakCount(WeakCount&& other) noexcept : control_block_(other.control_block_) {
    other.control_block_ = nullptr;
  }

  ~WeakCount() noexcept {
    if (control_block_ != nullptr) control_block_->WeakRelease();
  }

  void Swap(WeakCount& other) noexcept {
    SpCountedBase* tmp = control_block_;
    control_block_ = other.control_block_;
    other.control_block_ = tmp;
  }

  bool empty() const noexcept { return control_block_ == nullptr; }
  int64_t use_count() const noexcept { return control_block_ ? control_block_->use_count() : 0; }

 private:
  SpCountedBase* control_block_;
};

这个类一旦写对,WeakPtr::Reset/Swap/移动语义 都会非常“薄”,不容易出资源交接 bug。


3.2 新增:SharedCount 从 WeakCount 构造(为 lock() 服务)

这是 lock() 线程安全的“心脏”——必须调用 AddRefLock() ,把“检查非 0 + 递增”合成原子动作。

// include/shared_count.h
class SharedCount {
 public:
  explicit SharedCount(const WeakCount& weak) noexcept : control_block_(weak.control_block_) {
    if (control_block_ != nullptr && !control_block_->AddRefLock()) {
      control_block_ = nullptr;  // 已销毁(use_count==0),提升失败
    }
  }

  bool empty() const noexcept { return control_block_ == nullptr; }

 private:
  SpCountedBase* control_block_;
};

3.3 新增:WeakPtr(Lock/expired/use_count/Reset/Swap)

WeakPtr 只做两件事:

  1. 记住 ptr_(可能悬空,禁止直接用)
  2. 通过 WeakCount count_ 观察控制块,并用 Lock() 安全提升
// include/my_weak_ptr.h
template <typename T>
class WeakPtr {
 public:
  WeakPtr() noexcept : ptr_(nullptr), count_() {}

  template <typename U>
  WeakPtr(const SharedPtr<U>& shared) noexcept : ptr_(shared.ptr_), count_(shared.count_) {}

  SharedPtr<T> Lock() const noexcept { return SharedPtr<T>(*this, detail::SpNoThrowTag{}); }

  bool expired() const noexcept { return count_.use_count() == 0; }
  int64_t use_count() const noexcept { return count_.use_count(); }

  void Reset() noexcept {
    WeakPtr tmp;
    Swap(tmp);
  }

  void Swap(WeakPtr& other) noexcept {
    T* tmp_ptr = ptr_;
    ptr_ = other.ptr_;
    other.ptr_ = tmp_ptr;
    count_.Swap(other.count_);
  }

 private:
  T* ptr_;
  WeakCount count_;
};

你今天真实踩到的坑也在这里:Reset 之后 expired() 仍为 false,根因通常就是 Swap 没把 count_ 交换/清空(或 WeakCount::Swap 写错)。你已经修到全绿了。


3.4 新增:SharedPtr 从 WeakPtr 构造(nothrow tag)

WeakPtr::Lock() 不直接“魔法提升”,而是走一条明确构造路径:
SharedPtr(WeakPtr, tag) → 内部用 SharedCount(WeakCount)AddRefLock()

// include/my_shared_ptr.h
namespace detail {
struct SpNoThrowTag {};
}

template <typename T>
class SharedPtr {
 public:
  template <typename U>
  SharedPtr(const WeakPtr<U>& weak, detail::SpNoThrowTag) noexcept
      : ptr_(nullptr), count_(weak.count_) {
    if (!count_.empty()) ptr_ = weak.ptr_;
  }

  explicit operator bool() const noexcept { return ptr_ != nullptr; }

 private:
  T* ptr_;
  SharedCount count_;
};

4. 单元测试:功能 + 并发 lock + 循环引用实战闭环

  • test/test_weak_ptr.cc 覆盖:

    • WeakPtr 基本用法、生命周期、lock() 成功/失败
    • 多线程竞争 Lock():主线程 Reset() 后既有成功也有失败,且不崩溃
    • Reset/Swap、拷贝/移动语义、expired() 推荐用法
  • test/test_cycle.cc 覆盖:

    • 双向链表:next 强、prev 弱 → 全部析构(无泄漏)
    • 父子关系:父强持有子、子弱指向父 → 全部析构
    • 缓存系统:map 存 WeakPtr → 不阻止资源释放
    • 反例演示:纯 SharedPtr 环 → 析构不会发生(泄漏现象清晰可复现)

5. 开发过程中遇到的关键问题

问题 1:为什么 Reset()expired() 仍然为 false?

定位原因expired/use_count 只看控制块(count_),不是看 ptr_
如果 Reset() 没能让 count_ 变空(例如 WeakCount::Swap 写错、按值传参、交换临时副本),那么 use_count() 仍然读到 > 0,自然 expired()==false

最小修改点:确保 WeakPtr::Reset() 是 “swap-with-empty”,并且 WeakPtr::Swap() 同步交换 ptr_count_,且 WeakCount::Swap(WeakCount& other) 必须按引用交换底层指针。

提醒坑:如果只交换 ptr_ 不交换 count_,会出现“指针与控制块不匹配”的高危 UB。

问题 2:lock() 为什么不能写成 if (!expired()) AddRefCopy()

定位原因:这是经典竞态窗口:
线程 A 检查 use_count>0 后,线程 B 可能已经把最后一个 SharedPtr Release 到 0 并 Dispose 了对象;
线程 A 再 AddRefCopy 可能把计数“复活”,但对象已释放 → UB。

最小修改点:必须使用 AddRefLock()(CAS 循环)实现原子化“检查+递增”。

补必要测试:你已有 多线程 lock() 竞争(大量循环 + 主线程 Reset)足够覆盖。


6. Day 05 学习检查清单(今日 6 问)

Q1:理解为什么需要 weak_ptr?

因为 SharedPtr 在强引用环里无法把计数减到 0;weak_ptr 不增加强计数,能把环里至少一条边变成“观察”而非“拥有”,让释放链条重新可达。

Q2:能画出循环引用的内存布局图?

能:看“强引用边”是否闭环。闭环就泄漏;把回指边改成 weak 就断环。(第 2 节已解释)

Q3:知道 weak_ptr 如何打破循环?

把“关系”拆成两类:

  • 需要决定生命周期的 → SharedPtr(拥有)
  • 只是回指/缓存/观察 → WeakPtr(不拥有)
    当外部 SharedPtr 退出后,环内不会自持,最终会归零并释放。

Q4:理解 lock() 的线程安全实现?

lock 的关键是把:use_count != 0 ? ++use_count : fail
做成一个原子步骤,这就是 AddRefLock()(CAS conditional increment)。否则会把已 Dispose 的对象“复活”。

Q5:能解释双计数器的设计?

use_count 管对象、weak_count 管控制块;weak_count 含隐式 +1,使“对象活着时控制块必活”,对象销毁时释放这份隐式弱引用,最后一个 WeakPtr 才能销毁控制块。

Q6:知道何时使用 weak_ptr?

  • 双向结构回指:链表 prev、树/图的 parent 指针
  • 缓存/对象池索引:map 存 weak,不阻止释放
  • 观察者:需要“可选地访问”,用前先 Lock()
    反之:需要“保证对象活着直到用完”就用 SharedPtr(或先 lock 成 SharedPtr 再用)。

7. 今日迭代进度

✅ Day05 已完成:WeakPtr 与循环引用闭环

  • WeakCount / WeakPtr / Lock 提升路径落地
  • 双计数器生命周期规则跑通(对象与控制块分离销毁)
  • 多线程 Lock() 竞争测试通过
  • 循环引用实战案例全部无泄漏 + 反例可复现
  • 修复真实坑:Reset/Swap 必须同步清空/交换控制块引用(否则 expired 行为错误,甚至埋 UB)