C# ファイル入出力:using・非同期 API・UTF-8 BOM・Path の罠を実測

File・StreamReader の基本、using 宣言、非同期 API、UTF-8 BOM の実バイト、Shift_JIS、Path.Combine の罠、ReadAllLines と ReadLines の速度・メモリ実測。

2026年11月改訂: 2021年に韓国語で書いたファイル入出力の講座を、.NET 10.0.14(C# 14)で macOS の一時フォルダーを使って再実行しました。コードはすべてコンパイルできましたが、最初の例は 2回目の実行で IOException(ファイルが既に存在)になり、表にあった「StreamReader は文字列から読み込む TextReader」という説明は誤りでした(文字列から読むのは StringReader)。修正に加えて、using 宣言、非同期 API、UTF-8 BOM の実際のバイト、レガシーな文字コード、Path.Combine の罠、読み込み方法ごとの速度とメモリの実測を追加しました。

この記事は、C# でログや CSV、設定ファイルを読み書きしていて「別のプロセスで使用中」、文字化け、BOM のせいで CSV の先頭列名が一致しない、大きなファイルでメモリが跳ね上がる、といった問題にぶつかった人向けです。結論から言うと、ストリームは using で閉じる、文字コードは必ず明示する、大きなファイルは ReadLines かバッファを大きくした StreamReader で1行ずつ読む、の3つでほとんど解決します。すべてのコードは Directory.CreateTempSubdirectory() で作った一時フォルダーの中だけで実行しました。

File、FileInfo、FileStream:2021年の例を2回実行すると

File は静的メソッド、FileInfo は1つのパスを包むインスタンスです。1回限りの操作なら File、同じファイルのサイズや日時、属性を何度も見るなら FileInfo が便利です。

操作 File FileInfo
作成 File.Create("a.txt") → FileStream new FileInfo("a.txt").Create()
コピー File.Copy("a.txt", "b.txt") src.CopyTo("b.txt")
削除 File.Delete("a.txt") file.Delete()
移動 File.Move("a.txt", "b.txt") file.MoveTo("b.txt")

2021年の最初の例(a.txt と b.txt を作り、c.txt と d.txt にコピー)を同じフォルダーで2回実行しました。

run 1: ok -> a.txt, b.txt, c.txt, d.txt
run 2: IOException: The file '…/fio-k2wd69/c.txt' already exists.

File.Create は既存ファイルを黙って上書きしますが、File.Copy と CopyTo は コピー先があると例外 を投げます。上書きしたいときは第3引数に overwrite: true を渡します。メッセージ中の一時パスは短縮しています。ランタイムの例外メッセージは、DOTNET_CLI_UI_LANGUAGE=ja にしてもこの環境では英語のままでした。

2021年の記事は「ストリームを閉じないと、使用中のためアクセスできないという例外が出る」と書いていました。Windows の話に聞こえますが、.NET は Unix でも FileShare の挙動を再現するので、macOS でもそのまま起きました。

FileStream open = File.Create("e.txt");   // FileShare.None で開かれる
File.Copy("e.txt", "f.txt");              // 閉じる前にコピー
while open: IOException: The process cannot access the file '…/e.txt' because it is being used by another process.

FileStream 自体はバイト単位のストリームです。テキストを扱うときはその上に StreamReader / StreamWriter を重ねます。

StreamReader・StreamWriter と using 宣言

2021年の表を直すと、StreamReader は バイトストリームを指定した文字コードで文字に変換して読む TextReader で、StreamWriter はその逆です。文字列から読むクラスは StringReader です。

Close() を忘れたらどうなるかを測りました。

var sw = new StreamWriter("h.txt");
sw.WriteLine("written but never flushed");
Console.WriteLine(new FileInfo("h.txt").Length);   // 0
sw.Close();
Console.WriteLine(new FileInfo("h.txt").Length);   // 26
h.txt length before Close: 0 bytes
h.txt length after Close:  26 bytes

StreamWriter は内部バッファにためておき、Flush か Close(=Dispose)のときにディスクへ書きます。例外で Close までたどり着かないと ファイルは0バイトのまま です。だから今のコードは Close() を手で呼ばず using を使います。C# 8 からは波かっこなしの using 宣言が書けます(LangVersion を 7.3 に下げてビルドすると error CS8370 になることで確認)。2021年の例を書き直したものです。

{
    using var sw = new StreamWriter(path);
    sw.Write("sw.Write()");
    sw.Write(" sw.Write()");
    sw.WriteLine(" sw.WriteLine()");
    sw.WriteLine("sw.WriteLine()");
}   // ここで Dispose(Flush + Close)

using (var sr = new StreamReader(path))
{
    while (sr.ReadLine() is { } line)
        Console.WriteLine(line);
}
sw.Write() sw.Write() sw.WriteLine()
sw.WriteLine()

出力は2021年と同じでした。using var は 変数を含むブロックの終わり で破棄されるので、同じメソッド内ですぐ読み直すなら上のようにブロックで囲みます。2021年の while (sr.Peek() >= 0) も動きますが、ReadLine() が末尾で null を返すことをパターンで受けるほうが簡潔です。改行は Environment.NewLine に従い、macOS では \n(1バイト)、Windows では \r\n(2バイト)になります。

非同期 File API

UI アプリやサーバーでは、ディスクを待つ間スレッドを占有しないよう非同期 API を使います。.NET 10 で実行したコードと出力です。

await File.WriteAllLinesAsync(path, ["alpha", "beta", "gamma"]);
await File.AppendAllTextAsync(path, "delta\n");
string[] all = await File.ReadAllLinesAsync(path);
Console.WriteLine($"ReadAllLinesAsync: {all.Length} lines");

await foreach (string line in File.ReadLinesAsync(path))
    if (line.StartsWith('g')) Console.WriteLine($"ReadLinesAsync found: {line}");

await using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true))
{
    byte[] buf = new byte[5];
    int n = await fs.ReadAsync(buf);
    Console.WriteLine($"FileStream.ReadAsync: {n} bytes -> {Encoding.UTF8.GetString(buf, 0, n)}");
}
ReadAllLinesAsync: 4 lines
ReadLinesAsync found: gamma
FileStream.ReadAsync: 5 bytes -> alpha
bytes on disk: 23 (newline = "\n")

