はじめに 「全部読み込む」はもう卒業しよう
大きなログファイル、巨大な CSV、数 GB のテキスト。
こういうファイルを相手にするときに、File.ReadAllText や File.ReadAllLines を使うと、一瞬でメモリがパンパンになります。
業務で扱うファイルは「いつも小さい」とは限りません。
だからこそ、「大容量ファイルを“少しずつ”読む」という感覚を、早めに体に入れておくと強いです。
ここでは、C# 初心者向けに、
- テキストファイルを行単位で逐次読み込む
- バイナリファイルをバッファ単位で逐次読み込む
- 非同期読み込みで“待ち時間”を隠す
- 実務でよくあるパターン(巨大ログの集計など)
を、例題付きでかみ砕いて説明していきます。
テキストファイルの逐次読み込みの基本
なぜ ReadAllLines を使わないほうがいいのか
File.ReadAllLines(path) は便利ですが、「全部メモリに載せる」前提のメソッドです。
100 行ならいいですが、100 万行、1000 万行になってくると、メモリ消費がシャレになりません。
大容量ファイルでは、
- 「必要な分だけ読み、処理し終わったら捨てる」
- 「常にメモリ使用量を一定に保つ」
というスタイルが基本になります。
そのための道具が StreamReader と ReadLine です。
行単位で読む最も基本的なパターン
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 アプリやサーバーアプリでは、「読み込み待ちの間に他の処理を進めたい」ことがよくあります。
そこで使えるのが ReadAsync と async/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 でも、同じコードで動く形を目指す。
この感覚が一度つかめると、「とりあえず全部読み込んでから考える」コードから卒業して、
業務で長く生き残れるユーティリティを書けるようになっていきます。
