ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Qt MinGW版内存持续增长排查:从工具选型到实践全解析

Qt MinGW版内存持续增长排查:从工具选型到实践全解析

排查内存增长这件事,说穿了是一场围绕“怀疑”的持久战:怀疑自己代码写得有问题,怀疑Qt框架哪块缓存没清,甚至怀疑Windows调度器在捣鬼。而“QT MinGW编译器下的内存异常持续增长检查”这个主题,正是我在某跨平台桌面客户端项目里被折磨过最久的一类问题。应用在Windows上跑着跑着,任务管理器里的内存占用就开始匀速爬坡,业务端同事隔几天来催一次重启。听起来像典型内存泄漏,可真要在MinGW这套工具链下动起手来,你会发现一个尴尬事实:MSVC那句顺手的_CrtDumpMemoryLeaks在MinGW下完全不存在,Valgrind在Windows上又不能直接用,Qt Creator自带的分析器在Windows+MinGW这个组合下对原生C++堆内存的检查能力又相当有限。这篇文章就围绕这个场景,把我验证过的工具选型、完整排查流程和实际踩过的坑整理出来,给同样在用Qt+MinGW做Windows桌面应用、又被内存曲线折磨的人一个可落地的参考。

1. 内存异常增长的本质与排查思路拆解

1.1 先搞清楚“内存增长”到底涨的是哪个指标

很多人看到任务管理器里“内存”那一列往上走,就下结论说程序内存泄漏,其实这个结论经常下早了。任务管理器默认显示的是工作集(Working Set),这个数值不仅包含进程真正持有的物理内存,还包含Windows为了效率主动映射进来的缓存页面,系统完全可以在不违反任何内存管理规则的前提下让这个数字上下跳动。排查内存异常增长,最该盯的其实是私有字节(Private Bytes)和提交大小(Commit Size)这两个计数器,它们反映的是进程真正申请并持有、没有归还给操作系统的虚拟内存量。

我自己的习惯是先用Windows自带的性能监视器(perfmon)把进程的Private Bytes加进去,然后固定跑一段业务操作,观察曲线整体趋势。如果Private Bytes是单边持续上涨,几乎不回落,那基本可以断定程序内部有对象在被累积持有,也就是真正需要排查的异常增长;如果只是工作集涨、Private Bytes平稳,那大概率只是页面缓存波动,不用太紧张。这个判断是整个排查工作的起点,方向错了,后面所有工具跑出来的结果都会让你困惑。

1.2 内存增长并不等于内存泄漏:先分类再动手

把“内存增长”笼统归因为“内存泄漏”,会漏掉很多同样让内存曲线飙升的情况。我一般会在动手前先给现象分类,粗粒度来看大概有五种:

类型典型特征常见场景工具能不能直接发现
真泄漏分配后失去引用,永远无法释放new之后没有delete、父对象没设置Dr. Memory、ASan都能直接报
伪泄漏/缓存膨胀对象仍被引用,但持有数量只增不减单例里的列表越攒越长、QPixmapCache无限写入工具只会报“still reachable”,需要人工判断
生命周期过长对象功能上不需要了,却还被某个长生命周期对象持有窗口关闭但未delete、信号槽连接未断开工具难直接定性,需要审查对象关系
隐式共享造成的假象单个元素很小,但背后共享着大缓冲区QString/QByteArray拷贝切片、QImage局部引用工具看不出逻辑,需要熟悉容器语义
碎片化膨胀总分配量没涨,但堆碎片让空闲内存难以复用频繁申请释放不同大小对象工具基本无能为力

你看,真正能靠泄漏检测工具一步到位的只有第一类,剩下的还得靠代码理解。所以我的排查原则是:先把现象归类,再决定上什么工具。如果Private Bytes涨得很规律,每次固定操作涨固定幅度,那基本都是对象持续new出来没释放,工具会非常给力;如果增长节奏不规律、幅度忽大忽小,就要多考虑缓存和生命周期问题。

1.3 为什么MinGW环境下这个问题格外麻烦

这套问题放在MinGW下,难度会翻倍。原因很现实:MSVC生态里那套调试接口——_CrtSetBreakAlloc、_CrtDumpMemoryLeaks、Visual Studio自带的诊断工具——在Windows上确实是好东西,可MinGW用的是GCC运行时,这套CRT调试接口根本没有,连想靠Visual Studio的诊断窗口分析一个MinGW编译出来的exe都做不到。Valgrind的Memcheck在设计上高度依赖Linux的ptrace机制,Windows下跑不起来,有人尝试在WSL里跑Valgrind,但那只能分析Linux版本的程序,对你的Windows Qt应用毫无帮助。

