资讯动态

C++智能指针循环引用检测:运行时引用图方案与工程实践

发布时间:2026/10/18 20:31:19 来源:尧图企业网站定制
搞C时间长了几乎每个人都遇过这么一件事程序跑得好好的内存却一路涨最后在凌晨三点被监控告警叫醒。我最初碰到这种问题时第一反应是查裸指针泄漏给所有new都加了日志结果一个没抓到。后来才意识到真正的问题不是new了没delete而是shared_ptr互相引用析构函数从头到尾压根没被调用。这篇文章就围绕C智能指针循环引用检测方案展开。我会以一个实际排查案例为引子先讲清楚循环引用为什么难发现再盘点常规检测手段的边界然后给出我最后落地的运行时引用图检测方案包括能直接参考的代码骨架和实测中踩过的坑。如果你正在维护一个长期运行的服务或者接手了一个成员变量全是shared_ptr的老项目这篇应该能帮到你。1. 先搞明白shared_ptr循环引用是怎么把泄漏伪装成正常存活的1.1 对象释放的本质条件是强引用计数归零shared_ptr的实现其实不复杂它自身保存两个指针一个指向目标对象一个指向控制块。控制块里是这个对象的强引用计数、弱引用计数、删除器等信息。当最后一个强引用被释放时强引用计数从1降到0控制块才调用删除器真正销毁对象。这里的关键点是析构函数只有在强引用计数归零时才执行。如果两个对象互相持有对方的shared_ptr那么A的强引用计数是1被B持有B的强引用计数也是1被A持有这个状态就是僵局。外部不管是栈上还是容器里已经没有任何指针能访问到它们了但它们彼此撑着对方谁也不肯先松手。我把这个状态比喻成两个站在冰面上的人互相扶住对方看着很稳实际上已经没人能引导他们上岸。程序里的表现就是没有崩溃、没有越界、没有报错甚至内存工具看到的堆块都是正常存活的——因为它们确实还没被释放。1.2 最容易中招的两个典型代码模型第一个是链式结构尤其是双向链表和图。看这段代码struct Node { std::shared_ptrNode next; std::shared_ptrNode prev; }; auto n1 std::make_sharedNode(); auto n2 std::make_sharedNode(); n1-next n2; n2-prev n1;单独看每一行赋值都没问题甚至n1和n2在栈上的shared_ptr销毁之后很多人会觉得引用计数应该减为0了吧。实际不是。因为n1被n2的prev字段持有n2被n1的next字段持有两个计数各自停在1。第二个是回调闭包的自持有。这种情况更隐蔽一般出现在把this传进异步任务的代码里。为了让回调生命周期安全有人会在lambda里捕获一个shared_from_this()class Service : public std::enable_shared_from_thisService { public: void Start() { worker_ std::thread([self shared_from_this()] { self-RunLoop(); }); } private: std::thread worker_; };如果这个线程本身是Service的成员那么Service持有threadthread的lambda捕获了Service的shared_ptrService的强引用计数永远不归零。这种自循环比两个对象互相引用更隐蔽因为从变量关系上看只有一条链却照样泄漏。1.3 为什么这种泄漏特别难排查它难是因为它把一个无法释放的对象伪装成了一直有人用的对象。分配点的调用栈早就退出了你dump内存时看到的只是一个孤零零的堆块没有任何指向它的引用——至少静态分析不出来。内存工具只能报告这块内存没有被释放但不会告诉你它内部还有一根链锁着自己。我遇到的那次泄漏内存曲线并不是陡峭上涨而是非常温和的斜坡每天涨一点点到第四天才OOM。这种曲线很容易被误判为请求量波动实际上就是循环引用的对象在慢慢累积。想靠人工盯曲线发现它基本等于大海捞针。2. 盘点常规检测手段哪些真的有用哪些只是心理安慰2.1 人工review的局限不在眼睛而在思维模型你叫几个人一起review代码每个人看局部代码时都会觉得没问题。root-head n1没问题n1-next n2没问题n2-prev n1看着也正常。循环引用不是局部代码错误而是对象之间跨函数、跨模块的图式关系人脑很难在一个review会议里快速构建出完整的生命周期图。更麻烦的是很多循环是运行期装配的。比如两个节点在某个if分支里才互相设置对方指针静态阅读时很难把所有路径穷举完。所以靠review只能防住一眼就看出来的循环动态形成的循环几乎防不住。2.2 静态分析工具的边界只能抓编译期可见的循环常见的静态分析工具会尝试检测类A持有shared_ptr类B又持有shared_ptr 这种成员类型互指的情况。这是编译期可见的循环确实能提示。但真正让你头疼的循环往往不是类型层面的互指而是运行期动态建立的引用链。比如A持有BB持有CC在某个回调里拿到了A的shared_ptr并保存下来。这条链在类型定义里根本看不出来静态分析工具没有运行时信息自然无法判断。你要是在大型项目里依赖静态分析兜底大概率会落空。它不是没用只是覆盖面有限查得越深误报和漏报都越严重。2.3 use_count断言和自定义deleter为什么都派不上用场有些人会在关键对象的析构函数里加断言期望看到use_count() 1。这个思路对提前释放有一点点用但对循环引用完全无效——因为析构函数压根不会执行断言根本没机会触发。还有人用自定义deleter在对象被释放时记录当时的使用计数。问题是一样的循环引用对象永远不会被释放deleter永远不会被调用。你装了一个计数器但这个计数器的触发条件永远不会满足。所以我当时的结论是继续盯着计数看没有意义。检测循环引用的正确姿势应该是从计数是否归零转向从根集合是否可达。一个对象就算强引用计数不为0只要根集合里没有一条引用链能走到它它就处于逻辑死亡状态——这就是检测器要抓的核心特征。3. 引用图可达性检测方案把对象关系当成一张图来扫3.1 检测原理节点、边、根集合这个方案的思路很简单把被测对象之间的关系组织成一张有向图。节点每个被shared_ptr管理的堆对象记录地址和类型名。有向边如果对象A内部有一个shared_ptr成员指向对象B就建一条从A到B的边。根集合程序里当前还能直接访问对象的入口比如全局变量、栈上的shared_ptr、长期存活的容器。然后从根集合出发做一次广度优先或深度优先遍历。遍历结束后凡是图里存在、但没有被访问到的节点都属于从根不可达的节点也就是逻辑泄漏。如果这群不可达节点内部存在互相指向的边那么它们就是典型的循环引用团伙。注意不可达不一定是循环也可能是某个对象被释放后遗留的悬垂记录。所以报告里要输出引用边信息让排查者自己判断。3.2 引用边从哪里来显式注册是目前最务实的选择聊到实现绕不开一个问题引用图的信息怎么收集自动收集有几个方向我逐个评估过。第一重载全局new并扫描对象内存找出成员变量里shared_ptr的裸指针。听起来很美但实际上对象的成员偏移是编译器决定的shared_ptr内部布局也不稳定扫描内存不仅慢而且容易把整数误认成指针属于高成本高风险。第二把项目里的shared_ptr全部替换成自定义智能指针在赋值操作里自动注册。这个改动量太大老代码基本不可能接受而且自定义智能指针要完整模拟shared_ptr的语义坑多到数不完。第三从标准库控制块的内部实现入手挂钩引用计数增减。这个依赖具体的标准库实现换个编译器就崩没法进正式工程。所以当时我选了最朴素的做法一个全局注册表加少量显式插桩。在被测对象创建时注册节点在shared_ptr成员赋值时注册一条边最后在检查点触发扫描。多写几行代码换来的是不侵入标准库、不挑编译器、逻辑完全可控。3.3 什么时候扫描最合适测试阶段查不在生产环境硬扛这个检测器只推荐在测试环境用。扫描本身需要锁住注册表否则多线程下边的关系一直在变扫出来也是错的。我用的触发点有这么几个单元测试跑完后的固定检查点、集成测试里内存水位超过阈值时的自动dump、还有退出前的手动触发。生产环境不挂这个工具一方面是锁开销不可接受另一方面是它会产生大量日志压垮磁盘。4. 核心实现骨架注册表、加边、DFS扫描与报告输出4.1 注册表的结构设计这个注册表不需要花哨核心是三个操作注册节点、添加边、扫描报告。我使用了一个自增的id作为节点身份。没有直接用裸指针当id是因为裸指针在节点释放后可能被新对象复用会造成身份错乱。自增id在整个进程生命周期内唯一至少在测试场景够用。内部结构如下instances_从id到InstanceRecord的映射记录地址、类型名、出边。roots_根节点id集合。addr_to_owned辅助用同一个裸指针重复注册时返回已有id。多线程方面我用了一个最简单的mutex。测试工具不追求性能只要不错乱就行。4.2 可直接参考的refgraph.h代码#pragma once #include cstdint #include iostream #include mutex #include string #include unordered_map #include unordered_set #include vector namespace refgraph { struct EdgeRecord { uint64_t target_id; std::string field_name; }; struct InstanceRecord { uint64_t id 0; void* address nullptr; std::string type_name; std::vectorEdgeRecord out_edges; }; class Registry { public: static Registry Get() { static Registry instance; return instance; } uint64_t RegisterInstance(void* address, const std::string type_name) { std::lock_guardstd::mutex lock(mutex_); for (const auto [id, rec] : instances_) { if (rec.address address) { return id; } } InstanceRecord rec; rec.id next_id_; rec.address address; rec.type_name type_name; instances_[next_id_] std::move(rec); return next_id_; } void AddEdge(uint64_t owner_id, uint64_t target_id, const std::string field_name) { std::lock_guardstd::mutex lock(mutex_); if (instances_.count(owner_id) 0 || instances_.count(target_id) 0) { std::cerr [refgraph] warn: unknown edge owner_id - target_id std::endl; return; } auto edges instances_[owner_id].out_edges; for (const auto e : edges) { if (e.target_id target_id e.field_name field_name) { return; } } edges.push_back({target_id, field_name}); } void AddRoot(uint64_t id) { std::lock_guardstd::mutex lock(mutex_); roots_.insert(id); } void ScanAndReport() { std::unordered_setuint64_t reachable; std::vectoruint64_t work; { std::lock_guardstd::mutex lock(mutex_); for (uint64_t r : roots_) { work.push_back(r); } } while (!work.empty()) { uint64_t id work.back(); work.pop_back(); if (!reachable.insert(id).second) { continue; } std::lock_guardstd::mutex lock(mutex_); auto it instances_.find(id); if (it instances_.end()) { continue; } for (const auto e : it-second.out_edges) { if (!reachable.count(e.target_id)) { work.push_back(e.target_id); } } } std::lock_guardstd::mutex lock(mutex_); for (const auto [id, rec] : instances_) { if (reachable.count(id)) { continue; } std::cout [refgraph] unreachable id id addr rec.address type rec.type_name std::endl; for (const auto e : rec.out_edges) { auto it instances_.find(e.target_id); if (it instances_.end()) { continue; } std::cout field[ e.field_name ] - id e.target_id type it-second.type_name (reachable.count(e.target_id) ? [reachable] : [unreachable]) std::endl; } } } private: std::mutex mutex_; std::unordered_mapuint64_t, InstanceRecord instances_; std::unordered_setuint64_t roots_; uint64_t next_id_ 1; }; } // namespace refgraph这段代码里我特意在AddEdge里加了未知id的告警。如果两个对象都被正确注册过这条告警不应该触发。一旦触发说明有节点在你不知道的时候被回收了或者RegisterInstance被漏调用这就是排查线索。4.3 在一个双向链表用例上的完整用法#include memory #include refgraph.h struct Root { std::shared_ptrstruct Node head; }; struct Node { std::shared_ptrNode next; std::shared_ptrNode prev; }; std::shared_ptrRoot g_root; void TestWithCycle() { auto root std::make_sharedRoot(); std::shared_ptrNode a std::make_sharedNode(); std::shared_ptrNode b std::make_sharedNode(); refgraph::Registry reg refgraph::Registry::Get(); uint64_t root_id reg.RegisterInstance(root.get(), Root); uint64_t a_id reg.RegisterInstance(a.get(), Node); uint64_t b_id reg.RegisterInstance(b.get(), Node); reg.AddRoot(root_id); root-head a; reg.AddEdge(root_id, a_id, head); a-next b; reg.AddEdge(a_id, b_id, next); b-prev a; // 制造循环 reg.AddEdge(b_id, a_id, prev); reg.ScanAndReport(); } int main() { TestWithCycle(); return 0; }输出大致是这个样子[refgraph] unreachable id2 addr0x5593... typeNode field[next] - id3 typeNode [unreachable] [refgraph] unreachable id3 addr0x5593... typeNode field[prev] - id2 typeNode [unreachable]这里root对象因为被注册为根而且从根可达所以不会被报告。a和b互相指向且不可达报告一次出现两个节点每条引用边的落点也标了[unreachable]看一眼就能确定是循环。4.4 插桩代码多可以用宏减少重复显式写RegisterInstance和AddEdge确实啰嗦。为了降低使用成本我封装了下面两个宏只在测试文件里用#define REFGRAPH_TRACK(ptr, type_name) \ refgraph::Registry::Get().RegisterInstance((ptr).get(), type_name) #define REFGRAPH_EDGE(owner_id, target, field_name) \ refgraph::Registry::Get().AddEdge(owner_id, REFGRAPH_TRACK(target, #target), field_name)使用时只需要先拿到owner_id然后一行注册边uint64_t a_id REFGRAPH_TRACK(a, Node); REFGRAPH_EDGE(a_id, b, next);宏有个天然缺点它只能拿到变量的字符串名拿不到运行时动态对象名所以报告里的type_name可能不太准确。对于定位问题来说够用了。真正的线上插桩我建议还是用显式AddEdge保持日志质量。5. 实测结果与踩坑记录不是扫一遍就完事的5.1 双向链表报告一眼定位循环我在一个模拟的项目X里做了实验场景是链表尾节点持有一个prev指针指回头节点但只有链表头被外部持有。跑了上面的检测器输出很干净两个不可达节点互相引用定位时间不到一分钟。如果循环有五个节点报告会把这五个都列出来边的朝向也清楚。唯一的麻烦是报告顺序取决于unordered_map的遍历顺序不稳定。如果你想看完整链路需要自己从某个不可达节点出发再遍历一次把环打印出来。当时我图省事直接在报告里把所有带[unreachable]出边的节点都标了出来人工也能拼出环。5.2 回调自持有关键是把隐藏引用也注册成边回调自持有的场景比链表难一些。因为持有关系隐藏在std::function内部不会直接在类的成员定义里出现。我在Service::Start里加了这样一段uint64_t service_id reg.RegisterInstance(self.get(), Service); uint64_t task_id reg.RegisterInstance(task_ptr.get(), Task); reg.AddEdge(service_id, task_id, worker_thread); reg.AddEdge(task_id, service_id, lambda_capture_self);扫描时就能看到Service和Task互相不可达从而确认循环。这个案例告诉我一个经验引用边不仅包括类成员直接持有的shared_ptr还包括lambda捕获、回调上下文、异步任务参数里保存的引用。漏了任何一条边报告都会失真。5.3 多线程和扫描时机锁、快照、停业务注册表加了mutex但扫描时只是逐个锁住注册操作并不代表拿到一致的图快照。因为另一个线程可能正在执行a-next b和AddEdge之间的代码边还没注册完扫描就开始了结果把本应可达的对象误报成不可达。我的处理方式是扫描前让测试线程池完全Join确保所有赋值动作已经结束再执行ScanAndReport。如果做不到就把扫描放在一个固定的同步点比如单测结束后的主线程。另外扫描过程中实例不会被销毁因为测试期间我们不会在扫描前清理被测对象。这点要特别注意如果你在测试中途把某个对象reset了然后立刻扫描注册表里那个id就变成本质上不存在的节点会带来各种奇怪的误报。5.4 三个真实踩过的坑你可能也会遇到坑一裸指针地址复用导致身份错乱。某个对象释放后新分配对象落到了同一地址RegisterInstance按地址查重就直接返回了旧id。getId拿到旧id后新对象的引用关系全记到了旧节点上。缓解办法是用能区分代际的id设计例如记录注册时刻的时间戳和对象use_count如果use_count是0就说明这是一个已死地址的复用。但标准库不会让你轻易拿到某个裸指针当前是否被shared_ptr管理的状态所以我在测试环境中干脆不频繁创建销毁被测对象让复用概率降到最低。坑二移动语义下的注册遗漏。auto b std::move(a)之后a的裸指针变成了nullb的裸指针指向原对象。如果你在移动前注册了a移动后忘记注册b引用图里就少了一个节点。解决办法是只在移动和赋值完成后统一对最终持有对象的shared_ptr做一次RegisterInstance不要迷信变量名。坑三根集合漏登记。栈上的shared_ptr在扫描时其实还活着但它没有被登记为根结果它指向的正常可达对象被误报成不可达。这个坑特别容易在写test case时出现你心里知道这个对象肯定还活着但检测器不知道。务必要把所有作用域内持有的shared_ptr都显式AddRoot。5.5 自动化方向上未来可以怎么走显式插桩能解决大部分测试场景但确实有一定维护成本。如果项目规模大可以考虑把RegisterInstance和AddEdge封装进一个tracked_shared_ptrT在构造和赋值时自动调用。这个思路不是不可行只是在老代码里替换所有shared_ptr太伤筋动骨只适合在新建模块中试用。还有一个更激进的方向利用控制块分配记录。很多标准库的make_shared会一次分配控制块对象的内存如果能捕获这段内存的首地址就有机会从里面解析出对象指针。但这是ABI级的依赖换个编译器或者换一个标准库版本就要重写风险很高。我的建议是测试期用显式插桩验收后把插桩代码删掉不给自己留一个脆弱的长期依赖。6. 比检测更省力的事情在代码设计阶段多加三条约束6.1 持有关系规范谁该用unique_ptr谁该用weak_ptr检测工具解决的是已经出现了问题怎么查但更高性价比的做法是不让问题出现。我现在的新代码里有一条几乎不变的规矩所有权明确、单一归属的一律用unique_ptr需要共享可空引用而反向引用不是强依赖的一律用weak_ptr。最常见的设计是父子结构父持有子用unique_ptr子反查父用裸指针或weak_ptr。比如双向链表节点之间就不该用两个shared_ptr互相指正确写法是next用unique_ptrprev用weak_ptr或者反过来。只要有一边是弱引用引用计数就不会被锁死。6.2 Review时我必问的三个问题每次代码审查看到类成员里有shared_ptr我都会连续问三个问题这个字段在哪些地方被赋值赋值来源是外部传入还是内部创建如果这个字段指向的对象反过来又指向自己所属对象用的是强引用还是弱引用这个对象有没有通过lambda或回调把this传出去传出去之后谁还持有它第三个问题是最容易漏的。很多人能注意到类成员互指但注意不到lambda捕获。实际上回调闭包自持有在异步框架代码里出现频率极高建议review时单独划出一类检查项。6.3 什么情况下才值得上运行时检测器我也被人问过既然设计规范能预防是不是永远不用检测工具我的看法是分情况。如果是十几万行以上的存量项目历史包袱已经背上了靠设计和review清理不现实运行时检测器就是有必要的。它能在测试阶段把循环引用候选簇全部列出来再结合代码上下文判断哪些是真泄漏。如果你的项目是不超过几万行的新项目成员关系还清楚我建议先把设计规范落地检测器可以缓一缓等出现第一个谜之内存曲线时再上也不迟。我个人最满意的做法是平时把检测器代码编译成单独一个测试目标不进生产包新代码严格按持有关系规范写每季度跑一次全量单测并扫描注册表。这样既不会让检测器成为日常负担也保留了发现新问题的能力。最后再分享一个小技巧如果测试代码里注册了很多对象扫描报告会非常长。前期可以先过滤掉那些has no out_edges的不可达节点因为它们只是简单的过早释放不是循环引用。重点看那些既有[unreachable]起始又有[unreachable]出边的节点循环引用一定出现在这群节点里。这个过滤规则能帮你把十分钟的排查缩短到一分钟。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