Day 04|线程安全引用计数:让 SharedPtr 支持并发拷贝/析构

12 阅读6分钟

目标:用 7 天时间,从“最简引用计数”迭代到接近 Boost shared_ptr 的控制块架构:默认删除器、自定义删除器、线程安全、weak_ptr、make_shared、最终工程化。
Day04 只做一件事:把控制块引用计数升级为原子,跑通多线程并发测试闭环


1. 今日目标

今天要把 Day03 的控制块架构升级到线程安全版本:

  • 控制块引用计数改为 std::atomic<long>(use/weak)
  • 引入原子操作:fetch_add / fetch_sub / compare_exchange_weak
  • 选择合适的内存序:relaxed / acq_rel / acquire
  • 实现 AddRefLock()(为 Day05 weak_ptr::lock 预埋)
  • 多线程并发测试验证:引用计数正确、对象只析构一次、无泄漏

核心收获:理解 shared_ptr 的线程安全边界 + 无锁引用计数的关键实现点。


2. 今日设计:shared_ptr 的线程安全边界

shared_ptr 的“线程安全”不是“所有操作都无锁安全”,而是很明确的承诺:

  • 不同 shared_ptr 实例(共享同一控制块)可以在不同线程里并发拷贝/析构
    因为它们只会并发修改控制块计数,计数原子化即可。
  • 同一个 shared_ptr 实例被多个线程同时读写仍是数据竞争
    例如一个线程 Reset(),另一个线程 get()/operator->,需要外部同步(mutex)。

所以 Day04 的改动点非常聚焦:只改控制块计数与其操作,SharedPtr 本体保持“薄”。


3. 关键实现

关键文件(与你项目一致):

  • include/sp_counted_base.h
  • include/sp_counted_impl.h
  • include/shared_count.h
  • include/my_shared_ptr.h
  • test/test_thread_safe.cc

3.1 原子操作封装:AtomicIncrement / AtomicDeincrement / AtomicConditionalIncrement

include/sp_counted_base.h

// 原子递增
inline void AtomicIncrement(std::atomic<long>* pw) {
  pw->fetch_add(1, std::memory_order_relaxed);
}

// 原子递减 返回变化前的值
inline long AtomicDecrement(std::atomic<long>* pw) {
  return pw->fetch_sub(1, std::memory_order_acq_rel);
}

// 条件递增:如果当前值非 0,则 +1;返回递增前的值
inline long AtomicConditionalIncrement(std::atomic<long>* pw) {
  long r = pw->load(std::memory_order_relaxed);
  while (true) {
    if (r == 0) return r;

    if (pw->compare_exchange_weak(
        r, r + 1,
        std::memory_order_relaxed,
        std::memory_order_relaxed)) {
      return r;  // 成功:返回旧值
    }
    // 失败:r 会被更新为当前 *pw 的值,继续循环重试
  }
}

要点:

  • fetch_add(relaxed):只需要计数原子性,不需要同步对象内容。

  • fetch_sub(acq_rel):最后一次 Release 可能触发 delete,需要建立同步。

  • compare_exchange_weak

    • expected 参数(这里的 r)失败时会被更新,所以循环能自动用最新值重试。
    • weak 允许“虚假失败”,必须放循环。

3.2 控制块基类:SpCountedBase(原子 use/weak + AddRefLock)

include/sp_counted_base.h

class SpCountedBase {
 protected:
  std::atomic<long> use_count_;
  std::atomic<long> weak_count_;

 public:
  SpCountedBase() : use_count_(1), weak_count_(1) {}
  virtual ~SpCountedBase() noexcept = default;

  virtual void Dispose() noexcept = 0;
  virtual void Destroy() noexcept { delete this; }

  void AddRefCopy() noexcept { AtomicIncrement(&use_count_); }

  bool AddRefLock() noexcept {
    return AtomicConditionalIncrement(&use_count_) != 0;
  }

  void Release() noexcept {
    if (AtomicDecrement(&use_count_) == 1) {
      Dispose();
      WeakRelease();
    }
  }

  void WeakAddRef() noexcept { AtomicIncrement(&weak_count_); }

  void WeakRelease() noexcept {
    if (AtomicDeincrement(&weak_count_) == 1) {
      Destroy();
    }
  }

  long use_count() const noexcept {
    return use_count_.load(std::memory_order_acquire);
  }
};

3.3 Day04 的“关键修复”:补齐 SharedCount / SharedPtr 的 move 语义

并发测试里写了:

threads.emplace_back([copy = std::move(copies[i])]() mutable {
  copy.Reset();
});

这要求 SharedPtr 必须有 move ctor/assign,否则 std::move 只会退化成拷贝(rvalue 绑定到 const&),导致 copies[i] 仍然持有引用,test2 最终 use_count 卡住。

代码里已经补上了:

include/shared_count.hpp

// move ctor
SharedCount(SharedCount&& other) noexcept : control_block_(other.control_block_) {
  other.control_block_ = nullptr;
}

