seraphyの日記

日記というよりは過去を振り返るときのための単なる備忘録

ファイルの変更を監視する方法

Win32 APIにはファイルの変更を監視する方法として、少なくとも2つの方法がある。

私は従来よりファイル監視のためのAPIとして、FindFirstChangeNotification関数を愛用している。


だが、WindowsNT系のUNICODEビルドであれば使えるというReadDirectoryChangeW関数によるファイル監視を使うことにより、パフォーマンスの改善が望めそうに思えたため、これを用いた場合には、どのような実装方法となるのか確かめたく実験してみることとした。

FindFirstChangeNotification APIを使ってファイルを監視する方法

Win32でファイルの変更通知を行うものとしては、Windows95時代から使える方法として、FindFirstChangeNotification関数があげられる。

このAPIは、指定したディレクトリまたは、その配下に対してファイルが作成・更新・削除されると、変更されたことを通知する。

どのファイルが変更されたかは通知されない。

実験コード
#include "stdafx.h"

// stdafx.hプリコンパイルヘッダでの定義は以下のとおり
// (XP SP2以降)
//#define _WIN32_WINNT 0x0502
//#include <stdio.h>
//#include <locale.h>
//#include <tchar.h>
//#include <vector>
//#include <conio.h>
//#include <Windows.h>

// エラーの表示
static void ShowError(LPCTSTR msg)
{
	DWORD errcode = GetLastError();
	_tprintf(_T("%s errorcode: %lx\r\n"), msg, errcode);
}

// メインエントリ
int _tmain(int argc, _TCHAR* argv[])
{
	// コンソール出力を日本語可能に
	setlocale(LC_ALL, "");

	// オプション引数の値を保持する
	LPCTSTR pDir = _T("C:\\temp\\fwatch2v");
	int waittime = 0;
	bool hasError = false;

	// 引数の解析
	_TCHAR **pArg = &argv[1];
	while (*pArg) {
		if (_tcsicmp(_T("/w"), *pArg) == 0) {
			// ウェイト時間
			pArg++;
			if (*pArg) {
				waittime = _ttoi(*pArg);
			}

		} else if (**pArg != '/') {
			// 監視先ディレクトリ
			pDir = *pArg;
			break;

		} else {
			_ftprintf(stderr, _T("不明な引数: %s\r\n"), *pArg);
			hasError = true;
		}

		pArg++;
	}
	if (hasError) {
		return 2;
	}

	if (waittime <= 0) {
		waittime = 0;
	}

	_tprintf(_T("監視先: %s\r\nループ毎の待ち時間: %ld\r\n"), pDir, waittime);


	// 監視条件
	DWORD filter = 
		FILE_NOTIFY_CHANGE_FILE_NAME   |  // ファイル名の変更
		FILE_NOTIFY_CHANGE_DIR_NAME    |  // ディレクトリ名の変更
		FILE_NOTIFY_CHANGE_ATTRIBUTES  |  // 属性の変更
		FILE_NOTIFY_CHANGE_SIZE        |  // サイズの変更
		FILE_NOTIFY_CHANGE_LAST_WRITE;    // 最終書き込み日時の変更

	// 対象のディレクトリを監視用にオープンする.
	HANDLE hWatch = FindFirstChangeNotification(
		pDir,  // 監視先ディレクトリ
		TRUE,  // サブディレクトリも監視
		filter // 監視条件
		);
	if (hWatch == INVALID_HANDLE_VALUE) {
		ShowError(_T("FindFirstChangeNotificationでの失敗"));
		return 1;
	}

	// 変更通知を受け取りつづけるループ
	for (;;) {
		// qで終了.
		if (_kbhit()) {
			if (_getch() == 'q') {
				break;
			}
		}

		// 変更通知まち
		DWORD waitResult = WaitForSingleObject(hWatch, 500); // 0.5秒待ち
		if (waitResult == WAIT_TIMEOUT) {
			// 待ち受け表示
			_tprintf(_T("."));
			continue;
		}

		// 変更通知があった場合 (イベントがシグナル状態になった場合)
		_tprintf(_T("\r\n★ファイルが変更されました!★\r\n"));

		// Sleepを使い、イベントハンドリングが遅いケースをエミュレートする.
		// Sleep表示中にファイルを変更した場合に検知漏れが発生しないか確認できる.
		const int mx = waittime * 10;
		for (int idx = 0; idx < mx; idx++) {
			_tprintf(_T("sleep... %d/%d \r"), idx + 1, mx);
			Sleep(100);
		}

		// 監視の継続
		if (!FindNextChangeNotification(hWatch))
		{
			ShowError(_T("FindNextChangeNotificationでの失敗"));
			break;
		}
	}

	// 監視の終了
	FindCloseChangeNotification(hWatch);

	return 0;
}

