写在前面的话
ReadDirectoryChangesW 是Windows提供一个API,用于读取文件夹的磁盘变更。该API很实用,目前市面上已知的所有运行在用户态同步应用,都绕不开这个接口。但正确使用该API相对来说比较复杂,该接口能真正考验一个Windows开发人员对线程、异步IO、可提醒IO、IO完成端口等知识的掌握情况,如果读者还不熟悉这些技术,请先补充一下相关背景知识。
感谢 jimbeveridge (Multithreading Applications in Win32的作者)的精彩文章Understanding ReadDirectoryChangesW,建议读者也去读一下。jimbeveridge使用了可提醒IO实现对文件夹的磁盘监控。为了更深入的了解该API,在jimbeveridge基础上,我提供另一种IO完成端口异步模型,实现对文件夹的磁盘监控。
相关代码请参考github, 代码未经过充分测试,仅提供参考。
API简介
其函数原型为:
BOOL WINAPI ReadDirectoryChangesW(
_In_ HANDLE hDirectory,
_Out_ LPVOID lpBuffer,
_In_ DWORD nBufferLength,
_In_ BOOL bWatchSubtree,
_In_ DWORD dwNotifyFilter,
_Out_opt_ LPDWORD lpBytesReturned,
_Inout_opt_ LPOVERLAPPED lpOverlapped,
_In_opt_ LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
函数各个参数说明可以参考MSDN,由于该函数提供了丰富的调用方式,包括同步和异步方式。异步方式可以采用以下三种方式获取完成通知:
- 在OVERLAPPED结构中的hEvent成员中设置一个事件句柄,使用GetOverlappedResult 获取完成结果。
- 使用可提醒IO, 在参数lpComletionRoutine指定一个回调函数。当ReadDirectoryChangesW异步请求完成时,驱动会将指定的回调函数(lpComletionRoutine)投递到调用线程的APC队列中。对可提醒IO,OVERLAPPED结构中的hEvent 字段操作系统并不使用,我们可以自己使用该值。
- 使用IO完成端口,通过GetQueuedComletionStatus获取完成结果。
同步方式比较简单,但不具可伸缩性,在实际应用中并不多。不同的异步方式也影响到线程模型的选择,所以如何正确使用该函数其实并不容易。
ReadDirectoryChangesW的第一参数是一个文件夹句柄,所以首先需要正确打开一个文件夹,接下来主要描述怎样正确使用可提醒IO和IO完成端口方式调用ReadDirectoryChangesW,最后再谈谈怎样得体地退出线程(也是最容易被忽略的点);
打开文件夹
打开一个文件夹使用CreateFile,函数原型:
HANDLE WINAPI CreateFile(
_In_ LPCTSTR lpFileName,
_In_ DWORD dwDesiredAccess,
_In_ DWORD dwShareMode,
_In_opt_ LPSECURITY_ATTRIBUTES lpSecurityAttributes,
_In_ DWORD dwCreationDisposition,
_In_ DWORD dwFlagsAndAttributes,
_In_opt_ HANDLE hTemplateFile
);
按照MSDN说法,打开文件夹时,必需要在dwDesiredAccess指定FILE_LIST_DIRECTORY的访问权限,同时需要在dwFlagsAndAttributes指定FILE_FLAG_BACKUP_SEMANTICS;另外由于我们是采用异步方式打开,还需要在dwFlagsAndAttributes中指定FILE_FLAG_OVERLAPPED,打开一个文件夹获取hDirectory句柄如下:
HANDLE hDirectory = ::CreateFile(strDirectory, // 文件夹路径
FILE_LIST_DIRECTORY, // 访问权限
FILE_SHARE_READ | FILE_SHARE_WRITE, // 共享读和写
NULL,
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED,
NULL);
使用可提醒IO
可提醒IO是异步IO的一种,为了支持可提醒IO, Windows为线程都增加了一个基础设施——APC(异步过程调用),即每个线程都有一个APC队列。当线程处理于可提醒状态时,系统会检测该线程的APC队列是否为空,如果不会空,系统会依次取出队列中的APC进程调用。
采用可提醒IO时,需要设置一个完成回调函数FileIOCompletionRoutine。当发起异步IO请求后,调用线程不会被阻塞,系统会将该异步请求交给驱动程序,驱动程序将该请求加入到请求队列中,当异步请求完成时,驱动程序会将完成回调函数加入到发起线程的APC队列中,当发起线程处于可提醒状态时,该完成回调函数就会被执行。
Windows提供了6个API,可以将线程置为可提醒状态, 分别是SleepEx
、WaitForSingleObjectEx
、WaitForMultipleObjectsEx
、SignalObjectAndWait
、GetQueuedCompletionStatusEx
、MsgWaitForMultipleObjectsEx
。
利用线程的APC队列,可以创建一个工作线程,该线程采用可提醒IO方式循环等待APC调用,当我们在工作线程中发起一个ReadDirectoryChangesW请求时,线程被挂起,当一个请求完成时,会将完成回调函数加入到线程的APC队列中,系统检测到APC队列不为空,线程会被唤醒,并取出APC队列中的一项进行调用,当APC队列为空中,线程会被再次挂起,直到APC队列中出现一项新的项。
读者可能会觉得上面的流程很复杂,其实实现很简单,复杂的东西都由系统帮我们做了,我们使用SleepEx
使工作线程变为可提醒状态,工作线程代码如下:
while (!m_bTerminate || HasOutstandingRequests())
{
::SleepEx(INFINITE, true);
}
有了工作线程帮我们处理完成回调函数的调用,我们还需要在该工作线程中发起一个ReadDirectoryChangesW请求,在请求时需要指定一个完成回调函数(最后一个参数)。对于倒数第二个参数OVERLAPPED,对可提醒IO来讲,系统并不关心hEvent,所以可以将该参数设计为业务相关的数据进行传递,在实现时设置为了一个请求对象的指针(具体参考代码实现),ReadDirectoryChangesW 请求代码如下:
BOOL success = ::ReadDirectoryChangesW(
GetDirectoryHandle(), // handle to directory
GetBuffer(), // read results buffer
GetBufferSize(), // length of buffer
IsWatchSubTree(), // monitoring option
GetNotifyFilter(), // filter conditions
NULL, // bytes returned
this, // overlapped buffer
&FileIoCompletionRoutine); // completion routine
完成回调函数需要我们自己实现,原型为:
VOID CALLBACK FileIOCompletionRoutine(
_In_ DWORD dwErrorCode,
_In_ DWORD dwNumberOfBytesTransfered,
_Inout_ LPOVERLAPPED lpOverlapped
);
读者可能会疑问,怎么让ReadDirectoryChangesW请求在工作线程中执行呢?Windows为我们提供了以下API,可以将一个APC投递到一个指定线程的APC队列中:
DWORD QueueUserAPC(
PAPCFUNC pfnAPC,
HANDLE hThread,
ULONG_PTR dwData
);
有了上面这个利器,我们可以很方便的在线程间通信,为了简化代码复杂度,采用无锁设计,我将添加文件夹、读取文件夹变更请求、移除文件夹、结束请求都投递到该工作线程中执行,并约定一些类成员变量只能在该线程中访问。
需要注意的是,由于我们需要不断监控文件夹的磁盘变更情况,所以在FileIOCompletionRoutine中处理完文件夹的变更数据后,需要再次发起一次ReadDirectoryChangesW请求,这样就形成了一条变更链,实现文件夹实时磁盘监控。关于文件夹变更数据的处理请自行参考代码;关于怎样退出,后面会单独说。
使用IO完成端口
IO完成端口,是Windows为打造一个出色服务器环境,提高应用程序性能而提出的解决方案。关于IO完成端口的背景知识并不是本文的重点,不熟悉的读者请自行补充。
ReadDirectoryChangesW 支持采用IO完成端口方式读取文件夹磁盘变更,为了简单起见,在不考虑线程模型的情况下,其流程大概如下:
1. 创建一个IO完成端口;
2. 打开一个文件夹;
3. 将打开的文件夹句柄关联到一个IO完成端口上;
4. 发起一次ReadDirectoryChangesW请求;
5. 调用GetQueuedCompletionStatus获取完成通知;
6. 处理完成通知;
7. 关闭文件夹句柄;
8. 关闭IO完成端口;
在第5步中,调用GetQueuedCompletionStatus会阻塞调用线程,在实际应用中,我们经常会在一个工作线程中调用GetQueuedCompletionStatus。为了实时监控文件夹的磁盘变更,我同样会创建一个工作线程,且该线程只用于处理IO完成端口的完成通知,代码如下:
while (1)
{
ULONG_PTR pCompKey = NULL;
DWORD dwNumberOfBytes = 0;
OVERLAPPED* pOverlapped = NULL;
BOOL bRet = m_iocp.GetStatus(&pCompKey, &dwNumberOfBytes, &pOverlapped);
DWORD dwLastError = ::GetLastError();
if (bRet)
{
ProcessIocpSuccess(pCompKey, dwNumberOfBytes, pOverlapped);
}
else
{
if (!ProcessIocpError(dwLastError, pOverlapped))
{
break;
}
}
}
工作线程就绪后,在做完2,3步之后,仍然需要发起一个ReadDirectoryChangesW请求,对于IO完成端口,虽然请求并不是一定要在工作线程中执行,但我们仍然需要这样做,理由是除了简化我们的编程模型之外,也能使线程更容易得体地退出(稍后会说)。
跟可提醒IO不同的是,发起一个ReadDirectoryChangesW 请求时,IO完成端口会使用OVERLAPPED中的hEvent,所以我们不能将其设为一个请求对象的指针,而应该设为NULL, 但为了在上下文中传递请求对象指针,使用了点技巧,即将请求对象继承自OVERLAPPED,再将请求对象的指针传入即可(具体参考代码);另外并不需要再指定完成回调函数,如下:
BOOL success = ::ReadDirectoryChangesW(
GetDirectoryHandle(), // handle to directory
GetBuffer(), // read results buffer
GetBufferSize(), // length of buffer
IsWatchSubTree(), // monitoring option
GetNotifyFilter(), // filter conditions
NULL, // bytes returned
this, // overlapped buffer
NULL); // completion routine
同样,我们怎样让ReadDirectoryChangesW请求在工作线程中执行呢,幸运的是Windows提供了API:
BOOL WINAPI PostQueuedCompletionStatus(
_In_ HANDLE CompletionPort,
_In_ DWORD dwNumberOfBytesTransferred,
_In_ ULONG_PTR dwCompletionKey,
_In_opt_ LPOVERLAPPED lpOverlapped
);
以上API可以在任何线程中调用,将一个和完成键dwCompletionKey
关联的数据投递到任何一个调用GetQueuedCompletionStatus
的线程,当然这里只是我们的工作线程。这使得其它线程可以很容易和工作线程通信。
同样为了简化代码复杂度,采用无锁设计,仍然将添加文件夹、读取文件夹变更请求、移除文件夹、结束请求都投递到该工作线程中执行,并约定一些类成员变量只能在该线程中访问。
得体地退出工作线程
取消一个ReadDirectoryChangesW请求,可以使用CancelIo
或CancelIoEx
,这两个API的区别是,CancelIo
只能取消调用线程关联的IO设备;而CancelIoEx
可以取消指定线程关联的IO设备;但CancelIoEx
只能在Vista及之后的系统中使用,为了让代码能正常工作于XP及以后的系统,我使用了CancelIo
,这也是为什么我在使用IO完成端口的时候也要将请求放到工作线程中去执行的原因。
1. 可提醒IO退出
如上所说,CancelIo
需要在工作线程中去执行,我们先将m_bTerminate设为true, 再调用QueueUserAPC
将一个退出请求投递到工作线程中,然后在工作线程中调用CancelIO
,之后,系统会将完成回调函数加入到工作线程的APC队列中,并且将dwErrorCode
设为ERROR_OPERATION_ABORTED
,当收到该错误时,我们释放请求对象占用的系统资源,当所有请求对象都释放时,工作线程中的while循环结束,线程正常退出。
2. IO完成端口退出
和可提醒IO退出方式不同的是,GetQueuedCompletionStatus
的错误处理稍微复杂一点,是采用GetLastError
获得,同样在收到错误码为ERROR_OPERATION_ABORTED
时,释放请求对象占用的系统资源,当所有请求对象都释放时,工作线程中的while循环结束,线程正常退出。
关于代码结构
为了同时支持可提醒IO和IO完成端口异步请求的方式调用ReadDirectoryChangesW, 代码做了一些抽象,采用C/S模型。将ReadDirectoryChangesW调用封装到了CReadDirectoryRequest
类中,根据不同的异步模型派生出CCompletionRoutineRequest
和CIoCompletionPortRequest
类;
同样工作线程封装到了CReadDirectoryServer
类中,根据不同的异步模型,派生出CCompletionRoutineServer
和CIoCompletionPortServer
类;
CReadDirectoryChanges
类管理CReadDirectoryServer
对象的生命周期,并维护一个线程安全的队列用于缓存文件夹的变更数据,同时对客户端暴露基本服务接口。框架结构如下图所示:
更多参考
[1]. https://msdn.microsoft.com/en-us/library/windows/desktop/aa365465(v=vs.85).aspx
[2]. https://qualapps.blogspot.com/2010/05/understanding-readdirectorychangesw.html
[3]. https://www.codeproject.com/Articles/950/CDirectoryChangeWatcher-ReadDirectoryChangesW-all