好在MinGW-w64这条路线这些年一直在发展,两个主力替代方案可用:一个是动态插桩工具Dr. Memory,不需要重新编译源码,另一个是编译期插桩的AddressSanitizer,配合LeakSanitizer在较新的GCC版本上已经能工作。只是它们各有各的脾气,用得不对会让你白折腾大半天,后面我会把关键坑都点出来。

2. 工具选型解析:Windows下MinGW能用的三套内存检查方案

2.1 Dr. Memory:Windows上最接近Valgrind的选择

Dr. Memory是我在MinGW环境下最先尝试的工具,它在思路上和Valgrind很像,都是动态二进制插桩:不需要你重新编译程序,它直接拦截目标进程的内存分配和释放调用,在程序运行时收集所有堆操作。它最大的优势是兼容性好,只要是带调试符号的Debug构建,管你是MinGW编译的还是MSVC编译的,它都能分析。

使用上有一个前提必须满足:程序必须是Debug构建,而且要带-g调试符号。Release构建或者优化等级开太高(比如-O2)会让很多分配点的行号信息消失,报告里的调用栈会变得没法看。启动方式很简单,命令行下这样执行:

drmemory.exe -- your_qt_app.exe

程序退出后,Dr. Memory会自动生成报告,重点看LEAK条目。它会告诉你泄漏的内存大小、分配次数以及分配时的调用栈。我这里有一个真实场景的简化报告片段:

Error #1: LEAK 64 bytes in 1 allocation(s) allocation site: mainwindow.cpp:45 in MainWindow::openConfigDialog 0x0000000140012FAB in operator new(unsigned long)

如果报告里出现了很多指向Qt库内部的分配点,先别慌,那多半是Qt的全局缓存和线程池在退出时没有被完全清理,属于需要过滤的杂音。我的做法是把报告中的调用栈筛选一遍,只留下指向自己源码文件的部分,那才是真正需要查的地方。

2.2 AddressSanitizer与LeakSanitizer:编译期插桩方案

第二个方案是AddressSanitizer,通常配合LeakSanitizer一起用。它的原理是在编译阶段往代码里插入大量检查逻辑,运行时拦截堆操作并记录每次分配的调用栈。相比Dr. Memory,ASan的速度快很多,跑起来不那么煎熬,而且报告的调用栈质量非常高,直接对应源码行号。

在MinGW下启用ASan,首先确认你的编译器够新。我建议使用GCC 10以上的MinGW-w64工具链,老版本离线安装包里带的编译器可能压根不支持-fsanitize=address。CMake构建时这样配置:

