缘起
之前基于 .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
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)
{
}
}
还有一点可以优化:
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
收集及分析工具,可以收集机器级别的信息,包括但不限于句柄,文件读写,注册表读写,进程事件,网络事件等等。
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!