File.ReadLinesAsync は IAsyncEnumerable<string> を返すので await foreach で受け、非同期ストリームは await using で閉じます。ただし後の実測の通り 非同期のほうが速いわけではありません。待ってもかまわないコンソールツールやバッチなら、同期 API のほうが単純で速いです。

文字コード:UTF-8 BOM と Shift_JIS の実際のバイト

"가あA"(ハングル1文字、ひらがな1文字、ASCII 1文字)を6通りの方法で保存し、ディスク上のバイトをそのまま出力しました。

File.WriteAllText(path, s)                      7 bytes  EA B0 80 E3 81 82 41
… , Encoding.UTF8                              10 bytes  EF BB BF EA B0 80 E3 81 82 41
… , new UTF8Encoding(false)                     7 bytes  EA B0 80 E3 81 82 41
new StreamWriter(path)                          7 bytes  EA B0 80 E3 81 82 41
new StreamWriter(path, false, Encoding.UTF8)   10 bytes  EF BB BF EA B0 80 E3 81 82 41
… , Encoding.Unicode                            8 bytes  FF FE 00 AC 42 30 41 00

文字コードを省略すると BOM なし UTF-8、Encoding.UTF8 を明示すると BOM(EF BB BF)付き UTF-8 になります。「UTF-8 と明示したら CSV の先頭列名が一致しなくなった」という不具合の原因がここです。確実に BOM なしで書くなら new UTF8Encoding(false) を渡します。逆に、BOM がないと UTF-8 と判定しない古い Windows 版 Excel に渡すファイルなら、あえて Encoding.UTF8 を使います。

読むときにも差があります。

File.ReadAllText(BOM file).Length          = 3
Encoding.UTF8.GetString(bytes).Length      = 4  first=U+FEFF
File.ReadAllText(UTF-16 file) == text      = True

File.ReadAllText と StreamReader は BOM から文字コードを判定して BOM を取り除きます。一方、File.ReadAllBytes の後に Encoding.UTF8.GetString で変換すると U+FEFF が見えない先頭文字として残り、文字列比較や JSON の解析が失敗します。

古い Windows ソフトが出力した Shift_JIS(コードページ 932)のファイルは、.NET Core 以降は標準では読めません。

932: NotSupportedException: No data is available for encoding 932. For information on defining a custom encoding, see the documentation for the Encoding.RegisterProvider method.

起動時に Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); を1行入れれば解決します(.NET 10 では追加の NuGet パッケージなしでコンパイルできました)。登録後に "あいう" を Shift_JIS で保存すると 82 A0 82 A2 82 A4 の6バイトになり、UTF-8 として読むと ������ と化け、Encoding.GetEncoding(932) で読めば あいう に戻ります。

