一、新的需求

本周的工作主要实现了以下几点:

1. 非托管内存细分:看清"系统资源"部分由什么组成

2. 后台常驻采样:每 30 秒自动记录一次内存快照

3. 趋势曲线:把采样数据画成折线图,直观看到变化

4. 历史存储:数据存入本地数据库,保留 7 天

5. 异常兜底:辅助功能出错不能拖垮主程序

下面按实现过程逐项说明,过程中穿插涉及的知识点。

、实现过程

2.1 非托管内存细分:用 P/Invoke 调系统 API

背景:托管与非托管

程序的内存分两类。托管内存是程序自己 new 出来的对象,由 .NET 的 GC(垃圾回收器)自动管理,不需要开发者手动释放。非托管内存则属于操作系统直接管理的资源,比如窗口、画笔、字体,程序只是"借用",用完必须自己归还。借了不还,就叫泄漏——资源越积越多,内存就"变胖"了。

之前面板只能显示"非托管内存"的总值,但不知道里面是什么。这次要把它拆开。

P/Invoke:C# 调用系统 DLL

C# 里没有现成的 API 能统计 GDI 对象数量,但 Windows 系统有——它藏在 user32.dll 里。P/Invoke(平台调用)就是 C# 调用这类 C/C++ 编写的系统函数的机制。它不是新语法,而是一座桥:声明函数签名,注明它在哪个 DLL,C# 运行时负责连接。

[DllImport("user32.dll")]
private static extern int GetGuiResources(IntPtr hProcess, int uiFlags);

[DllImport] 特性告诉编译器"这个函数在 user32.dll 里";extern 表示只声明不实现,实现由系统提供。

三个细分项

GDI 对象:画图用的资源,包括画笔、画刷、字体、位图。USER 对象:界面元素,包括窗口、菜单、光标。句柄(Handle):Windows 给每个资源发的"身份证号",程序凭这个数字使用资源,不能直接碰资源本体。GetGuiResources 传入标志位 0 得到 GDI 数量,传 1 得到 USER 数量;Process.HandleCount 得到句柄总数。

这些资源本身占用内存不大,但它们的数量异常增长就是泄漏的信号——有资源创建了却没人释放。对长期运行的软件来说,数量趋势比字节数更能说明问题,所以面板上用"个数"展示,而不是换算成 MB。

线程栈则是另一项:每个线程创建时,系统默认分配 1MB 栈空间存放函数调用数据,所以线程数 × 1MB 就是线程栈的占用。

四个项目加起来,"非托管"这个黑盒被拆成了看得见的四部分。

2.2 后台常驻采样:定时器到点抓快照

DispatcherTimer:UI 线程上的定时器

DispatcherTimer 是 WPF 提供的定时器控件:设定间隔后,每到一个时间点就触发一次事件。它不同于"到点开个新线程干活"的定时器——它的回调直接在当前界面线程上执行,所以回调里不能做耗时的操作。

private void StartMemorySampling()
{
    _memSamplingTimer = new DispatcherTimer();
    _memSamplingTimer.Interval = TimeSpan.FromSeconds(30);
    _memSamplingTimer.Tick += MemSampling_Tick;
    _memSamplingTimer.Start();
}

+= 这一行是事件订阅:把方法挂到定时器的 Tick 事件上,定时器每跳一次就调用它。这是 C# 的发布-订阅模式。

为什么是"采样"而不是"连续记录"

内存每秒都在变,全部记录会撑爆数据库。采样就是每隔固定时间抓一张快照:30 秒一条,一天 2880 条,一周约 2 万条。趋势分析不需要全量数据,足够密度的采样点就能还原曲线的形状——这是采样定理的直观体现。

2.3 趋势曲线:数据空间映射到像素空间

拿到一串采样点后,要把它们画成图。项目已有界面框架是 WPF,我们选择用自带的 Canvas + Polyline 实现,不引入第三方图表库:需求只是两条折线,平台原生能力足够,多一个第三方包就多一份依赖和维护成本。

Canvas 是 WPF 的布局面板,特点是子元素放在绝对坐标上(SetLeft/SetTop),适合自由绘图。Polyline 是折线元素:给它一串点,它把点按顺序连成线。

核心问题在于坐标映射:数据是"1205MB、14:00:00",画布是"500 像素宽、180 像素高",需要一套公式把数据空间换算成像素空间:

