首页 / 资讯中心 / 文章详情

C++ shared_ptr循环引用检测:从原理到实战的排查与修复指南

C++ shared_ptr循环引用检测:从原理到实战的排查与修复指南 ★ FEATURED ARTICLE
做项目做久了你会发现真正的内存泄漏往往不是没人释放而是你觉得自己已经释放了内存却还在那儿赖着不走。我在维护某跨平台系统的消息模块时就撞上过一例进程退出时对象的析构日志一条不少堆内存却像被人按住了排水口只进不出。定位到最后是两个组件互相用shared_ptr指着对方引用计数永远降不到零代码里看着“有引用、有释放”实际谁都没被释放。C的shared_ptr循环引用排查起来隐蔽就隐蔽在它不像悬空指针那样直接崩溃也不像忘记delete那样有明显空行等着你补。它更像两个人在门口互相扶着对方不让对方走结果两个人都被困在原地。这篇文章就围绕“循环引用检测”这件事把我实际用过、验证过的方案完整拆一遍怎么从原理上判断环、如何用对象引用图做自动检测、怎么配合LeakSanitizer定位环路、以及拆环时的设计取舍。适合所有正在维护中大型C项目、对智能指针有基础但想往深处排障的人。1. 循环引用的本质与典型场景1.1 从引用计数的数学看泄漏要谈检测先得把shared_ptr的内存模型说透。一个std::shared_ptrT由两部分组成指向对象本身的裸指针以及指向控制块的指针。控制块里最关键的两个计数是use_count强引用计数和weak_count弱引用计数。对象析构的条件只有一个use_count归零。弱引用计数只影响控制块本身的释放不影响对象寿命。循环引用之所以致命是因为它可以让use_count在数学上永远不可能归零。假设A对象内部持有B的shared_ptrB对象内部持有A的shared_ptr那么A的use_count B内部引用数 外部引用数B同理。哪怕你清空了外部所有引用内部这两条边依然互相支撑着对方计数至少是1。从代码看你只创建了对象、只传递了指针、只在析构里做了清空没有任何明显错误从内存看这两个对象就是Linus说的“永远不释放”。这里有个容易忽略的细节如果用std::make_sharedT()创建对象对象和控制块是同一块连续内存。循环引用发生时不光是对象没法析构连着那一整块控制块也跟着泄漏你看到的泄漏体积会比预期的大不少。而用new传入shared_ptr构造函数的话对象和控制块分开对象泄漏是一块控制块又是另一块。这个差别在LeakSanitizer的报告里还挺明显的前者通常只显示一块“still reachable”内存后者会显示两块。1.2 最容易踩坑的三种场景循环引用不是只在教科书例子里出现实际项目中最容易挂在下面三类结构上第一类是树形或图结构中的父子互持。父节点用一个容器装子节点的shared_ptr子节点为了访问父节点又在内部存一份父节点的shared_ptr。这种模型在UI控件树、场景管理、配置继承链里非常常见。问题在于开发者写“子节点持有父节点”时脑子里想的是“引用”而不是“所有权”于是顺手用了shared_ptr。养成了习惯之后只要树再深一层每个节点都夹带一条指向祖先的回边环就出来了。第二类观察者模式。观察者对象持有被观察者的shared_ptr以便查询状态被观察者内部维护一个观察者列表也是shared_ptr列表。两边都觉得自己只是在“保存一个回调对象”结果就是互指成环。而且这种耦合往往分散在不同模块排查时需要同时看发布者和订阅者的生命周期。第三类lambda闭包捕获shared_ptr。这类最阴险因为它连对象之间的显式成员变量都没有环藏在事件系统里。常见的写法是这样一个类void runTask() { auto self shared_from_this(); someService-postCallback([self]() { self-onComplete(); }); }这段代码约等于“把我自己拷贝一份交给服务”服务一直存着这个闭包闭包又持有我。如果this本身还被服务间接持有环就形成了。等任务结束后闭包不释放整个对象也跟着被锁死。你检查成员变量怎么也看不到谁引用谁因为那条边是闭包捕获列表里的一条隐式边。2. 检测方案的总体设计2.1 几种检测思路的对比我先按实际经验把循环引用检测的手段分个层次。最原始的手段是代码审查。让你一帧一帧地检查每个对象的成员变量和lambda捕获列表找出所有shared_ptr的指向关系然后手动判断是否存在环。这个方法在小demo里可行但在十几万行代码的项目里基本靠运气人脑默认会把引用关系当成无环的树而现实中对象图往往是带环的图。第二层是用工具做事后的泄漏检测。Valgrind的memcheck、GCC/Clang的LeakSanitizer都能查出“内存没被释放”其中LeakSanitizer因为跑在AArch64和x86上都很顺、开销相对低更适合准入测试。但这类工具有一个硬伤它只能告诉你“这块内存是泄漏的”不会告诉你“谁和谁互相持有形成了环”。对循环引用来说这个信息恰恰是最关键的。你拿到一块泄漏栈看到的往往只是对象的构造函数、某次make_shared调用完全看不到环路的另一头在哪。第三层是运行时对象引用图追踪。原理很简单把程序里所有关键对象的引用关系看作一张有向图A持有B的shared_ptr就是一条边A → B。循环引用就等于这张图里存在一个环。只要我们能在运行期间收集这张图并定期检测有没有环就能在内存泄漏发生前或发生当下拿到完整路径。这种方式定位精度最高缺点是需要在代码里做一些侵入式改造。我最终采用的方案是第三层为主、第二层为辅用对象引用图找出环路用LeakSanitizer来验证环确实造成了泄漏。2.2 核心设计对象引用图与显式登记在真正落地之前我列过两个约束第一不能要求团队把所有shared_ptr都换成自定义类型第二不能为了检测一个环把所有路径都跑一遍。所以我没有选择“自动追踪所有智能指针”这种宏大的路子而是采用了一个折中的设计只追踪需要关注生命周期关键对象。具体来说定义了一个TracingBase基类需要监控的类都继承它。基类构造函数会把this地址登记进一张全局引用图析构函数再把地址注销。这样对象的“生死”完全自动进入追踪范围。引用关系的记录则靠一个非常轻量的工具函数或者一个TrackedPtr模板类。在实际代码里我更常直接这样写class MessageNode : public TracingBase { public: MessageNode(std::shared_ptrMessageNode parent) : m_parent(parent) { // 登记一条 this → parent 的引用边 ObjectGraph::instance().ref(this, parent.get(), MessageNode.parent); } private: std::shared_ptrMessageNode m_parent; };在构造函数里顺手登记边的成本极低而且因为登记和析构注销都是成对出现的即使代码跑到一半崩溃也只是丢了一条边记录不会把进程拖住。等到周期性的检测时间点到来全局引用图就能回答有没有环环的具体路径是什么是哪个成员变量负责把环闭合的。2.3 为什么不上全套自动追踪有人可能觉得既然要建引用图干脆把std::shared_ptr重写一遍所有拷贝、赋值都自动登记检测不是更省事吗理论上确实如此但现实里代价太大。第一项目里往往存在大量已经写好的代码成员类型写死为std::shared_ptrT除非全局搜索替换否则没法自动捕获。全量替换带来的编译时间、二进制体积、第三方库兼容性问题会立刻淹没检测工具本身的价值。第二全自动追踪会制造海量噪音。一个稍微复杂一点的对象可能通过临时变量、容器拷贝、接口传参产生几百条中间边其中大部分和循环引用没有任何关系。追踪器如果把这些全记下来生成的对象图会大到没法看。我见过某个开源项目做过类似的全量追踪结果dump出来的图光打印路径就要几分钟。第三追踪器的正确性需要和代码同步维护。任何“全自动”方案的本质都是把很多逻辑藏起来一旦隐藏逻辑出问题你分不清是业务代码有环还是追踪器本身造了个假环。显式登记虽然侵入但每当开发者写下一行ref(this, other)时就等于在声明一条语义清晰的引用关系这种“声明式”的代码其实对查错更友好。所以我的结论是检测器要做的不是“监控一切”而是“监控你怀疑的地方”然后把怀疑变成证据。3. 循环引用检测器的实现细节3.1 引用图追踪器的骨架全局引用图的核心数据结构不复杂一个从对象地址到类型名的映射一个从对象地址到出边列表的映射。我基于DFS染色法写了一个找环实现实际项目里可以直接改改就用// ObjectGraph.h #pragma once #include mutex #include string #include unordered_map #include unordered_set #include vector class ObjectGraph { public: static ObjectGraph instance(); void addObject(const void* addr, const std::string typeName); void removeObject(const void* addr); void ref(const void* from, const void* to, const std::string edgeName); void unref(const void* from, const void* to); void dumpCycles() const; private: struct Record { void dfs(const void* cur, std::vectorconst void* path, std::unordered_setconst void* visiting, std::unordered_setconst void* done, const ObjectGraph* graph) const; std::string typeName; std::vectorstd::pairconst void*, std::string edges; }; mutable std::mutex m_mutex; std::unordered_mapconst void*, Record m_nodes; };找环采用DFS染色法。每个节点有三种状态未访问、正在访问、访问完成。当DFS沿着出边走到一个“正在访问”状态的节点说明当前路径上出现了环。这种写法比拓扑排序更适合输出完整路径因为递归栈里天然保存了当前探索链。void ObjectGraph::dumpCycles() const { std::lock_guardstd::mutex lock(m_mutex); std::vectorconst void* path; std::unordered_setconst void* visiting; std::unordered_setconst void* done; for (auto [addr, record] : m_nodes) { if (done.count(addr)) continue; std::functionvoid(const void*) dfs [](const void* cur) { visiting.insert(cur); path.push_back(cur); auto it m_nodes.find(cur); if (it ! m_nodes.end()) { for (auto [to, edgeName] : it-second.edges) { if (visiting.count(to)) { // 打印环 std::string cycleLog 检测到循环引用: ; auto start std::find(path.begin(), path.end(), to); for (auto p start; p ! path.end(); p) { cycleLog m_nodes.at(*p).typeName; cycleLog - ; } cycleLog m_nodes.at(to).typeName; cycleLog (闭合边: edgeName ); std::cerr cycleLog std::endl; } else if (!done.count(to)) { dfs(to); } } } path.pop_back(); visiting.erase(cur); done.insert(cur); }; dfs(addr); } }这段代码有个小讲究edgeName是给“闭合边”用的它记录了“到底是谁手里那条shared_ptr把整个环给焊死了”。定位到环之后最想知道的不是“有个环”而是“哪条引用是多余的”。edgeName在这时候能少翻很多代码。我建议检测器不要做成实时每次都扫描。简单项目里可以在定期心跳里跑更省事的做法是暴露一个dumpCycles()在进程收到特定信号或退出前手动调用一次。让检测器在业务代码跑得最稳定的时候加载比在运行高峰期扫描更容易定位问题。3.2 给引用图喂数据的正确姿势前面说过我不建议全量自动追踪。那实际工程里怎么喂数据呢我总结了一套标准操作流程。第一步识别需要追踪的对象类型。典型的是Manager、Service、Container这种生命周期长、互相引用复杂的对象。普通值对象和数据对象不需要追踪因为它们的生死由上面的管理器控制不会自己成环。第二步让这些类型继承TracingBase。基类构造时登记析构时注销class TracingBase { public: TracingBase() { ObjectGraph::instance().addObject(this, typeid(*this).name()); } virtual ~TracingBase() { ObjectGraph::instance().removeObject(this); } };注意typeid(*this).name()在构造阶段并不一定是多态后的真实类型因为虚表可能还没完全建立。我在实际项目里更愿意用一个显式的类型名参数比如TracingBase(MessageBus)。别迷信RTTI尤其在构造和析构阶段RTTI会给你意外结果。第三步在需要的地方登记边。有两种方式。一种是改造后使用TrackedPtrT它在赋值和拷贝时自动调ref和unref另一种是我推荐的、侵入更小的方式——在成员变量发生改变的地方显式调用ObjectGraph::instance().ref(this, target, memberName)。我平时最常用的是一条宏或者一个内联辅助函数inline void trackRef(const void* from, const void* to, const char* edgeName) { if (from to) { ObjectGraph::instance().ref(from, to, edgeName); } }然后代码里写着m_worker std::make_sharedWorker(); trackRef(this, m_worker.get(), m_worker);这套方案真正的价值在于当你需要排查一个具体泄漏时不用把所有代码都插桩只需要给怀疑链路上的对象加上宏重跑一次复现场景dump出来的路径就会精确地告诉你环在哪。定位一个环基本半小时以内。3.3 配合LeakSanitizer做现场取证引用图dump能指出环但它本身证明不了“这个环真的造成了泄漏”。万一环上的对象还有其他外部强引用那也谈不上泄漏。为了闭环验证我习惯在复现泄漏的时候同时开启LeakSanitizer。编译阶段加上g -fsanitizeaddress -fsanitizeleak -g -O1 -o app app.cppASan自带LSan。运行时通过环境变量控制报告的详细程度ASAN_OPTIONSdetect_leaks1 LSAN_OPTIONSreport_objects1:print_suppressions0 ./appreport_objects1非常重要它会在报告里列出每个泄漏堆块的内存地址。这个地址和引用图里对象的地址可以对应上如果你在引用图dump里看到一个环上的对象地址恰好也出现在LSan的泄漏列表里那基本实锤了。有一种常见情况是LSan报告显示泄漏对象是用shared_ptr创建的栈上只有make_shared和构造函数看不到谁会释放它。这时候再用引用图跑一遍路径就会把“谁持有谁”展开。两者的关系就像雷达和望远镜雷达告诉你前方有障碍物望远镜告诉你那只是一个易拉罐还是一条船。夏洛克的推论链条走到这里基本完整环被找到了对应对象也确实没释放接下来就该谈怎么拆环了。4. 修复把环拆开的方法论4.1 weak_ptr在哪条边上动手最安全检测出环之后最常见的修复动作是把环上的某一条shared_ptr换成weak_ptr。但选哪条边是个技术活选错了可能引入悬空引用运行起来时不时崩溃。原则其实很明确谁拥有谁释放而非谁引用谁保命。在父子结构中父节点持有子节点的shared_ptr代表着所有权子节点回持父节点的shared_ptr只是为了方便向上查找并不代表子节点应该决定父节点的生死。所以默认把“子→父”这条边改成weak_ptr而不是把“父→子”那条边改成weak_ptr。如果两条边语义上都是“强依赖”那说明设计上就有问题一个对象不该同时拥有两个互相都得活着的关系方。比如两台服务器互相持有了对方的客户端连接还都允许对方决定自己的生命周期这在架构上就是死锁的雏形换weak_ptr只是临时救火。weak_ptr的使用有个配套习惯先lock()再使用不要直接weak_ptr-xxx调用。因为lock()会原子的增加临时强计数防止对象在你访问时被另一个线程释放。很多人写wx-xxx()时觉得更简洁但weak_ptr不保证对象存活这种简洁是拿命换的。4.2 从结构上消灭环的几种重构有些场景换成weak_ptr只是把问题藏起来了。比如回调闭包捕获shared_ptr构成的自环单纯改成弱引用可能会让回调执行时对象已经被销毁你还是要在回调入口处做空判断。所以更彻底的办法是结构重构。第一种结构重构是把“互持”改成“注册表ID引用”。A不直接持有B而是持有B的字符串ID需要访问B时通过一个CentralRegistry去查。这样A和B之间根本没有边不可能成环。代价是每次访问从“解引用”变成了“查表解引用”对性能敏感、调用频率极高的内层循环不太友好。第二种结构重构是引入事件总线。A和B互相引用往往是为了互相通知状态变化。与其让A直接调用B的方法不如让A发布事件到消息总线B订阅这个事件。订阅是一种“弱关系”总线自身不拥有订阅者订阅者也不拥有总线。这样对象图里的强边大幅减少环自然消失。第三种最暴力但有时最高效把其中一个对象的shared_ptr改成拥有者持有的unique_ptr。如果B是A内部的私有组件且B的存活完全由A决定那么unique_ptr就是合理的。B反过来需要访问A时用A的裸指针或weak_ptr即可。这大大减少了引用计数的开销也把“A→B”这条强边变成了非共享的所有权边。4.3 一个消息链路的修复记录我在某后台服务的消息处理链路里碰到过这样一个场景一个TaskProcessor持有一堆Worker每个Worker在完成工作后会回调TaskProcessor的完成函数。当时的设计是Worker内部存了一份TaskProcessor的shared_ptrTaskProcessor的Worker列表里也存了Worker的shared_ptr。两边都觉得自己只是在“保证自己的回调查得通”结果形成硬环。我分两步修复。第一步把Worker内部的shared_ptrTaskProcessor改成weak_ptrTaskProcessor。回调时void Worker::onComplete() { auto processor m_processorWeaks.lock(); if (!processor) return; // 处理器已经离开不处理 processor-notifyTaskDone(this-taskId()); }第二步把TaskProcessor持有的Worker从shared_ptr改成unique_ptr。因为TaskProcessor就是这些Worker的唯一创建者和销毁者没有任何其他对象需要单独持有某个Worker。这样原图上的双向强边变成了单向“拥有者”边环就彻底没了。修复之后最明显的变化是进程退出时的析构日志再也不是“日志全打但内存不降”的诡异状态而是所有Worker析构之后TaskProcessor跟着析构顺序清晰且彻底。顺带一提日志里再也没出现过那种让人头皮发麻的“某个对象析构了两次”的重复项因为不再是多个强引用交叉管理同一个对象了。5. 常见问题与排查技巧实录5.1 工具选型对照不同的循环引用场景适合不同的工具我把常用的几张牌列个表工具手段定位精度运行开销对代码侵入适用阶段代码审查中无无每次评审适合当规范和预防手段Valgrind memcheck低只能报泄漏地址极高无复现疑难问题慢但准ASan/LSan低只能报泄漏地址中等编译参数CI接入回归检测自研引用图高能报环路径和闭合边低~中需插桩定位阶段配合上面工具使用实际项目里我建议这样组合CI阶段开ASan/LSan用来拦“发生时”的泄漏遇到怀疑是循环引用但LSan只给了个still reachable的堆块时再上自研引用图。把引用图当“显微镜”而不是当“检测仪”效率最高。Valgrind虽然慢但它有一个独特优势能精确报告内存的完整生命周期分配/释放序列甚至有引用计数变化时给出的历史信息。有时候循环引用内部的计数变化痕迹自研图追踪没法完全模拟我就会用Valgrind单独跑一个小型复现用例。5.2 检测器本身容易踩的坑写引用图追踪器最典型的坑是静态析构顺序问题。如果ObjectGraph::instance()返回的是一个函数内的局部静态对象而某个追踪对象在静态析构阶段才被析构那这个对象析构时就会访问一个可能已经被销毁的追踪器直接崩溃。我的做法是把全局追踪器设计成“永不销毁”的模式也就是用new创建且故意不释放让操作系统在进程退出时统一回收。对一个调试工具来说泄漏一个固定大小的单例对象完全可接受。第二个坑是ref和unref的不配对。边既有记录函数就得有删除函数否则对象明明已经析构引用图里却还留着它的出边等下次扫描就会碰到悬空地址。为了减少这种错误我在项目里倾向于边不单独做unref而是等removeObject时同步删除与该对象相关的所有出入边。也就是“人没了影子也别留”。第三个坑是线程安全。引用图的插入、删除、扫描都可能发生在不同线程我这里用的是全局互斥锁。扫描只在dump时短暂锁住对业务影响很小。如果你要高频扫描可以把m_nodes做成读写锁但那属于后话调试工具不需要那么快。第四个坑比较隐蔽enable_shared_from_this和引用图的冲突。如果某个对象继承自enable_shared_from_this并且在构造函数里调用shared_from_this()会直接抛异常因为此刻控制块还没有绑定。我的引用图基类不会触发这个但如果你一边用追踪一边又在构造函数里shared_from_this就会莫名踩雷。建议所有追踪对象的构造阶段都不要尝试创建自身的强引用。5.3 一套可以复用的排查流程面对一个“疑似循环引用”的泄漏我建议按下面五步走步子不用乱第一步复现并锁定范围。让程序跑一个可复现的压测场景用ASan确认泄漏堆块存在记下对象类型和创建栈。第二步打开引用图追踪。只给怀疑链路上的类型挂上TracingBase并加边记录。复现一次后调用dumpCycles()如果输出里能看到一条完整的环路径恭喜证据链完成一半。第三步对照LSan确认“泄漏堆块”归属。对比引用图里的对象地址和LSan报告的still reachable地址确认环上的对象已经成了泄漏对象。第四步读环确定闭合边。环的闭合边一般就是某个成员变量或某个lambda捕获列表。看它的语义是“所有权”还是“临时观察”。所有权的那条保留临时观察的那条换成weak_ptr。第五步修复后复跑压测。确认LSan不再报泄漏引用图里也不再有环。顺手清理一下插桩代码或者保留追踪类供未来调试用但别让它长期开着跑在生产环境。这套流程我在多个项目里重复用过排查单个循环引用基本都在一天内收工。最难的部分往往不是找环而是说服自己“原来这里真的是环”——因为代码看着实在太合理了谁都会觉得“我保存一下他他保存一下我很自然啊”。这个心理关克服之后工具和技术都只是时间问题。6. 一些长线经验最后说点个人体会。循环引用这个问题的根源其实不在shared_ptr的使用方法上而在对象所有权的设计上。C里“指针”和“所有权”长期混为一谈shared_ptr出现之后更是让人误以为“只要用shared_ptr就能永远安全”。循环引用就是一个挤兑式的提醒如果你不明确谁拥有谁所有权就会自己纠缠成一团。我后来在项目里制定了一条不成文规矩任何对象内部保存的另一对象必须先回答“它在我这里是为了住下来还是为了找我聊天”。凡是“聊天”性质的引用一律weak_ptr或裸指针凡是“住下来”的性质才用shared_ptr。这条规矩推广之后循环引用出现的频率直接降了一个数量级排查工具反而用得越来越少了。另外引用图追踪器虽然好用但我劝你不要一上来就做全量插桩。先做最小可复现的路径把证据链走通了再考虑把它做成CI的一环。工具是为了找准问题服务的不是为了证明你的工具比别人大。定位完问题的工具最好的归宿是从生产代码里优雅退场需要时再把它唤回来。
阅读完成 · 觉得有帮助?
咨询建站