C# Tips | ファイル・ディレクトリ操作:大容量ファイル逐次読み込み

C sharp C#
スポンサーリンク

はじめに 「全部読み込む」はもう卒業しよう

大きなログファイル、巨大な CSV、数 GB のテキスト。
こういうファイルを相手にするときに、File.ReadAllTextFile.ReadAllLines を使うと、一瞬でメモリがパンパンになります。

業務で扱うファイルは「いつも小さい」とは限りません。
だからこそ、「大容量ファイルを“少しずつ”読む」という感覚を、早めに体に入れておくと強いです。

ここでは、C# 初心者向けに、

  • テキストファイルを行単位で逐次読み込む
  • バイナリファイルをバッファ単位で逐次読み込む
  • 非同期読み込みで“待ち時間”を隠す
  • 実務でよくあるパターン(巨大ログの集計など)

を、例題付きでかみ砕いて説明していきます。


テキストファイルの逐次読み込みの基本

なぜ ReadAllLines を使わないほうがいいのか

File.ReadAllLines(path) は便利ですが、「全部メモリに載せる」前提のメソッドです。
100 行ならいいですが、100 万行、1000 万行になってくると、メモリ消費がシャレになりません。

大容量ファイルでは、

  • 「必要な分だけ読み、処理し終わったら捨てる」
  • 「常にメモリ使用量を一定に保つ」

というスタイルが基本になります。

そのための道具が StreamReaderReadLine です。

行単位で読む最も基本的なパターン

using System;
using System.IO;
using System.Text;

public static class LargeTextReader
{
    public static void ProcessLines(string path, Encoding encoding)
    {
        using var reader = new StreamReader(path, encoding, detectEncodingFromByteOrderMarks: true);

        string? line;
        long lineNumber = 0;

        while ((line = reader.ReadLine()) is not null)
        {
            lineNumber++;

            ProcessLine(lineNumber, line);
        }
    }

    private static void ProcessLine(long lineNumber, string line)
    {
        if (lineNumber <= 5)
        {
            Console.WriteLine($"{lineNumber}: {line}");
        }
    }
}
C#

使い方のイメージです。

LargeTextReader.ProcessLines(
    @"C:\logs\app.log",
    Encoding.UTF8);
C#

ここでの重要ポイントを整理します。

メモリにためないで、その場で処理する
ReadLine で 1 行読み、すぐ ProcessLine に渡して処理しています。
行を List<string> にためたりせず、その場で完結させるのが「逐次読み込み」の基本です。

using で必ず閉じる
StreamReader はファイルハンドルを掴むので、using で確実に閉じます。
大容量ファイルを扱うときほど、リソース管理はきっちりやるのが大事です。

エンコーディングを明示する
Encoding.UTF8 なのか Encoding.GetEncoding(932)(Shift_JIS)なのか、
「何で書かれたファイルか」を意識して指定します。


実務っぽい例:巨大ログから条件に合う行だけ数える

「エラー行が何件あるか」を数える

例えば、数 GB のログファイルから「ERROR を含む行が何件あるか」を数えたいとします。
全部メモリに載せる必要はなく、1 行ずつ見てカウントすれば十分です。

using System;
using System.IO;
using System.Text;

public static class ErrorCounter
{
    public static long CountErrorLines(string path, Encoding encoding)
    {
        long count = 0;

        using var reader = new StreamReader(path, encoding, detectEncodingFromByteOrderMarks: true);

        string? line;
        while ((line = reader.ReadLine()) is not null)
        {
            if (line.Contains("ERROR", StringComparison.OrdinalIgnoreCase))
            {
                count++;
            }
        }

        return count;
    }
}
C#

使い方です。

long errorCount = ErrorCounter.CountErrorLines(
    @"C:\logs\app.log",
    Encoding.UTF8);

Console.WriteLine($"ERROR 行数: {errorCount}");
C#

ここでのポイントはシンプルですが強力です。

「1 行読んで判定 → カウンタだけ残す」
行の中身はすぐ捨てて、カウンタだけを保持しています。
メモリ使用量は「1 行分+カウンタ」だけで済みます。

ファイルサイズが 10MB でも 10GB でも、コードは同じ
処理時間は増えますが、メモリの使い方は変わりません。
これが「スケールするコード」の感覚です。


バイナリファイルの逐次読み込み

テキストではなく「生のバイト列」を扱う場合

画像、動画、圧縮ファイル、独自バイナリ形式などは、
StreamReader ではなく FileStream で「バイト配列」として扱います。

大容量バイナリを扱うときも、「全部読み込む」のではなく、
固定サイズのバッファで少しずつ読むのが基本です。

バッファ単位で読み込む基本パターン

using System;
using System.IO;

public static class LargeBinaryReader
{
    public static long ProcessBinary(string path)
    {
        const int bufferSize = 1024 * 1024;

        byte[] buffer = new byte[bufferSize];

        long totalBytes = 0;

        using var stream = new FileStream(
            path,
            FileMode.Open,
            FileAccess.Read,
            FileShare.Read);

        int read;
        while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
        {
            totalBytes += read;

            ProcessChunk(buffer, read);
        }

        return totalBytes;
    }

