Debug调试之道:使用Windows API排查程序的线程泄露
本文介绍了Windows系统中程序线程泄露的排查方法。首先分析了线程泄露的三大原因(未正确终止、线程池管理不当、资源依赖未解除)及其后果(资源耗尽、性能下降、系统不稳定)。随后讲解了两种排查工具:Windows资源监视器和Procexp进程资源管理器。重点给出了通过Windows API编写的代码解决方案,包括线程信息结构体定义、线程快照捕获、线程状态比对等核心功能,可输出线程ID、内存地址及所属模块信息,帮助准确定位泄露源。最后演示了如何将监控信息输出到界面或日志中,便于实时分析线程变化情况。

前言
本篇博客主要是讲解如何排查在Windows中程序线程泄露导致异常的情况,由于很多第三方库会存在自己的线程库,并且采用多线程的方式去优化自己算法的执行效率。这个时候我们很难分辨是哪些模块发生了线程创建不释放的情况,只能通过调用Windows API来输出相关的模块信息,所以本篇博客就是将排查Windows中程序线程泄露的思路和代码进行分享
线程泄露的原因和后果
导致线程泄露的主要原因有以下几个:
1.线程未正确终止:线程执行完毕后未调用 CloseHandle(Windows API)或未触发线程退出条件(如未设置终止标志位)
2.线程池管理不当:未使用线程池时,频繁手动创建线程且未回收
3.资源依赖未解除:线程持有对象强引用,导致线程对象无法被垃圾回收
最终会导致进程发生以下异常:
1.资源耗尽:每个线程占用约1MB栈内存(可配置),大量泄露导致进程内存耗尽,并且线程内核对象句柄超限(默认单进程约10,000个),后续线程创建将会失败
2.性能劣降低:泄露线程可能空转或频繁上下文切换,占用CPU资源但无实际工作,或者线程调度开销增大,主线程因资源竞争被阻塞,导致界面卡顿或无响应
3.系统稳定性降低:内存或句柄耗尽触发系统强制终止进程
通过Windows自带的资源监视器监控线程泄露
当我们怀疑进程发生线程泄露时,我们可以通过Windows自带的资源监视器来分析是否是线程泄露,可以在资源监视器中勾选要监控的进程(以百度翻译为例),在线程数一列中可以看到该进程当前的线程总数
PS:通过监视器只能判断是否是线程泄露,并不能知道是哪一个模块发生了线程泄露 
图1.资源监视器判断线程泄露
通过Procexp进程资源管理器分析线程泄露
除去Windows自带的资源监视器以外,我们还可以通过进程资源管理器Procexp进行分析。此处以微信为例,右键要排查的进程,并点击Properties后在Threas选项中查看当前进程的线程数和所属模块。

