C# WinFormsでC:\配下の全ファイル列挙が固まる原因と解決策|EnumerateFiles・アクセス拒否・UIフリーズ対策

WinFormsでC:\配下の全ファイルを列挙してListBoxに表示したいのに、Directory.GetFiles(directoryPath, "*.*", SearchOption.AllDirectories)で固まって見える──この症状は珍しくありません。原因の多くは「アクセス拒否で止まる」か「件数が膨大でUIスレッドが塞がる」ことです。安全に列挙して表示する実装パターンをまとめます。

目次

「固まる」の正体を先に整理する

まず押さえたいのは、本当に停止しているのではなく、長時間ブロックされて「進んでいないように見える」ケースが大半だという点です。C:\配下には、既定でアクセスできないフォルダー(システム保護領域、他ユーザー領域など)が混ざります。さらにSearchOption.AllDirectoriesで全階層を対象にすると、対象件数が桁違いになり、列挙に数分〜数十分かかることもあります。

症状起きやすい原因最初に確認すること
フォームが応答なしになり、ボタンも押せないUIスレッドで列挙している(同期処理)列挙をTask.Run等でバックグラウンド化し、UI更新だけをUIスレッドへ戻す
途中まで出るが、ある時点で止まる/例外で落ちるUnauthorizedAccessException(アクセス拒否)フォルダー単位で例外を捕捉してスキップする/.NET 6+ならIgnoreInaccessibleを使う
しばらく待つと一気に表示されるGetFilesが全件を配列化してから返すためEnumerateFilesで逐次列挙し、見つかった順に追加する
CPUは低いのに遅い/ディスクが点滅し続けるIOボトルネック、ウイルス対策ソフトの介入、巨大ディレクトリ対象拡張子・除外フォルダー・最大件数・キャンセルを設ける
フォルダー階層が深いところで落ちる長いパス(特に.NET Framework)例外を捕捉して継続/長いパス対応の設定や.NETの選択を検討

Directory.GetFilesが「固まりやすい」理由

Directory.GetFiles(directoryPath, "*.*", SearchOption.AllDirectories)は便利ですが、全件を一括で集めてstring[]として返すという特性があり、次の点で不利になります。

  • 戻り値を作り切るまでUIに何も返らない(途中経過が出ず、固まったように見える)
  • 対象が多いほどメモリ消費が増える(パス文字列の配列化は意外と重い)
  • 途中でアクセス拒否が発生すると例外で中断しやすい(「スキップして続行」が難しい)
API返り値列挙のタイミング大量データへの強さUI更新のしやすさ
Directory.GetFilesstring[]全件収集してから返す弱い(メモリ・時間が膨らみやすい)低い(途中経過が出ない)
Directory.EnumerateFilesIEnumerable<string>見つかった順に逐次返す強い(ストリーミングで扱える)高い(見つかった分だけ追加できる)
フォルダー単位の再帰+例外スキップIEnumerable<string>アクセス可能な範囲だけ進む最も強い(中断しにくい)高い(バッチ更新と相性が良い)

対策の全体像

「C:\を全走査してListBoxに表示する」という要件は、開発中は便利でも本番では重くなりがちです。まずは次の3つの対策を押さえると、固まりにくく、落ちにくい実装にできます。

  1. 1階層ずつたどり、アクセスできないフォルダーはスキップする(例外を握って継続)
  2. GetFilesではなくEnumerateFilesで逐次列挙する(途中結果を反映)
  3. 対象を「マイ ドキュメント」などに絞る/無視設定つき列挙を使う(運用の現実解)

実装例:アクセス拒否をスキップしながら安全に列挙する

SearchOption.AllDirectoriesを直で使うと、アクセス拒否の時点で例外が発生して中断しがちです。そこで、フォルダー単位で例外を捕まえてスキップしながら進む実装にすると安定します。再帰呼び出しでも書けますが、深い階層でのスタック消費を避けるため、ここではスタックでの反復(イテレーティブ)例を示します。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;

