c# 无传统协程,其 async/await 基于 iocp/epoll/kqueue 实现真异步文件 io,需显式启用 useasync: true、分块读取、避免 task.run 同步 io,否则将导致线程阻塞或性能退化。

不能直接用“协程”这个词描述 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 关键字也没用。










