首页
社区
课程
招聘
[原创]调试实战 | 谁在偷偷占用我的文件?原来是我自己
发表于: 2024-1-6 14:39 3920

[原创]调试实战 | 谁在偷偷占用我的文件?原来是我自己

2024-1-6 14:39
3920

缘起

之前基于 .net 官方提供的 FileSystemWatcher 写了一个文件变化监听工具,具体参考这篇文章 。主要解决了以下三个问题:

  1. 事件触发时,文件可能还不能被访问。
  2. 如果监听选项设置的过多,有可能会多次触发文件变化事件。
  3. 监听过滤器不够灵活,我没找到同时监听多种特定文件类型的方法(比如,同时只监听 .docx.bmp 文件)。

为了解决问题1,我在调用用户注册的回调函数前,会先调用 WaitUntilCanAccess() 来确保文件是可访问状态。没想到在测试过程中发现了一个意想不到的问题。本文记录了解决这个问题的过程。

说明: 我写了一个示例工程,本文所有的叙述都是基于这个示例工程的。

测试程序

TestMonitorImage.exe递归监听当前程序所在目录中的所有 .bmp 类型的文件变化,监听到相应事件后会调用 picturebox.Load(path) 把图像显示到界面上。

copy.ps1 是一个辅助脚本,会每隔一秒拷贝一张图片到子目录中,会触发文件变化事件。按理说,每隔一秒界面上应该显示一张新图片,如此往复,如下图:

right-result
但是……

初遇问题

没想到只显示了一张图片后就停止显示了,就像下面这样。

wrong-result
这不可能啊(哈哈,最近的口头禅)!但是现象确实是不对的,到底是哪里出了问题呢?

调查

附加调试器,通过调试信息可以判断监听还在正常进行,因为能不断输出调试日志。
debug-output

接着观察一下回调函数是否正常。在回调函数里加断点,奇怪,怎么没命中?难道是通知环节出了问题?

观察一下通知线程的运行情况,观察了几秒钟,发现这个线程会反复遇到同一个异常——文件正由另一进程使用,因此该进程无法访问此文件。
file-is-being-used-by-another-process-exception

按理说,powershell 脚本拷贝完文件后,就不会占用这个文件了。而且我机器上装的是固态硬盘,本地文件拷贝过程应该非常快,就算再慢,1 秒也应该拷贝完了。即使遇到文件被占用的异常,最多只会在最开始的十几毫秒内遇到,不应该过了这么久还会遇到这种异常。那到底是谁在占用这个文件呢?

请出 process explorer

打开 process explorer,搜索 testimage1.bmp,发现只搜到一条结果,而且居然还是自己的进程!
search-testimage1

看来应该是某处代码打开了这个文件,但是没有关闭。在 windows 中,打开文件后会返回一个句柄(HANDLE),不再使用这个文件的时候需要关闭。如果打开了文件,但是没有关闭它,则会导致句柄泄露,而且下次再尝试打开这个文件的时候可能会遇到文件被占用,无法访问的情况。有没有一种机制可以追踪句柄打开/关闭的情况呢?如果能同时显示对应的调用栈,那就更完美了。我知道两种方法:

  1. 使用 windbg!htrace 命令
  2. 使用 ETW(Event Trace for Windows) 追踪句柄。

如果可以调试的话,可以用 windbg 附加到对应的进程中,然后执行 !htrace -enable 开启句柄追踪,等程序运行一段时间后,执行 !htrace -diff 即可查看句柄变化情况,而且可以看到对应的调用栈。虽然 windbg 很强大,但是今天的主角不是 windbg,而是 perfView —— 一款开源、免费、绿色而且非常强大的 ETW 事件收集及分析工具。

采集数据

打开 perfView,点击 Collect -> Collect Alt+C ,会以管理员权限弹出收集界面,如下图:
perfview-collect

点击 Advance Options 按钮,即可打开高级选项。勾选 Handle 即可追踪句柄,勾选 File I/O 即可追踪文件操作。

设置好后,点击 Start Collection 即可开始收集。问题重现后,点击 Stop Collection 停止收集。完整操作过程如下:
collect-event-with-perfview

说明:

  1. 除了点击 Collect 菜单下的 Collect,还可以点击 Run,与 Collect 的区别是:Run 可以自动启动指定的程序,当程序结束运行时,自动停止收集。
  2. 需要说明的是:不论是 Collect 还是 Run,收集是机器级别的,不能只针对某个进程进行收集。

