ファイル一覧取得のパフォーマンス (C#)
開発部の本橋です。
C# において、あるディレクトリに格納されているファイルの一覧を取得するには通常 Directory.GetFiles
メソッド*1を使います。
ですが、ファイルが大量に格納されているディレクトリの場合は非常に時間がかかるというデメリットがあります。
.NET 4.0 から Directory.EnumerateFiles
メソッド*2が追加されており、これを使うと早い(というか要素が遅延評価される)のですが、では実際どのぐらい違うのか?というのを計測してみることにします。
準備
対象のディレクトリを準備
まずは大量のファイルが格納されたディレクトリを作るため、こんな PowerShell スクリプトを用意しました。
# _gen.ps1 # 10,000個のファイルと1,000個のディレクトリを作る # 各ディレクトリには1,000個のファイルを作る for ($i = 0; $i -lt 10000; $i++) { New-Item "$i.txt" -Force } for ($i = 0; $i -lt 1000; $i++) { New-Item "$i" -Force -ItemType Directory for ($j = 0; $j -lt 1000; $j++) { New-Item "$i\$j.txt" -Force > $null } }
これを実行します。
PS> cd C:\too_many_files PS> . _gen.ps1
これで C:\too_many_files
には(生成用スクリプトも合わせて)1,010,001個のファイルと1000個のディレクトリができました。
計測用の関数
計測用関数のソースはこんな感じです。ファイル一覧を取得するコールバックを受け取り、取得にかかる時間とメモリ増加量を標準出力に出力します。
private static void Measure(Func<IEnumerable<string>> getFiles) { Stopwatch sw = new Stopwatch(); long ws = Environment.WorkingSet; // ファイル一覧を取得 sw.Start(); IEnumerable<string> files = getFiles(); sw.Stop(); Console.WriteLine("getFiles: {0} [seconds]", sw.Elapsed.TotalSeconds); Console.WriteLine(" => workingset usage: {0:N0} [bytes]", Environment.WorkingSet - ws); ws = Environment.WorkingSet; sw.Reset(); Console.WriteLine(); // ファイル数を取得 sw.Start(); int count = files.Count(); sw.Stop(); Console.WriteLine("Count(): {0:N0} [count], {1} [seconds]", count, sw.Elapsed.TotalSeconds); Console.WriteLine(" => workingset usage: {0:N0} [bytes]", Environment.WorkingSet - ws); }
計測
Directory.GetFiles を使用
まずは Directory.GetFiles
メソッドを使って計測します。
Measure(() => Directory.GetFiles( @"C:\too_many_files", "*", SearchOption.AllDirectories)); // getFiles: 3.7320308 [seconds] // => workingset usage: 87,420,928 [bytes] // // Count(): 1,010,001 [count], 4.77E-05 [seconds] // => workingset usage: 81,920 [bytes]
ファイル一覧の取得に 3.7 秒もかかっていますね。Directory.GetFiles
メソッドは string
型の配列を返すので、ファイル数の取得は一瞬で終わっています。
Directory.EnumerateFiles を使用
次に Directory.EnumerateFiles
メソッドを使って計測します。
Measure(() => Directory.EnumerateFiles( @"C:\too_many_files", "*", SearchOption.AllDirectories)); // getFiles: 0.0006461 [seconds] // => workingset usage: 548,864 [bytes] // // Count(): 1,010,001 [count], 3.3133729 [seconds] // => workingset usage: 4,624,384 [bytes]
こちらはファイル一覧の取得は一瞬で終わりますが、ファイル数の取得に 3.3 秒かかりました。Directory.EnumerateFiles
メソッドで返されるコレクションは要素が遅延評価されるためですね。
使い分け
取得した要素をすべて利用するような場合はどちらでも大差ないと言えますが、GetFiles
の方は配列で返ってきてしまうので扱いにくいケースがあるかもしれません。
要素の一部だけ使いたい場合は EnumerateFiles
のほうが良さそうですので、どちらにしても EnumerateFiles
の方を使うのが無難でしょうか。
おまけ : P/Invoke
以前、弊社に在籍していたベテランエンジニアが
「Directory.GetFiles は遅いからネイティブコードを呼び出すんですよ」
と言っていました。先人の教えに従ってネイティブコード呼び出しのケースも計測してみることにします。
準備
FindFirstFile
関数、FindNextFile
関数、FindClose
関数を使えるように DllImport
します。*3
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] internal static extern IntPtr FindFirstFileEx( string lpFileName, FINDEX_INFO_LEVELS fInfoLevelId, out WIN32_FIND_DATA lpFindFileData, FINDEX_SEARCH_OPS fSearchOp, IntPtr lpSearchFilter, int dwAdditionalFlags); [DllImport("kernel32.dll", CharSet=CharSet.Auto)] static extern bool FindNextFile(IntPtr hFindFile, out WIN32_FIND_DATA lpFindFileData); [DllImport("kernel32.dll")] static extern bool FindClose(IntPtr hFindFile); internal enum FINDEX_INFO_LEVELS { FindExInfoStandard = 0, FindExInfoBasic = 1 } internal enum FINDEX_SEARCH_OPS { FindExSearchNameMatch = 0, FindExSearchLimitToDirectories = 1, FindExSearchLimitToDevices = 2 } [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Auto)] internal struct WIN32_FIND_DATA { public uint dwFileAttributes; public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime; public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime; public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime; public uint nFileSizeHigh; public uint nFileSizeLow; public uint dwReserved0; public uint dwReserved1; [MarshalAs(UnmanagedType.ByValTStr, SizeConst=260)] public string cFileName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst=14)] public string cAlternateFileName; }
これらを使ってファイル一覧を取得する関数を作ります。以下ページのサンプルコードを改造して、再帰的にファイル一覧を取得するようにします。
http://pinvoke.net/default.aspx/kernel32/FindFirstFile.html
private static void myGetFiles(string dir, string pattern, List<string> result) { WIN32_FIND_DATA findData; FINDEX_INFO_LEVELS findInfoLevel = FINDEX_INFO_LEVELS.FindExInfoStandard; int additionalFlags = 0; if (Environment.OSVersion.Version.Major >= 6) { findInfoLevel = FINDEX_INFO_LEVELS.FindExInfoBasic; additionalFlags = 2; //FIND_FIRST_EX_LARGE_FETCH } IntPtr hFile = FindFirstFileEx( Path.Combine(dir, pattern), findInfoLevel, out findData, FINDEX_SEARCH_OPS.FindExSearchNameMatch, IntPtr.Zero, additionalFlags); int error = Marshal.GetLastWin32Error(); if (hFile.ToInt32() != -1) { do { if ((findData.dwFileAttributes & (uint)FileAttributes.Directory) != (uint)FileAttributes.Directory) { //Console.WriteLine("Found file {0}", findData.cFileName); result.Add(findData.cFileName); } else if (findData.cFileName != "." && findData.cFileName != "..") { myGetFiles(Path.Combine(dir, findData.cFileName), pattern, result); } } while (FindNextFile(hFile, out findData)); FindClose(hFile); } }
計測
Measure(() => { List<string> result = new List<string>(); myGetFiles(@"C:\too_many_files", "*", result); return result; }); // getFiles: 1.4302673 [seconds] // => workingset usage: 38,064,128 [bytes] // // Count(): 1,010,001 [count], 3.14E-05 [seconds] // => workingset usage: 45,056 [bytes]
ファイル一覧の取得で 1.4 秒ほどかかりますが、すべての要素が評価済みであることを考えると非常に早いですね。先人の教えすごい。
ですがコードが非常に長くなってしまうので・・・これは最後の手段として秘密にしておきしましょう。
結論
というわけで、良さそうな方針としては以下のようになるかと思います。
- .NET 4.0 以上が使えるなら
Directory.EnumerateFiles
を使う。 - .NET 4.0 以上が使えないなら
Directory.GetFiles
で我慢する。 - パフォーマンス要件が非常に厳しい場合は最後の手段として P/Invoke を使う。