前言
这篇博客,我打算结合我最近正在开发的 C盘管理工具 内部的功能实现,给各位读者一些在实现方式上的参考,文章很长,但会很详细,请做好心理准备。
问题引入
在管理C盘的时候,我们总是会好奇:
为什么劳资前不久才清理的C盘,现在又冒红了?
到底是什么软件在吃我的C盘?它们都干了些啥啊,我嘞个豆。
这其实不是我们的问题,很多应用虽然明面上 让我们在安装的时候调整了安装路径,但是大部分应用 都会在C盘留下一些用户数据,或者一些关键的依赖包,配置信息 (这种大部分在 Program Files 里面),所以,应该如何知道应用修改了哪些目录?这些目录到底有谁在写入数据?
这就是这篇文章要研究的内容,解决 这些目录到底有谁在写入数据 的难题。
思考
既然要监控目录,那我们就得想到这是涉及 Windows 底层 的玩意,而且实时性很高 ,读写量很大,对于不同的目录,对应的读写压力也天差地别。所以正确监控的方式 是只选取部分的,覆盖范围尽量小的目录,比如 桌面,下载目录。
小声嘀咕: 如果你的电脑可以让你 直接监控 C盘 ,扛住天大的压力,那当我没讲ヽ(ー_ー)ノ
然后就是 监控哪些进程的操作,因为一个目录 可能有很多程序会执行操作,如果全部都监控,到时候留下来的记录就会很多,而且很难检索。所以需要一些手段来排除一部分的进程。
再就是 想想如何实现,根据我们上面的思考内容,我们目前需要:
-
选择自己监控的目录
-
选择自己需要或者不需要的进程。
相关的限制 :
-
监控文件需要涉及 Windows 底层
-
进程过滤或者选取需要记住进程名 (进程ID是不可信的,每次启动都会变化)
到这里,一个答案就呼之欲出了------ WinForm
实现方案
知道了使用Winform 来解决,那就是 制作Windows桌面应用 了,具体的技术介绍以及前期的页面学习我这里不会涉及,感兴趣的可以自行搜索。
那种枯燥的介绍我才懒得打字,甚至看都不想看
前期页面学习内容太多,太杂,会大幅提升篇幅,这里只讲必须项。
文件监控
用到的东西是 FileSystemWatcher ,这是一个让我们可以监控文件各种事件的C#类,内部声明了很多委托,在使用的时候需要进行订阅,这篇文章中,可能使用到的有:
Created 监控目录存在 创建文件 行为的时候触发
Changed 监控目录或者文件中存在 修改内容 行为的时候触发Deleted 监控目录存在 删除文件 行为的时候触发
Renamed 监控目录存在重命名文件 行为的时候触发
Error 监控目录或者文件出现 报错内容 的时候触发,比如无法识别文件编码 的错误
订阅方式: new FileSystemWatcher().Created += Your_HandleFunctionName
这里给出我的代码片段:
ini
private void StartWatchingInternal(string dir, bool includeSubdirs)
{
if (!System.IO.Directory.Exists(dir)) return;
try
{
var watcher = new FileSystemWatcher(dir)
{
NotifyFilter = NotifyFilters.FileName
| NotifyFilters.Size
| NotifyFilters.LastWrite,
IncludeSubdirectories = includeSubdirs,
InternalBufferSize = 64 * 1024,
EnableRaisingEvents = true
};
watcher.Created += OnFileEvent;
watcher.Changed += OnFileEvent;
watcher.Deleted += OnFileEvent;
watcher.Renamed += OnRenamed;
watcher.Error += OnError;
_watchers.Add(watcher);
_watcherMap[dir] = watcher;
}
catch (Exception ex)
{
MonitorError?.Invoke($"无法监视目录 {dir}: {ex.Message}");
}
}
随后就是 具体的订阅处理,我这里是采用的保存相关的记录然后投放到 WinForm-DataGridView 中进行展示,具体的实现逻辑更多是涉及窗体控件,这里不展开。
给出我的简要订阅实现【后续会跟着文章内容拓展】:
ini
private void OnFileEvent(object sender, FileSystemEventArgs e)
{
var changeType = e.ChangeType switch
{
WatcherChangeTypes.Created => ChangeType.Created,
WatcherChangeTypes.Changed => ChangeType.Changed,
WatcherChangeTypes.Deleted => ChangeType.Deleted,
_ => ChangeType.Changed
};
var record = new FileChangeRecord
{
Timestamp = eventTime,
ChangeType = changeType,
FullPath = e.FullPath,
FileName = Path.GetFileName(e.FullPath),
Directory = Path.GetDirectoryName(e.FullPath) ?? "",
SizeBytes = GetFileSizeSafe(e.FullPath)
};
PublishRecord(record);
}
以及简单的页面效果:

