CDiskClean 开发历程——文件&进程实时监控-实现方案参考

前言

这篇博客,我打算结合我最近正在开发的 C盘管理工具 内部的功能实现,给各位读者一些在实现方式上的参考,文章很长,但会很详细,请做好心理准备。

问题引入

管理C盘的时候,我们总是会好奇:

为什么劳资前不久才清理的C盘,现在又冒红了?
到底是什么软件在吃我的C盘?它们都干了些啥啊,我嘞个豆。

这其实不是我们的问题,很多应用虽然明面上 让我们在安装的时候调整了安装路径,但是大部分应用 都会在C盘留下一些用户数据,或者一些关键的依赖包,配置信息 (这种大部分在 Program Files 里面),所以,应该如何知道应用修改了哪些目录?这些目录到底有谁在写入数据?
这就是这篇文章要研究的内容,解决 这些目录到底有谁在写入数据 的难题。

思考

既然要监控目录,那我们就得想到这是涉及 Windows 底层 的玩意,而且实时性很高读写量很大,对于不同的目录,对应的读写压力也天差地别。所以正确监控的方式 是只选取部分的,覆盖范围尽量小的目录,比如 桌面,下载目录。

小声嘀咕: 如果你的电脑可以让你 直接监控 C盘 ,扛住天大的压力,那当我没讲ヽ(ー_ー)ノ

然后就是 监控哪些进程的操作,因为一个目录 可能有很多程序会执行操作,如果全部都监控,到时候留下来的记录就会很多,而且很难检索。所以需要一些手段来排除一部分的进程。

再就是 想想如何实现,根据我们上面的思考内容,我们目前需要

  1. 选择自己监控的目录

  2. 选择自己需要或者不需要的进程。

    相关的限制

  3. 监控文件需要涉及 Windows 底层

  4. 进程过滤或者选取需要记住进程名 (进程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 实现:

这个方案 就比较复杂了,具体来讲:

  1. FSW 这边,调用获取的时候,只执行一次获取,不等待不判断不触发其他服务(比如变更提醒),直接将 ETW返回值回馈给UI
  2. ETW 这边,需要在 FSW 获取的时候 保留文件相关信息然后开启异步线程,持续等待 ETW的返回值
  3. 异步线程 获取到之后,将获取到的返回值填充进保留的文件信息 ,提醒页面更新 ,并触发 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 搞的总结,就当看个乐子吧。

相关推荐
猿长大人4 小时前
C# | MediatR 入门指南:后端架构解耦
分布式·后端·架构·c#·.net
回忆2012初秋4 小时前
C# 中的 EAP:基于事件的异步编程全面指南(一)
开发语言·c#
曹牧5 小时前
C#:Type
开发语言·c#
用户3721574261356 小时前
C# 如何在 Excel 中创建下拉列表:3 种常见数据源实现方式
c#
czhc11400756637 小时前
工业视觉检测中的通用设计模式与技术笔记
c#
明如正午7 小时前
【C#】volatile 关键字深度解析:为什么我的停止按钮有时失灵?
c#
猿长大人21 小时前
C# | JSON 序列化中的多态接口处理:基于 Attribute 实现精准 $type 控制
c#·json
一位狮子座的程序员1 天前
如何用RAG解决AI智能体的知识盲区?
开发语言·c#