    private static void ProcessChunk(byte[] buffer, int length)
    {
        if (length > 0)
        {
            byte first = buffer[0];
            Console.WriteLine($"チャンク先頭バイト: {first}");
        }
    }
}
C#

使い方です。

long size = LargeBinaryReader.ProcessBinary(
    @"C:\data\bigfile.bin");

Console.WriteLine($"読み込んだバイト数: {size}");
C#

重要ポイントです。

固定サイズのバッファを使い回す
毎回 new byte[] せず、同じ配列に読み込んでいます。
これで GC の負荷を抑えられます。

Read の戻り値が「実際に読めたバイト数」
最後のチャンクはバッファサイズより小さいことが多いので、
length をちゃんと使って処理します。

テキストと違い、「どこで区切るか」は自分で決める
バイナリ形式によっては、「ヘッダ」「レコード長」などのルールに従って解釈する必要があります。
ここはフォーマットごとの話になるので、まずは「バッファ単位で読む」感覚だけ押さえれば十分です。


非同期での逐次読み込み(async/await)

読み込み待ちの時間を“隠す”

ディスク I/O は、CPU に比べて圧倒的に遅いです。
UI アプリやサーバーアプリでは、「読み込み待ちの間に他の処理を進めたい」ことがよくあります。

そこで使えるのが ReadAsyncasync/await です。

テキストファイルを非同期で行単位処理する例

StreamReader 自体に「行を非同期で列挙する」機能はないので、
自前で ReadLineAsync をループします。

using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;

public static class LargeTextReaderAsync
{
    public static async Task ProcessLinesAsync(string path, Encoding encoding)
    {
        using var reader = new StreamReader(path, encoding, detectEncodingFromByteOrderMarks: true);

        string? line;
        long lineNumber = 0;

        while ((line = await reader.ReadLineAsync()) is not null)
        {
            lineNumber++;

            await ProcessLineAsync(lineNumber, line);
        }
    }

    private static Task ProcessLineAsync(long lineNumber, string line)
    {
        if (lineNumber % 100_000 == 0)
        {
            Console.WriteLine($"{lineNumber} 行処理済み");
        }

        return Task.CompletedTask;
    }
}
C#

使い方です。

await LargeTextReaderAsync.ProcessLinesAsync(
    @"C:\logs\big.log",
    Encoding.UTF8);
C#

ポイントはこうです。

ReadLineAsync で I/O 待ちを非同期化
ディスクからの読み込み中、スレッドをブロックせずに解放できます。
サーバーアプリなどで特に効果があります。

行処理側も async にしておくと拡張しやすい
後で「DB に書く」「HTTP で送る」など、非同期処理を足したくなったときに、そのまま書き換えられます。


実務ユーティリティとしての設計ポイント

「何を残し、何を捨てるか」を最初に決める

大容量ファイルを逐次読み込むとき、一番大事なのは、

「最終的に何を残したいのか」

を最初に決めることです。

例えば、

  • エラー行の件数だけ欲しい → カウンタだけ残す
  • 条件に合う行だけ別ファイルに書き出したい → その行だけ書き出して、元の行は捨てる
  • 集計結果(合計値・最大値など)だけ欲しい → 集計用の変数だけ残す

というふうに、「結果だけをメモリに持つ」設計にすると、
ファイルがどれだけ大きくても、メモリ使用量はほぼ一定になります。

途中で中断できるようにしておく

巨大ファイルの処理は、時間がかかることがあります。
実務では、「途中でキャンセルしたい」こともあります。

少しレベルは上がりますが、CancellationToken を受け取る形にしておくと、
「ユーザーがキャンセルボタンを押したら処理を止める」といったこともできるようになります。

例外処理とログ

大容量ファイルを処理しているときに例外が出た場合、
「どの行まで処理できていたか」「どのファイルで失敗したか」をログに残しておくと、
リトライや調査がしやすくなります。

逐次処理は「どこまで進んだか」が明確なので、
行番号やバイトオフセットをログに出しておくと、かなり実務的になります。


まとめ 「大容量ファイル逐次読み込み」は“スケールするコード”の第一歩

大容量ファイルを逐次読み込む、というのは、
単に「メモリ節約テクニック」ではなく、

  • 入力サイズが大きくなっても破綻しない
  • 結果だけを残し、途中経過は流していく
  • I/O と CPU のバランスを意識する

といった、“スケールするコードの考え方”そのものです。

押さえておきたいポイントをまとめると、

  • テキストは StreamReader.ReadLine で 1 行ずつ処理し、ため込まない。
  • バイナリは固定サイズのバッファで FileStream.Read を繰り返す。
  • 非同期版(ReadLineAsync / ReadAsync)を使うと、I/O 待ちを隠せる。
  • 「最終的に何を残すか」を決めて、結果以外はどんどん捨てる設計にする。
  • ファイルサイズが 10MB でも 10GB でも、同じコードで動く形を目指す。

この感覚が一度つかめると、「とりあえず全部読み込んでから考える」コードから卒業して、
業務で長く生き残れるユーティリティを書けるようになっていきます。

タイトルとURLをコピーしました