Windows 进程 CPU、内存、I/O 与调度指标采样

0 阅读20分钟

Windows 进程 CPU、内存、I/O 与调度指标采样

开源项目参考:github.com/wangwei-cm/… 在这里插入图片描述

目标与四步流程

目标是取得指定 Windows 进程的 CPU 使用率、I/O 速率、内存快照和调度状态,并说明这些结果属于哪个进程实例和哪个时间窗口。要完成这件事,分为 4 步。

  1. 建立可追溯的样本,并打开目标进程。
  2. 采集进程身份、累计时间和实际时钟。
  3. 采集内存、I/O 与调度快照。
  4. 核验两份样本并计算 CPU 与 I/O 差分。

PID(Process Identifier,进程标识符)是 Windows 分配给运行中进程的数值编号。进程退出后,这个数值未来可以交给另一个进程使用。进程对象由内核维护,包含创建时间、线程集合、访问权限和退出状态。查询方用 OpenProcess 得到 HANDLE(句柄)。句柄是当前进程持有的受访问控制引用,指向一个具体进程对象,使用完必须关闭。

同一个进程对象连接着多个 Windows 组件。内核调度器把全部线程的用户态和内核态 CPU 时间累积到进程层。内存管理器提供工作集和提交量快照。I/O 管理器积累读、写与其它传输量。对象管理器维护句柄表项。调度器读取优先级类、动态优先级提升设置和处理器亲和范围。各来源的更新时刻并不完全相同,样本需要保存字段状态与采样时钟。

整个过程为:输入 PID -> 建立样本和进程句柄 -> 读取样本 A -> 等待实际时间窗口 -> 读取样本 B -> 核验实例与计时条件 -> 输出快照或差分结果。

一个样本分为三类数据。实例身份由 PID 和创建时间组成。累计计数包括 CPU 时间和 I/O 传输字节。瞬时属性包括工作集、私有提交量、句柄数、优先级类、优先级提升设置和亲和掩码。累计量只有放在两个有效样本之间才有速率含义。瞬时属性的单个数值只描述读取瞬间,报告中应附带采样时间和字段成功状态。

创建时间是识别 PID 复用的关键。监控服务常在每轮按 PID 重新打开进程,也可能在缓存恢复后继续使用旧基线。此时仅比较 PID 会把两个不同生命周期的累计 CPU 或 I/O 相减。创建时间来自进程对象的时间信息,它与 PID 一起形成实例键。键发生变化后,当前样本只可作为新的基线。

1. 建立可追溯的样本,并打开目标进程

采样结构需要让读取失败可见。零工作集、零 I/O 增量和零句柄变化都可能是真实结果。把 API 失败也写成零会把“未知”伪装成“没有活动”。下面的结构把值、单位和字段状态分开。FieldStatus::error 只在 availablefalse 时解释为失败代码,成功字段的错误码没有业务含义。

struct FieldStatus {
    bool available = false;      // 本字段的 API 是否成功返回。
    DWORD error = ERROR_SUCCESS; // 失败时紧接 API 调用取得的 GetLastError 结果。
};

struct ProcessSample {
    DWORD pid = 0;               // 数值 PID。单独使用不能区分复用。
    ULONGLONG creation100ns = 0; // 创建时间,单位是 100ns,和 pid 构成实例键。
    ULONGLONG wall100ns = 0;     // 系统墙钟,单位是 100ns,便于记录可读时间线。
    ULONGLONG active100ns = 0;   // 非休眠运行时间,单位是 100ns,用作速率分母。
    ULONGLONG cpu100ns = 0;      // 内核态与用户态累计 CPU 时间之和。
    ULONGLONG ioBytes = 0;       // 读、写、其它 I/O 的累计传输字节之和。
    SIZE_T workingSetBytes = 0;  // 当前工作集字节数。
    SIZE_T privateCommitBytes = 0; // 当前私有提交字节数。
    DWORD handleCount = 0;       // 当前句柄表项数量。
    DWORD priorityClass = 0;     // NORMAL_PRIORITY_CLASS 等优先级类。
    FieldStatus identity, cpu, io, memory, handles, priority;
};

SIZE_T 是与当前程序指针宽度相同的无符号整数类型,内存计数以字节为单位时用它保存最合适。ULONGLONG 是 64 位无符号整数,适合保存 100ns 累计时间和 I/O 字节。进程长期运行时累计值会变大,使用 32 位 DWORD 保存 CPU 时间或 I/O 字节会很快截断高位。