x = 左边距 + (时间 - 最早时间) / 总时长 × 绘图区宽
y = 上边距 + 绘图区高 - (内存值 - 最小值) / (最大值 - 最小值) × 绘图区高

所有绘图库做的第一件事都是这个换算,只是封装得更好。

坐标轴必须是动态的:最大值、最小值每次重画时重新从数据里计算,而不是写死。程序刚启动时内存约 800MB,跑一天后可能到 2GB,固定刻度会让小波动变成一条平线;动态缩放才能把当前数据的变化区间撑满画布,看到细节。

2.4 历史存储:用 ORM 操作 SQLite

为什么用数据库而不是文件

采样数据要按时间范围查询、按时间删除、排序展示。用文本文件做这些操作要么全量遍历、要么整体重写;数据库把这一切封装成现成能力。SQLite 是嵌入式数据库,整个数据库就是一个文件,无需安装服务,适合单机桌面软件。

ORM 与 CodeFirst:不写 SQL 的数据库操作

ORM(对象关系映射)解决的是"C# 对象"与"数据库表记录"之间的翻译问题。没有它,插入一条数据要手写 SQL 字符串,查出来还要手动填进对象;有了它,直接传对象、收对象。

CodeFirst(代码先行)是 ORM 的一种建表方式:先写一个 C# 实体类描述数据结构,框架根据类定义自动生成建表语句,表不存在时自动创建。

[SugarTable("MemorySample")]
public class MemorySampleRecord
{
    [SugarColumn(IsPrimaryKey = true, IsIdentity = true)]
    public int Id { get; set; }
    public DateTime Timestamp { get; set; }
    public double WorkingSetMB { get; set; }
    public double ManagedMB { get; set; }
    public int GdiObjects { get; set; }
    public int HandleCount { get; set; }
}

字段就是表结构,类名对应表名。改字段定义,表结构自动同步,不用维护独立的建表脚本。

数据保留策略

数据库不能无限增长。数据保留策略就是定期清理过期数据:启动时删除 7 天前的记录。"保留多久"是个权衡——太短没有参考价值,太长浪费磁盘;30 秒一条的采样密度,7 天约 2 万条,既能看出一周趋势,占用的磁盘空间又可忽略。

2.5 异常兜底:try-catch 与日志

try-catch:"吞掉"辅助功能的异常

异常是程序运行时的错误信号,比如数据库文件被占用、磁盘空间不足,代码执行到这里会抛出一个异常对象。try-catch 把可能出错的代码包起来:出错时进入 catch 分支处理,程序不崩溃。

try
{
    sqlSugar.SaveMemorySample(sample);
}
catch (Exception ex)
{
    Logger.Error($"[内存采样] 采样或存储失败: {ex.Message}");
}

这里刻意采用了"吞异常"策略:内存监测是辅助功能,它的失败不能影响主流程(数据采集才是核心业务)。所以所有采样相关代码都单独包 try-catch,出错只记录,程序照常运行。

log4net:给程序装黑匣子

吞掉异常不等于不处理。日志是程序的"黑匣子",按时间顺序记录发生了什么。项目使用 log4net 框架,调用方式很简单:

Logger.Info("后台采样已启动"); // 普通信息
Logger.Error("采样失败: ..."); // 错误信息

log4net 会自动附加时间戳、日志级别、来源位置,并写入文件。这里有一条原则:catch 里必须有日志——错误可以不中断运行,但不能不留痕迹,否则出了问题连追查的线索都没有。

、完成发现:五个功能串成一条流水线

实现完成后回看,五个功能不是孤立的,而是一条完整的数据链路:

定时器触发 → 采集快照 → 内存列表(实时趋势图)→ 数据库(7天历史)
└───────────── 全程被 try-catch + 日志兜底 ─────────────┘

先有定时器才有数据,先有数据才有趋势图和历史记录,任何一个环节出错都被异常处理拦住。这也印证了一个工程观点:监测工具本身要足够"轻"和"稳"——它存在的意义是长期观察,如果它自己会崩,观察就没有意义了。

、优化方向

趋势图目前是原始折线直接绘制,数据点很多时线条显得密集凌乱。后续可以考虑降采样:点数超过阈值时按比例抽稀,视觉上更干净,性能也更好;也可以给两条折线加半透明填充区域,用色块区分总内存和托管堆,重叠时依然可读。此外,采样周期、保留天数目前是常量,后续可做成配置项,让不同使用场景各自调整。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