Path.Combine の罠

パスを + でつなぐなという理由で Path.Combine が勧められますが、Path.Combine にも罠があります。macOS での実行結果です。

string root = "/srv/app/uploads";
Path.Combine(root, "photo.jpg")                         // 1
Path.Combine(root, "/etc/passwd")                       // 2
Path.Join(root, "/etc/passwd")                          // 3
Path.Combine(root, "../../secret.txt")                  // 4
Path.GetFullPath(Path.Combine(root, "../../secret.txt"))// 5
Path.Combine("logs", @"2026\11\app.log")                // 6
1 /srv/app/uploads/photo.jpg
2 /etc/passwd
3 /srv/app/uploads/etc/passwd
4 /srv/app/uploads/../../secret.txt
5 /srv/secret.txt
6 logs/2026\11\app.log
  • 2:第2引数がルートパスだと、基準フォルダーが 丸ごと捨てられます。アップロードされたファイル名のようなユーザー入力を渡すと、任意のパスにアクセスされます。
  • 3:Path.Join はルートを捨てずに単純につなぎます。
  • 4・5:どちらも .. は防げません。正規化すると基準フォルダーの外です。
  • 6:Windows 式の \ は macOS・Linux では区切り文字ではなく ファイル名の一部 です。Windows で組み立てたパス文字列を Linux サーバーに持ち込むと、2026\11\app.log という名前のファイルが1つできます。

ユーザー入力からパスを作るときは、正規化してから基準フォルダーの中かを確認します。

static string SafeJoin(string baseDir, string userPath)
{
    string full = Path.GetFullPath(Path.Join(baseDir, userPath));
    string prefix = Path.GetFullPath(baseDir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
    if (!full.StartsWith(prefix, StringComparison.Ordinal))
        throw new UnauthorizedAccessException($"outside of base: {userPath}");
    return full;
}
SafeJoin(photo.jpg) -> /srv/app/uploads/photo.jpg
SafeJoin(/etc/passwd) -> /srv/app/uploads/etc/passwd
SafeJoin(../../secret.txt) -> outside of base: ../../secret.txt
SafeJoin(../uploads-evil/x) -> outside of base: ../uploads-evil/x

肝心なのは接頭辞の末尾に区切り文字を付けることです。付けないと /srv/app/uploads-evil も /srv/app/uploads で始まるので通ってしまいます。Windows ではパスの大文字小文字を区別しないので StringComparison.OrdinalIgnoreCase が適切です(この部分は Windows では実行していません)。

大きなファイルの読み込み実測:ReadAllLines・ReadLines・バッファ付きストリーム

一時フォルダーにログ形式のファイル(200万行、116.3MiB)を作り、,ERROR, を含む行(400,353行)を数えました。.NET 10.0.14、Release ビルド、Apple M1 Pro。方法ごとに別プロセスで、ウォームアップ後に5回測った中央値を取り、それを3回繰り返して中央値の中央値を載せています。最大メモリは /usr/bin/time -l の maximum resident set size(プロセス全体)、確保量は GC.GetTotalAllocatedBytes の差です。初回以降ファイルは OS のキャッシュに載っているので、ディスク速度ではなく .NET 側の処理コスト の比較です。

File.ReadAllLines(path).Count(l => l.Contains(",ERROR,"));
File.ReadLines(path).Count(l => l.Contains(",ERROR,"));
// 64KB バッファ + SequentialScan
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 16, FileOptions.SequentialScan);
using var r = new StreamReader(fs, Encoding.UTF8, false, 1 << 16);
while (r.ReadLine() is { } l) if (l.Contains(",ERROR,")) c++;
await foreach (var l in File.ReadLinesAsync(path)) …
File.ReadAllText(path).Split('\n').Count(…);
方法 時間(中央値) 3回の範囲 1回の確保量 最大メモリ
File.ReadAllLines 350ms 342〜414 321.9MiB 511〜568MiB
File.ReadLines 115ms 113〜133 274.6MiB 約50MiB
StreamReader 64KB バッファ 77ms 76〜104 274.8MiB 47〜50MiB
File.ReadLinesAsync 201ms 194〜202 417.9MiB 約51MiB
ReadAllText + Split 392ms 372〜586 756.1MiB 約804〜807MiB
読み込み時間の棒グラフ:File.ReadAllLines 350ms、File.ReadLines 115ms、64KB バッファの StreamReader 77ms、File.ReadLinesAsync 201ms、ReadAllText と Split 392ms。1行ずつ流して読むと、全部読み込むより3〜4倍速い。
200万行・116.3MiB のファイルで ERROR 行を数えた時間(OS キャッシュ済み)。5回測定の中央値をプロセス3回分取り、その中央値。.NET 10.0.14、Release ビルド、Apple Silicon Mac(M1 Pro)。最大メモリは ReadAllLines 511〜568MiB、ReadAllText+Split 約805MiB、ストリーム系は約50MiB。
  • 確保の総量は ReadLines と ReadAllLines でほぼ同じです(1行ごとに文字列を作る点は同じ)。差は その文字列を一度に抱えるかどうか です。ReadAllLines は200万個の文字列を配列で生かしたままにするので最大メモリが10倍以上になり、生きたオブジェクトを GC が移動させるため3倍遅くなりました。
  • ReadAllText + Split はファイル全体の文字列と行の配列を同時に持つため、最も重い結果でした。2021年ごろよく見た書き方ですが、大きなファイルでは避けてください。
  • バッファを64KB にした StreamReader が最速でした。今回のコードで ReadLines と違うのはバッファサイズと SequentialScan だけです。
  • ReadLinesAsync はメモリは同程度でしたが、1行ごとに非同期のステートマシンを通るため同期の ReadLines より約1.7倍遅くなりました。非同期は速さではなく スレッドを解放するため に使います。
