1. 介绍
回想我们加载大图片的例子。图片加载时,应用会“卡住”。处理文本文件时也一样,尤其是文件很大(几十 GB 的日志、巨量 CSV 报表、以文本形式的数据库备份)。
想象你在写一个应用,它会:
解析超大日志文件,以便找到错误。如果你同步读取,界面可能会卡住几秒甚至几分钟,用户会以为程序崩了。
边生成边写入报告文件。如果写入阻塞主线程,数据生成和界面交互都会受影响。
Web 服务器,需要处理成千上万的请求。每个请求可能要读写文件。如果这些 I/O 都是同步的,服务器线程会空等磁盘响应,服务器会很快被请求压垮。
在这些场景下,异步 I/O 不只是“nice-to-have”,而是必需的。它能让你的应用在磁盘“思考”时去做别的事(比如更新界面、处理其他请求或进行计算)。
基础概念: async/await 和 任务
- 关键字 async 表示该方法可能包含“等待点”(await)。
- 操作符 await 暂时交出控制权,直到异步任务完成(比如文件读取)。
- 异步方法在做输入/输出时不会阻塞当前线程:在没有数据的时候线程是空闲的。
这就是处理文件时异步的基本魔法。
2. 用于文件的异步方法
在现代 .NET 版本中,几乎所有主要的文件操作类都有异步版本。对文本文件常用的有:
- StreamReader.ReadLineAsync()
- StreamReader.ReadToEndAsync()
- StreamWriter.WriteLineAsync()
- StreamWriter.WriteAsync()
- 还有静态方法:File.ReadAllTextAsync()、File.WriteAllTextAsync() 等。
| 读取 | 写入 |
|---|---|
|
|
|
|
|
|
3. 异步读取整个文本文件
我们来把整个文件读成一个字符串。通常用于小文件:配置、小日志。
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string path = "input.txt";
// 异步读取整个文件
string fileContents = await File.ReadAllTextAsync(path);
Console.WriteLine("文件内容:");
Console.WriteLine(fileContents);
}
}
注意:方法 Main 现在标注为 async Task Main()。从 C# 7.1 开始可以这么做。一个 await — 就能让一切异步运行!
4. 异步逐行读取大文件
当文件真的很大,把它一次性读进内存不是好主意。更好的办法是逐行读取:
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string path = "biglog.txt";
// 打开 StreamReader 用于异步读取
using StreamReader reader = new StreamReader(path);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
// 在这里可以处理该行(例如查找错误)
Console.WriteLine(line);
}
}
}
这是怎么工作的?
每次调用 await reader.ReadLineAsync() 都会释放线程——这在文件在网络盘或云上时尤其有用。在几十万行并且还要同时和用户交互(比如在服务器 API 中)时,异步处理就非常关键。
5. 异步写入行到文件
同样地,可以异步写入文件(例如在生成报表时):
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string path = "output.txt";
using StreamWriter writer = new StreamWriter(path);
for (int i = 0; i < 5; i++)
{
await writer.WriteLineAsync($"第{i + 1}行");
}
// 可以显式调用 FlushAsync 来确保写入
await writer.FlushAsync();
Console.WriteLine("数据已异步写入!");
}
}
调用 FlushAsync() 并不总是必须的——关闭 StreamWriter 时缓冲会被冲洗。但如果你想立即确保“现在就写入”,就用它。
6. 多个异步文件操作的交互
假设你需要读取一个文本文件,同时把转换后的内容写到另一个文件:
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
string sourcePath = "even_biggerlog.txt";
string destinationPath = "copy_biggerlog.txt";
using StreamReader reader = new StreamReader(sourcePath);
using StreamWriter writer = new StreamWriter(destinationPath);
string? line;
int linesProcessed = 0;
while ((line = await reader.ReadLineAsync()) != null)
{
// 小魔法:把所有字母转换成大写
string processed = line.ToUpperInvariant();
await writer.WriteLineAsync(processed);
linesProcessed++;
}
Console.WriteLine($"已处理的行数:{linesProcessed}");
}
}
这里读和写都是异步的。每次 await 都会释放控制权,让应用去做别的事情。
7. 实际应用:哪里会用到?
- Web 开发 (ASP.NET Core): 上传/下载文件不阻塞其他请求处理;服务器保持响应。
- 桌面应用 (WPF, WinForms): 打开日志或保存报表时 UI 不会“卡死”。
- 游戏引擎: 异步加载资源(贴图、模型)可以不打断动画和游戏逻辑。
- 大数据处理: 逐行解析巨大的 CSV/JSON/XML,边读取边处理,减少内存占用。
- 后台服务和守护进程: 日志记录、缓存、队列处理,能更高效地使用线程和磁盘。
总结:异步能帮你做出现代、响应快且可扩展的应用。“阻塞”很糟糕,异步更好!
8. 注意事项和最佳实践
别忘了 await! 如果你调用带有 Async 后缀的方法却不等它,得到的是一个 Task,但代码会继续往下跑,可能导致执行顺序错误。
// 不好:忘了 await
FileManager.ReadTextFileAsync("nonexistent.txt"); // 会启动,但 Main 会继续往下走
Console.WriteLine("我马上就执行了,尽管文件还在读(或者已经抛错)!这很糟糕!");
编译器通常会提醒你忘了 await,但不会阻止编译通过。
using 用于所有实现了 IDisposable 的东西:所有流(FileStream、StreamReader、StreamWriter)都要正确释放。使用 using 块或 C# 8+ 的 using 声明来保证关闭和冲刷缓冲。
缓冲区大小(bufferSize):StreamReader/StreamWriter 已经做了优化,但在特殊需求下可以调试试验。默认值通常是合适的(在 FileStream 中常见的默认是 4096 字节)。
错误处理:异步方法也会抛异常。把操作包在 try-catch 里。异常会在对相应 Task 执行 await 时“浮出水面”。
ConfigureAwait:在库和 Web 场景(不需要同步上下文,比如 GUI)中,使用 await SomeAsync().ConfigureAwait(false) 可以减少切换上下文的开销。在控制台和 UI 应用里通常可以省略它。
多练习——很快 async Task 就会像 Console.WriteLine 一样顺手了。
9. 常见错误和重要注意点
如果不使用 await(只是调用带有 Async 的方法),你会得到一个 Task,但结果不会自动等待。你需要通过 await 或者显式等待(通常不推荐)来等待它完成。
不能在同步代码中等待异步方法,除非把 async 向上“抛”到调用栈里。使用 .Result 或 .GetAwaiter().GetResult() 可能导致死锁——最好把调用方也改成 async。
不要同时读取和写入同一个文件(即便是异步的)。那样容易产生竞态条件和数据损坏。
异步会释放调用线程,但不会让底层操作更快:如果磁盘或网络慢,异步仍会很慢——只是不会阻塞 UI 或工作线程。
GO TO FULL VERSION