1. 介绍
想象你是乐队指挥(你的应用)。如果每次小提琴手调音(耗时的 I/O)你都等着,才能继续其他乐器,整个乐队就会停。但如果小提琴手说:“我去调音,你们先继续,我准备好会打个信号”,——这就是异步!
在 C# 和 .NET 9 世界里有专门的工具实现这种“多任务但不卡住”的效果。今天的主角就是 Read 和 Write 的异步版本:ReadAsync 和 WriteAsync。
它们让你可以启动读或写操作并立即“放开”当前执行流,让它去做别的事。等 I/O 操作完成(比如数据从盘读出来或写进盘里),你的代码会“醒过来”,从停下的地方继续执行。
要用这些方法,我们需要两个“魔法词”,它们从 2012 年随 C# 5.0 引入(现在 C# 14 已经很熟练地用了):
- async:这是你加在方法上的修饰词,告诉编译器:“这个方法里可能有异步操作,我会用 await”。
- await:这是你在异步操作(比如 ReadAsync 或 WriteAsync)前面用的操作符。意思是:“开始这个操作,但不要在这里等它完成。把控制权交还给调用方,等操作完成再回来。”
别担心,如果这些概念现在看起来有点模糊。我们会有专门一节深入讲 async 和 await(第 58 级),会把细节拆开讲。现在重要的是理解:它们帮我们“不阻塞”主线程。
2. ReadAsync:慢慢读取
方法 ReadAsync 允许异步从流读取数据。不需要一直等字节从磁盘读完,你发起读取就可以马上切换去做其他任务。
它读到缓冲区的主要签名长这样:
public virtual ValueTask<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
或者,在现代 C#(和 .NET 9)中更常用的,用 Memory<byte> 的版本:
public virtual ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default
)
参数解释:
- buffer:这是一个 byte 数组(或 Memory<byte>),数据会读到这里。记住,我们之前说过缓冲用于优化?这里在异步场景下也是一样。
- offset:在 buffer 里开始写入读到的字节的偏移位置。
- count:要读的最多字节数。
- CancellationToken cancellationToken:很有用的参数,允许在不需要时取消操作(比如用户关了应用或按了 “取消” 按钮)。
- ValueTask<int>:这是个“承诺”,表示操作完成时会返回一个 int,等于读取到的字节数。ValueTask 是比 Task 更省的选择,适用于结果可能同步可用或异步可用的场景。
示例 1:异步读取文件
假设我们有个大文本文件,想在不阻塞主线程的情况下读取它。基础示例:
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
// 异步函数:读取文件并统计行数
public static async Task<int> CountLinesAsync(string filePath)
{
int lineCount = 0;
// 异步打开文件
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
using StreamReader reader = new StreamReader(fileStream, Encoding.UTF8);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
lineCount++;
}
return lineCount;
}
static async Task Main()
{
string filename = "bigtext.txt";
int count = await CountLinesAsync(filename);
Console.WriteLine($"在文件 {filename} 中的行数: {count}");
}
}
代码说明:
- 注意 useAsync: true 传给 FileStream。这对真正的异步很重要。
- 我们对 ReadLineAsync 使用了 await,这样读取行时不会阻塞线程。
- 方法 Main 现在是异步的(C# 7+ 支持)。
如果这是个真实的图形应用,那么在读文件期间(当 ReadAsync 在等待磁盘数据时)用户可以点按钮、滚动或做别的事,因为 UI 主线程没被阻塞。在控制台应用中感觉不明显,但原理一样。
3. WriteAsync:无延迟写入
和 ReadAsync 类似,WriteAsync 允许异步向流写数据。当你需要写大量数据但不想让应用等写完时,这很有用。
主要签名:
public virtual ValueTask WriteAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
还有用到 ReadOnlyMemory<byte> 的版本(写时我们不修改缓冲区):
public virtual ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default
)
参数和 ReadAsync 类似:
- buffer:包含要写的数据的 byte 数组(或 ReadOnlyMemory<byte>)。
- offset:在 buffer 中开始读取要写的数据的偏移。
- count:要写的字节数。
- CancellationToken cancellationToken:用于取消操作。
- ValueTask:没有返回值,因为写入的字节数由 count 指定。
示例 2:异步写文件
现在我们异步写点东西到文件:
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
public static async Task WriteTestAsync(string filePath)
{
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
using StreamWriter writer = new StreamWriter(fs, Encoding.UTF8);
for (int i = 0; i < 10000; i++)
{
await writer.WriteLineAsync($"第 {i} 行");
}
}
static async Task Main()
{
string filename = "testout.txt";
await WriteTestAsync(filename);
Console.WriteLine($"写入 {filename} 已完成。");
}
}
这里循环写入 10000 行,主线程不被阻塞:如果这是 GUI 应用,界面不会“卡死”。
借助 async 和 await,我们的控制台程序也可以在做文件拷贝的同时保持对用户输入的响应(比如按 Enter 取消)。这是写现代高性能、响应式 C# 应用的基础原则。
4. 有用的细节
可视化:异步读/写 是怎么工作的
┌───────────────────┐ Start Async Read ┌────────────────────────────────┐
│你的代码 (UI/逻辑) │ ─────────────────────→ │ OS/E/S: 异步操作 │
└─────┬─────────────┘ └───────┬────────────────────────┘
│(去做别的事) │(读文件,等磁盘)
│<────────────────────────────────────────→│
└─ 等 Task 完成,拿到结果 ←──────────┘
大致流程:当磁盘慢吞吞工作时,你的代码可以去做别的事。只有在真正需要数据时才等 Task 返回结果。
实际应用场景:什么时候真需要?
- 桌面应用:如果你的 app 读写大文件(日志、数据库、视频),异步是必须的。即便机器很快,用户可能打开的是网络文件,网速可能慢得像午餐时间的乌龟。
- 后端或 Web 应用:服务器可能同时被几十、几百甚至几千用户请求。如果每个线程在读文件时都被阻塞——就会卡顿,甚至出现 502 Bad Gateway。
- 移动应用:打开或写文件耗时会让用户体验变差——用异步避免卡顿。
- 任何大批量文件处理:处理文件集合的程序(压缩、解析、分析)会从异步 I/O 明显受益。
同步 vs 异步 读/写
| 方法 | 会阻塞线程吗? | 实现简单吗? | 性能更好? | 对 UI/服务器 好用吗? |
|---|---|---|---|---|
| 同步 (Read/Write) | 是 | 是 | 否 | 否 |
| 异步 (ReadAsync) | 否 | 差不多 | 是 | 是 |
5. 细节和最佳实践
缓冲依然重要:即便用 ReadAsync 和 WriteAsync,每次读写一个字节仍然极不高效。异步能避免阻塞,但不会魔法般地加速逐字节的读写。常用起始缓冲大小是 4096-8192 字节,对于大文件可以试试 65536 或 131072。
“异步要贯穿到底”(Async All The Way Down):如果在某处开始用了 async/await,通常要把这个思路沿调用链传下去:C 做异步 -> C 是 async Task,B 也 async Task,A 也是 async Task。否则可能会有阻塞,甚至在 UI 应用里死锁。
异常处理:在异步代码里用正常的 try-catch。常见的是 OperationCanceledException 和 IOException ——要显式处理它们。
释放资源(await using):对流和其他 IDisposable 对象要正确释放。如果类型实现了 IAsyncDisposable,用 await using 会调用 DisposeAsync();如果只有 IDisposable,就会调用 Dispose()。
底层发生了什么(简要):在 await 的时候编译器把方法变成状态机:操作启动,方法“暂停”,控制权返回给调用者。结果准备好时,SynchronizationContext(在 UI)或 ThreadPool(在控制台/服务器)会从暂停处恢复执行。这允许用一个线程去服务很多“挂起”的任务而不阻塞。
总之,使用 async 和 await 的异步编程是构建响应快、可扩展应用的强大工具。它能让你的代码高效利用系统资源,不阻塞 UI 或服务器线程。刚开始可能会有点不习惯,但真的值得学!下几节我们会继续深入异步和并行的世界。回见!
GO TO FULL VERSION