※ ソースはgist: 2623100にも登録しました。


このコンソールプログラムは、引数として

  • /w nnn 待機時間 (省略時は0) (0を指定すると待機なし)
  • 監視先フォルダ

を指定することができる。

説明

プログラムの内容は、次の通り。

  1. FindFirstChangeNotificationで指定したフォルダと監視条件を設定する。
  2. WaitForSingleObject等でイベントが発生するまで待機する
    1. イベントが発生したら、なんらかの変更があったことを意味する
  3. 引き続き監視する場合は、FindNextChangeNotificationを実行し、2に戻る。
    (WaitForSingleObjectでイベントを受け取ってから、FindNextChangeNotification を呼び出すまでの間にファイルの変更があったとしても、次のFindNextChangeNotificationは変更を感知してくれている。)
  4. 終了する場合は、FindCloseChangeNotificationでハンドルを解放する.
ファイル監視のための実用的なロジックにするためには

ただし、このAPIで通知されるのは「変更の有無」だけであり、どのファイルが変更されたかは通知されない。

変更されたファイルを知り、ファイルの変更をイベントとして扱えるようにするには、上記の変更通知だけでは対応できない。


また、変更通知は、変更があった時点で通知される。いつ、変更が完了したのかは通知からはわからない。
そのため、一定時間待って変化がなくなったときが完了したものとみなす、などの処置が必要になる。
(ログファイル等、ファイルによっては開かれっぱなしのものもあるであろうから、このあたりの判定方法はアプリケーション次第といえる。)


よって、一般的には、おそらく以下のようなロジックが必要になるだろう。

  1. まずフォルダを全スキャンしてファイルの一覧を予め作成しておく。
  2. ファイルが変更されたことが通知されるまで待機する。(WaitForMultipleObjects関数などで)
  3. 通知をうけたら再度フォルダを全スキャンする。
  4. 通知の前後でのスキャン内容を照合し、差分を把握する。
    1. 追加されたもの(スキャン前に存在しないもの)
    2. 削除されたもの(1スキャン前にしか存在しないもの)
    3. 変更されたもの(日付、サイズ、属性などが変わったもの)
  5. 差分についてアクションを起こすようにマークするが、まだアクションは起こさない
    1. 最後に変更されてから一定時間変化がなければアクションを実行する
    2. 変更されつづけている場合は、まだアクションは起こさない

※ ちなみに、このロジックを実装した拙作フリーウェア 「fwatch ファイル監視」sourceforge.jpに置いてあります。

FindFirstChangeNotificationの利点

FindFirstChangeNotificationは、変更されたファイルはわからない、という性質をもってはいるが、
実際のアプリケーションにおいては自分が現在開いている、もしくは参照しているフォルダに影響があるかどうかを調べるために
APIを使うことが多いと思われるため、その場合には、かなり単純に使うことができる点で有利と思う。


もし、ファイルを特定する必要があれば前述のように事前スキャン・事後スキャンの差異をとる必要がある。

後述するReadDirectoryChangeWでは、変更されたファイル名も通知できるようになっているため、ReadDirectoryChangeWと比較してFindFirstChangeNotificationはスキャンの手間が多くなるように思われるが、実際のところ、ReadDirectoryChangeWは、いつでもファイル名まで通知できるとは限らず、スキャンと併用する必要がある。

むしろ、名前が特定されている場合はフルスキャンではなく部分的なスキャンだけで済む、という最適化ができるぐらいと考えたほうがよさそうである。