分析数据

点击 Stop Collection 按钮后, perfView 会自动保存采集结果,然后显示在左侧列表中。
perfview-left-expand

选中刚刚采集的文件,找到 Events 项,双击即可查看所有原始的 ETW 数据。其它项都是为了方便查看某些数据而创建的。比如,CPU Stacks 可以查看进程 CPU 使用情况。

因为采集的文件中包含海量的数据,而且大多数与我们要分析的问题无关,因此我们需要过滤出感兴趣的数据。

过滤数据

perfView 提供了很灵活的过滤机制,既可以根据进程过滤,也可以根据事件类型过滤,还可以根据事件内容进行过滤。

Process Filter 可以根据进程进行过滤。输入 TestMonitorImage

Event Types 可以根据事件类型进行过滤。在对应的 Filters 中输入 file|handle(表示过滤文件和句柄事件),在过滤出来的结果中选择具体的事件类型(CloseHandle, CreateHandle, DuplicateHandle, HandleDCEnd),按回车即可在右侧显示出过滤后的事件。

Text Filter 可以根据事件内容进行过滤。输入 .bmp,回车即可过滤出事件内容中包含 .bmp 的记录。

Columns To Display 可以设置显示的列。为了更好的观察感兴趣的字段,点击 Cols 按钮选择需要显示的列,我选择了 ObjectName, ThreadID*

注意:

我在操作的过程中先显示了 filehandle 相关的事件,然后才只显示 handle 相关的事件。如果上来就只显示 handle 相关的事件,那么根据 .bmp 过滤的话,会过滤不出来任何记录。我猜是 perfView 自动根据 file 事件中的句柄和文件名推断出了对应的 handle 事件的 ObjectName
filter-result

从上图可以很清楚的看到 .bmp 相关的事件。注意 ThreadID 一列,线程ID10164 的线程只出现了一次,对应的事件是 CreateHandle。(缺少了对应的 CloseHandle,说明这个线程只打开了文件,并没有关闭)在对应行的 Time MSec 这一列,右键,点击 Open Any Stack Alt+S (或者在对应列上按 Alt + S)即可查看对应的调用栈,如下图:
call-stack

在对应行上,右键,点击 Goto SOurce(Def) Alt+D (或者在对应行上按 Alt+D)即可打开对应的源码,如下图:
go-to-source

可以发现在 OnFileChanged() 回调函数中,会使用 s_form.pictureBox1.Load(e.FullPath) 加载文件。可以猜测 Load() 函数内部打开对应的文件后并没有关闭。

目前程序在每次通知前会使用 WaitUntilCanAccess() 检查文件是否可以读写(内部通过 File.Open(e.FullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); 进行判断)。

试想,如果下次收到同一个文件变化的通知,WaitUntilCanAccess() 内部会使用 File.Open() 尝试打开这个文件,而这个文件已经被打开了,是不是有可能触发文件被占用的异常呢?确实有可能会,也可能不会!

文件打开后没关闭,再次尝试打开时不一定会触发异常,比如两次都是通过 File.Open(e.FullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); 打开文件,第二次是可以顺利打开文件的。

那么什么情况下会触发文件被占用的异常呢?

深入分析

关于FileAcessFileShare 的关系,请参考我总结的这篇文章 —— 《开发常识 | 彻底理清 CreateFile 读写权限与共享模式的关系》

简单来说就是:后续的访问权限与先前的共享权限不能冲突。后续的共享权限与先前的访问权限不能冲突。

检查一下 线程ID10164 的线程打开文件时指定的 ShareAccess,是 ShareAccess.Read,如下图:
fileIO-Create-on-callback

后续如果用 FileAccess.ReadWrite 打开,肯定会报错。而 WaitUntilCanAccess() 内部调用 File.Open()FileAccess 参数的值就是 FileAccess.ReadWrite

而且 WaitUntilCanAccess() 指定的 ShareAccessFileShare.None。只要在调用 WaitUntilCanAccess() 的时候,已经在其它地方打开了这个文件,肯定会触发文件被占用的异常。

小结

简单做个总结,整个过程是这样的:

当监听目录下的文件发生变化后,会进入内部的监听回调函数,监听回调函数会通过 AddEventData() 把数据放到通知队列中,并通过 eventFiredEvent.Set() 触发通知事件,通知线程收到消息后开始依次处理队列中的事件。

