Windows標準のエクスプローラーの使い勝手がいまいちだからなのか、世の中にはファイラーがたくさんあります。
ファイラーで自分がやりたかったのは、Everythingのようなファイル検索をNAS上のファイルも対象にすることです。
Everythingは、ローカルのファイルに対してはUSNジャーナルを使っていてインデックス作成も高速です。
NASも対象にできますが、自分の環境ではインデックスが途中で消えてしまうことがよくありました。
スキャン中にネットワークが切れると途中経過も無くなってしまうようで不便でした。
という事で、NAS上のファイルをスキャンして検索できるアプリ(WPF/.NET 10 + SQLite)を作ってみました。
Parallel Scope
Windows Storeで公開しています。
過去作っていたのをClaude Codeで色々と見直しました。
実装のポイントを記事にします。
1. 表示は「キャッシュ即時 → 実体で上書き」の2段階
フォルダを開いた瞬間にファイルシステムを列挙すると、NASや大量ファイルのフォルダで固まります。
そこで、表示は必ずSQLiteキャッシュを使う構成にしました。
- キャッシュから直下エントリを読んで即表示
- バックグラウンドでファイルシステムを読み直し、一覧とキャッシュを置き換え
- キャッシュから集計した「子フォルダの合計サイズ」を後追いで反映
これにより高速表示と実体との差のバランスを取っています。
連続リクエストの統合
一覧更新・検索・フォルダサイズ計算はどれも「実行中に次のリクエストが来る」のが常態です(ツリー連打、検索欄へのタイプ)。
各所に_isRunningと_pendingRequestのフラグを書いていたので、汎用クラスへ切り出しました。
/// <summary>
/// 連続したリクエストを1つに統合し、非同期ハンドラを直列に実行する汎用キュー。
/// 実行中に新しいリクエストが来た場合は最新のものだけを保持し、完了後に処理する
/// (途中のリクエストは破棄される = 最新状態のみが反映される)。
/// </summary>
public sealed class SingleFlightCoalescer<TRequest>
{
private readonly Func<TRequest, Task> _handleAsync;
private readonly object _gate = new();
private TRequest? _pendingRequest;
private bool _hasPendingRequest;
private bool _isRunning;
public SingleFlightCoalescer(Func<TRequest, Task> handleAsync)
{
_handleAsync = handleAsync;
}
public void Request(TRequest request)
{
lock (_gate)
{
_pendingRequest = request;
_hasPendingRequest = true;
if (_isRunning)
{
return; // 実行中なら「最新リクエストの上書き」だけして即リターン
}
_isRunning = true;
}
_ = RunAsync();
}
/// <summary>保留中のリクエストが無くなるまでハンドラを順番に実行する。</summary>
private async Task RunAsync()
{
try
{
while (true)
{
TRequest request;
lock (_gate)
{
if (!_hasPendingRequest)
{
_isRunning = false;
return;
}
// 最新のリクエストだけを取り出す(途中で上書きされたものは処理されない)
request = _pendingRequest!;
_pendingRequest = default;
_hasPendingRequest = false;
}
await _handleAsync(request).ConfigureAwait(false);
}
}
finally
{
lock (_gate)
{
_isRunning = false;
}
}
}
}
UIの表示更新は最新状態さえ反映されればよく、全部処理するのは無駄でした。
1つ用意しておくと一覧更新・検索・フォルダサイズ・フラット表示で使い回せて、非同期まわりのバグがまとめて消えました。
2. スキャンは「ウェーブ単位の並列列挙」
キャッシュを最新に保つため定期的にフルスキャンします。
単純な再帰列挙はローカルでは十分速いのにNASでは遅い。
1ディレクトリの列挙がラウンドトリップ数回分になるので、直列だと「レイテンシ × フォルダ数」が積み上がります。
対策として、ディレクトリをウェーブ(束)単位で取り出して並列に列挙します。
// 全コアを使うとバックグラウンドのフルスキャン中にスレッドプールが飽和し、
// UIの応答(ツリー選択時のキャッシュ読み込み等のTask.Run)が待たされる。
// 列挙はほぼI/Oバウンド(特にNASではラウンドトリップのレイテンシが支配的)なので、
// ディレクトリ単位で並列化しつつ同時実行数は絞る。
private static readonly ParallelOptions DirectoryEnumerationParallelOptions = new()
{
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount / 2)
};
// ウェーブは並列度より大きめに取り、列挙の速いディレクトリと遅いディレクトリの偏りを均す
var waveCapacity = DirectoryEnumerationParallelOptions.MaxDegreeOfParallelism * 4;
並列にするのは副作用のない列挙だけです。
列挙結果は配列のスロットに置くだけにして、失敗原因の判定・子ディレクトリの積み直し・キャッシュへの書き込みはウェーブ完了後に逐次で行います。
共有状態に触る処理を逐次側に寄せることで、ロックをほぼ書かずに済み、並行バグの入る余地を減らしています。
// ウェーブ内のディレクトリを並列に列挙する。列挙失敗は null として記録し、
// 失敗原因の分類(切断か削除か)は Directory.Exists を伴うため逐次側で行う
Parallel.For(0, wave.Count, DirectoryEnumerationParallelOptions, i =>
{
try
{
waveEntries[i] = ReadEntriesFromFileSystem(wave[i]);
}
catch
{
waveEntries[i] = null;
}
});
「列挙に失敗した」を空フォルダと区別する
NAS相手ではスキャン中のネットワーク切断が普通に起こります。
ここで例外を握りつぶして空リストを返すと、既存キャッシュが空で上書きされ「フォルダの中身が全部消えた」ように見えてしまいます。
失敗の種類で扱いを分けるようにしています。
| 状況 | 判定 | 扱い |
|---|---|---|
| 列挙が例外、フォルダ自体は見える | 一時的なI/Oエラー | キャッシュを書き換えず、そのルートを未完走扱い(走査は継続) |
| 列挙が例外、ルートごと見えない | ネットワーク切断 | そのルートを打ち切り、未完走扱い |
| 列挙が例外、フォルダだけ消えた | 直前に削除された | 通常の残骸掃除に任せる |
そして完走したルートについてだけ「走査しなかった親パス=削除済み」とみなして残骸行を消します。
未完走のルートを掃除対象に入れると、一時的に見えなかっただけのフォルダのキャッシュを削ってしまいます。
3. SQLite / EF Coreについて
数十万〜数百万行を「書きながら読む」ので、設定を詰める必要がありました。
-
Cache=Sharedを付けない。
Microsoft.Data.Sqliteの既定はPrivateですが、以前は接続文字列に明示的に付けていました。
共有キャッシュは接続間でテーブルロックを共有するため、WALの「書き込み中でも読み取り可能」という利点が消えます。
付けていた時はフルスキャン中のフォルダ移動が引っかかっていました。 -
接続単位のPRAGMAはインターセプターで毎回適用する。
journal_modeはDBファイル側に永続化されますが、synchronousやcache_sizeは接続単位です。
起動時の1接続にだけ適用していた時は、プールに接続が増えると既定値のまま動く不整合がありました。
// 接続オープン時に、接続単位でしか効かないPRAGMAを毎回適用するインターセプター。
// PRAGMAの再適用はI/Oを伴わず数マイクロ秒で済む。
cmd.CommandText = @"
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -16000;
PRAGMA temp_store = MEMORY;
-- チェックポイント時にWALファイルをこのサイズまで切り詰める(フルスキャンでの肥大対策)
PRAGMA journal_size_limit = 67108864;
";
-
使われないインデックスは外す。
検索の高速化のつもりでNameや(IsFolder, Name)、(ParentPath, Name)にインデックスを張っていました。
ところが部分一致検索(LIKE '%xxx%')は先頭が%なのでインデックスが効かず、書き込みのたびに更新コストだけがかかっていました。
実際のクエリで使われていないものを外したところ、フルスキャン時の書き込みが軽くなりました。 -
変更が無ければ書き込まない。
フルスキャンでも実際に中身が変わるフォルダはごく一部です。
100フォルダごとのバッチ書き込みで、キャッシュ済み内容と比較して差分があった親パスだけ DELETE + INSERT しています。
書き込み量とWALの膨張が大幅に変わりました。
4. インクリメンタルサーチはキャッシュだけで完結させる
検索欄はUpdateSourceTrigger=PropertyChangedでバインドし、1文字ごとにリクエストを投げます。
-
ライブのファイルシステムに触らない。
切断中のNASへのDirectory.Existsは1キー入力ごとにUIスレッドを数秒〜数十秒ブロックすることがあります。 -
結果が届くまで表示中の一覧を消さない。
入力のたび空にすると画面がちらつきます。 -
lower(Name)をやめてLIKEにする。
SQLiteのLIKEはASCIIの大文字小文字をもともと区別しないので、行ごとの文字列生成なしで同じ判定になります。
(lower()もASCIIしか変換しないので、全角英字の扱いを含めて挙動は変わりません)
エスケープ文字はパス区切りの\と衝突しない~を使用。
なお先頭が%の部分一致なのでインデックスは効かず、全件走査になる前提です。 -
Listにせずyieldで1件ずつ返す。
検索語1文字だと数十万件ヒットします。
中間リストを持つとピークメモリがほぼ倍増します。 -
遅れて返った結果は捨てる。
Interlocked.Incrementしたバージョンをリクエストに持たせ、UIスレッドで最新か確認してから反映します。
var namePattern = "%" + EscapeLikePattern(nameQuery) + "%";
var rows = db.FileSystemEntries
.AsNoTracking()
.Where(x => x.FullPath.StartsWith(rootWithSeparator)
&& EF.Functions.Like(x.Name, namePattern, "~"))
.AsAsyncEnumerable();
await foreach (var row in rows)
{
yield return row; // 中間リストを作らず1件ずつ返す
}
5. 数十万件をObservableCollectionで処理する
配下の全ファイルをフラット表示する「All Files」モードでは、コレクションの中身が数万〜数十万件規模で入れ替わります。
1件ずつAdd/Removeすると件数分のCollectionChangedが飛び(Removeは1件ごとに線形探索も走る)、UIスレッドが数秒ブロックされます。
そこで差分件数で処理を切り替えます。
- 差分が少ない(200件以下): 追加/削除/更新を個別に適用し、既存行の再描画を避ける
- 差分が多い:
ObservableCollectionごと差し替えて再バインド1回で済ませる
(DataGridの行仮想化により生成されるのは可視行だけ)
差し替える場合も既存アイテムのインスタンスは使い回します(後追いで埋めたサイズ表示などを保つため)。
同一性判定はFullPathではなく(Location, Name)のタプル。
FullPathは都度生成のプロパティなので、キーにすると差分計算のたびに全件分の文字列生成が走ります。
まとめ
効果が大きかった順に並べるとこうなります。
- 表示をキャッシュから始める(体感速度がすべて変わる)
- 連続リクエストを1クラスに統合する(非同期まわりのバグがまとめて消える)
- 並列化するのは副作用のないI/Oだけ(並行バグの余地を減らす)
- 失敗を「空」と区別する(NAS相手ではデータ破壊に直結する)
- インデックスとPRAGMAは実測で削る(付け足すより外すほうが効くことがある)
以降は主な機能についてです
一覧表示
ツリークリックで一覧表示します。
通常ではその階層のファイルを表示ですが、All Filesモードを有効にする事でその配下すべてのファイルを表示することができます。
フォルダにもサイズ表示
ファイルだけでなく、フォルダの容量も表示されます。(初回スキャン後)
フォルダサイズは、その配下すべてのファイルサイズの合計です。
フォルダのスキャン
フォルダのスキャンはアプリ起動時と定期的(デフォルトは3時間毎)にバックグラウンドで実行します。
ファイル検索
検索バーよりインクリメンタルサーチでファイル名を検索します。
検索はスキャン結果から行うので高速に処理されます。
ツリーの最上位のフォルダを選択して検索すれば、別のフォルダ/NASを横断して検索することができます。
右クリックメニュー
右クリックではコピーなどをできるようにしています。
このアプリは、検索や閲覧を主目的にしているので、フォルダ/ファイル情報を変更する機能は入れないようにしています。(現時点では)
そのため、一般的なファイラーの削除/作成/名前変更などの機能は入れていません。