一个成熟的监控记录还应保存每项 API 的错误码、进程退出状态、查询权限、采样线程的计时器类型和程序自身位数。查看结果的人看到 available=false 时应显示“未取得”,同时保留错误码供权限与生命周期诊断。将失败字段从速率计算中剔除,能够避免除零、错误尖峰和误判闲置。

打开目标进程与管理句柄所有权

GetLastError 读取当前线程最近一次失败的 Win32 API 错误。这个值只保留当前线程的最近失败状态。后续 API、格式化库或诊断代码都有机会改写它,因此必须紧贴失败调用保存。它没有参数、缓冲区和资源所有权。

// 作用:取得当前线程刚刚发生的 Win32 失败代码。
// 返回:错误编号。只在失败 API 的紧后位置读取。
DWORD GetLastError(void);

// 作用:取得当前进程对象的数值 PID。
// 返回:非零 PID。没有输入、输出缓冲区、权限要求和释放责任。
DWORD GetCurrentProcessId(void);

// 作用:按 PID 打开一个进程对象并建立当前进程拥有的 HANDLE。
// 返回:有效 HANDLE 成功。nullptr 失败,立即用 GetLastError 读取原因。成功句柄必须 CloseHandle。
HANDLE OpenProcess(
    DWORD dwDesiredAccess, // 输入:所需权限位。此处使用 PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ。
    BOOL bInheritHandle,   // 输入:FALSE 表示以后创建的子进程不继承这个 HANDLE。
    DWORD dwProcessId      // 输入:目标 PID。PID 可能在查询前退出或被后续进程复用。
);

// 作用:释放当前进程拥有的内核对象 HANDLE。
// 返回:非零成功。0 失败,立即读取 GetLastError。成功后句柄值失效,不可重复使用或重复关闭。
BOOL CloseHandle(HANDLE hObject); // 输入:OpenProcess 返回的真实 HANDLE,不能为 nullptr。

正确流程先把用户输入的 0 转成 GetCurrentProcessId 返回的 PID,再申请最低限度的权限。PROCESS_QUERY_LIMITED_INFORMATION 支持大多数只读属性查询。PROCESS_VM_READ 允许内存计数器读取相关进程信息。受保护进程、退出中的进程和安全描述符都可能让 OpenProcess 失败,失败后没有句柄可传给后续 API。

const DWORD targetPid = suppliedPid == 0 ? GetCurrentProcessId() : suppliedPid;
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
    FALSE, targetPid);
if (process == nullptr) {
    const DWORD error = GetLastError(); // 先保存,再写日志。
    // 记录 targetPid 与 error。当前轮没有任何有效字段。
} else {
    // 在这里读取时间、内存、I/O 与调度属性。每项读取都检查自己的返回值。
    if (!CloseHandle(process)) {
        const DWORD error = GetLastError();
        // 当前流程结束,不再把关闭状态不明的句柄交给其它查询。
    }
}

以下写法把失败句柄带入查询。空句柄没有指向任何进程对象,返回的错误属于错误调用本身,原始的拒绝访问或进程退出原因也会丢失。

HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, targetPid);
GetProcessTimes(process, &creation, &exitTime, &kernel, &user); // 错误:process 可能为 nullptr。
CloseHandle(process);                                           // 错误:未拥有有效句柄。

完成第 1 步后,已有一条能区分“读取失败”和“真实零值”的样本结构,也已持有指向目标进程实例的有效句柄。接下来要从这个进程对象取得创建时间、累计 CPU 时间和两个时钟,才能让后续速率计算有身份依据和实际分母。

2. 采集进程身份、累计时间和实际时钟

FILETIME 用两个 32 位字段表达一个 64 位计数,单位为 100ns(1 秒等于 10,000,000 个单位),基准点为 1601-01-01 UTC。GetProcessTimes 的创建时间、退出时间、内核 CPU 时间和用户 CPU 时间都采用这一单位。用户态 CPU 时间表示线程在应用代码和用户态库中获得处理器的累计时间。内核态 CPU 时间表示线程在系统服务、驱动和内核工作中获得处理器的累计时间。两者相加得到进程累计 CPU 时间。

