ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

shared_ptr 完全指南:引用计数、控制块与开销

shared_ptr 完全指南:引用计数、控制块与开销 std::unique_ptr把「独占所有权」钉死在类型上但现实里经常需要多个对象同时引用同一份资源缓存、父子节点、观察者列表……这时std::shared_ptr共享所有权指针shared pointer登场。它用引用计数reference count记录「现在有几个人在用」最后一个离开时才释放。这篇把控制块里到底存了什么、它比裸指针贵在哪、以及什么时候不该用它一次讲清。1. 引子unique_ptr 不够用的那一刻设想一个节点同时被两个容器引用。用unique_ptr做不到——它禁止拷贝。于是有人退回裸指针又掉回「谁负责释放」的坑或者写std::shared_ptrNode(new Node)硬扛却不清楚这一下多花了什么。共享所有权正是shared_ptr要解决的问题官方文档std::shared_ptr — cppreference2. 控制块control block里存了什么shared_ptr之所以「智能」是因为它背后有一个独立分配的控制块control block。控制块不在shared_ptr对象内部而是在堆上所有指向同一对象的shared_ptr共享它栈局部变量 shared_ptr 对象(16B) 堆 ┌──────────┐ ┌───────────────────┐ ┌──────────────────────────┐ │ sp │ ---- │ 对象指针 ptr ─────────────── │ 被管理的对象 T │ └──────────┘ │ 控制块指针 ctrl ─────────── │ ┌──────────────────────┐ │ └───────────────────┘ │ │ 控制块 control block │ │ │ │ 强引用计数 shared │ │ │ │ 弱引用计数 weak │ │ │ │ 删除器 deleter │ │ │ │ 分配器 allocator │ │ │ └──────────────────────┘ │ └──────────────────────────┘控制块里至少存四样东西强引用计数shared count——决定对象何时析构弱引用计数weak count——决定控制块本身何时释放删除器deleter——如何释放对象分配器allocator——如何释放整块内存。注意shared_ptr对象内部有两个指针一个指向对象T一个指向控制块。官方文档std::shared_ptr 的「实现说明」一节 — cppreference3. 实测16 字节、use_count、拷贝与 resetuse_count()返回当前强引用计数拷贝shared_ptr会把强引用计数原子地 1reset()放弃自己的那份引用并减 1。下面实测三者并顺带确认shared_ptr占 16 字节两个指针// sp_basic.cpp — 编译: g -stdc17 -Wall -O2 sp_basic.cpp -o sp_basic#includeiostream#includememoryintmain(){autopstd::make_sharedint(42);std::coutvalue *p\n;std::coutuse_count p.use_count()\n;std::coutsizeof(shared_ptr) sizeof(p)\n;std::coutsizeof(int*) sizeof(int*)\n;autoqp;// 拷贝 共享所有权std::coutafter copy use_count p.use_count()\n;std::coutsame object? (p.get()q.get())\n;q.reset();// q 放弃引用std::coutafter q.reset use_count p.use_count()\n;return0;}value 42 use_count 1 sizeof(shared_ptr) 16 sizeof(int*) 8 after copy use_count 2 same object? 1 after q.reset use_count 1同样是「指向一个int」shared_ptr要16 字节裸指针只要 8 字节——多出来的那个指针就是控制块指针。4. 它到底贵在哪三笔开销用shared_ptr不是零成本的代价可以拆成三笔开销具体表现什么时候疼体积shared_ptr是 2 个指针16Bunique_ptr是 1 个8B大量指针存进容器、作为参数满天飞分配次数构造函数要为控制块再做一次堆分配shared_ptrT(new T)共 2 次高频创建/销毁分配器压力大原子操作每次拷贝/析构都对强计数做原子自增/自减单线程高频拷贝白付内存栅栏的代价解引用本身并不额外多一次间接寻址——这一点常被说错。shared_ptr::operator*读的是对象内部那个对象指针和unique_ptr一样是一次解引用真正需要穿过控制块的是判存活、读计数、拷贝和析构这些操作。官方文档std::shared_ptr::operator* — cppreference想亲眼看汇编差异Compiler Explorergodbolt 把两种指针的解引用放一起对比最直观。5. 「两个指针分开」的铁证别名构造如果shared_ptr只有一个指针下面这段就不可能工作inner只指向Buffer的数组字段不是对象头部却依然和owner共享同一个控制块、让对象保持存活。这就是别名构造函数aliasing constructor// sp_alias.cpp — 编译: g -stdc17 -Wall -O2 sp_alias.cpp -o sp_alias#includeiostream#includememorystructBuffer{intheader7;intpayload[4]{1,2,3,4};};intmain(){autoownerstd::make_sharedBuffer();// 别名构造inner 与 owner 共享同一个控制块但对象指针指向 payloadstd::shared_ptrintinner(owner,owner-payload);std::coutowner.use_count owner.use_count()\n;std::coutinner.use_count inner.use_count()\n;std::coutinner points to payload? (inner.get()owner-payload)\n;std::cout*inner *inner\n;std::coutsizeof(inner) sizeof(inner)\n;return0;}owner.use_count 2 inner.use_count 2 inner points to payload? 1 *inner 1 sizeof(inner) 16inner的计数跟着owner一起变都是 2但*inner读到的是payload——对象指针和控制块指针确实是两个独立的值缺一不可。官方文档别名构造函数aliasing constructor— cppreference6. 删除器真的存在控制块里控制块存删除器意味着同一个类型shared_ptrWidget可以配不同删除器而类型不变。下面用一个自定义删除器证明它确实被调用// sp_deleter.cpp — 编译: g -stdc17 -Wall -O2 sp_deleter.cpp -o sp_deleter#includeiostream#includememorystructWidget{Widget(){std::coutWidget()\n;}~Widget(){std::cout~Widget()\n;}};intmain(){{// 这里为展示「删除器存于控制块」才手写裸 new真实项目请用 make_sharedstd::shared_ptrWidgetw(newWidget,[](Widget*p){std::coutcustom deleter runs\n;deletep;});}// 离开作用域时控制块里的删除器被调用std::coutscope ended\n;return0;}Widget() custom deleter runs ~Widget() scope ended输出的顺序印证了机制对象析构由控制块中的自定义删除器触发~Widget()在custom deleter runs之后才打印。也再次提醒——能用make_shared就用make_shared见《make_unique / make_shared vs 裸 new》。7. use_count() 只能用于调试use_count()在多线程下只是近似值——你读到它的时候别的线程可能正在拷贝或释放。所以它绝对不能写进业务逻辑例如「if (p.use_count() 1)我就做独占操作」是错的存在竞态。在单线程里它也要小心编译器可能在拷贝构造参数时零成本地调整计数让结果与直觉不符。它正确的用途只有调试和教学官方文档use_count 只是近似值 — cppreference8. 线程安全控制块是原子的对象不是shared_ptr的线程安全边界常被误解多个线程各自持有一份shared_ptr副本是安全的计数操作原子但同一个shared_ptr对象被多个线程同时读写比如一个拷贝、一个reset则不是那是数据竞争而指向的对象本身的读写安全shared_ptr完全不负责。操作是否线程安全多个shared_ptr副本各自独立指向同一对象安全计数原子同一个shared_ptr对象被多线程同时读写不安全数据竞争通过*p修改被管理对象不安全与shared_ptr无关官方文档std::atomicstd::shared_ptrC20— cppreference官方明确写了「控制块本身是线程安全的多个shared_ptr副本可以并发地reset/ 赋值」也写了「同一个shared_ptr对象被并发读写就是数据竞争」。9. 什么时候不该用 shared_ptr与 unique_ptr 对比没有共享需求时用shared_ptr只是白付三笔开销。能用unique_ptr表达独占所有权就别用shared_ptr维度unique_ptrTshared_ptrT所有权独占不可拷贝共享可拷贝对象大小1 个指针8B2 个指针16B拷贝代价不可拷贝只能move几乎免费原子自增强引用计数有开销解引用间接一次一次不经过控制块判存活计数无独占即唯一经控制块读强计数额外间接控制块分配无有堆上一次额外分配适用场景明确的唯一拥有者多处共享同一资源还有一条必须记住的shared_ptr之间互相强引用会成环泄漏这时要换weak_ptr见《weak_ptr 详解》。下面把「独占用 move、共享用拷贝」放一起对比// sp_vs_unique.cpp — 编译: g -stdc17 -Wall -O2 sp_vs_unique.cpp -o sp_vs_unique#includeiostream#includememoryintmain(){autou1std::make_uniqueint(7);autou2std::move(u1);// unique移动u1 变空std::coutu1 null? (u1nullptr)\n;std::cout*u2 *u2\n;autos1std::make_sharedint(7);autos2s1;// shared拷贝两者都有效std::couts1 valid? (s1!nullptr)\n;std::coutuse_count s1.use_count()\n;return0;}u1 null? 1 *u2 7 s1 valid? 1 use_count 2官方文档C Core Guidelines「R: Resource management」一节R.21无共享需求就用 unique_ptr10. 延伸阅读std::shared_ptr — cppreference完整接口、控制块语义与线程安全保证本篇所有论断的出处。std::make_shared — cppreference为什么优先用它一次分配见本系列第 28 篇。std::weak_ptr — cppreference控制块里那枚弱计数的使用者打破循环引用的钥匙。C Core Guidelines「R: Resource management」R.20 要求所有权一律用智能指针表达R.21 要求无共享需求就用unique_ptr。11. 一句话总结shared_ptr用堆上的控制块强/弱计数 删除器 分配器实现共享所有权代价是 16 字节体积、一次额外的控制块分配、以及原子计数的拷贝开销解引用本身不多一次间接但判存活和拷贝都要穿过控制块use_count()只用于调试、绝不能进逻辑没有共享需求时老老实实用unique_ptr更划算。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进