异步文件处理必须用 Stream
而不是 FileStream
直接 await
很多人一上来就写
await fileStream.ReadAsync(...),结果发现卡主线程或吞吐掉得厉害——根本原因是没理解
FileStream默认不开启异步 I/O。Windows 上它底层走的是同步模拟(APC 模式),Linux/macOS 更是直接退化成同步阻塞。
真正可伸缩的异步链,起点必须是显式启用异步支持的
FileStream构造: 构造时传入
FileOptions.Asynchronous(Windows 必须,.NET 6+ 在 Linux/macOS 也建议加上) 避免用
File.OpenRead(path)这类工厂方法,它们默认不带
Asynchronous别在
using var fs = new FileStream(...)里混用同步和异步方法,容易触发隐式同步回退
示例正确打开方式:
var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
责任链节点必须返回 ValueTask<t></t>
而非 Task<t></t>
文件管道常被高频调用(比如日志归档、上传中转),每一步都 new
Task会快速堆积 GC 压力。而
ValueTask<t></t>在多数路径下能复用对象、避免堆分配。
但注意:只有当你确定节点逻辑绝大多数时候是同步完成(如内存解码、头信息校验),或者内部已用
ValueTask包装了底层 I/O(如
MemoryStream.ReadAsync),才适合暴露
ValueTask。 不要把
async Task<t> DoX()</t>简单改成
async ValueTask<t> DoX()</t>—— 编译器仍会生成
Task状态机 真正要改的是:用
return new ValueTask<t>(result)</t>或
return _innerStream.ReadAsync(...)(后者由
Stream实现决定是否复用) 链上所有节点类型必须统一,混用
Task和
ValueTask会导致
await无法推导,编译报错
插拔式设计靠 Func<stream valuetask>></stream>
而非接口继承
想加个压缩环节?再加个加密?用传统接口(
IFileProcessor)会逼你为每个环节写新类、注册、维护生命周期。实际文件流是线性传递的,函数签名就是最轻量契约。
定义管道核心类型:
public record FilePipeline(Func<Stream, ValueTask<Stream>>[] Steps);每个步骤接收前序输出的
Stream,返回处理后的
Stream(可以是新实例,也可以是原实例 + 内部状态变更) 避免在步骤里
Dispose输入流——责任链不负责资源释放,交给最外层调用者 若某步骤需提前终止(如校验失败),抛出异常即可;不要返回
null流,那会引发后续
NullReferenceException调试时可在任意步骤包一层日志:
s => { Log("decrypting..."); return DecryptAsync(s); }
容易被忽略的流生命周期陷阱
异步链跑着跑着
ObjectDisposedException,十有八九是某个环节偷偷把流关了,或者多个步骤并发读同一份流。
MemoryStream是线程安全的读,但
FileStream不是——别让两个并行步骤同时
ReadAsync同一个
FileStream所有中间
Stream(如
GzipStream、
CryptoStream)必须设置
leaveOpen: true,否则
Dispose会连带关闭上游 最终输出流如果要保存到磁盘,别用
CopyToAsync(dest)后直接
Dispose源流——应确保
CopyToAsync完成后再释放,否则可能丢尾部数据
最稳的做法:整个链用同一个
Stream实例贯穿,只在必要环节包装(如
new CryptoStream(inner, ..., CryptoStreamMode.Read) { leaveOpen = true }),最后由调用方统一 Dispose。