判断図。問1:設定ファイルのような小さなファイルか。はいなら File.ReadAllText。いいえなら問2:行にインデックスで何度もアクセスするか。はいなら File.ReadAllLines(全行分のメモリを見込む)。いいえなら問3:スレッドを止められない UI・サーバーのコードか。はいなら await foreach で File.ReadLinesAsync。いいえなら File.ReadLines かバッファを大きくした StreamReader。
テキストファイルの読み込み API の選び方。決め手はサイズとアクセスのしかたで、非同期は速さではなくスレッドを止めないためのもの。

まとめ

状況 おすすめ
小さな設定ファイルを丸ごと File.ReadAllText / WriteAllText(文字コードを明示)
大きなファイルを1回なめる File.ReadLines かバッファを大きくした StreamReader
行にインデックスで何度もアクセス File.ReadAllLines(メモリに余裕があれば)
UI・サーバーでスレッドを止めない ReadAllLinesAsync、ReadLinesAsync、await using
BOM なし UTF-8 を保証 new UTF8Encoding(false)
Shift_JIS などのレガシーファイル CodePagesEncodingProvider を登録して GetEncoding(932)
ユーザー入力からパスを作る Path.Join + GetFullPath + 基準フォルダーの接頭辞チェック

I/O まわりの例外処理は C# の例外処理、行単位の処理に使う LINQ は LINQ の基礎 を参照してください。

検証環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14、macOS(Apple M1 Pro、APFS)。ファイルはすべて一時フォルダーに作成し、実行後に削除しました。Windows 固有の挙動(大文字小文字を区別しないパス、区切り文字の \)は Microsoft Learn の説明に従っており、Windows では実行していません。

よくある質問

File.ReadAllLines と File.ReadLines はどちらを使うべきですか?

1回なめて終わる処理なら ReadLines です。116MiB・200万行のファイルで ERROR 行を数えると、ReadAllLines は中央値 350ms・最大メモリ約 511〜568MiB、ReadLines は 115ms・約 50MiB でした(.NET 10、Release、Apple M1 Pro)。64KB バッファの StreamReader が 77ms で最速です。行にインデックスで何度もアクセスするときだけ ReadAllLines を選びます。

C# で UTF-8 保存すると BOM は付きますか?

書き方によります。File.WriteAllText(path, s) と new StreamWriter(path) は BOM なしで書き、Encoding.UTF8 を明示すると先頭に EF BB BF の3バイトが付きました。確実に BOM なしにするなら new UTF8Encoding(false) を渡します。読むときは File.ReadAllText が BOM を除去し、Encoding.UTF8.GetString は U+FEFF を文字として残しました。

Path.Combine にユーザー入力を渡しても安全ですか?

安全ではありません。第2引数が /etc/passwd のようなルートパスだと基準フォルダーが丸ごと捨てられ、../../ はそのまま残って上位フォルダーへ抜けます。Path.Join はルートを捨てませんが .. は防げないので、Path.GetFullPath で正規化してから基準フォルダーで始まるかを検査する必要があります。

  • #C#
  • #ファイル入出力
  • #StreamReader
  • #文字コード
  • #.NET 10

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