资讯中心

深入剖析C++智能指针:shared_ptr与weak_ptr源码实现与多线程安全

📅 2026/7/27 15:01:31
深入剖析C++智能指针:shared_ptr与weak_ptr源码实现与多线程安全
1. 项目概述从指针的“野性”到智能指针的“文明”在C的世界里混迹久了你一定会对内存管理这件事又爱又恨。爱的是手动管理内存带来的极致控制感和性能潜力仿佛手握生杀大权恨的是一个不小心内存泄漏、悬垂指针、双重释放这些“幽灵”就会找上门让程序在深夜里崩溃留下你独自对着调试器发呆。这感觉就像在刀尖上跳舞刺激但也容易受伤。传统的new和delete或者C风格的malloc和free赋予了开发者巨大的自由但也把沉重的责任完全压在了开发者肩上。你得时刻记得“谁申请谁释放”在复杂的对象生命周期和所有权流转中这根弦一松bug就来了。尤其是面对多态、容器存储、异常安全这些场景时手动管理几乎成了一场噩梦。于是智能指针应运而生它更像是给原始的“野指针”套上了一个文明社会的规则和自动化系统。std::shared_ptr和std::weak_ptr正是这套规则中的核心成员它们协同工作旨在解决基于引用计数的共享所有权场景下的内存管理难题。shared_ptr负责“强引用”只要还有一个shared_ptr指着那块内存它就活着weak_ptr则是“弱引用”它能看到对象但不会增加引用计数专门用来打破shared_ptr可能带来的循环引用死结。但知其然更要知其所以然。仅仅会调用make_shared或者reset只能算入门。当你在多线程环境下遭遇诡异的计数错误或者面对复杂对象关系图时疑惑于为何内存没有如期释放底层源码的理解就成了你手中最锋利的解剖刀。这篇文章我们就将深入libstdcGCC的标准库实现或libcLLVM的标准库实现的源码腹地不是走马观花而是带着问题去拆解shared_ptr和weak_ptr的内部构造、原子操作、控制块设计以及它们如何携手避免循环引用。这趟旅程有点长但相信我当你走出来时你对C内存管理的理解将不再是“使用API”而是“掌控机制”。2. 核心设计解析控制块、原子计数与所有权语义要理解shared_ptr和weak_ptr绝不能把它们看作简单的“包裹原始指针的类”。它们的核心是一个共享的、线程安全的控制结构。这个设计是理解所有行为的基石。2.1 控制块一切管理的核心控制块是一个堆上分配的对象它包含了管理动态资源所需的所有元数据。通常它的结构概念上包含以下部分指向被管理对象的指针这是用户真正关心的数据。强引用计数记录有多少个shared_ptr共享着对象的所有权。当此计数降为0时销毁被管理对象调用其析构函数并释放其内存。弱引用计数记录有多少个weak_ptr以及控制块本身引用着这个控制块。当强引用计数为0且弱引用计数也为0时才销毁控制块本身并释放其内存。删除器一个可调用对象默认为delete操作用于定制对象的销毁方式。分配器用于分配控制块内存的可选组件。这里有一个关键点控制块的生命周期与被管理对象即用户数据的生命周期是分离的。对象先死强引用计数归零时但控制块可能还活着因为还有weak_ptr存在。这保证了weak_ptr可以安全地查询对象是否还存活通过expired()或lock()。注意使用std::make_shared时编译器通常会进行一种称为“合并分配”的优化。它将对象内存和控制块内存分配在单块连续的内存中。这提高了局部性也减少了一次内存分配开销。但这也带来了一个副作用对象内存和控制块内存会一起被释放直到弱引用计数也归零。这意味着即使对象早已析构其占用的内存也可能因为仍有weak_ptr存在而无法归还系统。在内存敏感的场景这是需要权衡的。2.2 原子操作与线程安全C11标准要求shared_ptr和weak_ptr的引用计数操作是线程安全的。这意味着多个线程同时复制、销毁shared_ptr或weak_ptr其内部的强/弱引用计数都能通过原子操作如std::atomic::fetch_add,std::atomic::fetch_sub正确递增递减不会导致数据竞争。但是线程安全仅限于引用计数本身。它不保证通过不同shared_ptr实例访问其指向的同一对象是线程安全的。换句话说shared_ptr保证了管理行为的原子性但没有保证被管理对象内部状态的线程安全。你仍然需要互斥锁或其他同步机制来保护对象数据。// 线程安全的sp1和sp2的复制/析构修改的是同一个控制块里的原子计数 std::shared_ptrMyObj sp1 std::make_sharedMyObj(); std::thread t1([sp1](){ auto sp2 sp1; }); // sp2在线程内复制计数原子递增 std::thread t2([sp1](){ auto sp3 sp1; }); // sp3在另一线程复制计数原子递增 t1.join(); t2.join(); // sp2, sp3析构计数原子递减 // 线程不安全的通过sp1和sp4访问obj-data std::shared_ptrMyObj sp4 sp1; // 以下操作需要额外的锁来保护 MyObj::data // sp1-data ...; // sp4-data ...;2.3 所有权语义与循环引用问题shared_ptr代表的是共享所有权。每一次拷贝构造或赋值都意味着一个新的所有者加入引用计数加一。当所有者一个shared_ptr对象离开作用域被销毁时它放弃所有权引用计数减一。这种模型直观地反映了“多个实体共同使用一个资源”的场景。然而这种模型有一个著名的天敌循环引用。如果两个对象各自持有一个指向对方的shared_ptr就会形成一个闭环。即使外部没有任何shared_ptr指向它们中的任何一个它们的引用计数也永远不会降到0因为彼此互相持有导致内存泄漏。struct Node { std::shared_ptrNode next; std::shared_ptrNode prev; // 如果双向链表都用shared_ptr就可能循环引用 // ... data }; auto node1 std::make_sharedNode(); auto node2 std::make_sharedNode(); node1-next node2; // node2的计数变为2 node2-prev node1; // node1的计数变为2 // 此时即使外部node1, node2离开作用域计数各从2减为1但不为0内存泄漏这就是weak_ptr登场的时候。weak_ptr不拥有对象的所有权它只“观察”对象。它不会增加强引用计数因此不会阻止对象的销毁。在上面的例子中如果将prev改为std::weak_ptrNodenode2-prev node1;这行语句就不会增加node1的强引用计数。当外部shared_ptr释放后node1和node2的计数都能顺利归零从而正确析构。weak_ptr必须通过lock()成员函数来尝试获取一个临时的shared_ptr。如果对象还活着强引用计数0lock()会返回一个有效的shared_ptr并增加此时的强引用计数你可以安全地使用它如果对象已死lock()返回一个空的shared_ptr。这种“先检查后访问”的模式是安全使用weak_ptr的关键。3. 源码深度剖析从构造到析构的每一步让我们结合类的大致实现以概念模型为例并非逐行粘贴某个特定版本的源码来梳理关键操作背后的逻辑。理解这个模型看任何标准库的实现源码都会清晰很多。3.1 核心数据结构模拟首先我们想象一下控制块和智能指针类的大致样子// 概念模型非真实源码 templatetypename T class ControlBlock { T* ptr; // 指向用户对象 std::atomiclong shared_count; // 强引用计数 std::atomiclong weak_count; // 弱引用计数 Deleter deleter; // 删除器 // ... 可能还有分配器等信息 }; templatetypename T class shared_ptr { T* ptr; // 指向用户对象可能与ControlBlock中的ptr相同make_shared或不同分开分配 ControlBlockT* cb; // 指向控制块 public: // ... 构造函数、析构函数、赋值运算符等 }; templatetypename T class weak_ptr { T* ptr; // 指向用户对象 ControlBlockT* cb; // 指向控制块 public: // ... 构造函数、lock(), expired()等 };3.2 关键操作流程拆解3.2.1 构造make_sharedvs 裸指针构造std::make_sharedT(args...) 这是现代C推荐的首选方式。一次性分配足够的内存既能容纳T类型的对象也能容纳其ControlBlock。这通常通过::operator new的布置newplacement new或特化的分配器完成。在分配的内存的前部或后部依实现而定构造ControlBlock初始化shared_count 1,weak_count 1初始的1代表控制块自身被一个隐式的weak_ptr引用实际上weak_count初始值通常为1用于管理控制块自身的生命周期确保没有weak_ptr时控制块也能被正确释放。在紧接着控制块的内存地址上使用args...构造T类型的对象。构造一个shared_ptr将其内部的ptr和cb分别指向刚才构造的对象和控制块。 这个过程高效且异常安全。如果对象构造失败抛出异常已分配的内存会被妥善清理不会泄漏。通过裸指针构造shared_ptrT(new T(args...))先调用new T(args...)在堆上分配并构造对象。如果此处构造失败new操作会抛出std::bad_alloc没有内存泄漏。然后在堆上为ControlBlock分配另一块内存。在控制块中设置ptr指向步骤1的对象初始化shared_count 1,weak_count 1。构造shared_ptr设置其成员指针。 这种方式有两次独立的内存分配效率较低。更危险的是它不是异常安全的。考虑shared_ptrT sp1(new T), sp2(new T);如果sp2的构造在分配控制块时失败内存不足那么sp1已经成功构造但sp2的new T分配的对象内存就会泄漏因为还没有被任何shared_ptr管理起来。因此应避免将new表达式的结果直接传给shared_ptr构造函数或者使用std::make_shared。3.2.2 拷贝与赋值引用计数的舞蹈拷贝构造shared_ptr(const shared_ptr other)将this-ptr和this-cb复制为other.ptr和other.cb。如果cb非空即other非空则通过原子操作将cb-shared_count加1。 这个操作是“浅拷贝”只拷贝指针不拷贝底层对象但增加了所有权份额。赋值运算符shared_ptr operator(const shared_ptr rhs) 赋值操作需要小心处理自赋值和资源释放。首先临时增加rhs的引用计数如果rhs非空。这通过创建一个临时的shared_ptr副本或直接原子加1实现目的是防止rhs和*this是同一个对象时先释放自己导致rhs失效。然后减少*this原来所管理对象的引用计数如果原来有管理对象。这可能会触发原对象的析构和释放。最后将*this的指针指向rhs管理的对象和控制块。返回*this。 标准库的实现通常采用“copy-and-swap” idiom拷贝并交换或类似技术来保证异常安全和正确性。3.2.3 析构释放的连锁反应shared_ptr的析构函数~shared_ptr()是魔法发生的地方如果cb为空即这是一个空的shared_ptr则无事可做。如果cb非空通过原子操作将cb-shared_count减1。检查减1后的shared_count是否等于0如果等于0说明这是最后一个拥有所有权的shared_ptr。 a. 调用删除器或delete销毁ptr指向的用户对象。 b. 将ptr置为nullptr或一个无效值。 c. 接着因为用户对象已销毁需要处理控制块。将cb-weak_count原子减1这次减的是代表“强引用存在”的弱计数实际上在shared_ptr析构中它减少强计数后如果强计数为0它还需要减少弱计数这个弱计数的减少对应的是“强引用所有者”对控制块的引用。更准确地说控制块的weak_count初始为1每个shared_ptr和weak_ptr的构造都会增加它。当强计数归零时shared_ptr析构会减少弱计数这次减少意味着“强引用方放弃了对控制块的依赖”。 d. 检查减后的weak_count注意此时检查的是减操作之后的值。如果weak_count也变成了0意味着没有任何weak_ptr以及控制块自身引用这个控制块了那么就可以delete cb释放控制块内存。如果不等于0说明还有其他shared_ptr存在只减少计数即可。对象和控制块都保持原样。weak_ptr的析构则简单一些如果cb非空将cb-weak_count原子减1。检查减1后的weak_count是否为0。如果为0并且cb-shared_count也为0即对象已死且这是最后一个观察者那么就可以delete cb释放控制块内存。注意weak_ptr析构永远不会触发用户对象的析构。3.2.4weak_ptr::lock()尝试获取临时所有权这是weak_ptr最有用的操作其行为如下检查关联的cb是否为空或者cb-shared_count是否已经为0可以通过原子操作安全地读取一个近似值但判断需谨慎。如果条件成立说明对象已不存在直接返回一个空的shared_ptr。否则它需要尝试“提升”。这通常通过一个原子比较并交换Compare-And-Swap, CAS循环来实现在一个循环中先读取当前的shared_count如果它大于0对象还活着就尝试用原子操作将其增加1尝试获取所有权。这个“增加”操作必须和“其他线程可能正在减少计数导致对象析构”这个事件竞争。如果增加操作成功CAS成功说明在增加的那一刻对象肯定还活着并且你现在拥有了一个对它的引用。此时构造并返回一个管理此对象的shared_ptr。如果增加操作失败比如因为读取后到CAS前计数被其他线程减到0了则回到步骤1重试或直接失败返回空shared_ptr。 这个过程保证了lock()的线程安全性即使对象在lock()执行期间被析构你得到的要么是一个有效的shared_ptr要么是一个空指针永远不会是悬垂指针。4. 多线程场景下的挑战与陷阱理解了原子操作我们还需要看看在多线程编程中实际使用智能指针时有哪些坑等着我们。4.1 线程安全仅限于引用计数再次强调这是最重要的认知。以下代码是危险的std::shared_ptrint sp std::make_sharedint(0); void thread_func() { for(int i 0; i 100000; i) { // 以下操作不是原子的存在数据竞争。 *sp 1; // 即使写成 (*sp); 也一样。 } } std::thread t1(thread_func); std::thread t2(thread_func); t1.join(); t2.join(); std::cout *sp std::endl; // 结果大概率不是200000对sp指向的int值的修改需要额外的同步例如使用std::atomicint*或者用std::mutex保护。4.2 慎用shared_ptr的get()方法get()返回的是内部的裸指针。这个裸指针的生命周期依赖于管理它的shared_ptr。以下情况会导致未定义行为void bad_function(int* raw_ptr) { // 长时间运行或等待... std::this_thread::sleep_for(std::chrono::seconds(1)); *raw_ptr 42; // 灾难此时原始的shared_ptr可能早已析构对象已被销毁。 } void caller() { std::shared_ptrint sp std::make_sharedint(0); std::thread t(bad_function, sp.get()); // 传递裸指针 sp.reset(); // 主线程立即释放所有权对象可能被销毁 t.join(); // 子线程中的 raw_ptr 成了悬垂指针 }永远不要假设通过get()获得的裸指针在后续代码中仍然有效。如果需要在另一个上下文中使用对象应该传递shared_ptr的副本或引用或者使用weak_ptr::lock()来安全地获取临时所有权。4.3shared_ptr作为函数参数以值传递shared_ptr会涉及一次拷贝构造和一次析构增加和减少引用计数这是有成本的原子操作。通常的准则是如果函数需要共享所有权即存储这个shared_ptr在某个生命周期更长的结构中那么按值传递。如果函数只是需要使用对象而不需要延长或影响其生命周期那么应该按const shared_ptrT传递。这避免了不必要的原子操作开销。如果函数内部可能需要接管所有权或修改shared_ptr本身则按shared_ptrT传递。对于weak_ptr通常也按const weak_ptrT传递以避免不必要的拷贝。5. 性能考量与最佳实践智能指针不是零成本的抽象。了解其开销有助于你在正确性和性能间做出权衡。5.1 开销分析内存开销每个被shared_ptr管理的对象至少需要额外一个控制块的内存通常两个原子计数器、两个指针、删除器、分配器等大小可能是原始对象的几倍。make_shared能节省一次分配但对象和控制块内存绑定。时间开销构造/析构/拷贝涉及原子操作比裸指针操作慢得多。在频繁创建和销毁的热路径中这可能成为瓶颈。间接访问通过shared_ptr访问对象(sp-member)通常多一次指针解引用先拿到对象指针与裸指针访问开销相近。但make_shared优化后对象指针可能就是shared_ptr成员指针的直接偏移可能更好。缓存不友好控制块和对象可能分处不同内存位置访问时可能导致缓存失效。5.2 最佳实践指南优先使用std::make_shared它更高效一次分配、更异常安全、代码更简洁。只有在需要自定义删除器或分配器或者对象需要从this指针构造shared_ptr使用std::enable_shared_from_this时才考虑直接构造。避免循环引用在设计对象关系时仔细思考所有权。如果关系是“父拥有子子观察父”那么父对子用shared_ptr子对父用weak_ptr。树形结构通常子节点可以用unique_ptr因为所有权是唯一的。考虑std::unique_ptr如果所有权是独占的毫无悬念应该使用std::unique_ptr。它开销极小通常只是一个指针移动语义清晰能明确表达设计意图。不要滥用shared_ptrshared_ptr不是垃圾收集器。不要因为它方便就在所有地方都用它。清晰的、基于作用域和明确所有权的设计比依赖引用计数自动管理更容易推理性能也更好。警惕多线程开销如果智能指针在单线程内传递和使用某些标准库实现如libstdc的_Sp_counted_base可能有针对单线程的优化版本避免原子操作。但跨线程传递时一定会使用线程安全版本。确保你的使用场景与你的预期一致。对于weak_ptr总是检查lock()的结果不要假设lock()一定成功。必须像下面这样使用if (auto spt weakPtr.lock()) { // 安全地使用 spt spt-doSomething(); } else { // 对象已不存在处理失效情况 }避免从裸指针创建多个独立的shared_ptr这会导致多个控制块管理同一个对象从而造成双重释放。int* raw new int(10); std::shared_ptrint sp1(raw); std::shared_ptrint sp2(raw); // 错误raw 将被释放两次。深入shared_ptr和weak_ptr的源码就像打开了一个精密仪器的外壳看到了里面齿轮如何咬合弹簧如何运作。这不仅能让你在遇到诡异问题时有的放矢更能从根本上塑造你编写安全、高效C代码的思维模式。内存管理没有银弹智能指针是强大的工具但唯有理解其原理和代价你才能真正驾驭它而不是被它表面的自动化所迷惑。下次当你手指悬在std::make_shared上时脑海中能清晰地浮现出控制块和原子计数的运作图景那么这篇长文的目的就达到了。