这个 来源进程 会在文章中给出相关实现,这里先埋个伏笔。
进程监控
这里需要使用到的东西就 非常关键 ,非常关键 ,非常关键 了,是 Windows底层的ETW系列库 ,很多东西 并没有原生的支持,需要自己去下载相关的库。下载过程 不展开。
主要我自己也忘记是怎么下的了 ,反正不是自带的,可以尝试询问 AI。
命名空间声明 : using Microsoft.Diagnostics.Tracing.Session;
其中最关键 的类是 TraceEventSession ,它是我们进行进程监控的基础,内部通过封装 Source,然后Source 封装 Kernel,Kernel内部声明了很多委托,可能使用到的有: 【使用前要先开启FileIO】
FileIOCreate: 当提供的目录下 创建文件的事件 发生时触发
FileIOWrite: 当提供的目录下 文件写入的事件 发送时触发FileIODelete: 当提供的目录下 删除文件的事件 发生时触发
同样,给出我的代码参考:
ini
public void Start()
{
if (IsRunning) return;
_cts = new CancellationTokenSource();
var token = _cts.Token;
Task.Run(() =>
{
try
{
var sessionName = "CdiskCleanEtwSession";
try { TraceEventSession.GetActiveSession(sessionName)?.Dispose(); } catch { }
using (_session = new TraceEventSession(sessionName))
{
_session.EnableKernelProvider(KernelTraceEventParser.Keywords.FileIOInit);
var source = _session.Source;
source.Kernel.FileIOCreate += d => BufferEvent(d.FileName, d.ProcessName);
source.Kernel.FileIOWrite += d => BufferEvent(d.FileName, d.ProcessName);
source.Kernel.FileIODelete += d => BufferEvent(d.FileName, d.ProcessName);
IsRunning = true;
try
{
source.Process();
}
catch (OperationCanceledException) { }
}
}
catch (Exception)
{
IsRunning = false;
}
}, token);
}
鉴于代码比较复杂,而且和文章后面关联很大,这里简单做个解析:
ruby
首先 判断 进程监控 是否正在运行,如果正在运行就直接返回【防止多次触发】
在此基础上 还使用到了 **CancellationTokenSource** 取消标志 对过程进行控制,
当 _cts 执行取消操作的时候 会抛出 OperationCanceledException
=> 被catch 到
=> 强行终止还没执行完的 source.Process();
然后尝试新建线程运行 监控程序,过程中对 TraceEventSession 进行命名
【一个程序一个Session就够了,所以可以写死字符串,具体细节感兴趣的可自行搜索,和 Windows内核有关】
初始化 Kernel 的FileIO,告诉 Windows : 我现在要接收 文件修改的信息。
订阅事件【事件会提供文件修改的详细信息】
更新状态
细心的读者 就发现了,订阅的事件貌似是 只要有文件修改就会触发 。【完全正确】
这就是为什么要做这么多检查,因为这个一旦启动,给系统带来的负载是很大的【触发频率非常高 】
给出我 BufferEvent 的代码:
csharp
private void BufferEvent(string? fileName, string processName)
{
try
{
// 过滤空文件
if (string.IsNullOrEmpty(fileName)) return;
if (string.IsNullOrWhiteSpace(processName) ||
string.Equals(processName, CurrentProcessName, StringComparison.OrdinalIgnoreCase))
return;
// 对于 白名单中的 目录路径进行选取
var dirs = _watchDirectoryArray;
foreach (var dir in dirs)
{
if (IsPathInside(fileName, dir))
{
// 匹配成功
var normalized = NormalizePath(fileName);
_eventBuffer[normalized] = (processName, DateTime.Now);
break;
}
}
}
catch
{
// 忽略单条事件异常
}
}
目录的筛选 我们放在了处理函数中,但是这也避免不了庞大的触发量------调试的时候注意位置
需要 将监控的目录列表 和 这个处理函数 进行关联,怎么实现,读者自己想吧。
实现进程追踪
还记得之前文件监控末尾 提到的 来源进程字段吗,这个章节中 就会对其填充。
大致逻辑 : 在文件监控触发的时候 去寻找 进程监控记录 中记载好的修改事件
所以我们需要做的就是 在文件监控事件触发 的时候 调用ETW 查询记录信息 即可,代码如下:
ini
private void OnFileEvent(object sender, FileSystemEventArgs e)
{
var changeType = e.ChangeType switch
{
WatcherChangeTypes.Created => ChangeType.Created,
WatcherChangeTypes.Changed => ChangeType.Changed,
WatcherChangeTypes.Deleted => ChangeType.Deleted,
_ => ChangeType.Changed
};
string processName = _etwService.TryGetProcess(e.FullPath)
var record = new FileChangeRecord
{
Timestamp = eventTime,
ChangeType = changeType,
FullPath = e.FullPath,
FileName = Path.GetFileName(e.FullPath),
Directory = Path.GetDirectoryName(e.FullPath) ?? "",
SizeBytes = GetFileSizeSafe(e.FullPath),
SourceProcess = processName
};
PublishRecord(record);
}
获取线程名的方法:
csharp
public string? TryGetProcess(string filePath)
{
if (!IsRunning || string.IsNullOrEmpty(filePath)) return null;
var normalized = NormalizePath(filePath);
if (_eventBuffer.TryGetValue(normalized, out var entry))
{
return entry.ProcessName;
}
return "未知进程";
}
可是,真的有这么简单吗? 很多地方其实我们没有考虑到
为了方便讲述,后续 文件监控 使用 FSW ,进程监控 使用 ETW。
例如:
- 如何保证 FSW 一定能从 ETW 中获取到数据呢? 获取到的数据有没有可能是之前留下来的?
- 如何保证 FSW 和 ETW 之间数据的一致性? 会不会 FSW 获取到的是 在相同目录中 执行了操作的其他线程?
脑子有没有烧起来? (✪ω✪)
博主当时也是束手无策 o(╥﹏╥)o
FSW 和 ETW 同步方案
声明: 这里我是参考了AI的,介意的读者可以退出。
在我的实践调试过程中,总是 FSW 比 ETW 先到达,导致FSW获取不到。 针对这种场景 有两种方式:
- 短时间的不同步,考虑 在获取的时候 让FSW等一等,实在获取不到,就返回默认值。
- 启动异步线程,后台查询,FSW 仅做一次尝试,并显示空值。异步获取到了之后再更新页面 。
对于 FSW 和 ETW 数据不同步 的解决方案,我认为有且仅有一种解法
- 记录时间戳 ,在特定的时间范围内查询ETW的对应记录,同步最好,不同步也没招
方案1 实现
设定一个 FSW 的等待窗口 ,在这段时间内 ETW 的数据如果到达,就获取返回,如果没到达,结束等待。
代码参考:
csharp
public string? TryGetProcess(string filePath)
{
if (!IsRunning || string.IsNullOrEmpty(filePath)) return null;
var normalized = NormalizePath(filePath);
for (int i = 0; i < 10; i++)
{
if (_eventBuffer.TryGetValue(normalized, out var entry))
{
// 确保 数据同步 用作 时间戳判断,
// _bufferTtl 是允许的同步数据时间范围
if (DateTime.Now - entry.Timestamp <= _bufferTtl)
{
return entry.ProcessName;
}
}
// 等待窗口 20 * 10 = 200 ms
Thread.Sleep(20);
}
return "未知进程";
}
更多的代码实现 请原谅我不能给出,FSW 那边不需要动。
- 优势: 简单,不影响整体的逻辑。
- 缺点: 不稳定
方案2 实现:
这个方案 就比较复杂了,具体来讲:
- FSW 这边,调用获取的时候,只执行一次获取,不等待 ,不判断 ,不触发其他服务(比如变更提醒),直接将 ETW返回值回馈给UI
- ETW 这边,需要在 FSW 获取的时候 保留文件相关信息然后开启异步线程,持续等待 ETW的返回值
- 异步线程 获取到之后,将获取到的返回值填充进保留的文件信息 ,提醒页面更新 ,并触发 FSW中需要触发的其他服务 。
代码参考: 【用到了队列,整体会很长,但是效果比较好】
OnFileEvent FSW 事件处理函数
ini
private void OnFileEvent(object sender, FileSystemEventArgs e)
{
if (_paused) return;
// 清理功能产生的文件操作不应被记录(避免自己监听到自己)
if (_cleanupService?.ShouldIgnoreEvent(e.FullPath) == true) return;
var changeType = e.ChangeType switch
{
WatcherChangeTypes.Created => ChangeType.Created,
WatcherChangeTypes.Changed => ChangeType.Changed,
WatcherChangeTypes.Deleted => ChangeType.Deleted,
_ => ChangeType.Changed
};
var eventTime = DateTime.Now;
var attributionNotBefore = eventTime.AddMilliseconds(-500);
// 先用快速非阻塞方式查询 ETW 缓冲区
var processName = _etwService.TryGetProcessOnce(e.FullPath, attributionNotBefore);
var record = new FileChangeRecord
{
Timestamp = eventTime,
ChangeType = changeType,
FullPath = e.FullPath,
FileName = Path.GetFileName(e.FullPath),
Directory = Path.GetDirectoryName(e.FullPath) ?? "",
SizeBytes = GetFileSizeSafe(e.FullPath),
SourceProcess = processName
};
if (processName != null)
{
if (needIgnoreProcess(processName)) return;
PublishRecord(record);
return;
}
EnqueuePending(record, e.FullPath, attributionNotBefore);
}
TryGetProcessOnce 快速单次查找
csharp
/// <summary>
/// 快速单次查找,不重试,不移除缓冲。用于 FSW 事件触发时的即时查询。
/// </summary>
public string? TryGetProcessOnce(string filePath, DateTime? notBefore = null)
{
if (string.IsNullOrEmpty(filePath)) return null;
var normalized = NormalizePath(filePath);
if (_eventBuffer.TryGetValue(normalized, out var entry))
{
if (DateTime.Now - entry.Timestamp <= _bufferTtl &&
(!notBefore.HasValue || entry.Timestamp >= notBefore.Value))
return entry.ProcessName;
}
return null;
}
EnqueuePending 发送到队列 尝试异步查询
csharp
private void EnqueuePending(
FileChangeRecord record,
string filePath,
DateTime attributionNotBefore)
{
if (_pendingQueries.Count >= MaxPendingQueries)
{
PublishRecord(record);
MonitorError?.Invoke("待解析进程队列已达上限,部分记录将显示为未知进程");
return;
}
_pendingQueries.Enqueue(new PendingQuery(record, filePath, attributionNotBefore));
}
ProcessPendingQueriesAsync FSW 中启动的 队列服务
csharp
/// <summary>
/// 后台处理延迟查询队列。FSW 比 ETW 先触发时,在此异步重试获取进程名。
/// </summary>
private async Task ProcessPendingQueriesAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var batch = new List<PendingQuery>(MaxConcurrentQueries);
while (batch.Count < MaxConcurrentQueries &&
_pendingQueries.TryDequeue(out var query))
batch.Add(query);
if (batch.Count == 0)
{
try { await Task.Delay(100, ct); }
catch (OperationCanceledException) { break; }
continue;
}
await Task.WhenAll(batch.Select(query => ResolvePendingQueryAsync(query, ct)));
}
}
ResolvePendingQueryAsync: 队列中的 处理方法
scss
private async Task ResolvePendingQueryAsync(PendingQuery query, CancellationToken ct)
{
try
{
var processName = await _etwService.TryGetProcessAsync(
query.FilePath,
1500,
ct,
query.AttributionNotBefore);
ct.ThrowIfCancellationRequested();
if (processName != null)
{
query.Record.SourceProcess = processName;
if (needIgnoreProcess(processName))
return;
}
PublishRecord(query.Record);
}
catch (OperationCanceledException)
{
// 停止监控时不再发布尚未完成归因的记录。
}
catch (Exception ex)
{
MonitorError?.Invoke($"解析来源进程失败: {ex.Message}");
PublishRecord(query.Record);
}
}
TryGetProcessAsync 异步查询
csharp
/// <summary>
/// 异步重试查询进程名。ETW 事件可能比 FSW 晚到,在后台延迟重试。
/// </summary>
/// <param name="filePath">文件路径</param>
/// <param name="maxDelayMs">最大等待毫秒数(默认 1500ms)</param>
/// <param name="ct">取消令牌</param>
/// <returns>进程名,超时则返回 null</returns>
public async Task<string?> TryGetProcessAsync(
string filePath,
int maxDelayMs = 1500,
CancellationToken ct = default,
DateTime? notBefore = null)
{
if (string.IsNullOrEmpty(filePath)) return null;
var normalized = NormalizePath(filePath);
var sw = System.Diagnostics.Stopwatch.StartNew();
/*
!ct.IsCancellationRequested 是必要的,它可以在程序结束后快速 结束循环,避免阻塞线程。
程序停止后 会调用 Stop(),它会取消 _cts,从而触发 ct.IsCancellationRequested。
如果没有这个检查,循环可能会继续等待,直到达到 maxDelayMs 才结束,导致程序退出延迟。
另外,Task.Delay 本身也会抛出 OperationCanceledException,如果 ct 被取消,它会立即抛出异常,从而跳出循环。
检查 ct.IsCancellationRequested 可以让我们在循环中及时响应取消请求,避免不必要的等待。
*/
while (sw.ElapsedMilliseconds < maxDelayMs && !ct.IsCancellationRequested)
{
if (_eventBuffer.TryGetValue(normalized, out var entry))
{
// 如果事件在缓冲区中,并且没有过期,则返回进程名并移除缓冲区中的记录
if (DateTime.Now - entry.Timestamp <= _bufferTtl &&
(!notBefore.HasValue || entry.Timestamp >= notBefore.Value))
{
return entry.ProcessName;
}
}
try
{
await Task.Delay(50, ct);
}
catch (OperationCanceledException)
{
return null;
}
}
return null;
}
顺带一提,这一长串代码 中 涉及到的 IgnoreProcess(忽略进程服务 ) 我以后会结合着 拖拽的实现参考 来讲。
可以选择期待一手哦. ✧(≖ ◡ ≖✿
总结 ------ From DeepSeek
本文从 C 盘管理的实际痛点出发,逐步拆解了"监控目录并追踪写入进程"这一需求的实现路径。核心方案围绕 WinForm + FileSystemWatcher(FSW) + ETW(TraceEventSession) 构建,前者负责文件事件的实时捕获,后者负责进程来源的底层追踪。
实现过程中最大的挑战并非单一组件的使用,而是 FSW 与 ETW 事件到达时序不一致 的问题------FSW 往往先于 ETW 触发,导致进程信息缺失。为此,本文给出了两种同步策略:短时轮询等待 和 异步队列延迟归因,后者通过后台重试和超时兜底,在稳定性和实时性之间取得了较好平衡。
最终,该方案能够在不显著影响系统性能的前提下,准确识别指定目录下每个文件变动的来源进程,为 C 盘空间分析提供了可靠的数据支撑。后续还将补充进程过滤、拖拽交互等扩展实现,进一步提升工具的易用性与实用性。
技术没有银弹,但每一次细节的打磨,都让工具离"好用"更近一步。
实在是不想自己总结了,DeepSeek 搞的总结,就当看个乐子吧。