したがって、監視ファイル数が限定的で最適化までこだわらなければ、実際のところ、FindFirstChangeNotificationで充分なケースも多いと思われる。

欠点(欠陥?)

古い話ではあるが、FindFirstChangeNotification APIはネットワークドライブに対する監視が非常に脆く、あまり信頼性がないという印象がある。

  1. NT4ではネットワークが切断された場合、APIはエラーを検知せず、ネットワークが復旧しても監視を再開しなかった。
  2. Windows98LinuxのSMB共有に対して監視するとエラーにはならないが何も応答がないことがあった。
    (CentOS5のSMB共有で試したところ、ちゃんと通知は受け取れており、近年は、かなり良くなっているようであるが...。)
  3. 市販のNASでは、ファイル共有はできても、FindFirstChangeNotification APIを実行するとエラーを返して使えない機種もあるようである。(2010年の話)

...という経験から、ファイル監視系APIはローカルドライブに対して使うのが基本ではないか、と私は考えている。

ReadDirectoryChangesW APIを使ってファイルを監視する方法

WindowsNT系且つUNICODEビルドである場合にのみ使える方法として、ReadDirectoryChangeW関数がある。


このAPIの利点としては、変更の有無だけでなく、変更されたファイルが通知される、という点にある。


そこで、このAPIを使った場合にファイル監視のロジックが、どのようなものになるのか感触を確かめるべく、実験してみることとした。

実験コード
#include "stdafx.h"

// stdafx.hプリコンパイルヘッダでの定義は以下のとおり
// (XP SP2以降)
//#define _WIN32_WINNT 0x0502
//#include <stdio.h>
//#include <locale.h>
//#include <tchar.h>
//#include <vector>
//#include <conio.h>
//#include <Windows.h>


// エラーの表示
static void ShowError(LPCTSTR msg)
{
	DWORD errcode = GetLastError();
	_tprintf(_T("%s errorcode: %lx\r\n"), msg, errcode);
}

// キー入力のチェック
static inline bool CheckQuitKey()
{
	return _kbhit() && (_getch() == 'q');
}