public static class SafeFileEnumerator
{
    // 必要に応じて除外したいディレクトリ名(C:\直下で効く想定)
    private static readonly HashSet<string> DefaultExcludedDirNames =
        new(StringComparer.OrdinalIgnoreCase)
        {
            "Windows",
            "Program Files",
            "Program Files (x86)",
            "ProgramData",
            "System Volume Information",
            "$Recycle.Bin"
        };

    public static IEnumerable<string> EnumerateFilesSafe(
        string rootDirectory,
        string searchPattern,
        CancellationToken cancellationToken,
        Func<string, bool>? shouldSkipDirectory = null)
    {
        if (string.IsNullOrWhiteSpace(rootDirectory))
            throw new ArgumentException("rootDirectory is empty.", nameof(rootDirectory));

        // 既定の除外と、呼び出し側の除外条件を合成
        bool Skip(string dir)
        {
            var name = Path.GetFileName(dir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
            if (DefaultExcludedDirNames.Contains(name))
                return true;

            if (shouldSkipDirectory != null && shouldSkipDirectory(dir))
                return true;

            // ジャンクション/シンボリックリンク(ReparsePoint)はループや別ボリュームを辿りがちなので基本スキップ
            if (IsReparsePoint(dir))
                return true;

            return false;
        }

        var pending = new Stack<string>();
        pending.Push(rootDirectory);

        while (pending.Count > 0)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var current = pending.Pop();

            if (Skip(current))
                continue;

            // ファイル列挙(例外はフォルダー単位で握って続行)
            try
            {
                foreach (var file in Directory.EnumerateFiles(current, searchPattern, SearchOption.TopDirectoryOnly))
                {
                    cancellationToken.ThrowIfCancellationRequested();
                    yield return file;
                }
            }
            catch (UnauthorizedAccessException)
            {
                // アクセス拒否:スキップ
            }
            catch (PathTooLongException)
            {
                // パスが長すぎる:スキップ
            }
            catch (IOException)
            {
                // 一時的なIOエラーやディレクトリ消失等:スキップ
            }

            // サブディレクトリ列挙(ここもフォルダー単位で握って続行)
            try
            {
                foreach (var dir in Directory.EnumerateDirectories(current, "*", SearchOption.TopDirectoryOnly))
                {
                    cancellationToken.ThrowIfCancellationRequested();
                    pending.Push(dir);
                }
            }
            catch (UnauthorizedAccessException)
            {
            }
            catch (PathTooLongException)
            {
            }
            catch (IOException)
            {
            }
        }
    }

    private static bool IsReparsePoint(string directoryPath)
    {
        try
        {
            var attr = File.GetAttributes(directoryPath);
            return (attr & FileAttributes.ReparsePoint) != 0;
        }
        catch
        {
            // 属性取得すらできないなら、無理に入らない
            return true;
        }
    }
}

この実装のポイントは次のとおりです。

  • 例外を「フォルダー単位」で握る:アクセスできないフォルダーが混ざっても処理全体が止まりません。
  • EnumerateFilesで逐次返す:呼び出し側が途中結果を表示できます。
  • ReparsePoint(ジャンクション等)をスキップ:想定外のループや別ボリュームに入り込む事故を減らします。
例外典型的に起きる場面実務での扱い
UnauthorizedAccessExceptionシステム保護領域、他ユーザー領域、権限不足のフォルダーに入ろうとしたスキップして継続(必要ならログに残す)
PathTooLongException深い階層+長いファイル名(特に.NET Frameworkで遭遇しやすい)スキップ/長いパス対応の検討(アプリ設定・OS設定)
IOException列挙中にディレクトリが削除された、アクセスが一時的に不安定等スキップして継続、必要なら再試行は限定的に

実装例:バックグラウンド列挙+バッチ追加でUIフリーズを防ぐ

次に重要なのがUIスレッドで全走査をしないことです。WinFormsはUIスレッドがメッセージ処理(描画やクリック応答)を担当するため、列挙を同期実行するとフォームが「応答なし」になります。

おすすめは、列挙自体はバックグラウンドで回し、UIへの反映は一定件数ごとのバッチで行う方法です。1件ごとにInvokeすると、今度はUI更新がボトルネックになります。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

public partial class MainForm : Form
{
    private CancellationTokenSource? _cts;