通知线程会先通过 WaitUntilCanAccess() 确保这个文件是可读写的状态,然后再调用外部回调函数。在外部回调函数中打开了文件但是并没有关闭。

当通知线程处理后续事件时,对应的文件刚好是上一个被占用的文件,WaitUntilCanAccess() 内部调用 File.Open() 尝试打开文件时触发了文件被占用的异常,休息 FileAccessCheckIntervalMs 毫秒后,又会调用 File.Open() 检查文件是否可以访问,又会触发文件被占用的异常,如此往复。后续所有的事件都得不到通知了。

问题的核心有两点:

  1. 同一个文件的变化事件被通知了 2 次或多次。
  2. 外部回调函数中打开了对应的文件,但是没有关闭,而且打开文件时指定的 FileShareFileShare.Read 。而通知线程在调用 WaitUntilCanAccess() 检测文件是否可用时指定的 FileAccessFileAccess.ReadWrite,与 FileShare.Read 冲突。

解决问题

  1. 在调用外部回调函数前,尽量避免同一事件被通知多次。需要增加去除重复事件的支持。

    我已经在 FileSystemWatcherEx 中增加了 TryMergeSameEventDelayTriggerMs

    TryMergeSameEvent 表示是否合并"相同"事件,默认是 true

    DelayTriggerMs 只有在 TryMergeSameEventtrue 的时候才有效。表示在通知事件前等待的毫秒数,默认是 10 毫秒。在这段时间内发生的事件会做去重处理。

    说明: 虽然在一定程度上可以避免事件重复通知的问题,但依然有可能发生重复通知的情况,需要用户自己根据情况进行调整。

  2. 在外部回调中打开文件后尽快关闭。在本示例代码中,只需要换一种方式显示图片即可——把图像加载到内存后就立刻关闭文件。

    把回调函数中的代码改成下面这样即可。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public static void OnFileChanged_Ok(object sender, System.IO.FileSystemEventArgs e)
    {
      if (e.ChangeType == System.IO.WatcherChangeTypes.Created
          || e.ChangeType == System.IO.WatcherChangeTypes.Changed)
      {
        s_form.Invoke(new MethodInvoker(delegate()
        {
          ShowImage(s_form.pictureBox1, e.FullPath);
        }));
      }
    }
     
    public static void ShowImage(System.Windows.Forms.PictureBox pictureBox, string imagePath)
    {
      try
      {
        using (var imageStream = new FileStream(imagePath, FileMode.Open))
        {
          pictureBox.Image = (Bitmap)Image.FromStream(imageStream);
        }
      }
      catch (System.Exception)
      {
      }
    }
  3. 还有一点可以优化:

    WaitUntilCanAccess() 中用来判断文件是否可以访问的语句如下:

    1
    File.Open(e.FullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);

    ,其中的 FileShare 指定的太严格了,可以不做限制。改为如下语句:

    1
    File.Open(e.FullPath, FileMode.Open, FileAccess.ReadWrite, (FileShare)0xff);

示例代码

直接克隆

githubhttps://github.com/BianChengNan/FileSystemWatcherEx

giteehttps://gitee.com/bianchengnan/FileSystemWatcherEx

也可以直接下载压缩包:

百度云:https://pan.baidu.com/s/1OBSFpQYRDQHhO5A0Yviqmw 提取码: yic3

CSDN:https://download.csdn.net/download/xiaoyanilw/19648448

总结

  • process explorer 不仅可以用来查看进程基本信息,还可以查看哪些文件被哪些进程占用。

  • windbg 中的 !htrace 也可以用来追踪句柄情况,但是要求附加到对应的程序中。

  • perfView 是非常强大的 ETW 收集及分析工具,可以收集机器级别的信息,包括但不限于句柄,文件读写,注册表读写,进程事件,网络事件等等。


[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 5
支持
分享
最新回复 (5)
雪    币: 4120
活跃值: (5822)
能力值: ( LV8,RANK:120 )
在线值:
发帖
回帖
粉丝
2
感谢分享
2024-1-6 22:07
0
雪    币: 3070
活跃值: (30876)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
感谢分享
2024-1-6 23:08
1
雪    币: 300
活跃值: (2472)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
mark
2024-1-7 11:14
0
雪    币: 233
活跃值: (2292)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
感谢分享
2024-1-7 11:28
0
游客
登录 | 注册 方可回帖
返回
//