ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Windows性能数据采集:PDH API从入门到实战

Windows性能数据采集:PDH API从入门到实战

做 Windows 上的性能采集,绕不开 PDH 这个词。我最早认真研究 PDH(Performance Data Helper)是在写一个轻量监控客户端的时候:每隔一两秒要从本机甚至远程机器上拿 CPU、内存、进程级 CPU 占用,然后推给中心端汇总。一开始偷懒用 PowerShell 的 Get-Counter,高频采样时开销大得明显;换 WMI 也不理想,封装太舒服,反而看不到底下的消耗。最后老实回到 C++ 直接调 PDH API,才把采样开销压下去,也真正搞懂了 perfmon 界面上那些数字是哪儿来的。这篇就把我在落地 PDH 时用到的最小可用代码、路径语法、实例名处理和一堆踩坑记录整理出来,适合想自己写 Windows 监控采集器,或者想把性能计数器搬进自己程序里的开发者。

1. 为什么最终选了 PDH:性能数据读取的四条路

1.1 常见方案逐个排掉

Windows 上读性能数据的路子不少,但每一条都有明显的取舍。我先把试过的方案按实际情况摆出来:

方案优势短板适合场景
perfmon 图形界面可视化直观,自带“添加计数器”窗口,能看实例和计数器列表不方便自动化,长时间挂机占资源人工看趋势、临时故障分析
typeperf 命令一条命令出 CSV,快速验证路径是否有效逻辑组合弱,输出要解析文本,没法嵌入程序快速验证、简单日志
WMI(Win32_Perf* 系列)脚本友好,能结合 WQL 查询格式化开销大,高频采集会拖慢系统低频管理脚本
Get-Counter / .NET PerformanceCounter封好了 PDH,开发速度快对象创建和释放开销不小,不适合亚秒级循环中低频桌面工具和脚本
PDH API 直调开销小、控制粒度细、支持远程、能拿原始值和时间戳只适合 C/C++ 或做绑定封装,细节都得自己管高频采样、嵌入业务程序

我最后选 PDH 还有一个原因:上面那些封装层,大多数底层最终都会落到同一套 PerfLib 机制上。与其隔着几层封装去猜它什么时候采样、什么时候取数,不如直接站在数据出口旁边,自己控制节奏。

1.2 PDH 到底负责什么

要理解 PDH,先要摆正它的位置。PDH 不是数据源本身,系统里真正的性能数据来自各种 provider:内核、系统服务、第三方程序注册的性能对象。PDH 是这些 provider 和应用程序之间的“数据帮手”,负责把原始数据按计数器类型换算成可读的数值。

所以你在 perfmon 的“添加计数器”对话框里看到的树形结构,其实和 PDH 能枚举出来的东西是同一份。先用 perfmon 找到想要的对象、实例、计数器,再把那条路径抄进代码,是最省事的入门方式。

2. 把计数器当成文件系统树看:对象、实例、路径语法

2.1 三层结构:对象、实例、计数器

性能计数器的组织方式很像一棵三层的树:性能对象(Performance Object)下面可以有多个实例(Instance),每个实例下面有一堆计数器(Counter)。

举个例子:

  • Processor对象有_Total、0、1、2这样的实例,每个实例下面有% Processor Time、% User Time等计数器。
  • Memory对象没有实例,直接挂Available Bytes、Committed Bytes等计数器。
  • Process对象的实例就是进程名,出现同名进程时系统会自动加上#1、#2后缀。

路径语法就是把这三层串起来:

\\机器名\对象(实例#索引)\计数器

本地机器可以把机器名省略成两个反斜杠加对象名。下面几条是我最常用的路径:

\Processor(_Total)\% Processor Time \Memory\Available Bytes \Process(explorer)\% Processor Time \PhysicalDisk(_Total)\% Disk Time \Network Interface(以太网)\Bytes Total/sec

实际写在 C/C++ 字符串里时,反斜杠要转义成两个:

L"\\Processor(_Total)\\% Processor Time"

另外要注意:如果实例名里带括号、空格、百分比号之类的特殊字符,整个实例要加引号。比如有一个进程名带括号,路径要写成\Process("my(app)")\% Processor Time。

2.2 先枚举一遍看看有什么

不想靠猜路径,可以先用 API 把机器上所有对象和实例拉出来。PdhEnumObjectItemsW可以一次性拿到某个性能对象下所有实例名和计数器名:

DWORD bufSize = 0; DWORD itemCount = 0; // 第一次调用只拿缓冲区大小,返回 PDH_MORE_DATA PdhEnumObjectItemsW( NULL, // 数据源,NULL 表示本地 NULL, // 机器名 L"Process", // 对象名 NULL, &bufSize, // 实例名缓冲区 NULL, &itemCount, // 计数器名缓冲区 PERF_DETAIL_WIZARD, 0 ); wchar_t* instBuf = (wchar_t*)malloc(bufSize * sizeof(wchar_t)); wchar_t* cntBuf = (wchar_t*)malloc(itemCount * sizeof(wchar_t) * 256); // 稳妥起见按实际返回调整 PdhEnumObjectItemsW(NULL, NULL, L"Process", instBuf, &bufSize, cntBuf, &itemCount, PERF_DETAIL_WIZARD, 0); // 实例列表是双重 null 结尾的,手动遍历 wchar_t* p = instBuf; while (*p) { wprintf(L"%s\n", p); p += wcslen(p) + 1; } free(instBuf); free(cntBuf);

这个 API 在不知道“目标进程到底叫什么实例名”的时候特别好用,后面讲进程级监控时还要拿它兜底。

2.3 用 English 版本计数器,绕开本地化坑

中文 Windows 上,计数器名是本地化的,比如% Processor Time会变成“% 处理器时间”。如果你用PdhAddCounterW去加英文路径,大概率直接返回PDH_CSTATUS_NO_COUNTER。

解决办法是用PdhAddEnglishCounterW。这个函数强制按英文路径解析,不管系统是什么语言环境都能找到对应计数器。我的习惯是一律用它,这样代码放在哪个语言版本的系统上都不用改。

对应的,枚举类函数如果想拿英文名,需要结合PdhLookupPerfNameByIndex做转换,但实际开发中大多数场景直接用PdhAddEnglishCounterW就够了,不需要碰那套索引转换。

3. 第一版能跑的最小示例:查询、采样、取值全流程

3.1 核心流程只有五个函数

PDH 的使用流程非常固定,闭着眼都能背下来:

  1. PdhOpenQuery创建一个查询句柄。
  2. 对这个查询反复调用PdhAddEnglishCounter添加计数器。
  3. PdhCollectQueryData触发一次数据采集。
  4. 用PdhGetFormattedCounterValue把采集到的原始数据格式化成可读值。
  5. 最后PdhCloseQuery释放。

所有计数器都挂在同一个查询上,一次PdhCollectQueryData就能把它们都采一遍,比每个计数器单独开查询要高效得多。

3.2 一个同时采 CPU 和内存的完整示例

下面这个例子能跑出完整流程:采集整机 CPU 利用率和可用内存,每秒一次,打印 10 次。

#include <windows.h> #include <pdh.h> #include <cstdio> #pragma comment(lib, "pdh.lib") int main() { PDH_HQUERY hQuery = NULL; PDH_HCOUNTER hCpu = NULL; PDH_HCOUNTER hMem = NULL; if (PdhOpenQueryW(NULL, 0, &hQuery) != ERROR_SUCCESS) { printf("PdhOpenQuery failed\n"); return 1; } if (PdhAddEnglishCounterW(hQuery, L"\\Processor(_Total)\\% Processor Time", 0, &hCpu) != ERROR_SUCCESS) { printf("add cpu counter failed\n"); return 2; } if (PdhAddEnglishCounterW(hQuery, L"\\Memory\\Available Bytes", 0, &hMem) != ERROR_SUCCESS) { printf("add mem counter failed\n"); return 3; } // 先采一次做预热,第一次通常拿不到有效数据 PdhCollectQueryData(hQuery); Sleep(1000); PdhCollectQueryData(hQuery); for (int i = 0; i < 10; i++) { Sleep(1000); PDH_STATUS status = PdhCollectQueryData(hQuery); if (status != ERROR_SUCCESS) { printf("collect failed: 0x%08X\n", status); continue; } PDH_FMT_COUNTERVALUE v; PdhGetFormattedCounterValue(hCpu, PDH_FMT_DOUBLE, NULL, &v); if (v.CStatus == ERROR_SUCCESS) { printf("CPU: %.2f%%\n", v.doubleValue); } PdhGetFormattedCounterValue(hMem, PDH_FMT_DOUBLE, NULL, &v); if (v.CStatus == ERROR_SUCCESS) { printf("Free Mem: %.2f MB\n", v.doubleValue / 1024.0 / 1024.0); } } PdhCloseQuery(hQuery); return 0; }

编译时注意两件事:MSVC 下#pragma comment(lib, "pdh.lib")会自动链接;MinGW 环境就手动加-lpdh。头文件要包含<pdh.h>。

3.3 为什么第一次采集基本都会“失败”

这个示例里我先PdhCollectQueryData一次、睡 1 秒、再采一次,这不是多余动作。CPU 占用率这类计数器是“速率型”计数器,靠两个时间点的原始值做差才能算出百分比。第一次采集时,PDH 只有当前这一个点的数据,没有上一个基底,所以取格式化值时往往得到PDH_NO_DATA或PDH_INVALID_DATA。

正确姿势就是先让它采一次当作 warm-up,然后按正常节奏继续。这个坑几乎每个刚上手的人都会踩一次,知道原理以后就不会再困惑了。

4. 采样时会咬人的细节:首帧无数据、格式化标志位和原始时间戳

4.1 返回值顺利不代表数据有效

很多人拿到PdhGetFormattedCounterValue返回ERROR_SUCCESS就觉得数据没问题了。不对,还要检查PDH_FMT_COUNTERVALUE结构里的CStatus字段。这个字段才是当前这个值是否可用的最终裁决者。

实际开发里我写过这样的检查逻辑:只要CStatus != ERROR_SUCCESS,这个样本就直接丢弃,不要参与任何统计。尤其是刚启动程序的前几秒,CStatus常见的值是PDH_CSTATUS_INVALID_DATA或PDH_CSTATUS_NO_DATA,属于正常现象,继续循环就好。

4.2 格式化标志位不要乱选

PdhGetFormattedCounterValue的第二个参数是格式化类型,常用几个如下:

标志用途说明
PDH_FMT_LONG整数适合文件大小、线程数这类整数计数
PDH_FMT_DOUBLE双精度浮点CPU 百分比、速率类首选
PDH_FMT_LARGE64 位整数字节数、自启动以来的总计数
PDH_FMT_NOCAP100附加标志不让 PDH 把百分比强制截断到 100

NOCAP100这个标志容易忽略。默认情况下,某些百分比计数器会被 PDH 自动限制在 0 到 100 之间。如果你在多核机器上测进程 CPU,看到值一直被压到 100% 上不去,就可能是这个标志没加。加上PDH_FMT_DOUBLE | PDH_FMT_NOCAP100能拿到真实累加值,比如 8 核满载可能是 800%。

4.3 想要真实时间戳,去拿原始值

采集数据要上报给时序数据库时,每个点得带上采集时间。最开始我贪方便直接用本机当前时间,后来发现不行:查询、网络传输、格式化都有延迟,本机时间和计数器真正对应的采样时刻总有偏差,慢速场景下可能错好几秒。

PDH 里提供了PdhGetRawCounterValue,可以拿到原始计数器值和采样时间戳:

PDH_RAW_COUNTER raw = {}; PdhGetRawCounterValue(hCpu, NULL, &raw); // FILETIME 转 Unix 秒 ULONGLONG ft = ((ULONGLONG)raw.TimeStamp.dwHighDateTime << 32) | raw.TimeStamp.dwLowDateTime; ULONGLONG unixSec = (ft - 116444736000000000ULL) / 10000000ULL;

raw.TimeStamp才是系统真正记录这个样本的时刻,比“取完数再打当前时间”准确得多。做时间对齐、多指标聚合时,强烈建议用这个值。

4.4 采样间隔要留够

绝大多数性能计数器要求至少 1 秒的采样间隔,太快会导致两个采样点时间差太小,速率值抖动剧烈甚至无效。我们在项目里统一用 1 秒作为最低档位,只有个别瞬时状态类指标(比如某些直接计数型)才允许缩短。

Sleep(1000)的精度也有限,Windows 非实时时钟的实际粒度可能到 15.6 ms,但这对秒级采样完全够用。如果要求严格等间隔,可以用CreateWaitableTimer这类高精度定时器,不过那是另一套话题了。

5. 按 PID 盯指定进程:实例名这座大山要这么翻

5.1 进程实例名是个流动的靶子

盯单个进程的 CPU,最直接的路径是\Process(名字)\% Processor Time。问题在于“进程实例名”一点都不可靠:

  • 同名进程多开时会出现explorer、explorer#1、explorer#2。
  • 实例编号会随着进程退出而重新分配,不能当作稳定标识。
  • 进程退出后,用旧实例名加计数器会得到PDH_CSTATUS_NO_INSTANCE。

可靠的做法是:用 PID 当唯一标识,实时把 PID 映射到当前实例名,再按名字取 CPU 值。

5.2 一个通配符加两个数组的取数方案

Process对象支持通配符路径。我用的方案是同时加两个通配符计数器:

PdhAddEnglishCounterW(hQuery, L"\\Process(*)\\ID Process", 0, &hId); PdhAddEnglishCounterW(hQuery, L"\\Process(*)\\% Processor Time", 0, &hCpu);

每次采样后,用PdhGetFormattedCounterArrayW把两个计数器对应的实例列表都读出来,ID Process数组负责把 PID 映射到数组下标,再拿这个下标去 CPU 数组取数:

DWORD sz = 0, count = 0; PdhGetFormattedCounterArrayW(hId, PDH_FMT_LONG, &sz, &count, NULL); // 第一次返回 PDH_MORE_DATA,sz 里是所需大小 PDH_FMT_COUNTERVALUE_ITEM* ids = (PDH_FMT_COUNTERVALUE_ITEM*)malloc(sz); PdhGetFormattedCounterArrayW(hId, PDH_FMT_LONG, &sz, &count, ids); DWORD sz2 = 0, count2 = 0; PdhGetFormattedCounterArrayW(hCpu, PDH_FMT_DOUBLE, &sz2, &count2, NULL); PDH_FMT_COUNTERVALUE_ITEM* cpus = (PDH_FMT_COUNTERVALUE_ITEM*)malloc(sz2); PdhGetFormattedCounterArrayW(hCpu, PDH_FMT_DOUBLE, &sz2, &count2, cpus); int idx = -1; for (DWORD i = 0; i < count; i++) { if ((DWORD)ids[i].FmtValue.longValue == targetPid) { idx = (int)i; break; } } if (idx >= 0 && idx < (int)count2) { // 保险起见检查实例名是否一致 if (wcscmp(ids[idx].szName, cpus[idx].szName) == 0) { printf("PID %u: %.2f%%\n", targetPid, cpus[idx].FmtValue.doubleValue); } } free(ids); free(cpus);

整个阶段下来我的体会是:先按 PID 定位实例,再按实例名取数是这个场景下最稳的组合。只要每个循环都重查一次ID Process,进程退出、PID 被复用、实例编号变化,下一轮都会自动纠正,不需要手动重建计数器。

5.3 进程多到爆内存怎么办

通配符方案每秒钟会把所有进程的实例数组读一遍,进程数几百个时没问题,但如果是几千个进程的服务器,这个开销就不划算了。

更省的做法是先按上面的方式找到实例名,再用PdhRemoveCounter把通配符计数器删掉,重新用PdhAddEnglishCounterW按具体实例名加一个单实例计数器。这样之后的每次采集只读一个进程的数据。代价是 PID 变化时需要重新做一次映射,但现实中进程不可能每秒都在变,逻辑简单一点反而稳定。

5.4 多核机器上数值超过 100% 别慌

进程 CPU 的百分比是基于所有核心的累计时间算出来的,4 核机器上一个进程用满两个核,显示可能就是 200%。这不是代码算错了,是计数器本身的定义。想换算成“占整机总能力的百分比”,就除以逻辑核数;想看真实累加不封顶,就加PDH_FMT_NOCAP100。

6. 远程采集、位宽切换和排错速查

6.1 远程机器的采集套路

PDH 支持直接采远程机器,路径开头写上机器名即可:

PdhAddEnglishCounterW(hQuery, L"\\\\192.168.1.100\\Processor(_Total)\\% Processor Time", 0, &hCpu);

远程采集的依赖比较多,至少要把这几件套检查一遍:

  • 目标机器上“远程过程调用(RPC)”相关服务在运行。
  • 防火墙放行 RPC 动态端口和 445 端口。
  • 当前账户对目标机器有权限。
  • 目标机器能正确响应本机的网络访问。

最气人的情况是代码里路径写错,返回的却是权限类的错误码。所以我的调试顺序是:先用typeperf命令行测一遍远程路径,通路有问题立刻能看出来,没必要让代码反复试错:

typeperf "\\192.168.1.100\Processor(_Total)\% Processor Time" -si 1 -sc 3

如果 typeperf 能连续出数,说明网络和权限都通,剩下的才是代码问题。

6.2 32 位和 64 位程序看到的计数器可能不一样

这件事我吃过亏,必须单拎出来说。64 位 Windows 上,32 位进程是通过 WOW64 读性能计数器的,它只能看到 32 位 provider 注册的数据。内核自带的计数器大多还能读到,但某些第三方服务往系统里注册的计数器,32 位视图下可能根本不存在,路径写对了也报PDH_CSTATUS_NO_OBJECT。

做监控工具如果碰到“自己机器上能采,换台机器什么都采不到”的情况,先怀疑编译位数。我的项目后来直接全部改成 x64 编译,此类问题基本绝迹。

6.3 常见错误码速查表

下面是我在真实项目里遇到最多的几个 PDH 返回码,整理成表,排查时对照着看:

返回码宏典型含义处理方向
ERROR_SUCCESS正常继续
PDH_CSTATUS_NO_OBJECT性能对象不存在路径写错、对象未注册、位数不对
PDH_CSTATUS_NO_COUNTER计数器不存在用PdhAddEnglishCounterW,或先枚举验证名称
PDH_CSTATUS_NO_INSTANCE实例不存在目标进程已退出,刷新实例映射
PDH_CSTATUS_NO_DATA/PDH_NO_DATA尚无数据首次采集的正常现象,多采一次
PDH_MORE_DATA缓冲区不够数组类 API 先查尺寸再分配
PDH_INVALID_DATA数据无效采样间隔太短,或刚启动未到首个有效点
PDH_INVALID_PATH路径语法错误用PdhValidatePathW或PdhParseCounterPathW检查
PDH_INVALID_HANDLE句柄无效检查OpenQuery是否成功,是否已关闭

具体的十六进制数值在不同 SDK 版本里可能有差异,写代码时直接用宏名,别硬编码数字。宏定义在pdh.h里头都能查到。

6.4 我的排错顺序

真出了问题,别一上来就翻代码。我自己的固定套路是:

  1. typeperf "路径"验证路径本身能不能出数。
  2. 在代码里对OpenQuery、AddCounter、CollectQueryData每一步都打日志,确认卡在哪个环节。
  3. 如果AddCounter报错,先跑一遍PdhEnumObjectItems把真实的对象、实例名打出来对照。
  4. 如果CollectQueryData一直不成功,检查间隔和 provider 状态。
  5. 只有前面的链路都正常,才去怀疑格式化取值和标志位写错。

这套流程帮我省下了大量瞎猜的时间,尤其是“本地正常、远程不行”“普通机器正常、关键服务器不行”这类环境相关的问题,顺序排查比逐行看代码高效得多。

最后补一句我自己的习惯:正式上线前,至少留一个“原始值 + 时间戳”的导出开关,出现莫名其妙的数据偏差时能回放原始样本。PDH 的格式化值已经帮你算好了,但调试时只有原始值才能告诉你它到底是怎么算出来的。这个开关救过我太多次了。

RELATED READING

延伸阅读

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