做客户端性能优化的朋友,应该都遇到过这么一种情况:用户报障说软件“卡成PPT”,等你打开任务管理器一看,CPU占用率正在疯狂跳动,但你却说不清楚是哪一个模块在作怪,也不知道是瞬时尖峰还是持续占用。我在接手一个桌面客户端项目时,第一次碰到这类问题,就是靠先搭起一套CPU利用率获取与监控机制,才把问题从“玄学”变成了“科学”。所以这个系列第一篇,我想先从CPU利用率入手,讲清楚在C#客户端里到底怎么准确采集进程级和系统级的CPU数据,以及监控层应该怎么设计才不会反过来拖垮性能本身。
这篇文章适合正在做C#桌面客户端、上位机、游戏辅助工具或内部效率工具的朋友,也适合那些刚接触性能优化、想给现有项目加一个“自诊断”模块的开发者。读完你会有可以直接抄走的代码,也能理解这些代码背后的原理和取舍,下次再遇到高CPU问题,至少能拿出数据说话。
1. 为什么先从CPU利用率入手
1.1 客户端性能优化的切入点
客户端性能优化这个事,最容易踩的坑就是一上来就“优化”。什么意思?优化必须有依据,没有依据就动手改代码,基本是瞎忙。CPU利用率是客户端所有性能问题里最直观、最容易采集、也最能说明问题的一个指标。它不像内存泄漏那样需要长时间观测,也不像网络延迟那样依赖外部环境,它就在你眼前跳动,只要你能准确读出来,很多问题已经解决了一半。
我当时的场景是一个WinForms客户端,负责在本地做大量数据解析和界面刷新。用户反馈说“切页卡”,我的第一反应是UI线程被阻塞,但怎么证明?总不能拿个秒表在用户边上数吧。最好的方式就是在生产环境里让程序自己记录CPU数据,把异常时刻的CPU曲线和UI操作时间点对上。一旦你能做到这一点,就可以直接定位到“切页的时候CPU冲到90%”,然后再配合线程堆栈抓取,基本就能锁定元凶。
1.2 CPU利用率到底能告诉我们什么
CPU利用率并不是一个简单的数字,它至少分成两个层面:系统整体CPU利用率和当前进程CPU利用率。系统整体体现了这台机器当前有多忙,进程级则反映我们的程序自身吃了多少资源。两者往往要结合起来看,才能判断“卡顿是我的程序导致的,还是机器本身已经不行了”。
进程CPU利用率如果持续居高不下,那一定是代码层面有问题,比如死循环、频繁的GC、大量字符串拼接、没有必要的后台线程轮询等等。系统CPU利用率高但是进程CPU利用率低,那就要考虑是不是有其他程序抢占资源,或者硬件驱动、系统服务在捣乱。还有一种情况,进程CPU不高但UI卡顿,那问题很可能在IO、锁竞争或者渲染上,CPU再准也是查不出来的。所以CPU监控只是第一步,但它是把问题“可视化”的第一步,没有这一步,后面全是猜。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. C#里拿CPU数据的几种方案与选型
2.1 三套主流方案横向对比
C#里获取CPU利用率,常见的路子无非三种:PerformanceCounter、Process.TotalProcessorTime、GetSystemTimes P/Invoke。我先把它们的优缺点摊开来说。
PerformanceCounter是System.Diagnostics自带的性能计数器,用起来最“傻瓜”,只要new一个PerformanceCounter("Process", "% Processor Time", processName)就能开始读。但它的缺点也很明显:创建实例有开销,读取性能一般,在循环里高频读可能会造成不小的资源浪费;而且在部分精简版Windows系统或权限受限的环境里,PerformanceCounter可能拿不到数据。另外它依赖系统性能计数器的刷新频率,两次读取间隔太短时,数值几乎不变。
Process.TotalProcessorTime是Process类提供的一个属性,表示进程累计消耗的CPU时间(不包括等待时间),类型是TimeSpan。这个数据不是瞬时利用率,而是从进程启动到当前时刻的累计值。想要算利用率,就得自己两次采样做差,再除以采样间隔。这个方案不依赖额外组件,进程内读取极快,是最稳妥的手段。
GetSystemTimes是Win32 API,通过P/Invoke调用,能够拿到系统级Idle、Kernel和User时间,用来计算整个系统的CPU利用率。这个方案没有PerformanceCounter那么重,但需要自己写P/Invoke定义,而且要注意Kernel时间其实包含了Idle时间,做差的时候要处理一下。
我用一张表把三者区别列出来,方便你按场景做决策:
| 方案 | 数据范围 | 计算方式 | 优点 | 缺点 |
|---|---|---|---|---|
| PerformanceCounter | 系统/进程 | 计数器提供 | 使用简单,信息丰富 | 开销大,权限要求,刷新粒度受限 |
| Process.TotalProcessorTime | 当前/指定进程 | 两次采样差值 | 快速稳定,无需权限 | 只能算进程级,不能直接算系统级 |
| GetSystemTimes P/Invoke | 系统整体 | 两次采样差值 | 低开销,系统级准确 | 只能算系统级,进程级仍需配合Process |
2.2 我的选型结论与理由
如果你只想做一个可靠的进程级CPU利用率,我强烈建议优先用Process.TotalProcessorTime,而不是PerformanceCounter。原因有几个:
第一,Process.TotalProcessorTime是纯托管API,不依赖系统的性能计数器服务,在服务器精简环境、Windows容器、或者被安全软件加固过的机器上都不会突然失效。PerformanceCounter我踩过不少坑,最常见的就是开机后第一次创建计数器要等半天,有的机器上还直接给你抛异常。
第二,PerformanceCounter的读取粒度依赖系统刷新,通常两次读取间隔低于几百毫秒时数值不会更新,你根本没法用它做高频采样。而Process.TotalProcessorTime和GetSystemTimes都是自己算差值,采样间隔完全由代码控制,我实测在100ms的粒度下也能稳定工作。
第三,如果你还要统计CPU的多核归一化、分线程利用率、或者跨平台兼容,PerformanceCounter锁死在Windows上,Process.TotalProcessorTime的TimeSpan值则更容易做算术运算。
所以我在正式项目里的组合拳是:进程级用Process.TotalProcessorTime,系统级用GetSystemTimes P/Invoke。这两个都是“取累计值,算差值”的思路,完全控制在自己手里,可调试性也强。PerformanceCounter我只在临时排查问题的时候用,绝不放进长期运行的监控模块里。
3. 进程级CPU利用率的完整实现
3.1 用Process.TotalProcessorTime计算进程CPU
计算逻辑很简单:记下当前时刻的CPU累计时间t1和当前时间d1,等待一段时间,再记下t2和d2,利用率的公式就是(t2 - t1) / (d2 - d1) / 逻辑处理器数量 * 100%。
为什么要除以逻辑处理器数量?因为一个进程在多核机器上最多可以占用所有核心,CPU时间累计是“核·秒”的概念,按照Windows任务管理器显示的百分比,是相对单个核心的百分比。比如一个进程在8核机器上跑满了一个核心,Process.TotalProcessorTime在1秒内增加了1秒,如果直接用(t2-t1)/(d2-d1)算,结果是100%,但任务管理器的进程CPU应该显示12.5%左右。所以必须除以Environment.ProcessorCount。
来看代码实现,这个是我在实际项目里抽出来的简化版本:
csharp复制public sealed class ProcessCpuMonitor : IDisposable
{
private readonly Process _process;
private TimeSpan _lastCpuTime;
private DateTime _lastSampleTime;
private readonly int _processorCount;
public ProcessCpuMonitor(Process process)
{
_process = process ?? Process.GetCurrentProcess();
_processorCount = Environment.ProcessorCount;
_lastCpuTime = _process.TotalProcessorTime;
_lastSampleTime = DateTime.UtcNow;
}
public double GetCpuUsage()
{
var currentCpuTime = _process.TotalProcessorTime;
var currentTime = DateTime.UtcNow;
var cpuTimeDiff = (currentCpuTime - _lastCpuTime).TotalMilliseconds;
var timeDiff = (currentTime - _lastSampleTime).TotalMilliseconds;
_lastCpuTime = currentCpuTime;
_lastSampleTime = currentTime;
if (timeDiff <= 0)
{
return 0;
}
double usage = cpuTimeDiff / timeDiff / _processorCount * 100.0;
return Math.Min(100.0, Math.Max(0.0, usage));
}
public void Dispose()
{
_process.Dispose();
}
}
这段代码有几个地方要特别注意。
TimeSpan.TotalMilliseconds是double,计算精度够用,不要贪方便直接用Ticks,那样数值太大反而不好读。采样间隔不要太短,我个人建议最少100ms,低于这个值,Windows的线程调度粒度会导致数据抖动得像心电图。初始化时就把_lastCpuTime和_lastSampleTime放在构造函数里,而不是在第一次GetCpuUsage时再取,是为了避免第一次调用时出现陡增或者负数。
还有一点,Process.GetCurrentProcess()获取的进程对象内部维护了进程句柄,用完必须Dispose,否则句柄泄漏在长期监控的场景里会被放大。如果监控的是外部进程,也要记得在进程退出后捕获异常,否则Process.TotalProcessorTime会直接抛异常。
3.2 处理进程退出和异常
监控外部进程,或者监控本进程但用户在功能里动态启停子进程,会遇到进程已退出的情况。直接访问TotalProcessorTime会抛出InvalidOperationException或Win32Exception,所以一定要包一层try-catch,并在异常时返回-1或者上一个有效值,让上层逻辑能够识别。
我在实际项目里的做法是让监控器暴露一个事件:当连续几次读取失败时,触发ProcessExited,上层就可以做告警、记录或者停止监控。千万别在GetCpuUsage里反复抛异常,那本身就是在浪费CPU。
另外一个很容易被忽略的问题:Process对象有缓存机制,你拿到的TotalProcessorTime不一定实时更新。微软文档里也提到Process类内部会缓存一些数据,调用Refresh方法才能更新。所以每次采样前最好先调用_process.Refresh(),虽然这会多一点点开销,但保证数据新鲜度更重要。我一开始没调Refresh,结果CPU数据滞后了一拍,排查问题的时候时间线对不上,非常痛苦。
3.3 多核归一化的进阶处理
前面基础公式里除以Environment.ProcessorCount,这在大多数场景下没问题,但有个隐患:它取的是逻辑处理器数量,不是物理核心数。在开了超线程的机器上,逻辑处理器数量是物理核心的两倍,而Windows任务管理器默认显示的百分比也是按逻辑处理器来归一化的,所以反而和任务管理器一致性更好。
但如果你的客户端跑在云端虚拟机里,宿主机的CPU配额可能低于vCPU数量,这时候按Environment.ProcessorCount归一化,算出来的利用率会虚低。这种情况要么不看百分比,直接看CPU时间增量,要么通过GetSystemTimes自己算系统CPU作为参照。我自己遇到过一台4 vCPU但实际只能用到2核的虚拟机,进程CPU跑出200%(没归一化)才能吃满配额,这种环境下“100%”的含义就跟物理机完全不同了。
我建议把CPU利用率的计算方式做成可配置的:默认按Environment.ProcessorCount归一化,同时保留一个“不归一化模式”,方便在一些特别的环境里排查。
4. 系统级CPU利用率获取
4.1 用GetSystemTimes读取系统全局CPU
进程级CPU只能看到自己这一亩三分地,排查“程序卡了是不是因为机器本身忙”的时候,就必须看系统全局CPU。这就要用GetSystemTimes这个Win32 API了。它在kernel32.dll里,原型需要接收三个FILETIME结构体,分别对应IdleTime、KernelTime、UserTime。注意KernelTime里已经包含了IdleTime,所以系统真正忙碌的时间应该是KernelTime + UserTime - IdleTime。
完整的P/Invoke定义和计算逻辑如下:
csharp复制[StructLayout(LayoutKind.Sequential)]
private struct FileTime
{
public uint LowDateTime;
public uint HighDateTime;
public ulong ToUInt64()
{
return ((ulong)HighDateTime << 32) | LowDateTime;
}
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetSystemTimes(out FileTime idleTime, out FileTime kernelTime, out FileTime userTime);
public double GetSystemCpuUsage()
{
if (!GetSystemTimes(out var idleTime, out var kernelTime, out var userTime))
{
return -1;
}
ulong idle = idleTime.ToUInt64();
ulong kernel = kernelTime.ToUInt64();
ulong user = userTime.ToUInt64();
ulong busy = (kernel + user) - idle;
ulong total = kernel + user;
if (_lastSystemTotal == 0 || _lastSystemBusy == 0)
{
_lastSystemTotal = total;
_lastSystemBusy = busy;
return 0;
}
ulong totalDiff = total - _lastSystemTotal;
ulong busyDiff = busy - _lastSystemBusy;
_lastSystemTotal = total;
_lastSystemBusy = busy;
if (totalDiff == 0)
{
return 0;
}
return (double)busyDiff / totalDiff * 100.0;
}
FILETIME转ulong的方式有多种,但最保险的是把HighDateTime左移32位再和LowDateTime按位或。我见过有人用BitConverter.ToInt64把16字节直接转,那样字节序容易搞反,得到的值完全是错的。这个坑相当隐蔽,因为数值看起来“像个时间”,但算出来的利用率完全不对。
GetSystemTimes算出来的系统CPU利用率是全局平均值,包含所有核心。它不需要除以处理器数量,因为total本身代表所有核心的总运行时间,busyDiff / totalDiff自然就是整体利用率。它反映的是这台机器所有核心的综合忙碌程度,任务管理器“性能”页里那个CPU百分比就是类似算法。
4.2 为什么系统CPU高但进程CPU不高
实际排查中我遇到最多的一类情况是:系统CPU接近100%,但我们的客户端进程CPU只有20%。很多同事第一反应是“我们的程序没问题”,然后就把锅甩给了其他软件。但深入看下去,往往不是这么简单。
有一次我排查一个内部工具卡顿,系统CPU 90%以上,进程CPU只有15%,但整个界面响应极慢。用GetSystemTimes算出高占用后,我用typeperf命令导出了系统进程列表,发现一个杀毒软件进程吃了40%CPU。但问题没有结束——那款杀毒软件在主文件被读取时会触发实时扫描,而我们的工具恰好频繁读写一个小型数据库文件,每次都触发扫描,才导致杀毒软件CPU飙升。所以看似是“别人家的程序占了CPU”,根因还是我们自己程序的IO行为有问题。
这说明系统级CPU监控不是用来甩锅的,而是用来提供上下文线索。你把系统CPU利用率、进程CPU利用率、磁盘IO三个指标放在一张时间轴上,很多问题一眼就能看穿。如果只盯着进程CPU,就会漏掉那些“间接吞噬CPU”的交互行为。
5. 监控架构设计与采样策略
5.1 采样频率:不是越快越好
很多人一上来就把采样间隔设为10ms、20ms,觉得越快越精确。在CPU监控这个场景,这是典型的过度设计。为什么?因为Windows的线程时间片粒度本身就在15ms左右,你10ms采一次,两次采样之间进程可能根本就没被调度,算出来的利用率要么是0要么是100%,曲线全是锯齿,没有任何分析价值。
我实践的采样间隔如下:
- 常规监控:1秒一次。这个频率足够画出平滑的曲线,也能覆盖日常性能波动。
- 判断瞬时尖峰:200ms-500ms一次,能抓到大致的尖峰轮廓,但没必要更低。
- 深度排查阶段:100ms一次,仅用于短时间抓取,比如用户执行某个操作的后几秒。
- 持续记录:5秒甚至更长,主要是为了分析内存泄漏伴随的CPU变化,不需要高精度。
低于100ms的采样我不建议做,因为获取Process.TotalProcessorTime本身不是零成本,还会触发进程内部状态刷新,高频调用反而给被监控进程增加负担,这就和监控的初衷背道而驰了。
5.2 采样线程与界面刷新解耦
监控模块最好不要直接跑在UI线程里,也不要让UI线程去轮询监控结果。我的做法是:采样线程是一个后台线程,它只负责把数据写入一个线程安全的环形缓冲区,同时对外发布一个CpuUpdated事件;UI或者其他消费方自行决定多久刷新一次展示。这样监控逻辑和展示逻辑互不干扰,即使缓冲区暂时堆积,也不会阻塞UI。
从工具代码里抽出来的核心部分大概是这个结构:
csharp复制public sealed class CpuMonitorService : IDisposable
{
private readonly Thread _monitorThread;
private readonly AutoResetEvent _stopEvent = new AutoResetEvent(false);
private readonly int _interval = 1000;
private bool _running;
public event EventHandler<double> ProcessCpuUpdated;
public event EventHandler<double> SystemCpuUpdated;
public void Start()
{
_running = true;
_monitorThread = new Thread(MonitorLoop)
{
IsBackground = true,
Name = "CpuMonitorThread"
};
_monitorThread.Start();
}
private void MonitorLoop()
{
var processMonitor = new ProcessCpuMonitor(Process.GetCurrentProcess());
var lastSample = Environment.TickCount64;
while (_running)
{
var now = Environment.TickCount64;
if (now - lastSample >= _interval)
{
double processCpu = processMonitor.GetCpuUsage();
double systemCpu = GetSystemCpuUsage();
ProcessCpuUpdated?.Invoke(this, processCpu);
SystemCpuUpdated?.Invoke(this, systemCpu);
lastSample = now;
}
_stopEvent.WaitOne(50);
}
processMonitor.Dispose();
}
public void Dispose()
{
_running = false;
_stopEvent.Set();
_monitorThread?.Join(2000);
_stopEvent.Dispose();
}
}
这里用AutoResetEvent做停止通知,比Thread.Sleep更可靠,因为在等待期间可以及时被唤醒。WaitOne(50)的50ms是个平衡值,既能让停止请求快速响应,又不会让线程空转消耗CPU。整个监控线程在空闲时大约只占不到0.1%的CPU,可以忽略不计。
如果你的消费方是WinForms的UI,事件处理函数里一定不要直接更新控件,要用BeginInvoke。我以前图方便直接在事件里写label.Text,结果高频更新下UI线程被自己的刷新事件拖慢,而且事件丢失严重。后来改成事件只负责把数据塞进一个队列,UI侧用Timer定时从队列取最新值,问题就消失了。
5.3 避免监控模块自身的性能陷阱
监控模块是“负责监督别人”的,最怕自己先出问题。我总结了几个容易踩的坑。
第一个坑是频繁创建PerformanceCounter。如果你在监控循环里每次采样都new一个计数器,CPU和内存开销都会明显上升,这等于给系统添乱。用Process.TotalProcessorTime方案就完全避开这个问题,因为取的是进程已有数据。
第二个坑是日志过多。如果每次采样都打一条日志,日志库本身可能比被监控的程序更吃CPU。我的策略是只在CPU超过阈值时记录状态,或者把数据累积到内存里,定期批量写一次,决不打点对点日志。
第三个坑是事件委托的内存泄漏。监控服务是被长期持有的单例,如果你在UI层反复订阅CpuUpdated事件但从不取消订阅,事件源会一直引用UI对象,导致UI无法被垃圾回收。在窗体关闭事件里必须调用_cpuMonitor.ProcessCpuUpdated -= handler,或者干脆让监控类实现IDisposable并在释放时清空事件。
6. 数据呈现与使用场景
6.1 曲线图比数字更有用
数字只告诉你“现在CPU高不高”,曲线能告诉你“什么操作触发的高”。我当时在工具里做了一个简单的CPU曲线面板,横轴是时间,纵轴是CPU百分比,同时画进程CPU和系统CPU两条线,再叠加一个用户操作标记点(比如“打开报表”“保存数据”)。
有了这个面板之后,优化工作的效率提升了一个数量级。以前是用户模糊反馈“有点卡”,现在是看到曲线上有一个尖峰正好对应“打开报表”的标记,我就可以拿这个时间点去抓性能分析快照。而且曲线图能区分持续占用的平台期和偶发抖动,定位问题的思路完全不同。平台期大概率是某个常驻线程死循环或者高频轮询,偶发抖动大概率是某个一次性操作里做了重计算。
画曲线图本身很简单,WinForms用双缓冲的PictureBox就能画,WPF用Polyline绑定ObservableCollection,Unity客户端也可以用UIText或者MLogger。核心是数据结构里维护一个固定长度的队列,新数据进来旧数据出,只保留最近几分钟的数据,避免内存无限增长。
6.2 阈值告警和自动抓取
曲线图是给人看的,阈值告警则是给机器看的。我在监控模块里加了一个简单的规则引擎:当进程CPU持续超过80%超过10秒,或者系统CPU超过95%超过30秒,就触发告警,并把前后各30秒内的CPU采样数据写成一个CSV快照。同时还可以利用性能分析API抓取当前线程的调用栈,不过这个在C#里要配合ETW,属于进阶玩法,后面单独开一篇再说。
阈值的选择要谨慎。不要把阈值定得太低,比如60%,很多正常操作稍微重一点就触发了,告警疲劳会让人忽略真正的问题。也不要把持续时间设置得太短,5秒钟的打嗝式尖峰不代表需要干预。我个人习惯是把阈值定在任务管理器能看到的“明显异常”之上,比如持续高于85%至少10秒才触发。这样告警数量少,每一条都有价值。
6.3 从CPU监控到根因定位的完整闭环
CPU监控不是终点,它负责输出“什么时候出了问题”,紧接着还需要配合其他工具回答“为什么出问题”。我通常的流程是这样的:
第一步,CPU监控曲线发现异常时间点。第二步,在异常时间点采集当前的线程列表,找出哪个线程的CPU时间最高。第三步,抓取该线程的调用栈或等待链,定位到具体的代码位置。第四步,针对定位到的方法做专项分析,比如看执行次数、等待时间、内存分配。
第二步在C#里可以做,用ProcessThread类就能遍历进程内所有线程的TotalProcessorTime,找出最“活跃”的线程ID,再用线程ID去匹配调用栈。这个细节比较多,我先留个悬念,后续文章会专门讲线程级CPU分析和抓栈实操。
7. 常见问题与排查技巧实录
7.1 常见问题速查表
这一节把我实际运营中遇到的典型问题整理成一个速查表,你可以直接对照排查:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| CPU数值一直是0 | 采样间隔太短,线程时间片粒度大于间隔 | 把采样间隔提升到500ms以上再测试 |
| CPU数值超过100% | Process.TotalProcessorTime未除以处理器数量 | 补上/Environment.ProcessorCount |
| 系统CPU与任务管理器不一致 | 计算时未排除IdleTime | 检查公式是否做了kernel - idle的修正 |
| 程序运行越久监控越慢 | 监控进程对象未Dispose导致句柄泄漏 | 用using包裹或实现IDisposable释放 |
| 重启后PerformanceCounter报错 | 性能计数器服务未启动或数据损坏 | 切换为Process.TotalProcessorTime方案 |
| 事件订阅导致内存暴涨 | 事件未取消订阅 | 在窗体关闭时统一取消订阅 |
| CPU曲线全是锯齿 | 采样间隔低于100ms | 降低采样频率或用滑动平均平滑 |
| 进程CPU高但系统CPU低 | 单核跑满但整体空闲 | 检查是否有单线程死循环或锁竞争 |
这里面最容易被新手忽略的就是“CPU值一直是0”,很多人一上来就把间隔设置成50ms,然后发现数据根本不更新,还以为是代码逻辑错了。实际上Windows的线程调度时间片在桌面系统上通常是15ms左右,但进程CPU时间的更新粒度受时钟中断影响,采样间隔太近时两次读取的累计时间差为0,计算出来的利用率自然就是0。把间隔拉到200ms以上,现象立刻消失。
7.2 排查技巧:让监控数据“可回放”
有一次用户反馈一个问题,看起来像是偶发的CPU飙升,但我在场的时候始终复现不出来。后来我把监控模块改成了“无论如何都保留最近5分钟数据,只有在触发异常时才把数据写盘”的模式。这样每次告警都会自动生成一份带完整上下文的数据包,包含进程CPU、系统CPU、内存占用、线程数量、采样时刻的进程ID和UI操作记录。
效果立竿见影。第二次用户再反馈问题时,我直接让用户把日志目录下的CSV发过来,导入到一个简单的分析页面里,很快就看到了“出现异常的5分钟里,CPU从15%跳到90%,而且线程数量翻了一倍”。顺着这个线索,我发现是某个第三方组件的线程池在特定数据量下疯狂创建新线程,最终定位到一个资源释放不及时导致的任务队列积压问题。没有这份“可回放”的数据,这个问题估计还要折磨好几周。
7.3 关于采样数据的一致性
最后再说一个非常容易忽略的坑:DateTime.UtcNow和Environment.TickCount64在计算时间差时,前者受系统时间调整影响,比如用户手动改时间或者自动校时,会导致两次采样间隔变成负数或者巨大值;后者则是从开机开始计数的毫秒数,不受时间调整影响。所以在计算CPU利用率这类需要精确时间差的场景,我建议优先用Environment.TickCount64做时间基准。
当然TickCount64也不是完美的,它会在大约2.4万小时(约2.7年)后回绕,但现代客户端几乎不可能连续运行这么久不重启,而且即使回绕,前后差值计算也会因为ulong类型特性而正确。如果你实在不放心,就自己封装一个单调时钟类,底层用Stopwatch.GetTimestamp()也没问题,核心原则就是“不要用墙上时钟做性能计算”。
我记得第一次把CPU监控模块上线时,自己在本地测试一切正常,结果用户机器上却出现了CPU数值整体偏低的报告。排查了半天才发现那台机器是虚拟机,宿主机的CPU配额限制了真实计算能力,导致Environment.ProcessorCount和实际可用CPU不匹配。从那以后,我在监控模块里多加了两个字段:逻辑处理器数量和操作系统版本号,每当看到奇怪的CPU数据,先看这两个字段是否正常。这个习惯帮我避掉了后面不少“环境背锅”的问题。
如果你现在正准备给自己的客户端加上CPU监控,我建议不要急着把代码铺得到处都是,先做一个最小可用的采集类,记录到本地文件,跑上一天看看数据稳定不稳定,再逐步加入曲线面板、阈值告警和线程级分析。毕竟监控模块本身也是代码,也需要被验证,一上来就做大而全的方案,往往连基础的数据准确性都没保证就要去处理各种bug。
