【文件監控】之一:理解 ReadDirectoryChangesW part1
理解 ReadDirectoryChangesW
- 原作者:Jim Beveridge
- 原文:http://qualapps.blogspot.com/2010/05/understanding-readdirectorychangesw.html?amp
- 渣翻譯:[email protected]
世界上最長,最詳細的 ReadDirectoryChangesW 的使用方法描述。
下載本文的示例代碼
之前,我花了一周時間研究文檔少得可憐的 ReadDirectoryChangesW。希望這篇文章可以為大家節約一些時間。我相信我已經讀過了我能找到所有相關文章和大量代碼。幾乎所有的例子,包括微軟自己的那個例子,都有明顯缺陷或低級錯誤。
我曾在《Multithreading Applications in Win32》這本書中的某一章,介紹了同步IO,激發態內核對象,重疊IO,IO完成端口的區別。現在要談的這個問題,對我來說是小菜一碟。只不過上次寫 重疊IO的痛苦折磨了我好多年,這次應該也不會例外。
監控文件和目錄的四種方式
我們先看一下 SHChangeNotifyRegister,這個函數通過窗口消息實現,所以需要一個窗口句柄。它由Shell (Explorer)驅動,所以應用程序只會接收到 Shell 關心的通知,這些通知很難滿足你的需求。它僅僅對監控用戶對Explorer的操作有用。
在 Windows Vista 中,SHChangeNotifyRegister 已經可以報告所有文件的所有變更。但問題是,還存在上億不打算立即升級的 Windows XP 用戶。
由於 SHChangeNotifyRegister 基於窗口消息,所以還會帶來性能上的問題。如果發生了太多文件變更,應用程序會不斷接收到變更消息,你必須自己確認實際發生的事情。對於一部分應用程序來說,這實在是相當的囧。
Windows 2000 引入了兩個新接口,FindFirstChangeNotification 和 ReadDirectoryChangesW。 FindFirstChangeNotification 很容易使用,但沒有給出變更文件的信息。即便如此,這個函數對某些應用程序還是很有用的,比如傳真服務和 SMTP 服務可以通過拖拽一個文件到一個目錄來接受任務隊列。ReadDirectoryChangesW 會給出變更的內容和方式, 不過相對的,在使用上也更復雜一些。
同 SHChangeNotifyRegister 一樣,這兩個新函數也會有性能問題。與 Shell 通知相比,它們的運行速度有明顯提升,但在不同目錄間移動上千個文件仍然會導致你丟失一部分(或者很多)通知。丟失通知的原因很復雜。令人驚訝的是,似乎與你處理通知的速度有關。
註意,FindFirstChangeNotification 和 ReadDirectoryChangesW 是互斥的,不能同時使用。
Windows XP 引入了最終解決方案,變更日誌(Change Journal)可以跟蹤每一個變更的細節,即使你的軟件沒有運行。很帥的技術,但也相當難用。
第四個,同時也是最後一個解決方案需要安裝文件系統過濾驅動,Sysinternals 的 FileMon 就使用了這種技術。在 Windows 驅動開發包(WDK)中有一個例子。這個方案本質上是一個設備驅動,如果沒有正確的實現,有可能導致系統穩定性方面的問題。
對我來說,使用 ReadDirectoryChangesW,在性能和復雜度上會是一個很好的平衡。
謎題
使用 ReadDirectoryChangesW 的最大挑戰在於,在IO模式,處理信號,等待方式,以及線程模型這幾個問題的整合上,存在數百種可能性。如果你不是 Win32 I/O 方面的專家,即使最簡單的場景,你也很難搞定。
- A. I/O模式:
- 阻塞同步(Blocking synchronous)
- 觸發式同步(Signaled synchronous)
- 重疊異步(Overlapped asynchronous)
- 完成例程(Completion Routine) (又名 Asynchronous Procedure Call or APC)
- B. 當調用 WaitForXxx 函數的時候:
- 等待目錄句柄
- 等待 OVERLAPPED 結構體裏的 Event 對象
- 什麽都不等 (APCs)
- C. 處理通知:
- 阻塞
- WaitForSingleObject
- WaitForMultipleObjects
- WaitForMultipleObjectsEx
- MsgWaitForMultipleObjectsEx
- IO完成端口(I/O Completion Ports)
- D. 線程模型:
- 每個工作線程調用一次 ReadDirectoryChangesW.
- 每個工作線程調用多次 ReadDirectoryChangesW.
- 在主線程上調用多次 ReadDirectoryChangesW.
- 多個線程進行多個調用. (I/O Completion Ports)
最後,當調用 ReadDirectoryChangesW 的時候,你可以通過 flags 選擇你要監控的內容,包括文件創建,內容變更,屬性變更等等。你可以多次調用,每次一個 flag,也可以在一次調用中使用多個 flag。多個 flag 總是正確的解決方案。但如果你為了調試方便,需要一個 flag 一個 flag 的調用的話,那就需要從 ReadDirectoryChangesW 返回的通知緩沖區中讀取更多的數據。
如果你的腦子正在囧的話,那麽你就能夠明白為什麽那麽多人都沒法搞定這件事了。
建議的解決方案
那麽正確的答案是什麽呢?我的建議是:取決於你認為最重要的是什麽。
簡單 - A2C3D1 - 在單獨的線程中調用 ReadDirectoryChangesW,然後通過 PostMessage 發送給主線程。對於性能要求不高的 GUI 程序最合適。在 CodeProject 上的 CDirectoryChangeWatcher 就是使用的這個策略。微軟的 FWATCH 例子 也是使用的這個策略。
性能 - A4C6D4 - 性能最好的解決方案是使用I/O完成端口,但是,這個激進的多線程方案實在太過復雜,應當僅限在服務器上使用。對任何 GUI 程序來說,這個方案似乎都是不必要的。如果你不是一個多線程專家,請遠離這個策略。
平衡 - A4C5D3 - 通過完成例程(Completion Routines),在一個線程中完成所有工作。你可以發起盡可能多的 ReadDirectoryChangesW 調用,由於完成例程是自動分派的,所有不需要等待任何句柄。你可以通過回調傳遞對象的指針,以便跟蹤原始的數據結構。
起初我曾經認為 GUI 程序可以通過 MsgWaitForMultipleObjectsEx 將變更通知混入到窗口消息中。但由於對話框有自己的消息循環,當對話框顯示的時候,通知便無法處理了。於是這個好主意被現實無情的碾碎了。
錯誤的技術
在研究解決方案的時候,我見識過各種用法:不靠譜的,錯誤的,以及錯得離譜的。
如果你正在使用上面提到的簡單方案,不要使用阻塞調用,因為唯一取消調用的方法是關閉句柄(未在文檔中列出的方法),或者調用 Vista 之後的函數 CancelSynchronousIo。正確的辦法是使用觸發式的同步I/O模式,也就是等待目錄句柄。結束線程的時候,不要使用 TerminateThread,因為這個時候,資源無法釋放,從而導致各種各樣的問題。而是創建一個手動重置的 Event 對象,作為 WaitForMultipleObjects 等待的第二個句柄。當 Event 被設置的時候,退出線程。
如果你有上千個目錄需要監控,不要使用簡單方案。轉換為平衡方案。或者監控公共的根目錄,並忽略不關心的文件。
如果你需要監控整個驅動器,請三思。你會接收到每個臨時文件,每個Internet緩存文件,每個應用程序數據變更的通知。簡單來說,大量的通 知會拖慢整個系統。如果你需要監控整個驅動器,你應當使用變更日誌(Change Journal)。這樣即使你的程序沒有運行,也可以跟蹤每一個變更。絕對不要用 FILE_NOTIFY_CHANGE_LAST_ACCESS 標誌監控整個驅動器。
如果你使用了不帶I/O完成端口的重疊I/O,不要等待句柄,而是使用完成例程(Completion Routines)。這樣可以不受64個句柄的限制,可以讓操作系統處理調用的分發,還可以通過 OVERLAPPED 傳遞你自己的對象指針。等一下我會給出例子。
如果你使用了工作線程,將結果傳回給主線程的時候,不要使用 SendMessage,而是使用 PostMessage。如果主線程很繁忙,同步的 SendMessage 需要很久才能返回。這就失去了使用工作線程的意義了。
通過提供較大的緩沖區來嘗試解決丟失通知的問題,會是一個誘人的選項。但這不是明智的行為。不管給定的緩沖區體積是多少,內核的未分頁內存池都 是分配相同大小的緩沖區。如果你分配太大的緩沖區,有可能導致包括藍屏在內的一系列問題。感謝 MSDN 社區內容的匿名投稿人。
獲取目錄句柄
現在我們來看看之前提到平衡方案的實現細節。在 ReadDirectoryChangesW 的聲明中,你會註意到第一個參數是一個目錄的句柄。你是否知道你可以獲得一個目錄的句柄呢?名為OpenDirectory的函數是不存在 的,CreateDirectory也不會返回句柄。第一個參數的文檔是這樣描述的:”這個目錄必須以 FILE_LIST_DIRECTORY 訪問權限打開“。在後面的 Remarks 節提到:”要獲取目錄的句柄,需要以 FILE_FLAG_BACKUP_SEMANTICS flag 調用 CreateFile 函數。“實際的代碼如下:
HANDLE hDir = ::CreateFile(
strDirectory, // 文件名的指針
FILE_LIST_DIRECTORY, // 訪問(讀/寫)模式
FILE_SHARE_READ // 共享模式
| FILE_SHARE_WRITE
| FILE_SHARE_DELETE,
NULL, // security descriptor
OPEN_EXISTING, // 如何創建
FILE_FLAG_BACKUP_SEMANTICS // 文件屬性
| FILE_FLAG_OVERLAPPED,
NULL); // 文件屬性的模板文件
第一個參數, FILE_LIST_DIRECTORY, 甚至沒有在 CreateFile() 的文檔中提到。而是在文件安全和訪問權限(File Security and Access Rights)中有一些沒什麽用的描述。
類似的,FILE_FLAG_BACKUP_SEMANTICS 有這樣一行有趣的標註:“如果此標誌沒有與 SE_BACKUP_NAME 和 SE_RESTORE_NAME一起使用,仍然會進行適當的安全檢查。”在我過去的印象中,使用這個標誌需要管理員權限。這個標註證實了這一點。不管怎 樣,在 Windows Vista 系統中,如果啟用了 UAC,調整安全令牌以啟用這些權限的操作是不管用的。這裏,我不確定到底是要求改變了,還是文檔有歧義。其他類似的內容也令人困惑。
共享模式也存在一個陷阱,我看到一些例子沒有使用 FILE_SHARE_DELETE。也許你認為目錄不會被刪除,所以沒有問題。但是,這回導致其他進程無法重命名或者刪除這個目錄下的文件
這個函數另一個潛在的陷阱在於,被引用的目錄本身處於”使用中“的狀態,並且無法被刪除。如果希望在監控目錄的同時,還允許目錄被刪除,你應當監控該目錄的父目錄及父目錄下的文件和子目錄。
調用 ReadDirectoryChangesW
實際調用 ReadDirectoryChangesW 是整個操作中最簡單的環節。如果你使用了完成例程,唯一需要註意的就是緩沖區必須是DWORD對齊的。
OVERLAPPED 結構體用來指定重疊操作,但實際上 ReadDirectoryChangesW 沒有使用結構體中的任何一個字段。關於完成例程,這裏有一個大家都知道的小技巧,就是你可以提供一個C++對象的指針。文檔是這麽說 的:”OVERLAPPED 結構的的 hEvent 成員不會被系統使用,所以你可以按自己的方式使用。“這意味著你可以將你自己對象的指針放進去。你可以在下面的示例代碼中看到這一點:
void CChangeHandler::BeginRead()
{
::ZeroMemory(&m_Overlapped, sizeof(m_Overlapped));
m_Overlapped.hEvent = this;
DWORD dwBytes=0;
BOOL success = ::ReadDirectoryChangesW(
m_hDirectory,
&m_Buffer[0],
m_Buffer.size(),
FALSE, // monitor children?
FILE_NOTIFY_CHANGE_LAST_WRITE
| FILE_NOTIFY_CHANGE_CREATION
| FILE_NOTIFY_CHANGE_FILE_NAME,
&dwBytes,
&m_Overlapped,
&NotificationCompletion);
}
由於我們使用了重疊I/O,m_Buffer直到完成例程被調用的時候才會填充。
分派完成例程
對於我們討論的平衡方案,有兩個方法等待完成例程被調用。如果所有分派都使用完成例程,那麽只需要 SleepEx就可以。如果你需要在分派完成例程的同時等待句柄,那麽你需要使用 WaitForMultipleObjectsEx。這個函數的Ex版本要求將線程置為 "alertable" 狀態,"alertable"狀態指完成例程將要被調用。
如果要結束使用SleepEx的線程,你可以設置一個 SleepEx 循環中的標記,以退出SleepEx 循環。如果調用完成例程,你可以使用QueueUserAPC,這個函數允許一個線程調用另一個線程中的完成例程。
處理通知
通知例程很簡單,只要讀取數據並保存就可以了。真的是這樣麽?錯。完成例程的實現也有其復雜度。
首先,你需要檢查並處理錯誤碼 ERROR_OPERATION_ABORTED,這個錯誤碼意味著 CancelIo 被嗲用,這是最後的通知,你需要做合適的清理工作。CancelIo的更多細節會在下一節描述。在我的實現中,我使用 InterlockedDecrement 來減少 cOutstandingCalls 的值,這個變量用來跟蹤活動調用的計數,然後返回。我的對象都由 MFC 框架進行管理,所以不需要再完成例程中釋放。
你可以在單次調用中接收多個處理。務必遍歷數據結構,並挨個檢查非空的 NextEntryOffset 字段
ReadDirectoryChangesW 是一個 "W"例程,所以它使用Unicode。這個例程沒有 ANSI 版本。因此,數據緩沖區自然也是Unicode。字符串不是 NULL 結尾的,所以你不能使用 wcscpy。如果你使用 ATL 或 MFC 的 CString 類,你可以 用原始字符串加上給定的數字來實例化一個寬字符的CString
FILE_NOTIFY_INFORMATION* fni = (FILE_NOTIFY_INFORMATION*)buf;
CStringW wstr(fni.Data, fni.Length / sizeof(wchar_t));
最後,你必須在退出完成例程前,重新發起 ReadDirectoryChangesW 的調用。你可以重用相同的 OVERLAPPED 結構體。文檔指出,在完成例程被調用後,OVERLAPPED 結構體不會再次被 Windows使用。但是,你必須確保緩沖區與當前調用使用的緩沖區不同,否則會遇到“競態條件”。
有一點我不太清除,那就是在完成例程被調用和發起新的 ReadDirectoryChangesW 調用之間,變更通知做了什麽事情。
我還必須重申,如果很多文件在短時間發生變更,你有可能丟失通知。根據文檔描述,如果緩沖區溢出,整個緩沖區的內容都會被丟 棄,lpBytesReturned會返回0。但是我不清除完成例程是否會將 dwNumberOfBytesTransfered 為 0 ,或者是否會將 dwNumberOfBytesTransfered 指定為錯誤碼。
有幾個關於完成例程錯誤實現的有趣例子。我最喜歡的一個是在 stackoverflow.com上找到的。那個家夥在噴完一個求助帖後,展示了他自己的完成例程實現,並叫囂:”這玩意看起來也不難嘛“。他 的代碼漏掉了錯誤處理,他沒有處理 ERROR_OPERATION_ABORTED,沒有處理緩沖區溢出,他甚至沒有重新發起 ReadDirectoryChangesW 調用。我覺得,如果忽略了這些困難的事情,剩下的,的確沒什麽難的。
Using the Notifications
當你接受並解析一個通知時,你需要確定如何處理它。這並不容易。首先,你將經常接收到多個重復的變更通知,特別是一個進程在寫入一個大文件時。如果要等待文件的寫入完成,你需要等待直到一段時候內都不再有文件更新之後,才能開始進行處理。
Eric Gunnerson 的一篇文章指出,FILE_NOTIFY_INFORMATION的文檔有一個關鍵的描述:如果文件既有長文件名,又有短文件名,那麽文件會返回其中的一 個名字,但不確定返回哪一個。大多數時候,在短文件名和長文件名之間轉換都很容易,但是文件被刪除後,就不一樣了。因此,你必須維護一個跟蹤文件的列表, 同時跟蹤長文件名和短文件名。我無法在 Windows Vista 上重現這個行為,不過我只在一臺計算機上做過嘗試。
你有可能接收到你沒有預料到的通知。例如,即使你設置了 ReadDirectoryChangesW 的不接收子目錄通知的參數,你仍然會接收到子目錄本身的通知。假設你有兩個目錄 C:\A 和 C:\A\B。你將文件 info.txt 從第一個目錄移動到第二個目錄。你將會接收到 C:\A\info.txt 的 FILE_ACTION_REMOVED 通知,以及 C:\A\B 的 FILE_ACTION_MODIFIED 通知。不過,你不會接收到任何關於 C:\A\B\info.txt 的通知。
令人驚訝的事情還會發生。你是否使用過 NTFS 的硬鏈接?硬鏈接允許你將多個文件名引用同一個物理文件。如果你在一個目錄中監控一個引用,在另一個目錄中監控另一個引用,當修改第二個目錄中的文件時,會生成第一個目錄的通知。灰常的神奇。
另一方面,如果你使用Windows Vista引入的符號鏈接,被鏈接的文件不會生成通知。仔細想想,也說得過去,但是你得小心各種各樣的可能性。
還有第三種可能,就是 Junction 從一個分區鏈接到另一個。這種情況下,對子目錄的監控不會監控被鏈接分區中的文件。這種行為也說得通,但是當發生在用戶的機器上時,這種現象會令人感到困惑。
關停
我沒有找到任何文章和代碼(即使在開源代碼中)適當的清理了重疊調用。MSDN文檔 指出通過調用 CancelIo 來取消重疊I/O。這很容易。但是,我的應用程序退出的時候會崩潰。堆棧顯示,我的某個第三方庫正在將線程置為 ‘alertable‘ 狀態(意即可以調用完成例程了),並且即使在我調用了CancelIo,關閉了句柄,刪除了 OVERLAPPED 結構體之後,我的完成例程還是被調用了。
於是我搜索了各種各樣的關於調用 CancelIo 的網頁,我找到這個網頁 中包含這樣的代碼:
CancelIo(pMonitor->hDir);
if (!HasOverlappedIoCompleted(&pMonitor->ol))
{
SleepEx(5, TRUE);
}
CloseHandle(pMonitor->ol.hEvent);
CloseHandle(pMonitor->hDir);
CancelIo(pMonitor->hDir);
這個看起來很有希望成功,我信心滿滿得把這段代碼拷貝到我的程序中,但是不管用。
我再次查閱了 CancelIo 的文檔,其中指出,”所有被取消的I/O操作都會以ERROR_OPERATION_ABORTED 錯誤結束,並且所有的I/O完成通知都會正常發生。“換句話說,在CancelIo被調用後,所有的完成例程都都至少會被調用最後一次。對 SleepEx 的調用也本該允許,但不是這樣子。最後我認為,等待5毫秒太短了。也許將"f"改成"while"就能解決這個問題了,但是這個方案要求輪詢每一個重疊結 構體,於是我選擇了不同的方式。
我最終的解決方案是跟蹤未完成的請求數目,然後持續調用 SleepEx 直到計數為0,在示例代碼中,關停的順序如下:
- 程序調用 CReadDirectoryChanges::Terminate (或者簡單的析構對象)
- Terminate 通過 QueueUserAPC 發送消息到工作線程中的 CReadChangesServer,通知其結束。
- CReadChangesServer::RequestTermination 將 m_bTerminate 設置為 true,然後將調用轉發給 CReadChangesRequest 對象,每個對象對自己的目錄句柄調用 CancelIo 然後關閉目錄句柄。
- 控制返回到 CReadChangesServer::Run 函數,註意這時還沒有任何東西實際結束。
void Run() { while (m_nOutstandingRequests || !m_bTerminate) { DWORD rc = ::SleepEx(INFINITE, true); } }
- CancelIo 導致 Windows 自動對每一個 CReadChangesRequest 重疊請求調用完成例程。每個調用的 dwErrorCode 都被設置為 ERROR_OPERATION_ABORTED。
- 完成例程刪除 CReadChangesRequest 對象,減少 nOutstandingRequests 計數,然後在不發起新請求的情況下返回。
- 由於一個或多個APCs完成,SleepEx返回。nOutstandingRequests 為0,bTerminate 為true,於是函數退出,線程被幹凈的結束。
萬一關停沒有被合適的處理,主線程會根據一個超時時間等待工作線程結束。如果工作線程沒有順利結束,我們就讓 Windows 結束時幹掉它。
網絡驅動器
ReadDirectoryChangesW 可以使用在網絡驅動器上,當且僅當遠程服務器支持這個功能。從基於Windows的計算機共享的目錄可以正確的生成變更通知。 Samba 服務器則不會生成通知,大概因為相關操作系統不支持這個功能。網絡附加存儲(NAS)設備通常運行Linux系統,所以也不支持通知。至於高端存儲域網絡 (SANs),那就誰也說不準了。
ReadDirectoryChangesW 當緩沖區長度大於 64 KB 並且程序監控網絡上的一個目錄時,會失敗並返回錯誤碼 ERROR_INVALID_PARAMETER。這是因為相關的網絡共享協議對包大小有限制。
總結
如果你看到了這裏,我要為你的"can-do"態度鼓掌。希望你清晰的了解了如何使用ReadDirectoryChangesW,以及為什麽要懷疑你看到的所有關於這個函數的示例代碼。仔細的測試很關鍵,也包括性能測試。
【文件監控】之一:理解 ReadDirectoryChangesW part1