关于 dotnet-file-io
dotnet-file-io 是介绍 .NET 应用文件 I/O 模式的技能,涵盖异步和按偏移访问、文件监视、内存映射、路径安全及跨平台行为。
主要功能
- 01配置异步 FileStream 访问
- 02使用 RandomAccess 并发进行按偏移 I/O
- 03处理并合并 FileSystemWatcher 事件
- 04用 MemoryMappedFile 访问大文件和实现 IPC
- 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);
Scatter/Gather 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 可能会触发重复事件(编辑器会写入临时文件、重命名文件并删除旧文件)。使用计时器进行防抖。
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 监视 |
| macOS | FSEvents (kqueue fallback) | 事件粒度较粗;可能会稍有延迟地批量处理事件 |
MemoryMappedFile
持久化(大型文件访问)
将文件映射到虚拟内存,以便随机访问,而无需显式调用读写操作。对于无法完全装入内存的大型文件,此方式效率很高——操作系统会按需将数据分页调入和调出。
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 的安全性
当第二个参数是根路径时,Path.Combine 会静默丢弃第一个参数。如果将用户输入作为第二个参数传入,就可能引发路径遍历攻击。
// 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() 会创建一个名称模式可预测的零字节文件;当临时目录中包含 65,535 个 .tmp 文件时会抛出异常。请改用 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 上,操作系统会保证最后一个句柄关闭时删除文件。在 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 或 Dispose 时才显现,而不是在调用 Write 时显现。刷新时务必检查是否抛出异常。
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 预取读取位置之后的数据。它有利于顺序读取,但可能降低随机访问模式下的性能。
Agent 注意事项
- 不要在未设置
useAsync: true的情况下使用 FileStream 异步方法 —— 未启用异步标志时,ReadAsync/WriteAsync会将同步 I/O 分派到线程池,导致线程阻塞并增加开销。始终传入useAsync: true或FileOptions.Asynchronous。 - 不要将
Path.Combine与不可信输入一起使用 —— 如果第二个参数是根路径,Path.Combine会静默丢弃基础路径,从而导致路径遍历。请使用Path.Join(.NET Core 2.1+),并验证解析后的路径位于预期的基础目录下。 - 不要使用
Path.GetTempFileName()—— 它会创建可预测的文件名,并在文件数达到 65,535 时抛出异常。请使用Path.GetRandomFileName()和FileMode.CreateNew,以安全、原子地创建临时文件。 - 不要忽略 FileSystemWatcher 的重复事件 —— 编辑器和工具可能会因一次逻辑更改触发多个事件。请使用计时器或 Channel 节流来实现防抖。
- 不要仅依赖 FileSystemWatcher 来可靠地检测更改 —— 缓冲区溢出会导致事件静默丢失。请处理
Error事件,并将定期重新扫描作为后备方案。 - 不要假设文件锁定在 Linux/macOS 上是强制性的 —— 在 Unix 上,
FileStream.Lock和FileShare标志使用建议锁定,不遵守协议的进程可以绕过它。请据此设计协议。 - 不要只捕获
IOException而忽略UnauthorizedAccessException—— 权限错误会抛出UnauthorizedAccessException,它不继承自IOException。请在文件访问错误处理中同时处理这两种异常。 - 不要假设
WriteAsync会立即报告磁盘已满错误 —— 数据可能已缓冲。磁盘已满导致的IOException可能会在FlushAsync或Dispose时出现。请始终处理刷新时的异常。