缘起
之前基于 .net
官方提供的 FileSystemWatcher
写了一个文件变化监听工具,具体参考这篇文章 。主要解决了以下三个问题:
- 事件触发时,文件可能还不能被访问。
- 如果监听选项设置的过多,有可能会多次触发文件变化事件。
- 监听过滤器不够灵活,我没找到同时监听多种特定文件类型的方法(比如,同时只监听
.docx
和.bmp
文件)。
为了解决问题1,我在调用用户注册的回调函数前,会先调用 WaitUntilCanAccess()
来确保文件是可访问状态。没想到在测试过程中发现了一个意想不到的问题。本文记录了解决这个问题的过程。
说明: 我写了一个示例工程,本文所有的叙述都是基于这个示例工程的。
测试程序
TestMonitorImage.exe
会递归监听当前程序所在目录中的所有 .bmp
类型的文件变化,监听到相应事件后会调用 picturebox.Load(path)
把图像显示到界面上。
copy.ps1
是一个辅助脚本,会每隔一秒拷贝一张图片到子目录中,会触发文件变化事件。按理说,每隔一秒界面上应该显示一张新图片,如此往复,如下图:
但是……
初遇问题
没想到只显示了一张图片后就停止显示了,就像下面这样。
这不可能啊(哈哈,最近的口头禅)!但是现象确实是不对的,到底是哪里出了问题呢?
调查
附加调试器,通过调试信息可以判断监听还在正常进行,因为能不断输出调试日志。
接着观察一下回调函数是否正常。在回调函数里加断点,奇怪,怎么没命中?难道是通知环节出了问题?
观察一下通知线程的运行情况,观察了几秒钟,发现这个线程会反复遇到同一个异常——文件正由另一进程使用,因此该进程无法访问此文件。
按理说,powershell
脚本拷贝完文件后,就不会占用这个文件了。而且我机器上装的是固态硬盘,本地文件拷贝过程应该非常快,就算再慢,1
秒也应该拷贝完了。即使遇到文件被占用的异常,最多只会在最开始的十几毫秒内遇到,不应该过了这么久还会遇到这种异常。那到底是谁在占用这个文件呢?
请出 process explorer
打开 process explorer
,搜索 testimage1.bmp
,发现只搜到一条结果,而且居然还是自己的进程!
看来应该是某处代码打开了这个文件,但是没有关闭。在 windows
中,打开文件后会返回一个句柄(HANDLE
),不再使用这个文件的时候需要关闭。如果打开了文件,但是没有关闭它,则会导致句柄泄露,而且下次再尝试打开这个文件的时候可能会遇到文件被占用,无法访问的情况。有没有一种机制可以追踪句柄打开/关闭的情况呢?如果能同时显示对应的调用栈,那就更完美了。我知道两种方法:
- 使用
windbg
的!htrace
命令 - 使用
ETW(Event Trace for Windows)
追踪句柄。
如果可以调试的话,可以用 windbg
附加到对应的进程中,然后执行 !htrace -enable
开启句柄追踪,等程序运行一段时间后,执行 !htrace -diff
即可查看句柄变化情况,而且可以看到对应的调用栈。虽然 windbg
很强大,但是今天的主角不是 windbg
,而是 perfView —— 一款开源、免费、绿色而且非常强大的 ETW
事件收集及分析工具。
采集数据
打开 perfView
,点击 Collect -> Collect Alt+C
,会以管理员权限弹出收集界面,如下图:
点击 Advance Options
按钮,即可打开高级选项。勾选 Handle
即可追踪句柄,勾选 File I/O
即可追踪文件操作。
设置好后,点击 Start Collection
即可开始收集。问题重现后,点击 Stop Collection
停止收集。完整操作过程如下:
说明:
- 除了点击
Collect
菜单下的Collect
,还可以点击Run
,与Collect
的区别是:Run
可以自动启动指定的程序,当程序结束运行时,自动停止收集。- 需要说明的是:不论是
Collect
还是Run
,收集是机器级别的,不能只针对某个进程进行收集。
分析数据
点击 Stop Collection
按钮后, perfView
会自动保存采集结果,然后显示在左侧列表中。
选中刚刚采集的文件,找到 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
和 *
。
注意:
我在操作的过程中先显示了
file
和handle
相关的事件,然后才只显示handle
相关的事件。如果上来就只显示handle
相关的事件,那么根据.bmp
过滤的话,会过滤不出来任何记录。我猜是perfView
自动根据file
事件中的句柄和文件名推断出了对应的handle
事件的ObjectName
。
从上图可以很清楚的看到 .bmp
相关的事件。注意 ThreadID
一列,线程ID
为 10164
的线程只出现了一次,对应的事件是 CreateHandle
。(缺少了对应的 CloseHandle
,说明这个线程只打开了文件,并没有关闭)在对应行的 Time MSec
这一列,右键,点击 Open Any Stack Alt+S
(或者在对应列上按 Alt + S
)即可查看对应的调用栈,如下图:
在对应行上,右键,点击 Goto SOurce(Def) Alt+D
(或者在对应行上按 Alt+D
)即可打开对应的源码,如下图:
可以发现在 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);
打开文件,第二次是可以顺利打开文件的。
那么什么情况下会触发文件被占用的异常呢?
深入分析
关于FileAcess
和 FileShare
的关系,请参考我总结的这篇文章 —— 《开发常识 | 彻底理清 CreateFile 读写权限与共享模式的关系》。
简单来说就是:后续的访问权限与先前的共享权限不能冲突。后续的共享权限与先前的访问权限不能冲突。
检查一下 线程ID
为 10164
的线程打开文件时指定的 ShareAccess
,是 ShareAccess.Read
,如下图:
后续如果用 FileAccess.ReadWrite
打开,肯定会报错。而 WaitUntilCanAccess()
内部调用 File.Open()
时 FileAccess
参数的值就是 FileAccess.ReadWrite
。
而且 WaitUntilCanAccess()
指定的 ShareAccess
是 FileShare.None
。只要在调用 WaitUntilCanAccess()
的时候,已经在其它地方打开了这个文件,肯定会触发文件被占用的异常。
小结
简单做个总结,整个过程是这样的:
当监听目录下的文件发生变化后,会进入内部的监听回调函数,监听回调函数会通过 AddEventData()
把数据放到通知队列中,并通过 eventFiredEvent.Set()
触发通知事件,通知线程收到消息后开始依次处理队列中的事件。
通知线程会先通过 WaitUntilCanAccess()
确保这个文件是可读写的状态,然后再调用外部回调函数。在外部回调函数中打开了文件但是并没有关闭。
当通知线程处理后续事件时,对应的文件刚好是上一个被占用的文件,WaitUntilCanAccess()
内部调用 File.Open()
尝试打开文件时触发了文件被占用的异常,休息 FileAccessCheckIntervalMs
毫秒后,又会调用 File.Open()
检查文件是否可以访问,又会触发文件被占用的异常,如此往复。后续所有的事件都得不到通知了。
问题的核心有两点:
- 同一个文件的变化事件被通知了
2
次或多次。 - 外部回调函数中打开了对应的文件,但是没有关闭,而且打开文件时指定的
FileShare
是FileShare.Read
。而通知线程在调用WaitUntilCanAccess()
检测文件是否可用时指定的FileAccess
是FileAccess.ReadWrite
,与FileShare.Read
冲突。
解决问题
在调用外部回调函数前,尽量避免同一事件被通知多次。需要增加去除重复事件的支持。
我已经在
FileSystemWatcherEx
中增加了TryMergeSameEvent
和DelayTriggerMs
。TryMergeSameEvent
表示是否合并”相同”事件,默认是true
。DelayTriggerMs
只有在TryMergeSameEvent
为true
的时候才有效。表示在通知事件前等待的毫秒数,默认是10
毫秒。在这段时间内发生的事件会做去重处理。说明: 虽然在一定程度上可以避免事件重复通知的问题,但依然有可能发生重复通知的情况,需要用户自己根据情况进行调整。
在外部回调中打开文件后尽快关闭。在本示例代码中,只需要换一种方式显示图片即可——把图像加载到内存后就立刻关闭文件。
把回调函数中的代码改成下面这样即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public 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)
{
}
}还有一点可以优化:
WaitUntilCanAccess()
中用来判断文件是否可以访问的语句如下:1
File.Open(e.FullPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
,其中的
FileShare
指定的太严格了,可以不做限制。改为如下语句:1
File.Open(e.FullPath, FileMode.Open, FileAccess.ReadWrite, (FileShare)0xff);
示例代码
直接克隆
github
:https://github.com/BianChengNan/FileSystemWatcherEx
gitee
: https://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
收集及分析工具,可以收集机器级别的信息,包括但不限于句柄,文件读写,注册表读写,进程事件,网络事件等等。