// メインエントリ
int _tmain(int argc, _TCHAR* argv[])
{
	// コンソール出力を日本語可能に
	setlocale(LC_ALL, "");

	// オプション引数の値を保持する
	LPCTSTR pDir = _T("C:\\temp\\fwatch2v");
	size_t bufsiz = 0;
	int waittime = 0;
	bool hasError = false;

	// 引数の解析
	_TCHAR **pArg = &argv[1];
	while (*pArg) {
		if (_tcsicmp(_T("/b"), *pArg) == 0) {
			// バッファサイズ
			pArg++;
			if (*pArg) {
				bufsiz = _ttol(*pArg);
			}

		} else if (_tcsicmp(_T("/w"), *pArg) == 0) {
			// ウェイト時間
			pArg++;
			if (*pArg) {
				waittime = _ttoi(*pArg);
			}

		} else if (**pArg != '/') {
			// 監視先ディレクトリ
			pDir = *pArg;
			break;

		} else {
			_ftprintf(stderr, _T("不明な引数: %s\r\n"), *pArg);
			hasError = true;
		}

		pArg++;
	}
	if (hasError) {
		return 2;
	}

	if (bufsiz <= 0) {
		bufsiz = 1024 * 8;
	}
	if (waittime <= 0) {
		waittime = 0;
	}

	_tprintf(_T("監視先: %s\r\nバッファサイズ: %ld\r\nループ毎の待ち時間: %ld\r\n"),
		pDir, bufsiz, waittime);


	// 対象のディレクトリを監視用にオープンする.
	// 共有ディレクトリ使用可、対象フォルダを削除可
	// 非同期I/O使用
	HANDLE hDir = CreateFile(
		pDir, // 監視先
		FILE_LIST_DIRECTORY,
		FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
		NULL,
		OPEN_EXISTING,
		FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, // ReadDirectoryChangesW用
		NULL
		);
	if (hDir == INVALID_HANDLE_VALUE) {
		ShowError(_T("CreateFileでの失敗"));
		return 1;
	}

	// 監視条件 (FindFirstChangeNotificationと同じ)
	DWORD filter = 
		FILE_NOTIFY_CHANGE_FILE_NAME   |  // ファイル名の変更
		FILE_NOTIFY_CHANGE_DIR_NAME    |  // ディレクトリ名の変更
		FILE_NOTIFY_CHANGE_ATTRIBUTES  |  // 属性の変更
		FILE_NOTIFY_CHANGE_SIZE        |  // サイズの変更
		FILE_NOTIFY_CHANGE_LAST_WRITE;    // 最終書き込み日時の変更

	// 変更されたファイルのリストを記録するためのバッファ.
	// 最初のReadDirectoryChangesWの通知から次のReadDirectoryChangesWまでの
	// 間に変更されたファイルの情報を格納できるだけのサイズが必要.
	// バッファオーバーとしてもファイルに変更が発生したことは感知できるが、
	// なにが変更されたかは通知できない。
	std::vector<unsigned char> buf(bufsiz);
	void *pBuf = &buf[0];

	// 非同期I/Oの完了待機用, 手動リセットモード。
	// 変更通知のイベント発報とキャンセル完了のイベント発報の
	// 2つのイベントソースがあるためイベントの流れが予想できず
	// 自動リセットイベントにするのは危険。
	HANDLE hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

	// 変更通知を受け取りつづけるループ
	for (;;) {
		// Sleepを使い、イベントハンドリングが遅いケースをエミュレートする.
		// ループ2回目以降ならばSleep表示中にファイルを変更しても、
		// 変更が追跡されていることを確認できる.
		// またバッファサイズを超えるほどにたくさんのファイルを変更すると
		// バッファオーバーを確認できる。
		const int mx = waittime * 10;
		for (int idx = 0; idx < mx; idx++) {
			_tprintf(_T("sleep... %d/%d \r"), idx + 1, mx);
			Sleep(100);
		}
		_tprintf(_T("\r\nstart.\r\n"));

		// イベントの手動リセット
		ResetEvent(hEvent);

		// 非同期I/O
		// ループ中でのみ使用・待機するので、ここ(スタック)に置いても安全.
		OVERLAPPED olp = {0};
		olp.hEvent = hEvent;

		// 変更を監視する.
		// 初回呼び出し時にシステムが指定サイズでバッファを確保し、そこに変更を記録する.
		// 完了通知後もシステムは変更を追跡しており、後続のReadDirectoryChangeWの
		// 呼び出しで、前回通知後からの変更をまとめて受け取ることができる.
		// バッファがあふれた場合はサイズ0で応答が返される.
		if (!ReadDirectoryChangesW(
			hDir,   // 対象ディレクトリ
			pBuf,   // 通知を格納するバッファ
			bufsiz, // バッファサイズ
			TRUE,   // サブディレクトリを対象にするか?
			filter, // 変更通知を受け取るフィルタ
			NULL,   // (結果サイズ, 非同期なので未使用)
			&olp,   // 非同期I/Oバッファ
			NULL    // (完了ルーチン, 未使用)
			)) {
			// 開始できなかった場合のエラー
			ShowError(_T("ReadDirectoryChangesWでの失敗"));
			break;
		}

		// 完了待機ループ (qキーで途中終了)
		bool quit;
		while (!(quit = CheckQuitKey())) {
			// 変更通知まち
			DWORD waitResult = WaitForSingleObject(hEvent, 500); // 0.5秒待ち
			if (waitResult != WAIT_TIMEOUT) {
				// 変更通知があった場合 (イベントがシグナル状態になった場合)
				break;
			}
			// 待ち受け表示
			_tprintf(_T("."));
		}
		_tprintf(_T("\r\n"));

		if (quit) {
			// 途中終了するなら非同期I/Oも中止し、
			// Overlapped構造体をシステムが使わなくなるまで待機する必要がある.
			CancelIo(hDir);
			WaitForSingleObject(hEvent, INFINITE);
			break;
		}

		// 非同期I/Oの結果を取得する.
		DWORD retsize = 0;
		if (!GetOverlappedResult(hDir, &olp, &retsize, FALSE)) {
			// 結果取得に失敗した場合
			ShowError(_T("GetOverlappedResultでの失敗"));
			break;
		}

		// 変更通知をコンソールにダンプする.
		_tprintf(_T("returned size=%ld\r\n"), retsize);

		if (retsize == 0) {
			// 返却サイズ、0ならばバッファオーバーを示す
			_tprintf(_T("buffer overflow!!\r\n"));

		} else {
			// 最初のエントリに位置付ける
			FILE_NOTIFY_INFORMATION *pData =
				reinterpret_cast<FILE_NOTIFY_INFORMATION*>(pBuf);
			
			// エントリの末尾まで繰り返す
			for (;;) {
				// アクションタイプを可読文字に変換
				TCHAR *pActionMsg = _T("UNKNOWN");
				switch (pData->Action) {
				case FILE_ACTION_ADDED:
					pActionMsg = _T("Added");
					break;
				case FILE_ACTION_REMOVED:
					pActionMsg = _T("Removed");
					break;
				case FILE_ACTION_MODIFIED:
					pActionMsg = _T("Modified");
					break;
				case FILE_ACTION_RENAMED_OLD_NAME:
					pActionMsg = _T("Rename Old");
					break;
				case FILE_ACTION_RENAMED_NEW_NAME:
					pActionMsg = _T("Rename New");
					break;
				}

				// ファイル名はヌル終端されていないので
				// 長さから終端をつけておく.
				DWORD lenBytes = pData->FileNameLength; // 文字数ではなく, バイト数
				std::vector<WCHAR> fileName(lenBytes / sizeof(WCHAR) + 1); // ヌル終端用に+1
				memcpy(&fileName[0], pData->FileName, lenBytes);

				// アクションと対象ファイルを表示.
				// (ファイル名は指定ディレクトリからの相対パスで通知される.)
				_tprintf(_T("[%s]<%s>\r\n"), pActionMsg, &fileName[0]);

				if (pData->NextEntryOffset == 0) {
					// 次のエントリは無し
					break;
				}
				// 次のエントリの位置まで移動する. (現在アドレスからの相対バイト数)
				pData = reinterpret_cast<FILE_NOTIFY_INFORMATION*>(
					reinterpret_cast<unsigned char*>(pData) + pData->NextEntryOffset);
			}
		}
	}

	// ハンドルの解放
	CloseHandle(hEvent);
	CloseHandle(hDir);

	return 0;
}