图2.使用Procexp排查线程泄露
可能有些读者还没有下载过该工具,以下是Procexp的下载链接:
使用Windows API编写代码排查线程泄露
以上两种方式只是通过工具的形式去排查线程泄露的问题,而很多时候发生线程泄露我们并不能很好的分析发生的原因,例如在客户的离线环境上。此时则需要我们的进程本身支持对线程监控的功能,把线程的信息输出到日志中,具体头文件如下:
/** * @struct ThreadInfo * @brief 存储单个线程的核心信息 * @details 用于记录线程ID、所属模块及内存起始地址,便于线程行为分析*/struct ThreadInfo { quint64 tid; // 线程ID QString moduleName; // 线程所属模块(动态库)的文件名 quint64 startAddress; // 线程入口函数的起始内存地址};/** * @struct ThreadDiff * @brief 记录线程状态变化信息 * @details 通过对比两次线程快照,标识新增和终止的线程*/struct ThreadDiff { QVector<ThreadInfo> newThreads; // 新增线程列表 QVector<ThreadInfo> deadThreads; // 已终止线程列表};/*---------- 测试信息-------------*/class testInfo : public QObject { Q_OBJECTpublic: explicit testInfo(QObject* parent = nullptr); // 检测新增/消失线程 ThreadDiff diffThreads(); // 线程检测 QVector<ThreadInfo> listThreads(); // 新增线程检测 QVector<ThreadInfo> listNewThreads();private:#ifdef Q_OS_WIN //仅在 Windows 平台下被编译和生效 // 根据内存地址解析所属模块 QString getModuleFromAddress(quint64 addr);#endif // 保存上一次的线程ID集合 QSet<quint64> lastThreadIds; // 上次完整线程信息 QVector<ThreadInfo> lastThreadList;};由于仅在Windows中生效,所以需要定义宏来保证系统一致。具体的源文件如下:
#ifdef Q_OS_WIN#include <windows.h>#include <tlhelp32.h>#include <psapi.h>#include <QDebug>testInfo::testInfo(QObject* parent) : QObject(parent) {}// NtQueryInformationThread 使用的参数枚举typedef enum _THREADINFOCLASS { ThreadBasicInformation = 0, // 基础线程信息 ThreadQuerySetWin32StartAddress = 9 // 获取线程起始地址} THREADINFOCLASS;/*! * @typedef NtQueryInformationThreadPtr * @brief NtQueryInformationThread函数原型定义 * @param ThreadHandle 线程句柄 * @param ThreadInformationClass 信息类型 * @param ThreadInformation 输出信息缓冲区 * @param ThreadInformationLength 缓冲区长度 * @param ReturnLength 实际返回数据长度 * @return 状态码(0表示成功) */typedef LONG(WINAPI* NtQueryInformationThreadPtr)( HANDLE ThreadHandle, THREADINFOCLASS ThreadInformationClass, PVOID ThreadInformation, ULONG ThreadInformationLength, PULONG ReturnLength);/*! * @brief 获取当前进程所有线程信息 * @return QVector<ThreadInfo> 线程信息列表,包含: * - 线程ID * - 起始地址 * - 所属模块名 */QVector<ThreadInfo> testInfo::listThreads() { QVector<ThreadInfo> threads; DWORD pid = GetCurrentProcessId(); // 获取当前进程ID // 创建系统线程快照 HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); if (snapshot == INVALID_HANDLE_VALUE) return threads; THREADENTRY32 te; te.dwSize = sizeof(te); // 遍历线程快照 if (Thread32First(snapshot, &te)) { do { // 筛选属于当前进程的线程 if (te.th32OwnerProcessID == pid) { HANDLE hThread = OpenThread(THREAD_QUERY_INFORMATION, FALSE, te.th32ThreadID); if (hThread) { // 动态加载NTDLL函数 HMODULE hNtDll = GetModuleHandleA("ntdll.dll"); NtQueryInformationThreadPtr NtQueryInformationThread = (NtQueryInformationThreadPtr)GetProcAddress(hNtDll, "NtQueryInformationThread"); if (NtQueryInformationThread) { PVOID startAddr = nullptr; // 查询线程起始地址 if (NtQueryInformationThread(hThread, ThreadQuerySetWin32StartAddress, &startAddr, sizeof(startAddr), nullptr) == 0) { ThreadInfo info; info.tid = te.th32ThreadID; info.startAddress = (quint64)startAddr; info.moduleName = getModuleFromAddress((quint64)startAddr); // 解析所属模块 threads.push_back(info); } } CloseHandle(hThread); // 关闭线程句柄 } } } while (Thread32Next(snapshot, &te)); // 遍历下一个线程 } CloseHandle(snapshot); // 关闭快照句柄 return threads;}/*! * @brief 检测新增线程(与上次调用比较) * @return QVector<ThreadInfo> 新增线程列表 * @note 内部维护上次线程ID集合用于比对 */QVector<ThreadInfo> testInfo::listNewThreads() { QVector<ThreadInfo> allThreads = listThreads(); QSet<quint64> currentIds; QVector<ThreadInfo> newThreads; for (const auto& t : allThreads) { currentIds.insert(t.tid); // 检测不在上次记录中的线程 if (!lastThreadIds.contains(t.tid)) { newThreads.push_back(t); // 记录新线程 } } lastThreadIds = currentIds; // 更新线程ID记录 return newThreads;}/*! * @brief 检测线程状态变化(新增和终止) * @return ThreadDiff 结构体包含: * - newThreads: 新增线程列表 * - deadThreads: 终止线程列表 */ThreadDiff testInfo::diffThreads() { ThreadDiff diff; QVector<ThreadInfo> currentList = listThreads(); QSet<quint64> currentIds; // 检测新增线程 for (const auto& t : currentList) { currentIds.insert(t.tid); if (!lastThreadIds.contains(t.tid)) { diff.newThreads.push_back(t); } } // 检测终止线程 for (const auto& t : lastThreadList) { if (!currentIds.contains(t.tid)) { diff.deadThreads.push_back(t); } } // 更新线程记录 lastThreadIds = currentIds; lastThreadList = currentList; return diff;}/*! * @brief 根据内存地址解析所属模块 * @param addr 待查询的内存地址 * @return QString 模块文件名(不含路径) */QString testInfo::getModuleFromAddress(quint64 addr) { HMODULE hMods[1024]; DWORD cbNeeded; // 枚举当前进程所有模块 if (EnumProcessModules(GetCurrentProcess(), hMods, sizeof(hMods), &cbNeeded)) { for (unsigned int i = 0; i < (cbNeeded / sizeof(HMODULE)); i++) { MODULEINFO mi; // 获取模块信息 if (GetModuleInformation(GetCurrentProcess(), hMods[i], &mi, sizeof(mi))) { quint64 start = (quint64)mi.lpBaseOfDll; quint64 end = start + mi.SizeOfImage; // 检查地址是否在模块范围内 if (addr >= start && addr <= end) { char modName[MAX_PATH]; // 获取模块完整路径 GetModuleFileNameA(hMods[i], modName, sizeof(modName)); QString fullPath = QString::fromLocal8Bit(modName); return QFileInfo(fullPath).fileName(); // 提取纯文件名 } } } }}#endif对此,我们还需要在main文件中使用编写的接口函数。代码如下:
testInfo monitor;auto threads = monitor.listNewThreads();QString info = QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss.zzz") + "\n";info += QString("当前线程总数:[%1]").arg(monitor.listThreads().size()) + "\n";info += QString("全局线程池活跃线程数[%1]").arg(QThreadPool::globalInstance()->activeThreadCount()) + "\n";ThreadDiff diff = monitor.diffThreads();if (!diff.newThreads.isEmpty()) { info += "新增线程:\n"; for (auto& t : diff.newThreads) { info += QString(" TID:[%1] Addr:[0x%2] Module:[%3]\n").arg(t.tid).arg(QString::number(t.startAddress, 16)).arg(t.moduleName); }}if (!diff.deadThreads.isEmpty()) { info += "消失线程:\n"; for (auto& t : diff.deadThreads) { info += QString(" TID:[%1] Addr:[0x%2] Module:[%3]\n").arg(t.tid).arg(QString::number(t.startAddress, 16)).arg(t.moduleName); }}ui->textEdit->append(info); // 输出到界面中PS:可以选择输出到界面控件中或者日志中,并且也可以通过定时器来限制打印线程信息的频率,减少性能的占用。通过输出的信息来排查究竟是哪一个模块导致的线程泄漏,并进行针对性的解决