因为多线程下非原子操作会导致数据撕裂,必须用std::atomic<long>等原子类型保证计数器安全;强引用计数归零时析构资源,弱引用计数归零时才释放控制块。

为什么 std::shared_ptr 的控制块不能只用普通 int 计数
因为多线程环境下,非原子的自增/自减会丢失更新。比如两个线程同时执行 counter++,可能都读到 1,各自加 1 后都写回 2,实际应为 3。这不是竞态“偶尔出错”,而是必然发生的数据撕裂。
必须用原子类型,但选哪个?std::atomic_int 可以,但更常见、更安全的是 std::atomic<long></long> 或直接 std::atomic<int64_t></int64_t>——尤其当计数器要支持弱引用(weak count)时,需两个独立计数器,且生命周期不同步。
- 普通
int:仅限单线程模拟,上线即崩 -
std::atomic_int:足够用于简单强引用计数,但注意它不保证 128 位对齐,在某些平台(如 ARMv7)上fetch_add可能退化为锁实现 -
std::atomic<:size_t></:size_t>:推荐,和指针大小一致,天然适配大多数控制块布局
控制块内存布局怎么安排才不踩 cache line 伪共享
引用计数器和弱引用计数器如果放在同一 cache line(通常 64 字节),即使一个线程只改强计数、另一个只改弱计数,也会因 CPU 缓存一致性协议(MESI)频繁使对方 cache line 失效,造成性能陡降。
典型错误布局:struct control_block { std::atomic<:size_t> strong_count; std::atomic<:size_t> weak_count; T data; };</:size_t></:size_t> —— 两个原子变量紧挨着,大概率落在同一行。
立即学习“C++免费学习笔记(深入)”;
- 在两个计数器之间插入
alignas(64)填充,或直接用[[no_unique_address]] char padding[64];(C++20)隔离 - 更稳妥做法:把
weak_count搬到控制块末尾,并确保它距strong_count≥ 64 字节 - 验证方式:用
offsetof(control_block, strong_count)和offsetof(control_block, weak_count)打印偏移,确认不在同一 cache line
析构时机由谁决定:强计数归零 vs 弱计数归零
std::shared_ptr 的资源析构发生在强引用计数归零时,但控制块本身的释放,要等到弱引用计数也归零才进行。这是关键区分点,也是为什么控制块必须单独分配、且含两个计数器。
常见误判:以为 weak_count == 0 就能 delete 控制块——错。若此时还有 std::weak_ptr 持有该控制块(哪怕已过期),其构造/拷贝仍会 bump weak_count,所以必须等最后一个 weak_ptr 析构后才安全回收控制块内存。
- 强计数归零 → 调用
T的析构函数,但控制块还在 - 弱计数归零 →
delete控制块内存(通常用operator delete匹配当初的operator new) - 务必检查:控制块的
delete是否与分配方式匹配(例如用malloc分配的,就不能用delete)
手写简易版时最容易漏掉的三个细节
不是语法错,而是语义陷阱。写出来能编译、单线程能跑,但一并发就 crash 或泄漏。
- 控制块构造时,
strong_count初始化为 1(对应首个shared_ptr),weak_count初始化为 1(因为控制块自己持有一个弱引用,用于管理自身生命周期) -
shared_ptr拷贝构造中,必须先++strong_count,再处理异常(如分配失败);否则若先分配新控制块失败,原计数已被修改,导致提前释放 - 自定义删除器(deleter)必须和控制块一起存储,且其析构逻辑要和
T的析构严格解耦——很多手写实现把 deleter 存在栈上或用函数指针硬编码,导致跨线程调用时 dangling
控制块的内存生命周期比所管对象长,这个不对称性,是所有引用计数实现里最绕、也最容易被忽略的一环。

















