Day 2 - 异步 IO
建议用时:240-300 分钟
你将学会什么
- IO 到底是什么意思,为什么文件、网络、数据库都属于 IO
- 为什么 IO 等待适合用异步,而不是让线程原地干等
- 如何异步写文件、读文件、追加文件、逐行读取
- 如何把文件内容整理成列表或对象
- 如何处理文件不存在、路径错误、读取失败
- 网络请求为什么也是 IO,以及如何安全写一个基础请求
今天重点不是“多写几个 Async 方法”,而是理解:IO 的慢通常不是 CPU 算得慢,而是在等外部资源回应。异步 IO 的价值是等待期间不要把执行通道堵死。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“异步基础”,今天继续学习“异步 IO”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“异步 IO”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。IO 代码写不好,通常不是因为语法难,而是没有分清“路径、文件、内容、解析、异常”这些步骤。
1. IO 是什么
IO 是 Input / Output,也就是输入和输出。
程序自己在内存里计算,不叫 IO。程序和外部资源打交道,才叫 IO。
常见 IO:
| 类型 | 例子 |
|---|---|
| 文件 IO | 读取 orders.txt、写入 log.txt |
| 网络 IO | 请求接口、下载图片 |
| 数据库 IO | 查询订单、保存客户 |
| 控制台 IO | Console.ReadLine()、Console.WriteLine() |
IO 的特点是:程序经常要等外部资源。
2. 为什么 IO 适合异步
读取文件时,CPU 不是一直在算。
很多时间是在等:
- 操作系统找到文件。
- 磁盘返回内容。
- 网络返回响应。
- 数据库返回查询结果。
同步写法会让当前线程等在原地。
异步写法把等待变成 Task,调用处用 await 等结果回来。
3. 文件路径是什么
文件路径就是文件所在位置。
常见写法:
| 写法 | 含义 |
|---|---|
"orders.txt" | 当前程序运行目录下的文件 |
"data/orders.txt" | 当前目录里的 data 文件夹下 |
"/Users/yao/Desktop/orders.txt" | 绝对路径 |
练习时优先用简单相对路径,例如:
orders.txt这样文件会出现在程序运行目录里,容易观察。
4. 文件不存在怎么办
读取文件前要考虑文件不存在。
有两种常见做法:
| 做法 | 适合场景 |
|---|---|
File.Exists(path) 提前判断 | 文件不存在不是严重错误 |
try/catch 捕获异常 | 需要统一处理读取失败 |
本阶段先学第一种,因为它最直观。
5. 一次性读取和逐行读取
ReadAllTextAsync:
一次性把整个文件读成字符串适合小文件,例如配置、练习数据、小 CSV。
StreamReader.ReadLineAsync:
一次读一行适合文件较大、希望一行一行处理的场景。
6. 写入、追加、读取的区别
| API | 含义 |
|---|---|
File.WriteAllTextAsync | 写入文本,覆盖原文件 |
File.AppendAllTextAsync | 追加文本,不覆盖原内容 |
File.ReadAllTextAsync | 一次性读取全部文本 |
StreamReader.ReadLineAsync | 一行一行读取 |
练习时要注意:
WriteAllTextAsync 会覆盖旧内容。
AppendAllTextAsync 会在旧内容后面继续追加。7. 文件内容和对象不是一回事
文件里读出来的是字符串。
业务里通常需要对象。
所以中间要有一个解析过程:
文件文本 -> 行 -> 字段 -> 对象例如:
SO-001,张三,199可以解析成:
OrderNo = SO-001
CustomerName = 张三
Amount = 1998. 异步 IO 常见错误
| 错误 | 原因 | 修法 |
|---|---|---|
忘记 await | 任务还没完成就继续往下走 | 调用异步方法时加 await |
| 文件不存在 | 路径错或文件未创建 | 先 File.Exists |
| 解析金额失败 | 文本不是数字 | 用 decimal.TryParse |
| 行格式不对 | 缺少逗号或字段数量不对 | 检查 parts.Length |
| 覆盖旧文件 | 用了 WriteAllTextAsync | 需要追加时用 AppendAllTextAsync |
9. 网络请求也是 IO
请求接口时,程序也在等外部服务器返回结果。
所以网络请求也适合异步:
await client.GetStringAsync(url)但网络请求受环境影响大,可能失败:
- 没网。
- 地址错误。
- 服务器慢。
- 服务器返回错误。
所以网络请求一定要配合异常处理。
第二部分:把知识组合成完整例子
前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。今天最终要能把一个文件异步读出来,并整理成可用数据。
先看效果:异步读取订单文件
string path = "orders.txt";
await File.WriteAllTextAsync(path, "SO-001,张三,199\nSO-002,李四,88\nSO-003,王五,1299");
async Task<List<Order>> LoadOrdersAsync(string filePath)
{
if (!File.Exists(filePath))
{
return new List<Order>();
}
string text = await File.ReadAllTextAsync(filePath);
List<Order> orders = new List<Order>();
string[] lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (string line in lines)
{
string[] parts = line.Split(',');
if (parts.Length != 3)
{
continue;
}
if (!decimal.TryParse(parts[2], out decimal amount))
{
continue;
}
orders.Add(new Order(parts[0], parts[1], amount));
}
return orders;
}
List<Order> orders = await LoadOrdersAsync(path);
Console.WriteLine($"读取到 {orders.Count} 个订单");
foreach (Order order in orders)
{
Console.WriteLine($"{order.OrderNo} | {order.CustomerName} | {order.Amount}");
}
class Order
{
public Order(string orderNo, string customerName, decimal amount)
{
OrderNo = orderNo;
CustomerName = customerName;
Amount = amount;
}
public string OrderNo { get; }
public string CustomerName { get; }
public decimal Amount { get; }
}这个例子包含异步 IO 的完整闭环:
- 准备文件。
- 检查文件是否存在。
- 异步读取文本。
- 拆成行。
- 把每行转成对象。
- 输出结果。
第三部分:跟着敲代码
从这里开始动手。每个例子都是完整代码,可以直接放进 Program.cs 运行。
动手前先做这 3 件事
- 打开一个控制台项目。
- 每次只保留一个例子的代码,运行通过后再换下一个。
- 每个例子都要改一次文件内容、路径或字段,再运行观察结果。
例子 1:异步写入文件
string path = "hello.txt";
await File.WriteAllTextAsync(path, "你好,文件");
Console.WriteLine("写入完成");
Console.WriteLine($"文件路径: {Path.GetFullPath(path)}");运行后,会在程序运行目录创建 hello.txt。
例子 2:异步读取文件
string path = "hello.txt";
await File.WriteAllTextAsync(path, "第一行\n第二行");
string text = await File.ReadAllTextAsync(path);
Console.WriteLine("读取内容:");
Console.WriteLine(text);先写入,再读取,保证练习时文件一定存在。
例子 3:读取前检查文件是否存在
string path = "not-exists.txt";
if (!File.Exists(path))
{
Console.WriteLine("文件不存在,停止读取");
return;
}
string text = await File.ReadAllTextAsync(path);
Console.WriteLine(text);这个例子故意读取一个不存在的文件,用来练习边界处理。
例子 4:追加内容
string path = "log.txt";
await File.WriteAllTextAsync(path, "程序启动\n");
await File.AppendAllTextAsync(path, "读取订单\n");
await File.AppendAllTextAsync(path, "程序结束\n");
string text = await File.ReadAllTextAsync(path);
Console.WriteLine(text);AppendAllTextAsync 不会覆盖旧内容,而是在后面追加。
例子 5:把文件拆成多行
string path = "orders.txt";
await File.WriteAllTextAsync(path, "SO-001\nSO-002\nSO-003");
string text = await File.ReadAllTextAsync(path);
string[] lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Console.WriteLine($"行数: {lines.Length}");
foreach (string line in lines)
{
Console.WriteLine(line);
}很多文本文件的第一步处理,就是先拆成一行一行。
例子 6:逐行读取
string path = "orders.txt";
await File.WriteAllTextAsync(path, "SO-001\nSO-002\nSO-003");
using StreamReader reader = new StreamReader(path);
int lineNumber = 1;
while (true)
{
string? line = await reader.ReadLineAsync();
if (line is null)
{
break;
}
Console.WriteLine($"第 {lineNumber} 行: {line}");
lineNumber++;
}逐行读取适合一边读一边处理,不一定要把整个文件一次性放进字符串。
例子 7:解析 CSV 行
string line = "SO-001,张三,199";
string[] parts = line.Split(',');
if (parts.Length != 3)
{
Console.WriteLine("行格式不正确");
return;
}
if (!decimal.TryParse(parts[2], out decimal amount))
{
Console.WriteLine("金额格式不正确");
return;
}
Console.WriteLine($"订单号: {parts[0]}");
Console.WriteLine($"客户: {parts[1]}");
Console.WriteLine($"金额: {amount}");CSV 的核心就是按分隔符拆字段。真实 CSV 还会有引号、逗号转义等复杂情况,本阶段先掌握最直接的格式。
例子 8:异步读取 CSV 并转成对象
string path = "orders.csv";
await File.WriteAllTextAsync(path, "SO-001,张三,199\nSO-002,李四,88\nSO-003,王五,1299");
async Task<List<Order>> LoadOrdersAsync(string filePath)
{
string text = await File.ReadAllTextAsync(filePath);
string[] lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries);
List<Order> orders = new List<Order>();
foreach (string line in lines)
{
string[] parts = line.Split(',');
if (parts.Length != 3)
{
continue;
}
if (!decimal.TryParse(parts[2], out decimal amount))
{
continue;
}
orders.Add(new Order(parts[0], parts[1], amount));
}
return orders;
}
List<Order> orders = await LoadOrdersAsync(path);
foreach (Order order in orders)
{
Console.WriteLine($"{order.OrderNo} | {order.CustomerName} | {order.Amount}");
}
class Order
{
public Order(string orderNo, string customerName, decimal amount)
{
OrderNo = orderNo;
CustomerName = customerName;
Amount = amount;
}
public string OrderNo { get; }
public string CustomerName { get; }
public decimal Amount { get; }
}这是本页核心例子:异步 IO 加数据解析。
例子 9:读取失败时捕获异常
string path = "";
try
{
string text = await File.ReadAllTextAsync(path);
Console.WriteLine(text);
}
catch (Exception ex)
{
Console.WriteLine($"读取失败: {ex.Message}");
}不是所有错误都能提前用 File.Exists 判断。路径非法、权限不足等问题通常需要 try/catch。
例子 10:创建文件夹后再写文件
string folder = "data";
string path = Path.Combine(folder, "orders.txt");
Directory.CreateDirectory(folder);
await File.WriteAllTextAsync(path, "SO-001\nSO-002");
string text = await File.ReadAllTextAsync(path);
Console.WriteLine($"文件路径: {Path.GetFullPath(path)}");
Console.WriteLine(text);如果文件夹不存在,直接写入 data/orders.txt 会失败。先用 Directory.CreateDirectory 创建文件夹。
例子 11:基础网络请求
using HttpClient client = new HttpClient();
try
{
string html = await client.GetStringAsync("https://example.com/");
Console.WriteLine($"读取到字符数: {html.Length}");
}
catch (Exception ex)
{
Console.WriteLine($"请求失败: {ex.Message}");
}网络请求也是 IO。这个例子能编译运行,但网络是否成功取决于当前环境,所以必须写异常处理。
例子 12:把读取逻辑封装成方法
string path = "orders.txt";
await File.WriteAllTextAsync(path, "SO-001\nSO-002\nSO-003");
async Task<List<string>> ReadLinesAsync(string filePath)
{
if (!File.Exists(filePath))
{
return new List<string>();
}
string text = await File.ReadAllTextAsync(filePath);
return text
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.ToList();
}
List<string> orders = await ReadLinesAsync(path);
foreach (string order in orders)
{
Console.WriteLine(order);
}
封装以后,主流程就更清楚:
准备路径 -> 调用读取方法 -> 输出结果小白重复敲写训练
先用临时文本文件练异步 IO,每次都确认文件实际内容。
训练 1:异步写文件
string path = "note.txt";
await File.WriteAllTextAsync(path, "Hello Avalonia");
Console.WriteLine("写入完成");改动任务:打开文件确认内容,再把文本改成两行。
训练 2:异步读文件
string path = "note.txt";
string content = await File.ReadAllTextAsync(path);
Console.WriteLine(content);第二遍先判断 File.Exists(path),不存在时输出提示。
训练 3:逐行读取
await File.WriteAllLinesAsync("names.txt", new[] { "Tom", "Alice", "Bob" });
string[] lines = await File.ReadAllLinesAsync("names.txt");
foreach (string line in lines)
{
Console.WriteLine(line);
}改动任务:只输出名字长度大于 3 的行。
每日小测
做完本页后,用这 5 题检查是否真的掌握。
1. 判断题
本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。
答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。
2. 填空题
本页主题是:异步 IO。今天至少要掌握的 3 个点是:
1. IO 到底是什么意思,为什么文件、网络、数据库都属于 IO
2. 为什么 IO 等待适合用异步,而不是让线程原地干等
3. 如何异步写文件、读文件、追加文件、逐行读取答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。
3. 流程题
遇到本页相关功能时,先按什么顺序处理?
答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。
4. 找错误题
如果本页代码运行失败,第一步应该做什么?
答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。
5. 改需求题
在本页完整例子跑通后,至少改一个小需求。
可选改法:
- 改一个字段名称。
- 多加一个校验条件。
- 多输出一行结果。
- 把固定数据改成用户输入。
- 把一次处理改成多条数据处理。
答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:IO 到底是什么意思,为什么文件、网络、数据库都属于 IO。
上位机专项练习
串口、网络和文件都属于 IO。等待 IO 时使用异步方法,避免长时间占住界面线程。
下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。
专项例子 1:异步保存采集记录
class Program
{
static async Task SaveAsync()
{
await File.WriteAllTextAsync("reading.txt", "PLC-01,26.8");
}
static async Task Main()
{
await SaveAsync();
Console.WriteLine("记录已保存");
}
}运行结果或界面效果:
记录已保存改动任务: 打开 reading.txt,确认内容,再增加采集时间。
专项例子 2:异步读取设备配置
class Program
{
static async Task<string> LoadAsync()
{
await File.WriteAllTextAsync("device.txt", "PLC-01,192.168.1.10");
return await File.ReadAllTextAsync("device.txt");
}
static async Task Main()
{
Console.WriteLine(await LoadAsync());
}
}运行结果或界面效果:
PLC-01,192.168.1.10改动任务: 把 IP 改成 192.168.1.20。
专项例子 3:逐行读取报警日志
class Program
{
static async Task<string[]> LoadAlarmsAsync()
{
await File.WriteAllLinesAsync("alarms.txt", ["高温", "低压"]);
return await File.ReadAllLinesAsync("alarms.txt");
}
static async Task Main()
{
string[] alarms = await LoadAlarmsAsync();
foreach (string alarm in alarms) Console.WriteLine(alarm);
}
}运行结果或界面效果:
高温
低压改动任务: 增加“设备离线”并确认输出 3 行。
第四部分:作业完整答案
这一部分给出当天作业的完整答案。建议先照着敲一遍,再改文件内容和错误数据验证。
作业 1:异步写入和读取
要求:
- 写入
products.txt。 - 内容包含三行商品名。
- 再异步读取并输出。
完整答案
string path = "products.txt";
await File.WriteAllTextAsync(path, "键盘\n鼠标\n显示器");
string text = await File.ReadAllTextAsync(path);
Console.WriteLine("商品列表:");
Console.WriteLine(text);作业 2:读取行数
要求:
- 写入三行订单号。
- 读取文件。
- 拆成行。
- 输出行数和每一行。
完整答案
string path = "orders.txt";
await File.WriteAllTextAsync(path, "SO-001\nSO-002\nSO-003");
string text = await File.ReadAllTextAsync(path);
string[] lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Console.WriteLine($"订单数量: {lines.Length}");
foreach (string line in lines)
{
Console.WriteLine(line);
}作业 3:异步读取订单对象
要求:
- 文件内容格式:订单号、客户名、金额。
- 写
LoadOrdersAsync。 - 返回
List<Order>。 - 跳过格式不正确的行。
完整答案
string path = "orders.csv";
await File.WriteAllTextAsync(path, "SO-001,张三,199\nSO-002,李四,88\nERROR\nSO-003,王五,abc\nSO-004,赵六,500");
async Task<List<Order>> LoadOrdersAsync(string filePath)
{
if (!File.Exists(filePath))
{
return new List<Order>();
}
string text = await File.ReadAllTextAsync(filePath);
string[] lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries);
List<Order> result = new List<Order>();
foreach (string line in lines)
{
string[] parts = line.Split(',');
if (parts.Length != 3)
{
continue;
}
string orderNo = parts[0].Trim();
string customerName = parts[1].Trim();
string amountText = parts[2].Trim();
if (string.IsNullOrWhiteSpace(orderNo) || string.IsNullOrWhiteSpace(customerName))
{
continue;
}
if (!decimal.TryParse(amountText, out decimal amount))
{
continue;
}
result.Add(new Order(orderNo, customerName, amount));
}
return result;
}
List<Order> orders = await LoadOrdersAsync(path);
Console.WriteLine($"有效订单数: {orders.Count}");
foreach (Order order in orders)
{
Console.WriteLine($"{order.OrderNo} | {order.CustomerName} | {order.Amount}");
}
class Order
{
public Order(string orderNo, string customerName, decimal amount)
{
OrderNo = orderNo;
CustomerName = customerName;
Amount = amount;
}
public string OrderNo { get; }
public string CustomerName { get; }
public decimal Amount { get; }
}作业 4:追加日志
要求:
- 写一个
AppendLogAsync方法。 - 每次调用都向
app.log追加一行。 - 最后读取日志并输出。
完整答案
string path = "app.log";
await File.WriteAllTextAsync(path, "");
async Task AppendLogAsync(string filePath, string message)
{
string line = $"{DateTime.Now:HH:mm:ss} {message}\n";
await File.AppendAllTextAsync(filePath, line);
}
await AppendLogAsync(path, "程序启动");
await AppendLogAsync(path, "读取订单");
await AppendLogAsync(path, "程序结束");
string log = await File.ReadAllTextAsync(path);
Console.WriteLine(log);
作业 5:安全读取方法
要求:
- 写
SafeReadAllTextAsync。 - 文件不存在时返回空字符串。
- 读取失败时返回空字符串并输出错误。
完整答案
async Task<string> SafeReadAllTextAsync(string filePath)
{
if (!File.Exists(filePath))
{
return "";
}
try
{
return await File.ReadAllTextAsync(filePath);
}
catch (Exception ex)
{
Console.WriteLine($"读取失败: {ex.Message}");
return "";
}
}
string text1 = await SafeReadAllTextAsync("not-exists.txt");
Console.WriteLine($"第一次读取长度: {text1.Length}");
await File.WriteAllTextAsync("exists.txt", "hello");
string text2 = await SafeReadAllTextAsync("exists.txt");
Console.WriteLine($"第二次读取内容: {text2}");
本页最后要记住
- IO 是程序和外部资源打交道。
- 文件、网络、数据库都属于 IO。
- IO 慢通常是等待外部资源,不是 CPU 计算慢。
ReadAllTextAsync适合小文件一次性读取。- 大文件更适合逐行读取。
- 文件内容读出来是字符串,变成对象需要解析。
- 文件不存在、路径错误、格式错误都要单独处理。