Skip to Content
Week 04Day 2 - 异步 IO

Day 2 - 异步 IO

建议用时:240-300 分钟

你将学会什么

  • IO 到底是什么意思,为什么文件、网络、数据库都属于 IO
  • 为什么 IO 等待适合用异步,而不是让线程原地干等
  • 如何异步写文件、读文件、追加文件、逐行读取
  • 如何把文件内容整理成列表或对象
  • 如何处理文件不存在、路径错误、读取失败
  • 网络请求为什么也是 IO,以及如何安全写一个基础请求

今天重点不是“多写几个 Async 方法”,而是理解:IO 的慢通常不是 CPU 算得慢,而是在等外部资源回应。异步 IO 的价值是等待期间不要把执行通道堵死。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“异步基础”,今天继续学习“异步 IO”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“异步 IO”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。IO 代码写不好,通常不是因为语法难,而是没有分清“路径、文件、内容、解析、异常”这些步骤。

1. IO 是什么

IO 是 Input / Output,也就是输入和输出。

程序自己在内存里计算,不叫 IO。程序和外部资源打交道,才叫 IO。

常见 IO:

类型例子
文件 IO读取 orders.txt、写入 log.txt
网络 IO请求接口、下载图片
数据库 IO查询订单、保存客户
控制台 IOConsole.ReadLine()Console.WriteLine()

IO 的特点是:程序经常要等外部资源。

2. 为什么 IO 适合异步

读取文件时,CPU 不是一直在算。

很多时间是在等:

  1. 操作系统找到文件。
  2. 磁盘返回内容。
  3. 网络返回响应。
  4. 数据库返回查询结果。

同步写法会让当前线程等在原地。

异步写法把等待变成 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 = 199

8. 异步 IO 常见错误

错误原因修法
忘记 await任务还没完成就继续往下走调用异步方法时加 await
文件不存在路径错或文件未创建File.Exists
解析金额失败文本不是数字decimal.TryParse
行格式不对缺少逗号或字段数量不对检查 parts.Length
覆盖旧文件用了 WriteAllTextAsync需要追加时用 AppendAllTextAsync

9. 网络请求也是 IO

请求接口时,程序也在等外部服务器返回结果。

所以网络请求也适合异步:

await client.GetStringAsync(url)

但网络请求受环境影响大,可能失败:

  1. 没网。
  2. 地址错误。
  3. 服务器慢。
  4. 服务器返回错误。

所以网络请求一定要配合异常处理。

第二部分:把知识组合成完整例子

前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。今天最终要能把一个文件异步读出来,并整理成可用数据。

先看效果:异步读取订单文件

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 的完整闭环:

  1. 准备文件。
  2. 检查文件是否存在。
  3. 异步读取文本。
  4. 拆成行。
  5. 把每行转成对象。
  6. 输出结果。

第三部分:跟着敲代码

从这里开始动手。每个例子都是完整代码,可以直接放进 Program.cs 运行。

动手前先做这 3 件事

  1. 打开一个控制台项目。
  2. 每次只保留一个例子的代码,运行通过后再换下一个。
  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:异步写入和读取

要求:

  1. 写入 products.txt
  2. 内容包含三行商品名。
  3. 再异步读取并输出。

完整答案

string path = "products.txt"; await File.WriteAllTextAsync(path, "键盘\n鼠标\n显示器"); string text = await File.ReadAllTextAsync(path); Console.WriteLine("商品列表:"); Console.WriteLine(text);

作业 2:读取行数

要求:

  1. 写入三行订单号。
  2. 读取文件。
  3. 拆成行。
  4. 输出行数和每一行。

完整答案

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:异步读取订单对象

要求:

  1. 文件内容格式:订单号、客户名、金额。
  2. LoadOrdersAsync
  3. 返回 List<Order>
  4. 跳过格式不正确的行。

完整答案

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:追加日志

要求:

  1. 写一个 AppendLogAsync 方法。
  2. 每次调用都向 app.log 追加一行。
  3. 最后读取日志并输出。

完整答案

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:安全读取方法

要求:

  1. SafeReadAllTextAsync
  2. 文件不存在时返回空字符串。
  3. 读取失败时返回空字符串并输出错误。

完整答案

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}");

本页最后要记住

  1. IO 是程序和外部资源打交道。
  2. 文件、网络、数据库都属于 IO。
  3. IO 慢通常是等待外部资源,不是 CPU 计算慢。
  4. ReadAllTextAsync 适合小文件一次性读取。
  5. 大文件更适合逐行读取。
  6. 文件内容读出来是字符串,变成对象需要解析。
  7. 文件不存在、路径错误、格式错误都要单独处理。