dotnet-file-io について
dotnet-file-ioは、.NETアプリケーションのファイルI/Oパターンを扱うスキルです。非同期・オフセット指定アクセス、ファイル監視、メモリマッピング、パスの安全性、クロスプラットフォームの挙動を解説します。
主な機能
- 01FileStreamの非同期アクセス設定
- 02RandomAccessによる並行オフセットI/O
- 03FileSystemWatcherイベントの処理と集約
- 04大容量ファイルやIPCでのMemoryMappedFile利用
- 05パスと一時ファイルの安全な扱い
- 06プラットフォーム差異とI/Oエラーへの対応
活用シーン
- 01.NETアプリでファイルを非同期に読み書き
- 02ファイルの異なるオフセットへ並行アクセス
- 03頻繁なファイルシステム変更への確実な対応
- 04ユーザー指定パスを扱う際のパストラバーサル防止
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-file-ioインストール後、Claude Code で/dotnet-file-ioを入力して読み込まれたか確認します。
詳細
dotnet-file-io
.NET アプリケーション向けのファイル I/O パターンです。非同期フラグを指定した FileStream の作成、スレッドセーフなオフセットベースの I/O に使う RandomAccess API、File の便利なメソッド、FileSystemWatcher のイベント処理とデバウンス、大容量ファイルや IPC に使う MemoryMappedFile、パス処理のセキュリティ(Combine と Join の違い)、安全な一時ファイルの作成、クロスプラットフォームでの考慮事項、IOException の階層、バッファーサイズのガイダンスを扱います。
対象外: PipeReader/PipeWriter とネットワーク I/O については、[skill:dotnet-io-pipelines] を参照してください。Async/await の基礎については、[skill:dotnet-csharp-async-patterns] を参照してください。Span/Memory/ArrayPool の高度なパターンについては、[skill:dotnet-performance-patterns] を参照してください。JSON と Protobuf のシリアル化については、[skill:dotnet-serialization] を参照してください。BackgroundService のライフサイクルについては、[skill:dotnet-background-services] を参照してください。Channel のプロデューサー/コンシューマーについては、[skill:dotnet-channels] を参照してください。ピン留めされた配列やメモリマップドのバッキング配列が GC に与える影響については、[skill:dotnet-gc-memory] を参照してください。ファイルアップロードの検証(IFormFile)については、[skill:dotnet-input-validation] を参照してください。
テスト可能なファイルシステムアクセスには、System.IO.Abstractions の利用を検討してください。テストの分離パターンについては、[skill:dotnet-testing-strategy] を参照してください。
相互参照:[skill:dotnet-io-pipelines] は PipeReader/PipeWriter を使ったネットワーク I/O、[skill:dotnet-gc-memory] は POH とメモリマップドのバッキング配列が GC に与える影響、[skill:dotnet-performance-patterns] は Span/Memory の基礎と ArrayPool の使用方法、[skill:dotnet-csharp-async-patterns] はファイルストリームで使う async/await のパターンについて説明しています。
FileStream
非同期フラグの要件
FileStream の非同期メソッド(ReadAsync、WriteAsync)は、ストリームを非同期フラグ付きで開かないと、呼び出し元のスレッドを暗黙的にブロックします。これは .NET コードで最もよくあるファイル I/O の誤りです。
// CORRECT: async-capable FileStream
await using var fs = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
useAsync: true); // Required for true async I/O
byte[] buffer = new byte[4096];
int bytesRead = await fs.ReadAsync(buffer, cancellationToken);
// ALSO CORRECT: FileOptions overload
await using var fs = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 4096,
FileOptions.Asynchronous | FileOptions.SequentialScan);
useAsync: true または FileOptions.Asynchronous を指定しないと、ランタイムは同期 I/O をスレッドプールにディスパッチして非同期処理をエミュレートするため、スレッドを無駄にし、オーバーヘッドも増加します。
FileStreamOptions(.NET 6+)
await using var fs = new FileStream(path, new FileStreamOptions
{
Mode = FileMode.Open,
Access = FileAccess.Read,
Share = FileShare.Read,
Options = FileOptions.Asynchronous | FileOptions.SequentialScan,
BufferSize = 4096,
PreallocationSize = 1_048_576 // Hint for write: reduces fragmentation
});
PreallocationSize はファイルの作成時や上書き時にディスク領域をあらかじめ確保し、書き込み時のファイルシステムの断片化を抑えます。
RandomAccess API(.NET 6+)
RandomAccess は、静的なオフセットベースでスレッドセーフなファイル I/O を提供します。FileStream とは異なり、内部に位置状態を持たないため、複数のスレッドが同期なしで異なるオフセットを同時に読み書きできます。
using var handle = File.OpenHandle(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
FileOptions.Asynchronous);
// Thread-safe: offset is explicit, no shared position
byte[] buffer = new byte[4096];
int bytesRead = await RandomAccess.ReadAsync(
handle, buffer, fileOffset: 0, cancellationToken);
// Read from a different offset concurrently -- no locking needed
byte[] buffer2 = new byte[4096];
int bytesRead2 = await RandomAccess.ReadAsync(
handle, buffer2, fileOffset: 8192, cancellationToken);
スキャッター/ギャザー I/O
// Read into multiple buffers in a single syscall
IReadOnlyList<Memory<byte>> buffers = new[]
{
new byte[4096].AsMemory(),
new byte[4096].AsMemory()
};
long totalRead = await RandomAccess.ReadAsync(
handle, buffers, fileOffset: 0, cancellationToken);
RandomAccess と FileStream の使い分け
| シナリオ | 使用するもの |
|---|---|
| 異なるオフセットからの同時読み取り | RandomAccess |
| 順次ストリーミングでの読み書き | FileStream |
| インデックスファイル、データベースページ、メモリマップドの代替 | RandomAccess |
| Stream ベースの API との統合 | FileStream |
File の便利なメソッド
ストリーミングが不要な小さなファイルには、File の静的メソッドを使うと、より簡潔に正しく処理できます。
// Read entire file as string (small files only)
string content = await File.ReadAllTextAsync(path, cancellationToken);
// Read all lines
string[] lines = await File.ReadAllLinesAsync(path, cancellationToken);
// Stream lines without loading entire file (.NET 8+)
await foreach (string line in File.ReadLinesAsync(path, cancellationToken))
{
ProcessLine(line);
}
// Write text atomically (write-then-rename pattern not built-in)
await File.WriteAllTextAsync(path, content, cancellationToken);
// Read all bytes
byte[] data = await File.ReadAllBytesAsync(path, cancellationToken);
便利なメソッドが適している場合
| ファイルサイズ | 方法 |
|---|---|
| < 1 MB | File.ReadAllTextAsync / File.ReadAllBytesAsync |
| 1--100 MB | File.ReadLinesAsync またはバッファー読み取りを使う FileStream |
| > 100 MB | 明示的なバッファー管理を使う FileStream または RandomAccess |
FileSystemWatcher
基本的な設定
using var watcher = new FileSystemWatcher(directoryPath)
{
Filter = "*.json",
NotifyFilter = NotifyFilters.FileName
| NotifyFilters.LastWrite
| NotifyFilters.Size,
IncludeSubdirectories = true,
EnableRaisingEvents = true
};
watcher.Changed += OnChanged;
watcher.Created += OnCreated;
watcher.Deleted += OnDeleted;
watcher.Renamed += OnRenamed;
watcher.Error += OnError;
重複イベントのデバウンス
FileSystemWatcher は、1 回の論理的な変更に対して重複イベントを発生させます(エディターは一時ファイルへの書き込み、名前変更、古いファイルの削除を行います)。タイマーを使ってデバウンスしてください。
public sealed class DebouncedFileWatcher : IDisposable
{
private readonly FileSystemWatcher _watcher;
private readonly Channel<string> _channel;
private readonly CancellationTokenSource _cts = new();
public DebouncedFileWatcher(string path, string filter)
{
_channel = Channel.CreateBounded<string>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropOldest
});
_watcher = new FileSystemWatcher(path, filter)
{
EnableRaisingEvents = true
};
_watcher.Changed += (_, e) =>
_channel.Writer.TryWrite(e.FullPath);
}
// requires: using System.Runtime.CompilerServices;
public async IAsyncEnumerable<string> WatchAsync(
TimeSpan debounce,
[EnumeratorCancellation] CancellationToken ct = default)
{
using var linked = CancellationTokenSource
.CreateLinkedTokenSource(ct, _cts.Token);
var seen = new Dictionary<string, DateTime>();
await foreach (var path in
_channel.Reader.ReadAllAsync(linked.Token))
{
var now = DateTime.UtcNow;
if (seen.TryGetValue(path, out var last)
&& now - last < debounce)
continue;
seen[path] = now;
yield return path;
}
}
public void Dispose()
{
_cts.Cancel();
_watcher.Dispose();
_cts.Dispose();
}
}
バッファーオーバーフロー
内部バッファーのデフォルトは 8 KB です。短時間に多数の変更が発生すると、バッファーがオーバーフローしてイベントが失われます。InternalBufferSize を使ってサイズを増やし(Windows では最大 64 KB)、Error イベントを処理してください。
watcher.InternalBufferSize = 65_536; // 64 KB
watcher.Error += (_, e) =>
{
if (e.GetException() is InternalBufferOverflowException)
{
logger.LogWarning("FileSystemWatcher buffer overflow -- events lost");
// Trigger full directory rescan
}
};
プラットフォームごとの差異
| プラットフォーム | バックエンド | 主な動作 |
|---|---|---|
| Windows | ReadDirectoryChangesW | 最も信頼性が高く、最大 64 KB の InternalBufferSize をサポートします |
| Linux | inotify | ユーザーごとに監視数の上限があります(/proc/sys/fs/inotify/max_user_watches)。再帰的な監視では、サブディレクトリごとに inotify の監視を 1 つ追加します |
| macOS | FSEvents (kqueue fallback) | イベントの粒度が粗く、わずかな遅延を伴ってイベントがまとめて通知されることがあります |
MemoryMappedFile
永続化(大容量ファイルへのアクセス)
ファイルを仮想メモリにマップすると、明示的な読み取り/書き込み呼び出しなしでランダムアクセスできます。メモリに収まらない大容量ファイルに効率的です。必要に応じて OS がデータをページ単位でメモリに読み込み、メモリから書き出します。
using var mmf = MemoryMappedFile.CreateFromFile(
path,
FileMode.Open,
mapName: null,
capacity: 0, // Use file's actual size
MemoryMappedFileAccess.Read);
using var accessor = mmf.CreateViewAccessor(
offset: 0,
size: 0, // Map entire file
MemoryMappedFileAccess.Read);
// Read a struct at a specific offset
accessor.Read<MyHeader>(position: 0, out var header);
// Or use a view stream for sequential access
using var stream = mmf.CreateViewStream(
offset: 0,
size: 4096,
MemoryMappedFileAccess.Read);
非永続化(IPC 共有メモリ)
// Process A: create shared memory region
using var mmf = MemoryMappedFile.CreateNew(
"SharedRegion",
capacity: 1_048_576, // 1 MB
MemoryMappedFileAccess.ReadWrite);
using var accessor = mmf.CreateViewAccessor();
accessor.Write(0, 42);
// Process B: open existing shared memory region
using var mmf2 = MemoryMappedFile.OpenExisting(
"SharedRegion",
MemoryMappedFileRights.Read);
using var accessor2 = mmf2.CreateViewAccessor(
0, 0, MemoryMappedFileAccess.Read);
int value = accessor2.ReadInt32(0); // 42
メモリマップされたバッキング配列と POH の使用に伴う GC への影響については、[skill:dotnet-gc-memory] を参照してください。
パスの処理
Path.Combine と Path.Join のセキュリティ
2 番目の引数がルート付きパスの場合、Path.Combine は 1 番目の引数を黙って破棄します。ユーザー入力を 2 番目の引数に渡すと、パストラバーサル攻撃を許す可能性があります。
// DANGEROUS: Path.Combine drops basePath when userInput is rooted
string basePath = "/app/uploads";
string userInput = "/etc/passwd";
string result = Path.Combine(basePath, userInput);
// result = "/etc/passwd" -- basePath is silently ignored
// SAFER: Path.Join does not discard on rooted paths (.NET Core 2.1+)
string result2 = Path.Join(basePath, userInput);
// result2 = "/app/uploads//etc/passwd" -- preserves basePath
パストラバーサルの防止
public static string SafeResolvePath(string basePath, string userPath)
{
// Resolve to absolute, resolving ../ and symlinks
string fullBase = Path.GetFullPath(basePath);
string fullPath = Path.GetFullPath(
Path.Join(fullBase, userPath));
// OrdinalIgnoreCase: safe cross-platform default.
// On Linux-only deployments, Ordinal is more precise.
if (!fullPath.StartsWith(fullBase + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase)
&& !fullPath.Equals(fullBase, StringComparison.OrdinalIgnoreCase))
{
throw new UnauthorizedAccessException(
"Path traversal detected");
}
return fullPath;
}
クロスプラットフォームのパス区切り文字
/ や \\ をハードコードする代わりに、Path.DirectorySeparatorChar(プラットフォーム固有)と Path.AltDirectorySeparatorChar を使用してください。Path.Join と Path.Combine は区切り文字の正規化を自動的に処理します。
セキュアな一時ファイル
Path.GetTempFileName() は予測可能な名前のパターンで 0 バイトのファイルを作成し、一時ディレクトリ内に .tmp ファイルが 65,535 個あると例外をスローします。代わりに Path.GetRandomFileName() を使用してください。
// INSECURE: predictable name, may throw IOException
// string tempFile = Path.GetTempFileName();
// SECURE: cryptographically random name, explicit creation
string tempPath = Path.Join(
Path.GetTempPath(),
Path.GetRandomFileName());
// CreateNew ensures atomic creation -- fails if file exists
await using var fs = new FileStream(
tempPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 4096,
FileOptions.Asynchronous | FileOptions.DeleteOnClose);
await fs.WriteAsync(data, cancellationToken);
FileOptions.DeleteOnClose を指定すると、ストリームを閉じたときに一時ファイルが削除されます。Windows では、最後のハンドルが閉じられたときに OS が削除を保証します。Linux/macOS では Dispose の実行中に削除されますが、プロセスが突然終了した場合(SIGKILL)には削除されないことがあります。
クロスプラットフォームでの考慮事項
大文字と小文字の区別
| プラットフォーム | デフォルトのファイルシステム | 大文字と小文字の動作 |
|---|---|---|
| Windows | NTFS | 大文字と小文字を保持し、区別しません |
| macOS | APFS | 大文字と小文字を保持し、区別しません(デフォルト) |
| Linux | ext4/xfs | 大文字と小文字を区別します |
クロスプラットフォームで動作する必要があるパス比較には、StringComparison.OrdinalIgnoreCase を使用してください。大文字と小文字の扱いを決めつけず、必要に応じて実行時に確認してください。
UnixFileMode(.NET 7+)
// Set POSIX permissions on file creation (Linux/macOS only)
await using var fs = new FileStream(path, new FileStreamOptions
{
Mode = FileMode.Create,
Access = FileAccess.Write,
UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite
// 0600 -- owner read/write only
});
Windows では UnixCreateMode は黙って無視されます。Windows のセキュリティ制御として使用せず、代わりに ACL または Windows セキュリティ API を使用してください。
ファイルロック
| プラットフォーム | ロックの種類 | 動作 |
|---|---|---|
| Windows | 強制ロック | 他のプロセスはロックされた領域を読み書きできません |
| Linux | アドバイザリロック(flock/fcntl) | ロックは協調的なもので、プロセスは無視できます |
| macOS | アドバイザリロック(flock) | Linux と同様に協調的です |
FileShare フラグは Windows での共有を制御します。Linux/macOS ではアドバイザリロックに FileStream.Lock を使用しますが、ロックに協調しないプロセスは回避できることに注意してください。
エラー処理
IOException の階層
IOException
+-- FileNotFoundException
+-- DirectoryNotFoundException
+-- PathTooLongException
+-- DriveNotFoundException
+-- EndOfStreamException
+-- FileLoadException
特定の条件に対する HResult コード
try
{
await using var fs = new FileStream(path,
FileMode.Open, FileAccess.Read);
// ...
}
catch (IOException ex) when (ex.HResult == unchecked((int)0x80070070))
{
// ERROR_DISK_FULL (Windows) -- disk full
logger.LogError("Disk full: {Path}", path);
}
catch (IOException ex) when (ex.HResult == unchecked((int)0x80070020))
{
// ERROR_SHARING_VIOLATION -- file locked by another process
logger.LogWarning("File locked: {Path}", path);
}
catch (UnauthorizedAccessException ex)
{
// Permission denied (not an IOException subclass)
logger.LogError("Access denied: {Path}", path);
}
ディスク容量不足時の Flush の動作
書き込み操作は成功したように見えても、データをメモリにバッファーしている場合があります。ディスク容量不足の状態は、Write の呼び出し時ではなく、Flush または Dispose の実行時に発生することがあります。フラッシュ時の例外を必ず確認してください。
await using var fs = new FileStream(path,
FileMode.Create, FileAccess.Write,
FileShare.None, 4096, FileOptions.Asynchronous);
await fs.WriteAsync(data, cancellationToken);
// IOException (disk full) may throw here, not at WriteAsync
await fs.FlushAsync(cancellationToken);
バッファーサイズ
dotnet/runtime のベンチマークと内部の FileStream 実装に基づくガイダンスです。
| ファイル操作 | 推奨バッファー | 理由 |
|---|---|---|
| 小さなファイルのシーケンシャル読み取り(< 1 MB) | 4 KB(デフォルト) | OS のページサイズと一致し、FileStream のデフォルトです |
| 大きなファイルのシーケンシャル読み取り(> 1 MB) | 64--128 KB | システムコールのオーバーヘッドを分散します。128 KB を超えると効果は逓減します |
| ネットワーク接続ストレージ(NFS/SMB) | 64 KB | 大きなバッファーによってネットワークの往復回数を分散できます |
| SSD のランダムアクセス | 4 KB | SSD のページサイズと一致し、大きなバッファーでは先読みが無駄になります |
| FileStream のシーケンシャルスキャン | 4 KB + FileOptions.SequentialScan | OS の先読み機能でプリフェッチされます |
FileOptions.SequentialScan は、読み取り位置の先のデータをプリフェッチするよう OS にヒントを与えます。シーケンシャル読み取りには有効ですが、ランダムアクセスのパターンではパフォーマンスが低下することがあります。
エージェント利用時の注意点
useAsync: trueを指定せずに FileStream の非同期メソッドを使わないでください -- 非同期フラグを指定しない場合、ReadAsync/WriteAsyncは同期 I/O をスレッドプールにディスパッチするため、スレッドをブロックし、オーバーヘッドが増加します。必ずuseAsync: trueまたはFileOptions.Asynchronousを指定してください。- 信頼できない入力に
Path.Combineを使わないでください -- 2 番目の引数がルート化されている場合、Path.Combineはベースパスを暗黙に破棄するため、パストラバーサルが可能になります。Path.Join(.NET Core 2.1 以降)を使い、解決されたパスが意図したベースディレクトリ配下にあることを検証してください。 Path.GetTempFileName()を使わないでください -- 予測可能なファイル名が作成され、65,535 個のファイルがあると例外が発生します。安全かつアトミックに一時ファイルを作成するには、FileMode.CreateNewとPath.GetRandomFileName()を使ってください。- FileSystemWatcher の重複イベントを無視しないでください -- エディターやツールは、1 回の論理的な変更に対して複数のイベントを発生させます。タイマーまたは Channel によるスロットリングを使ってデバウンスを実装してください。
- 信頼性の高い変更検出を FileSystemWatcher だけに頼らないでください -- バッファーオーバーフローが発生すると、イベントが通知されないまま失われます。
Errorイベントを処理し、フォールバックとして定期的な再スキャンを実装してください。 - Linux/macOS ではファイルロックが強制されると思い込まないでください -- Unix では
FileStream.LockとFileShareフラグはアドバイザリロックを使用するため、ロックに協力しないプロセスはこれを回避できます。それに応じたプロトコルを設計してください。 IOExceptionだけを捕捉し、UnauthorizedAccessExceptionを無視しないでください -- 権限エラーはIOExceptionを継承しないUnauthorizedAccessExceptionをスローします。ファイルアクセスのエラー処理では両方を扱ってください。WriteAsyncがディスク容量不足のエラーをすぐに報告すると思い込まないでください -- データがバッファーに保持されている場合があります。ディスク容量不足によるIOExceptionはFlushAsyncまたはDisposeの際に発生することがあります。フラッシュ時の例外は必ず処理してください。