    public MainForm()
    {
        InitializeComponent();
    }

    private async void btnScan_Click(object sender, EventArgs e)
    {
        // 連打対策:前回が走っていればキャンセル
        _cts?.Cancel();
        _cts = new CancellationTokenSource();

        var root = @"C:\"; // 例:必要に応じてMyDocumentsに変更
        listBox1.Items.Clear();

        // Progress<T>は作成したスレッド(ここではUIスレッド)にコールバックしてくれる
        var progress = new Progress<List<string>>(batch =>
        {
            // UIスレッドでまとめて追加
            listBox1.BeginUpdate();
            try
            {
                listBox1.Items.AddRange(batch.Cast<object>().ToArray());
            }
            finally
            {
                listBox1.EndUpdate();
            }

            lblCount.Text = $"{listBox1.Items.Count:N0} 件";
        });

        btnScan.Enabled = false;
        btnCancel.Enabled = true;

        try
        {
            await Task.Run(() => ScanCore(root, progress, _cts.Token));
        }
        catch (OperationCanceledException)
        {
            // キャンセル時は何もしない(必要なら表示)
        }
        finally
        {
            btnScan.Enabled = true;
            btnCancel.Enabled = false;
        }
    }

    private void btnCancel_Click(object sender, EventArgs e)
    {
        _cts?.Cancel();
    }

    private static void ScanCore(string root, IProgress<List<string>> progress, CancellationToken ct)
    {
        const int batchSize = 300;
        var batch = new List<string>(batchSize);

        foreach (var file in SafeFileEnumerator.EnumerateFilesSafe(root, "*.*", ct))
        {
            batch.Add(file);

            if (batch.Count >= batchSize)
            {
                progress.Report(batch);
                batch = new List<string>(batchSize);
            }
        }

        if (batch.Count > 0)
            progress.Report(batch);
    }
}

この構成にすると、

  • 列挙中でもフォームが操作できる(応答なしになりにくい)
  • 表示が少しずつ増えるので「進んでいる」ことが分かる
  • キャンセルボタンで途中中断できる

といった実務上の使い勝手が大きく改善します。

ListBoxに大量表示する場合の実務メモ

  • 全件表示=全件保持なので、メモリは必ず増えます。要件次第では「最大表示件数」を設けるのが現実的です。
  • ファイル数が10万件を超えるようなケースでは、ListBoxよりListView(VirtualMode)や、検索窓つきの絞り込み表示のほうが実運用に向きます。
  • 進捗が欲しい場合は、ディレクトリ数や追加件数をカウントしてラベル表示するだけでも体感が変わります。

.NET 6+ならEnumerationOptionsで「無視して再帰」が簡潔になる

もしプロジェクトが.NET 6/7/8などの新しめの.NETで動いているなら、Directory.EnumerateFilesにEnumerationOptionsを渡す方法が強力です。IgnoreInaccessible = trueでアクセス不能を無視しつつ、再帰や属性スキップをまとめて指定できます。

using System;
using System.Collections.Generic;
using System.IO;

public static IEnumerable<string> EnumerateFilesModern(string root)
{
    var options = new EnumerationOptions
    {
        RecurseSubdirectories = true,
        IgnoreInaccessible = true,                 // アクセス不能を無視
        ReturnSpecialDirectories = false,
        AttributesToSkip = FileAttributes.System | FileAttributes.ReparsePoint
    };

    foreach (var file in Directory.EnumerateFiles(root, "*.*", options))
    {
        yield return file;
    }
}

「SearchOption.AllDirectories+例外スキップ」を自前で書くより短くなり、保守もしやすくなります。ただし、古い.NET FrameworkのWinFormsではこのオーバーロードが使えない場合があるため、プロジェクトのターゲットフレームワークは確認してください。

現場でおすすめの対象範囲:まずは「マイ ドキュメント」から

C:\の全走査は、権限・件数・速度の3点でハードルが高いです。要件が「ユーザーが作ったファイルを探したい」なのであれば、まずはユーザーフォルダー(マイ ドキュメントやデスクトップ)に絞るほうが、体感速度もトラブルも大幅に減ります。

using System;
using System.IO;

var myDocs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);

