1. はじめに
小さいファイルを扱うときは何も気にしないことが多いです:書いて、読んで、終わり。しかしファイルサイズが少なくとも100–500MBを超え、ましてやギガバイト級になると面白い現象が出てきます:
- 操作が遅くなることが多い。例えば「直球で」扱うと、File.ReadAllBytes()やFile.WriteAllText()がプログラム全体を遅くすることがある。
- RAMが足りなくなり、OutOfMemoryExceptionが出る可能性がある。
- システムがスワップ(ページング)を使い始め、システム全体が遅くなることがある。
- 並列操作がディスクに過負荷をかけることがある。
実際のケースでよく見られる例:
- サーバーログ(1日でギガバイト単位)。
- 大きなCSVやXMLファイルのエクスポート/インポート。
- ビデオ、オーディオ、アーカイブ、バイナリファイルの扱い。
- 大きなアセンブリのコピーやバックアップ。
2. 大きなファイルを扱うときの最適化戦略
最適化に飛びつく前に、何を速く/改善したいのかを明確にしましょう。よくある目的は次の通りです:
- 「ファイルをできるだけ速く読み書きして、システムを壊さない」こと。
- 「メモリを圧迫しないように、ファイルをチャンク単位で処理する」こと。
- 「メモリ内に不要なデータのコピーを作らない」こと。
- 「可能なら大きなファイルを並列で処理する」こと(条件次第)。
一般的なアプローチ:
- ストリームベースの読み書き:ストリームとバッファを使い、チャンクごとに読み書きする(FileStream, BufferedStreamなど)。
- ディスク上で直接操作:メモリに中間コピーを作らない。
- バッファとメモリを慎重に管理:ファイル全体をRAMに保持しない。
- 非同期操作:メインスレッドをブロックしたくない場合に有効(詳細は次の講義で)。
3. ストリーム読み書き:基本パターン
主要な原則:ファイルは部分ごとに扱え! 現代のC#では、FileStreamやBufferedStreamなどのクラスを使えば簡単にできます。
// 読み取り用にストリームを開く:
using FileStream fs = new FileStream("bigfile.bin", FileMode.Open, FileAccess.Read);
byte[] buffer = new byte[1024 * 1024]; // 1MB
int bytesRead;
// ファイルの終わりまで読み続ける
while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0)
{
// ここで読み取ったデータを処理する!
// 例えば、バイトの合計を計算してみる(練習用)
long sum = 0;
for (int i = 0; i < bytesRead; i++)
sum += buffer[i];
Console.WriteLine($"読み取ったバイト数 {bytesRead}, 合計: {sum}");
}
アドバイス:バッファサイズ(例:64KB、128KB、1MB)は実測で調整してください。小さすぎるとディスクアクセスが増えます。大きすぎると必ずしも速くならず、メモリを多く消費します。
なぜこうするのか? File.ReadAllBytes()やFile.ReadAllText()のように分割せずに読み込む方法はファイル全体をメモリに読み込もうとします。ファイルが巨大だと結果は明白で、OutOfMemoryExceptionが発生します。
4. BufferedStream:なぜ、いつ使うか
このクラスは前の講義でも触れましたが、念のため:BufferedStreamは任意のストリームの上に乗せるラッパーで、1バイトずつではなくブロック単位で読み書きできるようにします。
使用例:
using var fileStream = new FileStream("bigfile.bin", FileMode.Open, FileAccess.Read);
using var bufferedStream = new BufferedStream(fileStream, 1024 * 128);
byte[] buffer = new byte[1024 * 128];
int bytesRead;
while ((bytesRead = bufferedStream.Read(buffer, 0, buffer.Length)) > 0)
{
// データ処理
}
場合によってはBufferedStreamを使うことで高速化が得られます。特に少量ずつ読み取るようなケースや、ファイルシステムがブロックアクセスを好む場合に有効です。
豆知識:新しいバージョンの.NETではFileStream自体にバッファリングが組み込まれているため、BufferedStreamの手動使用はネットワークストリームや特殊デバイスなど「生の」ストリームで最も効果を発揮します。
5. 大きなテキストファイルの読み書き
バイナリファイルはチャンクで読み書きすれば良いことが多いです。では、大きなCSV、ログ、JSONなどのテキストファイルはどう扱うか?
ここで役立つのがテキスト読み取り用のStreamReaderと書き込み用のStreamWriterです。
行単位の読み取り:
using var reader = new StreamReader("biglog.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
// 行の処理
if (line.Contains("ERROR"))
Console.WriteLine("ERRORが検出されました: " + line);
}
なぜこれが良いのか?
- ファイル全体をメモリに保持しない。
- StreamReader内部のバッファリングは既に最適化されている。
行ごとの書き込み:
using var writer = new StreamWriter("output.txt");
for (int i = 0; i < 1000000; i++)
writer.WriteLine($"これは行番号 {i} です");
ストリーム読み取りのアーキテクチャ
[ディスク上のファイル]
|
[FileStream]
|
[BufferedStream (オプション)]
|
[StreamReader/StreamWriter (テキスト用)]
|
[あなたのコード:データ処理]
6. 実践で使ってみる
ログファイル処理アプリをさらに発展させます。7日より古いログを単に移動するのではなく、1つのアーカイブに圧縮するとします。ファイルが巨大であれば、メモリを潰さないようにチャンク単位で読み書きします。
簡単化のためストリームを使った標準的なコピーを使います:
void CopyLargeFile(string sourcePath, string destPath)
{
using var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read);
using var destStream = new FileStream(destPath, FileMode.Create, FileAccess.Write);
byte[] buffer = new byte[1024 * 256]; // 256KB
int bytesRead;
while ((bytesRead = sourceStream.Read(buffer, 0, buffer.Length)) > 0)
{
destStream.Write(buffer, 0, bytesRead);
// ここでプログレスバーを追加してもいい
}
}
どこで使う?
- バックアップのコピー
- 日別・月別のログ統合
- ファイルの事前処理(例:行のフィルタリング)
7. バッファ効果の評価方法
「どれくらい速くなったか?」を知りたいときは、処理時間を計測すれば簡単にわかります:
var watch = System.Diagnostics.Stopwatch.StartNew();
CopyLargeFile("source.bin", "dest.bin");
watch.Stop();
Console.WriteLine($"コピー時間: {watch.Elapsed.TotalSeconds} 秒");
典型的なバッファサイズ:
- 4KB — ファイルシステムの最小ブロック。
- 64KB/128KB — 実践でだいたいうまく動く。
- 1MB以上 — 高速なSSDかつ大きなファイルでのみ妥当。
いろんなバッファサイズを試してみてください!
8. 大きなファイルでの検索と処理
コピーだけでなく、特定の行や数値、フレーズを探したいことがあります。大きなファイルではチャンク単位の処理が最も効率的です。
例:巨大なログからエラー行をすべて見つける
using var reader = new StreamReader("server.log");
using var writer = new StreamWriter("errors.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
if (line.Contains("ERROR"))
writer.WriteLine(line); // 必要な行だけ書き出す
}
この方法ならギガバイト単位のログも大量のメモリを使わずに処理できます。
9. 大容量ファイル(>2GB)に関する注意点
.NETとWindowsはストリームを使えば(テラバイト級でも)問題なく動作しますが、注意点があります。
- 32ビットアプリケーションは2GBのアドレス空間制限があるので、x64を使ってください!
- 大きなファイルでは常に64ビット環境を使う(AnyCPUやx64)。
- 4GBを超えるファイルにはFAT32は使えません — NTFSやexFATが必要です。
大きなファイルの逐次処理
+------------------+
| Start |
+------------------+
|
v
+------------------------------+
| 読み取り用ストリームを開く |
+------------------------------+
|
v
+------------------------------+
| ファイル終端までループする |
+------------------------------+
|
v
+---------------------------+
| データブロックを読み取る |
+---------------------------+
|
v
+---------------------------+
| ブロックを処理する |
+---------------------------+
|
v
+--------------------------+
| 次のブロックへ移動 |
+--------------------------+
|
v
+--------------------+
| ストリームを閉じる |
+--------------------+
10. 便利なポイント
ファイルをストリームとして扱う:FileStreamの主なメソッド
| メソッド | 説明 |
|---|---|
|
ファイルの一部をバッファに読み取る |
|
バッファの一部をファイルに書き込む |
|
ファイル内の任意の位置に移動する |
|
ファイルサイズ(バイト) |
|
ファイル内の現在の位置 |
Seek()の使用例:
using var stream = new FileStream("bigfile.bin", FileMode.Open);
// 1GB先に移動してみる!
stream.Seek(1024L * 1024 * 1024, SeekOrigin.Begin);
byte[] buffer = new byte[1024];
int bytesRead = stream.Read(buffer, 0, buffer.Length);
// これでファイルの中間からデータを読むことができる!
どこで役立つ?
- ファイルのインデックス作成
- 大規模なデータベースのように、特定ブロックへ高速にアクセスする場面
マルチスレッドでのファイル処理
条件が許せば、大きなファイルを並列処理することもできます:例えばファイルをブロックに分割し、異なる部分を同時に読み書きする。ただし注意点として、HDDはランダムアクセスが遅く、SSDはより速いです。
個人用途の単純なケースでは、確信がない限り順次処理したほうが安全です。並列化は複数ファイルを同時に処理するときに有効であり、1つのファイルを分割して処理する場合の効果は限定的です。
11. 大きなファイルを扱うときの典型的なミス
ミス1:ファイル全体をメモリに読み込む。
初心者は巨大なファイルでもFile.ReadAllBytes()やFile.ReadAllText()を使いがちです。ファイルがギガバイト級だとアプリはクラッシュします。ストリーム読み取りを使いましょう。
ミス2:バッファを小さくしすぎる。
小さなバッファで読み続けるのはスープをティースプーンで飲むようなものです。ディスクへのアクセスが増えて無駄に時間を食います。適切なバッファサイズを選んでください。
ミス3:ストリームを閉じ忘れる。
作業後にストリームを閉じないと、ファイルディスクリプタが占有されたままになります。他のプログラムの邪魔になり、OSレベルのエラーを引き起こすことがあります。常にusingを使うと安全で綺麗です。
ミス4:同じファイルへの同時アクセス。
同じファイルを別の部分から同時に読み書きしようとすると、IOExceptionを招きやすいです。たとえ一時的に動作しても、安定性は望めません。
GO TO FULL VERSION