// 作用:读取一个进程的创建、退出、内核态 CPU 与用户态 CPU 时间。
// 返回:非零成功。0 失败,立即读取 GetLastError。每个输出 FILETIME 均由调用方分配且不可为 nullptr。
BOOL GetProcessTimes(
    HANDLE hProcess,          // 输入:可查询的进程 HANDLE。需要 PROCESS_QUERY_INFORMATION 或 LIMITED_INFORMATION。
    LPFILETIME lpCreationTime,// 输出:创建时间,100ns,调用方存储。
    LPFILETIME lpExitTime,    // 输出:退出时间。运行中进程通常为零值,调用方存储。
    LPFILETIME lpKernelTime,  // 输出:累计内核态 CPU 时间,100ns,调用方存储。
    LPFILETIME lpUserTime     // 输出:累计用户态 CPU 时间,100ns,调用方存储。
);

// 作用:写入当前系统 UTC 墙钟。
// 返回:void。lpSystemTimeAsFileTime 由调用方提供且不可为 nullptr,输出单位为 100ns。
VOID GetSystemTimeAsFileTime(LPFILETIME lpSystemTimeAsFileTime);

// 作用:取得从系统启动以来的非偏置中断时间。
// 返回:非零成功。0 失败,立即读取 GetLastError。UnbiasedTime 不可为 nullptr,输出单位为 100ns,排除睡眠和休眠时间。
BOOL QueryUnbiasedInterruptTime(PULONGLONG UnbiasedTime);

CPU 百分比需要真实的采样窗口。单逻辑处理器基准使用 ΔCPU / ΔActiveTime × 100,其中 ΔCPU 是内核与用户累计时间的差,ΔActiveTime 来自 QueryUnbiasedInterruptTime。多线程进程可以同时在多个逻辑处理器上运行,因此单逻辑处理器基准能够超过 100%。全机份额需要额外记录可用逻辑处理器数、处理器组、作业对象限制和 CPU 集合范围,再选择相应分母。

墙钟与非休眠时间各自回答不同问题。GetSystemTimeAsFileTime 便于记录“何时采到”,也会受人工校时、时间服务校正和睡眠恢复影响。QueryUnbiasedInterruptTime 适合描述系统处于运行状态的持续时间,并排除睡眠与休眠时间。连续样本同时保存两者:两者的增量明显不一致时,当前窗口跨越了休眠、恢复或时钟改变,应废弃旧基线。

ULONGLONG To100ns(const FILETIME& value) { // FILETIME 的两个部分由调用方传入,不涉及资源释放。
    ULARGE_INTEGER combined{};
    combined.LowPart = value.dwLowDateTime;
    combined.HighPart = value.dwHighDateTime;
    return combined.QuadPart;
}

FILETIME creation{}, exitTime{}, kernel{}, user{}, wall{};
if (GetProcessTimes(process, &creation, &exitTime, &kernel, &user)) {
    const ULONGLONG creation100ns = To100ns(creation);
    const ULONGLONG cpu100ns = To100ns(kernel) + To100ns(user); // 实现中还应检查无符号加法上溢。
    GetSystemTimeAsFileTime(&wall);                             // 记录可追溯的墙钟时间。
    ULONGLONG active100ns = 0;
    if (!QueryUnbiasedInterruptTime(&active100ns)) {
        const DWORD error = GetLastError(); // CPU 累计可保存。本轮不能用于速率分母。
    }
} else {
    const DWORD error = GetLastError();     // 创建时间和 CPU 字段同时标记为不可用。
}

进程退出状态需要独立读取。退出能发生在任何两个 API 调用之间,进程句柄仍可能保持有效并指向已经结束的对象。GetExitCodeProcess 返回查询瞬间的退出状态。STILL_ACTIVE 表示进程尚未报告退出。该常量值为 259,程序本身也可能以 259 退出,采集器只把它作为状态提示,并结合采样时间处理。

// 作用:读取目标进程当前退出码或活动状态。
// 返回:非零成功。0 失败,立即读取 GetLastError。lpExitCode 不可为 nullptr,输出由调用方保存。
BOOL GetExitCodeProcess(
    HANDLE hProcess,    // 输入:可查询的进程 HANDLE。
    LPDWORD lpExitCode  // 输出:STILL_ACTIVE 或进程已报告的退出码。
);