cmake_minimum_required(VERSION 3.16) project(memory_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(memory_demo main.cpp) if(WIN32) target_compile_options(memory_demo PRIVATE -fsanitize=address -g -O1) target_link_options(memory_demo PRIVATE -fsanitize=address -static-libasan) endif()

加了-static-libasan是想把ASan运行时静态链接进程序,减少动态库加载顺序带来的问题。这是我在实际项目中踩出来的经验:如果不静态链接,经常会遇到程序一启动就弹出ASan runtime does not come first的报错,原因是可执行文件启动时ASan运行时没有被最先加载,某些预编译的Qt库抢先加载了依赖库,导致运行时地址错乱。遇到这种报错,先检查是不是忘了静态链接,如果加上了还是报错,那就别再死磕,直接切回Dr. Memory,工具是服务目标的,不是用来跟它较劲的。

2.3 不依赖外部工具的最小方案:私有字节打点日志

不管用哪个检测工具,我都会先给自己留一条“后路”:在程序里埋一个内存打点日志。这条后路的好处是零依赖、零侵入式调试,而且能长期保留。具体做法是用Windows API读取当前进程的内存计数器,然后用定时器每隔一秒输出一次,重定向到日志文件。

#include <windows.h> #include <QDebug> #include <QTimer> #include <QDateTime> double currentPrivateMb() { PROCESS_MEMORY_COUNTERS pmc; pmc.cb = sizeof(pmc); if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc))) { return static_cast<double>(pmc.PrivateUsage) / (1024.0 * 1024.0); } return -1.0; } void startMemoryLog(QObject *parent) { QTimer *timer = new QTimer(parent); QObject::connect(timer, &QTimer::timeout, []() { qInfo().noquote() << QDateTime::currentDateTime().toString(Qt::ISODate) << currentPrivateMb() << "MB"; }); timer->start(1000); }

这段代码在MinGW下编译时需要链接psapi库,CMake里加一行target_link_libraries(memory_demo psapi)就行。平时它就是个隐藏得很深的小日志开关,只在排查时打开。它可以帮你回答两个关键问题:内存增长是不是稳定的?复现条件是什么?这两个问题不搞清楚,后面跑任何工具都像无头苍蝇。

3. 完整排查实操:从复现到定位再到回归验证

3.1 搭建可复现的基线场景

任何内存问题的排查,第一步都是把“现场”固定下来。你要是直接在正式环境里开着几十个窗口、让用户随机操作,那跑什么工具都白搭,报告里全是杂音。我的做法是把业务操作拆成一个固定动作序列,比如:启动程序->打开配置对话框->填入数据->关闭对话框->等两秒->再打开。然后循环这个序列两百次,用内存打点日志记录整个过程的Private Bytes变化。

我当时在某个模拟项目X里就复现得特别规律:每打开关闭一次配置对话框,内存Private Bytes稳定增加0.2MB左右。这个现象本身就强烈指向一个结论——对话框对象每次打开都new了一个新实例,关闭时并没有释放。有了这个稳定的基线,后面所有工具的验证都有了参照物。如果你连最小场景都无法稳定复现,那说明问题藏在更复杂的业务分支里,这时候不要硬来,先去缩小复现路径,把无关代码注释掉,一步一步逼近问题点。

3.2 用Dr. Memory跑第一轮:把可疑分配点逼出来

基线确定之后,我先把程序切到Debug构建,确保带-g -O0,然后用Dr. Memory启动程序,按固定动作序列操作一遍,最后正常退出程序。这里有个细节:一定要让程序正常退出,不要从任务管理器里强杀进程。Dr. Memory是在进程退出时做泄漏汇总的,强杀会导致报告缺失或格式混乱。

跑出来的报告质量取决于你的基础环境。我当时的报告核心内容是:

ERRORS FOUND: -------------- Error #1: LEAK 260 bytes in 1 allocation(s) @ 0x0000000002F1A040 allocation location: configdialog.cpp:78 in ConfigDialog::ConfigDialog mainwindow.cpp:45 in MainWindow::openConfigDialog

调用栈很清楚:分配点在ConfigDialog构造函数的第78行,调用者是主窗口的openConfigDialog。也就是说,每次打开配置对话框都会new一个对话框对象,而关闭对话框时这个对象没有被释放。报告里这个LEAK的含义是“进程退出时仍然存在且没有有效引用的内存”,它的可信度很高。

拿到这个结论后,我不急着改代码,而是先去源码里验证这个分配点是否真的具备泄漏条件。打开openConfigDialog看了一眼,果然:

void MainWindow::openConfigDialog() { auto *dlg = new ConfigDialog(); // 没有传入父对象,也没有保存指针 dlg->setAttribute(Qt::WA_DeleteOnClose); // 没设置关闭即删除 dlg->show(); }

new出来的对象没给父对象、没存成员指针、也没设置WA_DeleteOnClose,那么对话框关闭时默认只是hide(),对象本身一直挂在堆上,直到进程退出才被Dr. Memory抓个正着。这就是最典型的Qt内存泄漏写法。

3.3 用ASan再做二次确认:拿到更完整的调用栈

Dr. Memory给出了方向,但我习惯再用ASan确认一次,因为它的报告格式更适合交叉验证。我把CMake配置切到上一节那个带AddressSanitizer的版本,重新构建,跑同样的动作序列,程序正常退出时,LeakSanitizer会在控制台输出泄漏摘要,大致长这样:

ERROR: LeakSanitizer: detected memory leaks Direct leak of 260 byte(s) in 1 object(s) allocated from: #0 0x7ff68a54a9b2 in operator new(unsigned long) #1 0x7ff68a3f1a10 in ConfigDialog::ConfigDialog configdialog.cpp:78 #2 0x7ff68a3f1d05 in MainWindow::openConfigDialog mainwindow.cpp:45 #3 0x7ff68a3f1e8f in MainWindow::openConfigButtonClicked mainwindow.cpp:31

两个工具指向同一行号,到这个程度基本可以下结论了。注意跑ASan时同样要保证程序走完整个场景并且正常退出,因为LeakSanitizer是在退出阶段做最终扫描的。如果你用某种方式强杀进程,或者程序本身因为其他错误崩溃退出,报告可能就出不来了。

3.4 修复与回归验证:内存曲线拉平才算完

定位到根因之后,修复并不复杂。针对上面那个泄漏,最简单的办法是给对话框设置父对象,或者设置WA_DeleteOnClose属性,二者选一个就好。我自己更推荐设置父对象,因为这同时解决了生命周期管理和父子窗口的层级关系:

void MainWindow::openConfigDialog() { auto *dlg = new ConfigDialog(this); dlg->setWindowModality(Qt::WindowModal); dlg->show(); }

有了父对象,主窗口析构时对话框会跟着释放;对话框关闭默认只是隐藏,但父对象在,不会泄漏。改完后重新构建,再跑一遍同样的固定动作序列,这次用内存日志对比:修复前每循环一次涨0.2MB,循环两百次后Private Bytes涨了40MB;修复后同样两百次循环,Private Bytes几乎是一条直线,稳定在某个平台值附近。到这一步,这个问题的排查才算真正划上句号。

4. 常见问题与排查技巧实录

4.1 高频踩坑速查表

跑了很多轮之后,我把自己遇到过的坑整理成了一个速查表,每次排查前先扫一眼,能省下不少时间:

现象常见原因解决方案
Dr. Memory启动即崩溃杀毒软件拦截插桩进程,或程序路径含中文把exe和日志目录放到纯英文路径,给Dr. Memory加白名单
ASan报“ASan runtime does not come first”运行时加载顺序冲突CMake里加-static-libasan;不行就换Dr. Memory
任务管理器内存高但Private Bytes平稳工作集缓存波动用perfmon或打点日志看Private Bytes,别盯工作集
Debug能复现,Release复现不了优化改变了对象的生命周期和调用时机先以Debug构建为主排查,Release问题单独看业务逻辑
Dr. Memory报一大堆Qt库内部泄漏Qt全局缓存、线程池退出时未清理只看调用栈指向自己源码的条目,其余过滤
改完一处泄漏内存还是涨内存增长往往是多个泄漏点叠加一次只修一个点,修完立刻回归验证

4.2 那些“看着像泄漏”但不是泄漏的隐蔽场景

排查多了你会发现,不是所有内存增长都能归咎于new了没delete。有一种情况特别迷惑,就是Qt容器的隐式共享机制。看这段代码:

QByteArray fullData = readLargeFile(); // 假设50MB QList<QByteArray> chunks; for (int i = 0; i < 1000; ++i) { chunks.append(fullData.left(100)); // left返回的只是“引用”,背后共享fullData }

你以为存了1000个100字节的切片,总共才100KB,可实际上每个切片都共享着底层那50MB的大缓冲区,引用计数涨到了1001,内存占用直接飙升50MB。这种增长很难被泄漏检测工具定性,因为对象引用关系完全合法,工具只会认为它们是“仍然可达”的内存。要发现这类问题,靠的是对Qt容器语义的熟悉,而不是单纯跑工具。

还有一类是信号槽连接累积。往同一个信号上反复挂lambda槽,每次连接都会造成闭包对象累积,如果连接发生在循环或者高频刷新函数里,内存和调用开销会同时上涨。这类问题靠工具也难定位,代码审查时多留个心眼。

4.3 用Git二分缩小引入范围

当工具报告里什么都查不出来,或者你怀疑是多处改动共同导致问题时,Git二分是一个非常可靠的兜底手段。思路很简单:先确认一个内存稳定、一个内存异常的两个历史版本,然后用git bisect在中间翻找,每次checkout一个中间版本,重新构建,跑固定动作序列,读取内存日志里Private Bytes的峰值或增长斜率,判定当前版本是“好”还是“坏”。

我实际用的判定脚本思路不复杂,就是启动程序,循环跑固定动作N次,结束后把日志里最后几个时间点的Private Bytes读出来,如果比基线版本高过一定阈值就判为坏。每一步判定都自动化,二分大概六七轮就能定位到具体某次提交。找到问题提交之后,再结合提交信息里的改动文件,往往很快就能锁定根源,这比漫无目的地读代码高效得多。

5. 经验沉淀:一条更省时的排查路线

这几轮排查下来,我对工具和代码的关系有了更深的体会。工具只负责告诉你“内存是在哪里分配的”,它不会告诉你“这个分配是不是多余的”。Dr. Memory和ASan能精确到文件行号,但真正判断“该不该释放、该在什么时候释放”的,还是你对对象生命周期的理解。所以我现在的排查路线是固定的:先埋内存打点日志确认现象和复现条件,再用Dr. Memory跑一轮粗定位,随后用ASan做精细确认,最后回到代码层面做生命周期审查,修复后靠日志回归验证。如果是隐蔽的缓存膨胀或信号槽连接累积,工具给不出结论时,就切到代码审查加Git二分。

最后再分享一个小技巧:内存日志功能别等出问题再临时加,平时就可以做成一个隐藏菜单项或环境变量开关,线上版本也保留这个能力。出问题的时候,你让现场同事开一下开关,跑一段时间把日志发回来,你就可以在家复现、定位、验证,整个流程会顺畅非常多。这个习惯帮我省过不止一次远程救火的麻烦。

RELATED READING

延伸阅读

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