Knowlbo 開発者ブログ

株式会社Knowlboの開発者ブログです。

ファイル一覧取得のパフォーマンス (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 を使う。