错误写法把 Sleep(1000) 当作精确一秒,并把墙钟直接当作可靠分母。调度延迟会延长等待,睡眠恢复和校时会改变墙钟增量,速率会被放大或压低。两次实际计时结果才是计算依据。

完成第 2 步后,每份样本已有实例键、累计 CPU 时间、墙钟和非休眠时间。接下来读取内存、I/O 和调度字段,使结果同时说明进程此刻占用的资源与可运行范围。

3. 采集内存、I/O 与调度快照

工作集(Working Set)是当前驻留在物理内存中的页面集合,其大小由内存管理器回收、预取、共享页活动和进程访问模式共同影响。私有提交量(Private Usage)是只归该进程负责的已提交虚拟内存数量。工作集下降可以来自工作集修剪,私有提交量下降反映提交状态变化。两个字段不能互相替换。

I/O 计数器中的 ReadTransferCountWriteTransferCountOtherTransferCount 是累计传输字节。其它传输包括控制操作等非普通读写请求。API 成功返回时可把三者相加作为总 I/O 累计量。相加前应检查 64 位无符号溢出。报告速率时也可分别计算读、写、其它速率,以保留活动性质。

// 作用:取得进程内存计数器。
// 返回:非零成功。0 失败,立即读取 GetLastError。
BOOL GetProcessMemoryInfo(
    HANDLE Process,                         // 输入:具有查询和虚拟内存读取权限的进程 HANDLE。
    PPROCESS_MEMORY_COUNTERS ppsmemCounters,// 输出:调用方分配的结构,不能为 nullptr。结构所有权仍归调用方。
    DWORD cb                                 // 输入:结构容量,单位字节。调用前 ppsmemCounters->cb 也要写入相同容量。
);

// 作用:取得进程累计 I/O 操作次数和传输字节数。
// 返回:非零成功。0 失败,立即读取 GetLastError。
BOOL GetProcessIoCounters(
    HANDLE hProcess,             // 输入:可查询的进程 HANDLE。
    PIO_COUNTERS lpIoCounters    // 输出:调用方分配的 IO_COUNTERS,不能为 nullptr,字节字段为 ULONGLONG。
);

PROCESS_MEMORY_COUNTERS_EX 继承基础内存计数器并增加 PrivateUsage。调用前必须把 cb 写为整个结构的字节数,API 根据它判断调用方可接收的版本。IO_COUNTERS 由调用方在栈或其它本地存储中分配,API 填写结构。两个 API 都不转移任何缓冲区所有权。

PROCESS_MEMORY_COUNTERS_EX memory{};
memory.cb = sizeof(memory); // 容量单位为字节,零初始化后仍必须显式写入。
if (GetProcessMemoryInfo(process, reinterpret_cast<PPROCESS_MEMORY_COUNTERS>(&memory),
    sizeof(memory))) {
    const SIZE_T workingSetBytes = memory.WorkingSetSize;
    const SIZE_T privateCommitBytes = memory.PrivateUsage;
    // 两个值都是当前快照,直接带上采样时间展示。
} else {
    const DWORD error = GetLastError();
}

IO_COUNTERS io{};
if (GetProcessIoCounters(process, &io)) {
    const ULONGLONG readBytes = io.ReadTransferCount;
    const ULONGLONG writeBytes = io.WriteTransferCount;
    const ULONGLONG otherBytes = io.OtherTransferCount;
    // 保存三项原值。差分时使用同类字段相减,或经上溢检查后求总量。
} else {
    const DWORD error = GetLastError();
}

以下错误没有设置 cb,系统无法知道调用方缓冲区的有效布局。即使某个系统版本恰好返回成功,后续版本或结构扩展也会使结果失去可靠性。

PROCESS_MEMORY_COUNTERS_EX memory{};
GetProcessMemoryInfo(process, reinterpret_cast<PPROCESS_MEMORY_COUNTERS>(&memory), sizeof(memory));
// 错误:memory.cb 保持零,缺少结构容量约定。返回值也没有检查。

读取句柄数与调度属性

进程句柄数是对象管理器为该进程句柄表维护的可见表项数量。它会随着文件、线程、事件、注册表键和其它对象引用的打开关闭而变化,适合以时间序列观察趋势。句柄数本身不能指出对象类型,也不能单独证明泄漏。持续增长、对象分类和调用栈需要结合调查。