※ ソースはgist: 2623100にも登録しました。


このコンソールプログラムは、引数として

  • /b nnn バッファサイズ (省略時は8k)
  • /w nnn 待機時間 (省略時は0) (0を指定すると待機なし)
  • 監視先フォルダ

を指定することができ、監視先フォルダでファイルの作成・変更・削除すると、作成・変更・削除したファイルの相対パスがコンソールに出てくる。


引数に「/w 10」とか指定すると、通知を受けてから監視待機を再開するまで待ち時間を長くとれるので、これでバッファがあふれた場合の動きを見ることができる。

Windows7(x64)での実行例
C:\temp\working\fwatch2v\Debug>fwatch2v.exe /w 5 C:\temp
監視先: C:\temp
バッファサイズ: 8192
ループ毎の待ち時間: 5
sleep... 50/50
start.
.....................
returned size=44
[Added]<working\新しいフォルダー>
sleep... 50/50
start.

returned size=26
[Modified]<working>
sleep... 50/50
start.

returned size=114
[Rename Old]<working\新しいフォルダー>
[Rename New]<working\TESTDIR>
[Modified]<working>
sleep... 50/50
start.

returned size=122
[Added]<working\TESTDIR\新しいテキスト ドキュメント.txt>
[Modified]<working\TESTDIR>
sleep... 50/50
start.

returned size=266
[Rename Old]<working\TESTDIR\新しいテキスト ドキュメント.txt>
[Rename New]<working\TESTDIR\TEST_FILE.txt>
[Modified]<working\TESTDIR>
[Modified]<working\TESTDIR\TEST_FILE.txt>
sleep... 50/50
start.