// move assign
SharedCount& operator=(SharedCount&& other) noexcept {
  if (this != &other) {
    if (control_block_) control_block_->Release();
    control_block_ = other.control_block_;
    other.control_block_ = nullptr;
  }
  return *this;
}

include/my_shared_ptr.hpp

SharedPtr(SharedPtr&& other) noexcept
    : ptr_(other.ptr_), count_(std::move(other.count_)) {
  other.ptr_ = nullptr;
}

SharedPtr& operator=(SharedPtr&& other) noexcept {
  if (this != &other) {
    SharedPtr(std::move(other)).Swap(*this);
  }
  return *this;
}

要点:

  • move 必须把源对象置空(ptr_ = nullptrcontrol_block_ = nullptr),否则析构路径会重复释放。
  • SharedPtr(std::move(other)).Swap(*this) 是很稳的 move-assign 写法,最小改动、也更不容易写错资源交接顺序。

4. 单元测试:并发验证闭环

test/test_thread_safe.cpp 覆盖了:

  1. 并发拷贝
  2. 并发析构(你最关键的一关)
  3. 并发访问对象(不修改 shared_ptr 本身)
  4. 在线程间传递(按值传递)
  5. 引用计数压力测试
  6. 无内存泄漏验证(object_count 最终回到 0)
  7. 性能对比(单线程 vs 多线程)

跑出来的最终输出已经证明:

  • test2 最终引用计数回到 1 ✅
  • 对象数最终回到 0 ✅
  • 全流程无断言失败 ✅

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

问题 1:为什么 test2 最终 use_count 一直是 21?

原因:测试里使用了 std::move(copies[i]),但库侧如果缺 move ctor/assign,std::move 会退化成拷贝 → copies 仍持有那 20 份引用 → 最终 use_count 仍为 21。
修复:补齐 SharedCount/SharedPtr 的 move ctor/assign,并保证 moved-from 对象置空。

问题 2:为什么会出现 “lambda capture initializers only available with -std=c++14”?

原因:测试里用了 C++14 的 init-capture([copy = ptr])。
解决方式:将 CMake 的 CMAKE_CXX_STANDARD 升级到 14,或改写测试为 C++11 捕获写法(更啰嗦,不推荐)。


6. Day 04 学习检查清单

✅ 这一节就是你要求的“问题清单 = 今天四个检查清单”,并且给到可直接贴博客的详细回答。

Q1:理解为什么需要原子操作?

因为引用计数会被多个线程同时修改。普通 ++/-- 是“读-改-写”三步,线程间交错会丢失更新:

  • 计数偏小 → 可能提前 delete(悬空指针/崩溃)
  • 计数偏大 → 资源永远释放不了(泄漏)
  • 更糟:重复释放(UB)
    把 use/weak 计数改成 std::atomic<long> 并用 RMW(fetch_add/fetch_sub)保证“每次增减都是原子动作”,才能支撑 shared_ptr 的并发拷贝/析构承诺。

Q2:能解释 relaxed vs acq_rel 的区别?

  • memory_order_relaxed:只保证原子性,不建立跨线程的可见性/顺序约束。适合“仅修改计数”的场景。
    所以 AddRefCopy()fetch_add(relaxed):只是把计数 +1,不需要同步对象内容。
  • memory_order_acq_rel:成功的 RMW 同时具备 release + acquire 语义,用来建立关键 happens-before。
    所以 Release()fetch_sub(acq_rel):当它返回旧值为 1 时,当前线程即将执行 Dispose()/delete,必须确保 delete 与其他线程对对象的读写在内存模型上正确有序。

Q3:知道 compare_exchange_weak 为什么用在循环中?

两层原因:

  1. 并发竞争会导致 CAS 失败:别的线程可能在你读到 r 后修改了 *pw
  2. compare_exchange_weak 允许“虚假失败”(spurious failure),即使值相等也可能返回 false。
    因此标准写法必须是循环重试;并且 CAS 失败时 expected(这里的 r)会被更新为当前真实值,下一轮可以直接用新值继续尝试。

Q4:理解 AddRefLock() 的作用(为 weak_ptr 准备)?

weak_ptr::lock() 的语义是:只有对象还活着(use_count != 0)时才能把弱引用提升为强引用
如果用非原子的 “if (use_count != 0) ++use_count”,在竞态下可能出现:

  • 读到 use_count==1
  • 另一线程把最后一个 shared_ptr Release 到 0 并 delete
  • 再 ++ 把计数“复活”为 1(但对象内存已释放)
    这会直接 UB。
    AddRefLock() 用 CAS 把“检查 !=0”与“+1”合并成原子动作:要么成功提升,要么返回失败,绝不复活已销毁对象。

7. 今日迭代进度

✅ Day04 已完成:线程安全引用计数闭环

  • 控制块 use/weak 计数原子化(std::atomic<long>
  • AtomicIncrement / AtomicDeincrement / AtomicConditionalIncrement 落地
  • SpCountedBase::AddRefLock() 完成(为 weak_ptr::lock 预留)
  • 补齐 SharedCount/SharedPtr move 语义,跑通并发析构测试
  • 多线程测试全通过:引用计数正确、对象只析构一次、无泄漏