优先级类是进程级调度策略输入,例如 NORMAL_PRIORITY_CLASSHIGH_PRIORITY_CLASS。线程还有自己的相对优先级和动态提升,调度器会在运行中调整实际调度优先级。优先级提升禁用标志说明系统能否为该进程的线程应用动态提升。亲和掩码说明该进程可在哪些逻辑处理器上运行。超过一个处理器组的机器还涉及组范围,单个 DWORD_PTR 掩码只描述当前 API 适用的处理器集合。

// 作用:读取当前进程句柄表中的句柄数。
// 返回:非零成功。0 失败,立即读取 GetLastError。
BOOL GetProcessHandleCount(
    HANDLE hProcess,          // 输入:可查询的进程 HANDLE。
    PDWORD pdwHandleCount     // 输出:调用方保存的句柄数量,不能为 nullptr,单位为个。
);

// 作用:读取目标进程优先级类。
// 返回:非零优先级类成功。0 失败,立即读取 GetLastError。没有输出缓冲区和资源转移。
DWORD GetPriorityClass(HANDLE hProcess); // 输入:可查询的进程 HANDLE。

// 作用:读取进程是否禁用动态优先级提升。
// 返回:非零成功。0 失败,立即读取 GetLastError。
BOOL GetProcessPriorityBoost(
    HANDLE hProcess,                // 输入:可查询的进程 HANDLE。
    PBOOL pDisablePriorityBoost     // 输出:调用方 BOOL 存储,不能为 nullptr。非零表示禁用提升。
);

// 作用:读取进程和系统允许的逻辑处理器亲和掩码。
// 返回:非零成功。0 失败,立即读取 GetLastError。
BOOL GetProcessAffinityMask(
    HANDLE hProcess,                 // 输入:可查询的进程 HANDLE。
    PDWORD_PTR lpProcessAffinityMask,// 输出:进程可运行处理器位图,不能为 nullptr,单位为位集合。
    PDWORD_PTR lpSystemAffinityMask  // 输出:系统活动处理器位图,不能为 nullptr,单位为位集合。
);

读取这些属性时,每一个输出变量都归调用方所有,没有堆内存或句柄需要额外释放。优先级类返回零才表示失败,GetProcessPriorityBoostGetProcessAffinityMaskBOOL 返回值也必须检查。权限不足时保留失败字段,依然可以输出同一轮成功的 CPU、I/O 或内存信息。

DWORD handleCount = 0;
BOOL boostDisabled = FALSE;
DWORD_PTR processMask = 0, systemMask = 0;

if (GetProcessHandleCount(process, &handleCount)) {
    // handleCount 为当前快照,单位为个。
}
const DWORD priorityClass = GetPriorityClass(process);
if (priorityClass == 0) {
    const DWORD error = GetLastError();
}
if (GetProcessPriorityBoost(process, &boostDisabled)) {
    // boostDisabled 非零时,报告“禁用动态优先级提升”。
}
if (GetProcessAffinityMask(process, &processMask, &systemMask)) {
    // 每个置位 bit 表示一个允许的逻辑处理器位置。
}

完成第 3 步后,单份样本已经包含累计量、瞬时资源量和调度属性,并且每个字段都保留可用状态。接下来需要在等待后取得第二份样本,确认两份记录仍属于同一进程且计时窗口连续,才能把累计量换算为速率。

4. 核验两份样本并计算 CPU 与 I/O 差分

Sleep 用于在演示中形成最短等待请求。它并不提供精确计时保证:当前线程至少在指定毫秒后才重新具备运行机会,调度压力、调试暂停、系统睡眠和恢复都会拉长实际间隔。采样器因此使用两次实测计时值计算窗口长度。

// 作用:让当前线程在至少指定的毫秒数内不参与运行。
// 返回:void。dwMilliseconds 是输入等待下限,单位毫秒。无缓冲区、错误码或资源所有权。
VOID Sleep(DWORD dwMilliseconds);

有效基线要求所有参与差分的字段都可用,且两个样本描述同一实例。具体条件包括:PID 与创建时间相等。两次退出状态都显示活动。墙钟和非休眠时钟都向前。CPU 与 I/O 累计值未倒退。墙钟增量和非休眠时钟增量的差没有跨过设定阈值。任一条件失败时,当前快照替换旧基线,下一轮从它开始计量。

