不能直接用“协程”这个词描述 C# 的异步文件 IO,但可以用 async
/await
实现等效的非阻塞行为——它比协程更可靠、更贴合 .NET 运行时模型。
为什么 C# 没有传统意义的协程
.NET 不提供用户态协程调度器(如 Lua 的
coroutine或 Go 的 goroutine),
Task和
async/
await是基于线程池 + I/O 完成端口(IOCP)的异步抽象,不是协程切换。强行套用“协程”概念容易误解执行模型和资源开销。 所谓“协程挂起”在 C# 中实际是:方法返回
Task,控制权交还调用方,内核级异步操作(如
FileStream.ReadAsync)由 IOCP 在后台完成,完成后通过同步上下文或线程池回调恢复执行 没有栈保存/恢复、没有用户定义的 yield 点;
await是编译器重写的状态机,不是运行时协程调度 试图用
yield return+
IEnumerable模拟协程做文件读取?会阻塞线程且无法真正异步——
yield return不触发 I/O,只是延迟枚举
正确做法:用 FileStream
+ ReadAsync
/WriteAsync
这是 Windows/Linux/macOS 上真正非阻塞、可扩展的文件 IO 方式,底层走 IOCP(Windows)或
epoll/
kqueue(Unix-like),不消耗线程。 必须用
new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true),
useAsync: true是关键,否则即使调用
ReadAsync也会退化为同步读+线程池伪造 避免用
File.ReadAllTextAsync等封装方法处理大文件——它们内部仍会一次性分配完整缓冲区,可能触发 GC 压力或 OOM;应分块
ReadAsync+ 处理 不要在 UI 线程(如 WinForms/WPF)中
Wait()或
Result一个文件
Task,必然死锁;一律用
await
示例片段:
var stream = new FileStream("log.bin", FileMode.Open, FileAccess.Read, FileShare.Read, 8192, useAsync: true);
var buffer = new byte[8192];
int read = await stream.ReadAsync(buffer, 0, buffer.Length); // 真异步,不占线程常见错误:误以为 Task.Run
+ 同步 IO = 异步
这是最典型的“假异步”,不仅没解决阻塞,还额外增加线程池负担和上下文切换开销。
Task.Run(() => File.ReadAllBytes("huge.zip")):把同步读扔进线程池,线程被卡住,吞吐量随并发数线性下降
尤其在 ASP.NET Core 中,这会快速耗尽线程池,导致请求排队甚至超时;而真正的 ReadAsync可轻松支撑数万并发文件读 如果 API 只提供同步方法(比如某些第三方库),且你无法改源码,那确实只能
Task.Run—— 但这属于无奈兜底,不是设计选择
兼容性与性能要点
.NET 5+ 默认启用
useAsync: true(即构造
FileStream时省略该参数也行),但 .NET Core 3.1 及更早版本默认为
false,必须显式传入。 Linux/macOS 下,
useAsync: true在 .NET 6+ 才真正使用
io_uring(需内核 5.13+),此前仍走线程池模拟;若追求极致性能,得关注运行时版本和 OS 支持
MemoryMappedFile是另一条路,适合超大文件随机访问,但它本身是同步 API,配合
Task.Run使用仍是假异步;真异步映射目前无原生支持 调试时留意
System.IO.IOException: The handle is invalid—— 很可能是
FileStream被提前
Dispose,而
ReadAsync还在跑;用
using await(C# 8+)或确保生命周期覆盖整个异步操作
真正难的不是写
await,而是理解什么时候该让 IO 走内核异步路径、什么时候不该碰线程池。这点搞错,再多的
async关键字也没用。
