用 FileStream
配合缓冲区手动读写,别直接用 File.ReadAllText
高频小文件读写时,
File.ReadAllText和
File.WriteAllText看似方便,实则每次调用都分配新字符串、触发隐式编码转换、还绕不开内部临时
byte[]缓冲区——这些全是 GC 的“饲料”。尤其在循环中反复调用,会快速堆积 Gen 0 对象。
实操建议:
对已知大小或可预估长度的文件,用FileStream+ 栈上缓冲区(如
stackalloc byte[4096])或复用
ArrayPool<byte>.Shared.Rent()</byte>; 读文本时,优先用
StreamReader并传入复用的
byte[]缓冲区,避免它自己 new; 写文件时,用
StreamWriter构造函数指定
bufferSize(如 8192),并设
leaveOpen = true避免重复关闭底层流。
Span<byte></byte>
和 Memory<byte></byte>
能省掉哪些堆分配
从 .NET Core 2.1 起,文件 I/O API 大量支持
Span<t></t>,比如
FileStream.Read(Span<byte>)</byte>、
Stream.Write(ReadOnlySpan<byte>)</byte>。它们不产生数组引用,也不触发堆分配,直接操作栈内存或已有数组片段。
常见错误现象:把
Span<byte></byte>转成
byte[]再传给老接口,等于白换;或者在异步方法里捕获局部
Span<byte></byte>到 lambda 中——编译器会直接报错,因为
Span不能逃逸到堆。
使用场景:
解析日志行、CSV 片段、二进制协议头等固定结构数据,全程用Span<byte></byte>切片 +
Utf8Parser; 配合
Encoding.UTF8.GetChars(ReadOnlySpan<byte>, Span<char>)</char></byte>解码,避免生成中间
string; 注意
Memory<byte></byte>可以跨 await 边界,但背后若基于
ArrayPool,记得
.Dispose()或
.Return()归还。
异步文件操作不是万能解药,小心 async/await
带来的状态机开销
FileStream.ReadAsync在大文件或高吞吐场景确实能释放线程,但每次 await 都会生成一个状态机对象(Gen 0),如果每毫秒都读一次小块数据,GC 压力反而比同步+缓冲更大。
性能影响:
短时高频小读写(如配置热重载、监控采样),同步 + 大缓冲区 + 复用FileStream实例更稳; 真正耗时操作(> 10ms,如 GB 级日志归档),才值得上
ReadAsync; 不要对单个
FileStream多路并发
ReadAsync——Windows 上会退化为同步模拟,Linux 上可能触发
epoll误判,不如分拆成多个流或改用
MemoryMappedFile。
FileOptions.Asynchronous
必须和 FileStream
构造绑定生效
很多人以为只要调用
ReadAsync就自动走 IOCP,其实不然。如果创建
FileStream时没传
FileOptions.Asynchronous,即使后续调用异步方法,底层仍是同步读+线程池线程模拟,白白增加调度开销。
关键点:
必须显式传参:new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous); 缓冲区大小(第 5 个参数)建议设为磁盘扇区对齐值(通常 4096),否则系统可能额外拷贝; 这个 flag 在 Linux 上无效(.NET 6+ 用
io_uring自动优化),但 Windows 下漏掉就彻底失去异步优势。
GC 压力大的根因往往不在“用了多少 string”,而在于“谁在替你悄悄 new 数组”。缓存流实例、复用缓冲区、堵死 Span 逃逸路径——这几件事做扎实了,Gen 0 次数通常能砍掉七成以上。