returned size=70
[Removed]<working\TESTDIR>
[Modified]<working>
sleep... 50/50
start.
........
C:\temp\working\fwatch2v\Debug>
Windows Server 2008 R2の共有フォルダをWindowsXPから監視した場合

ネットワーク上のWindows2008R2サーバの共有フォルダを監視対象にしても、一応、動いているように見えた。(こまかな挙動や、ネットワークエラー発生時にどうなるのかは、まだ確認していない。)

Windows7上でFAT仮想イメージをTrueCryptでマウントした場合

ReadDirectoryChangesWはNTFSジャーナリングの仕組みで動いているという記事をどこかで見た気がするが、試しにマウントしてみたFATドライブでも監視できた。(各種リムーバブルディスクで大丈夫であるか、ExFATとかFAT32で大丈夫かは試していない。)

ジャーナリング云々は、別の話だったかもしれない。

Windows7上からMacのSMB共有を監視した場合

少し試した限りでは、MacのSMBファイル共有では、変更通知を受け取った場合でも、いつでもデータサイズ0(バッファオーバーと同じ)になっていて、なにが変更されたかは通知されない。また、サブフォルダ内も監視できない様子。

C:\temp\working\fwatch2v\Debug>fwatch2v.exe /w 0 \\SERAPHY-IMAC5\seraphy
監視先: \\SERAPHY-IMAC5\seraphy
バッファサイズ: 8192
ループ毎の待ち時間: 0

start.
...................................
returned size=0
buffer overflow!!

start.
.....
returned size=0
buffer overflow!!

start.
................
C:\temp\working\fwatch2v\Debug>

結論

機能からみた特徴
  • FindFirstChangeNotification関数とは異なり、ファイルを変更すると、ただちに対象ファイルが特定される。
  • ReadDirectoryChangesWを最初に呼び出したあと、通知を受けてから次のReadDirectoryChangesWを呼び出すまでの間にファイルが変更された場合でも、きちんとファイルの変更を追跡してくれる。
  • ただし、バッファオーバーした場合や、監視先の相手側が個別に通知できない場合は、変更の有無だけが通知され、どのファイルが変更されたかはわからない。ファイルがいつでも受け取れるとは想定することはできない。
プログラム的な観点
  • ディレクトリに対するハンドルを特別な方法で取得する必要がある。
  • バッファに対する操作が煩雑。バイトと文字数の計算とか、ファイル名がヌル終端でないとか。
  • 非同期I/Oを使わないと監視によって処理がブロッキングされるため、非同期I/Oの使用はほぼ必須といえる。
使い勝手の評価

バッファオーバーした場合はファイルが特定できず、また、いつでもファイルが特定できるとは限らないため、基本的にはFindFirstChangeNotificationと同じ使い方、つまりスキャンと併用するのが基本的な使い方だと考えられる。
(もし、ファイルが特定されている場合は、フルスキャンを省略して部分的なスキャンだけで済む、という最適化ができる。)


ちょっと注意しなければならないかな、と思う点は、フォルダが削除された場合、当然、フォルダ削除は通知されるが、フォルダの中にいた子も削除されたことは通知されない、という点である。

フォルダが消された場合に、それが保持していた子供も消えたことを認識させるには、やはり事前に一度はスキャンしていなければならない、ということになるだろう。


変更があった時点で通知され、いつ変更が完了したかはわからない、というのはFindFirstChangeNotificationで説明したものと同じであり、検出方法も、同様な方法をとる必要があるものと思われる。


また、MacのSMB共有でファイル通知がうまくされないところをみても、やはりファイル監視系APIではネットワークでの監視、とくに非Windowsマシンへの監視は当てにはできなさそう、という点はFindFirstChangeNotificationのケースと同じであろうことが予想される。


結論として、ReadDirectoryChangeW関数を使ったとしてもFindFirstChangeNotificationを使った場合よりロジックが劇的に簡略化されることはなく、ほぼ同等な処理が必要であり、若干の最適化のメリットがある程度と考えられる。
(ただし監視ファイル数が多い場合は、この最適化が大きなパフォーマンス上の利点を生む可能性はある。)