// first 与 second 均已保存 PID、创建时间、时钟、CPU 和 I/O 字段的可用状态。
const bool sameInstance = first.pid == second.pid &&
    first.creation100ns == second.creation100ns;
const bool countersAdvance = second.cpu100ns >= first.cpu100ns &&
    second.ioBytes >= first.ioBytes;
const bool clocksAdvance = second.wall100ns > first.wall100ns &&
    second.active100ns > first.active100ns;

if (sameInstance && countersAdvance && clocksAdvance) {
    const ULONGLONG elapsed100ns = second.active100ns - first.active100ns;
    const ULONGLONG cpuDelta100ns = second.cpu100ns - first.cpu100ns;
    const ULONGLONG ioDeltaBytes = second.ioBytes - first.ioBytes;
    const double cpuPercentOneCore = double(cpuDelta100ns) * 100.0 / double(elapsed100ns);
    const double ioBytesPerSecond = double(ioDeltaBytes) * 10000000.0 / double(elapsed100ns);
    // 输出前还要检查非休眠与墙钟的差值,识别睡眠恢复和时钟校正。
}

下列写法只看 PID 并把固定等待值硬编码为分母。进程退出重启、PID 复用、采样线程被延迟和系统恢复都会使结果失真。

Sleep(1000);
double cpuPercent = double(second.cpu100ns - first.cpu100ns) / 100000.0;
// 错误:未检查创建时间、字段成功状态、累计值倒退和实际窗口。100000.0 也假定了精确一秒。

休眠边界尤其需要明确。系统休眠期间应用线程不执行,墙钟通常继续前进,非休眠中断时间停止增加。恢复后的第一个样本若直接和休眠前相减,使用墙钟分母会压低速率,使用仅非休眠时钟又会把恢复附近的短暂活动放大。把两种时钟的差值当作边界信号并重建基线,能让下一段连续运行时间获得一致含义。

PID 复用、创建时间读取失败和进程退出同样属于基线边界。长时间运行的服务应为每个实例键维护独立基线,设定失效超时,并在采样间隔显著变化时记录真实窗口。多机汇总还应保存采集端时区、时钟来源和程序版本,避免把不同计时语义的序列直接拼接。

说明测量误差和报告范围

每类指标都存在自然误差范围。CPU 累计时间更新粒度、线程切换时刻和查询调用本身会使短窗口抖动更明显。较长窗口会平滑抖动,同时降低对短突发的敏感度。I/O 字节反映经过 I/O 路径的传输量,缓存、重定向、文件系统和设备完成时间会影响观察时刻。工作集和私有提交量属于内存快照,后台回收或提交操作可在两次查询之间改变它们。

权限和保护边界也会让同一轮字段出现不同可用性。拥有进程查询权限后,基础时间和调度属性常可读取。内存信息可能还要求虚拟内存读取权限。受保护进程会拒绝部分访问。采集器应逐字段保存错误码,报告“权限不足”“进程已退出”“结构读取失败”等实际状态,避免把缺失字段填为零。

进程状态在采样期间持续变化。第一次读取 CPU 后,线程可以继续运行。读取工作集时,内存管理器可以修剪页面。读取退出码后,进程仍可能立即退出。因此一轮样本只是一组接近的观测,不代表内核在单个原子时刻冻结的全局快照。需要更强一致性时,应缩短单轮调用链、保存每项读取时间,或使用专门的跟踪与事件机制。

CPU 百分比的分母也取决于展示目的。单逻辑处理器基准用于表达进程消耗了多少处理器时间,多线程负载可高于 100%。按全机可用容量归一化时,处理器亲和掩码、处理器组、容器限制、作业对象限制和离线核心都会改变可用容量。报告标题应直接写出“单逻辑处理器基准”或“按可用逻辑处理器归一化”,并带上窗口长度。

完成第 4 步后,CPU 与 I/O 差分只在身份、退出状态、累计值和时钟都通过核验时输出。可靠的输出至少包含:实例键、采样开始与结束时钟、非休眠窗口、累计量原值和差值、瞬时属性、字段可用状态、错误码、退出状态、访问权限和计算公式。这样一条 CPU 或 I/O 曲线才可以追溯其数据来源、时间范围和失效原因。

完整可运行程序在附件 在这里插入图片描述 wangweicm.lanzouu.com/iZKdz4125dp…