// 例:まずはmyDocsだけ、必要なら複数ルートを回す
var root = myDocs;

拡張子を絞るだけでも劇的に軽くなる

「全部列挙」は最も重い要件です。実務では、まずは対象拡張子を決めるだけで処理時間が大幅に短くなります(例:Officeファイルだけ、画像だけ、ログだけ)。

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;

var targets = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
    ".docx", ".xlsx"
};

CancellationToken ct = CancellationToken.None;

foreach (var file in SafeFileEnumerator.EnumerateFilesSafe(@"C:\Users", "*.*", ct))
{
    if (!targets.Contains(Path.GetExtension(file)))
        continue;

    // ここでListBoxに追加(バッチ推奨)
}

Meziantou.Framework.Globbingで「除外・拡張子指定」を設定で管理する

対象が「マイ ドキュメント配下」など比較的限定的で、さらに除外フォルダーや拡張子条件を柔軟に管理したい場合は、グロブ(Glob)に対応したライブラリを使う選択肢もあります。NuGetのMeziantou.Framework.Globbingのようなライブラリでは、**/*.docxや**/*.xlsxといったパターンで対象を表現でき、実装をシンプルにできます。

また、列挙側にEnumerationOptionsを渡せる設計になっている場合は、EnumerationOptions.IgnoreInaccessible = trueのようにしてアクセス不能フォルダーを無視しながら列挙できます。結果として「権限で止まらず、必要な種類だけ拾う」運用がしやすくなります。

ポイントは、コード側で例外処理を抱え込むよりも、パターン(対象)とオプション(無視・除外)を設定として外出しできることです。プロジェクトの要件が固まってきたら、設定ファイル化(JSON等)して「運用で変えられる」形にすると後々強いです。

「全部出す」を前提にしない設計が、結局いちばん速い

最後に、実務でよく効く考え方を整理します。C:\の全走査は、技術的には可能でも、ユーザー体験としては重くなりやすいです。次のような制限を入れるだけで、体感が大きく改善します。

制限・工夫効果実装のヒント
対象拡張子を限定する列挙件数が激減し、速度が上がるHashSetでPath.GetExtensionを判定/グロブライブラリを導入
除外フォルダーを設けるアクセス拒否・巨大ディレクトリを回避C:\Windows、Program Filesなどを除外
最大件数を設けるUIとメモリの暴走を防ぐ一定件数で打ち切り、検索条件の見直しを促す
キャンセルと進捗表示を入れる「固まった」誤解が減り、操作性が上がるCancellationTokenとIProgressを併用
UI追加はバッチで行うInvoke地獄を避け、表示が滑らかになる300〜1000件程度でまとめて追加(状況で調整)

よくある質問

管理者として実行すれば解決しますか?

一部のアクセス拒否は減りますが、すべてが解決するわけではありません。システム保護領域は管理者でも制限があることがあり、何より全走査の件数問題(時間・メモリ・UI)が残ります。権限に頼るより、スキップしながら進む実装+対象の絞り込みのほうが安定します。

EnumerateFilesに変えたのに、途中で例外が出て止まります

EnumerateFilesは逐次列挙ですが、アクセスできないフォルダーに到達した時点で例外が発生します(.NET 6+のIgnoreInaccessibleを使わない場合)。そのため、フォルダー単位で例外を捕捉してスキップする実装(前述のEnumerateFilesSafe)が効果的です。

速度をもっと上げたいです(並列化は有効?)

ファイル走査は多くの場合ディスクIOがボトルネックなので、無闇な並列化は逆効果になることがあります。まずは対象を絞る・除外を増やす・UI更新を減らすのが先決です。それでも足りない場合は、ディレクトリ単位で限定的に並列化し、同時実行数を小さく保つ(例:2〜4)と安定しやすいです。

最終的に「検索」っぽいUIにしたいです

全件をListBoxに出すより、バックグラウンドで索引(インデックス)を作って検索する構成のほうがスケールします。たとえば「最初はマイ ドキュメントだけ索引」「最近使ったファイルを優先表示」「検索語で絞り込み」などにすると、体感速度が上がり、ユーザーが欲しい結果に早く到達できます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次