Skip to Content
Week 04Day 5 - 异步异常处理

Day 5 - 异步异常处理

建议用时:240-300 分钟

你将学会什么

  • 异步方法里的异常为什么会在 await 处出现
  • try/catch 应该包住哪一行代码
  • Task.WhenAll 中有任务失败时会发生什么
  • 批量任务如何做到“一个失败不影响全部结果”
  • 取消异常和普通异常应该怎么区分
  • 为什么不能空 catch 把错误吞掉

异常处理不是把错误藏起来。好的异常处理要做到:程序不直接崩溃、错误原因可定位、用户提示能理解、后续状态能收尾。

本页固定顺序

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

今天只抓住 3 件事

  1. 异步异常通常在 await 那一行重新抛出。
  2. 批量任务不能让一个失败毁掉全部结果,要能记录每个任务状态。
  3. catch 不能空着,至少要提示或记录错误原因。

学习衔接

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

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

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。异步异常容易混乱,是因为异常不是总在“创建 Task 的那一刻”表现出来,而是在 await 时重新抛出。

1. 异常是什么

异常表示程序遇到无法继续按正常流程执行的情况。

常见异常:

异常可能原因
FileNotFoundException文件不存在
ArgumentException参数不合法
InvalidOperationException当前状态不允许操作
OperationCanceledException任务被取消
HttpRequestException网络请求失败

异常不是用来代替普通判断的。

例如用户输入数字,优先用 TryParse,不是故意让程序抛异常。

2. 同步异常在哪里抛

同步代码里,哪一行出错,异常通常就从哪一行抛出来。

string text = File.ReadAllText("missing.txt")

文件不存在时,这一行直接抛异常。

3. 异步异常在哪里抛

异步方法返回的是 Task

如果异步方法内部出错,异常会记录在这个 Task 里。

当你写:

await SomeAsync()

异常会在 await 这一行重新抛出来。

所以异步异常的基本写法是:

try { await SomeAsync() } catch { ... }

4. try/catch 要包住 await

下面这种写法才是本阶段最稳的:

try { string text = await ReadAsync() } catch { ... }

如果只创建任务,不 await,异常不会在你以为的位置出现。

5. 异步方法里主动抛异常

业务规则不允许继续时,可以主动 throw

例如:

订单号不能为空 金额必须大于 0 未提交订单不能支付

这些属于业务规则失败。

外层调用者再决定是提示用户、记录日志,还是停止流程。

6. Task.WhenAll 的异常

Task.WhenAll 等多个任务全部完成。

如果其中一个任务失败:

await Task.WhenAll(...)

会抛异常。

如果多个任务失败,Task.WhenAll 内部会记录多个异常。

阶段学习先记住:

  1. 任意一个任务失败,await WhenAll 会进入 catch
  2. 批量业务里,常常需要每个任务自己捕获错误并返回失败结果。

7. 取消异常要单独处理

取消通常会抛:

OperationCanceledException

取消不等于程序错误。

所以一般把取消单独放前面:

catch (OperationCanceledException) { Console.WriteLine("操作已取消"); } catch (Exception ex) { Console.WriteLine($"操作失败: {ex.Message}"); }

8. 不要空 catch

不要这样写:

catch { }

这样错误被吞掉了,程序表面没崩,但你也不知道哪里坏了。

至少要输出错误信息,真实项目里还要记录日志。

9. 异常处理的分层

层级应该做什么
输入层尽量校验,避免无意义异常
业务层规则不允许时抛明确异常
IO 层捕获文件、网络、数据库错误
外层入口给用户提示、记录日志、恢复状态

不要把所有异常都塞进最里面,也不要完全不处理。

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

前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。今天要能做到:异步任务失败时,程序给出明确提示,而不是直接中断。

先看效果:读取文件失败时给出提示

try { async Task<string> ReadRequiredFileAsync(string path) { return await File.ReadAllTextAsync(path); } string text = await ReadRequiredFileAsync("missing.txt"); Console.WriteLine(text); } catch (FileNotFoundException) { Console.WriteLine("文件不存在,请检查路径"); } catch (Exception ex) { Console.WriteLine($"读取失败: {ex.Message}"); }

这里最重要的是:

异常会在 await ReadRequiredFileAsync(...) 这一行被抛出来。

所以 try/catch 要包住 await

第三部分:跟着敲代码

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

动手前先做这 3 件事

  1. 打开一个控制台项目。
  2. 每次只保留一个例子的代码,运行通过后再换下一个。
  3. 每个例子都要改一次输入或失败条件,再运行观察结果。

例子 1:异步文件读取异常

try { string text = await File.ReadAllTextAsync("missing.txt"); Console.WriteLine(text); } catch (FileNotFoundException) { Console.WriteLine("文件不存在"); }

异常会在 await File.ReadAllTextAsync(...) 这一行被捕获。

例子 2:异常来自异步方法内部

try { async Task<string> LoadCustomerAsync(string customerId) { await Task.Delay(300); if (string.IsNullOrWhiteSpace(customerId)) { throw new ArgumentException("客户编号不能为空"); } return "张三"; } string name = await LoadCustomerAsync(""); Console.WriteLine(name); } catch (ArgumentException ex) { Console.WriteLine($"参数错误: {ex.Message}"); }

异常在 LoadCustomerAsync 里抛出,但调用处用 await 时捕获。

例子 3:业务规则异常

try { Order order = new Order("SO-001"); order.Pay(); } catch (InvalidOperationException ex) { Console.WriteLine($"支付失败: {ex.Message}"); } class Order { public Order(string orderNo) { OrderNo = orderNo; Status = OrderStatus.Draft; } public string OrderNo { get; } public OrderStatus Status { get; private set; } public void Pay() { if (Status != OrderStatus.Submitted) { throw new InvalidOperationException("只有已提交订单可以支付"); } Status = OrderStatus.Paid; } } enum OrderStatus { Draft, Submitted, Paid }

业务状态不允许继续时,用明确异常说明原因。

例子 4:输入错误优先用 TryParse

string input = "abc"; if (!int.TryParse(input, out int count)) { Console.WriteLine("数量格式错误"); return; } Console.WriteLine($"数量: {count}");

普通输入格式错误,不需要故意制造异常。能用 TryParse 就先用 TryParse

例子 5:finally 一定执行

try { Console.WriteLine("开始处理"); await Task.Delay(300); throw new InvalidOperationException("模拟失败"); } catch (InvalidOperationException ex) { Console.WriteLine($"处理失败: {ex.Message}"); } finally { Console.WriteLine("执行收尾清理"); }

finally 适合做收尾,例如释放资源、恢复状态、清理临时文件。

例子 6:取消异常单独处理

using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); try { await Task.Delay(2000, cts.Token); Console.WriteLine("任务完成"); } catch (OperationCanceledException) { Console.WriteLine("任务已取消"); } catch (Exception ex) { Console.WriteLine($"任务失败: {ex.Message}"); }

取消不是普通失败,所以单独捕获。

例子 7:Task.WhenAll 中一个任务失败

async Task<string> LoadAsync(string name, bool shouldFail) { await Task.Delay(300); if (shouldFail) { throw new InvalidOperationException($"{name} 读取失败"); } return $"{name} 读取成功"; } Task<string> task1 = LoadAsync("A", false); Task<string> task2 = LoadAsync("B", true); Task<string> task3 = LoadAsync("C", false); try { string[] results = await Task.WhenAll(task1, task2, task3); foreach (string result in results) { Console.WriteLine(result); } } catch (Exception ex) { Console.WriteLine($"批量读取失败: {ex.Message}"); }

只要其中一个任务失败,await Task.WhenAll(...) 就会进入 catch

例子 8:查看 WhenAll 中的多个异常

Task<string>[] tasks = { async Task<string> LoadAsync(string name, bool shouldFail) { await Task.Delay(300); if (shouldFail) { throw new InvalidOperationException($"{name} 失败"); } return $"{name} 成功"; } LoadAsync("A", true), LoadAsync("B", true), LoadAsync("C", false) }; Task allTask = Task.WhenAll(tasks); try { await allTask; } catch { IEnumerable<Exception> errors = allTask.Exception?.InnerExceptions ?? Enumerable.Empty<Exception>(); foreach (Exception error in errors) { Console.WriteLine($"错误: {error.Message}"); } }

多个任务同时失败时,可以从 allTask.Exception.InnerExceptions 里查看所有错误。

例子 9:每个任务返回自己的成功失败

int[] ids = { 1, 2, 3, 4, 5 }; Task<ProcessResult>[] tasks = ids async Task<ProcessResult> ProcessSafeAsync(int id) { try { await Task.Delay(300); if (id == 3) { throw new InvalidOperationException("模拟失败"); } return new ProcessResult(id, true, "处理完成"); } catch (Exception ex) { return new ProcessResult(id, false, ex.Message); } } .Select(id => ProcessSafeAsync(id)) .ToArray(); ProcessResult[] results = await Task.WhenAll(tasks); foreach (ProcessResult result in results) { Console.WriteLine($"{result.Id} | 成功: {result.Success} | {result.Message}"); } class ProcessResult { public ProcessResult(int id, bool success, string message) { Id = id; Success = success; Message = message; } public int Id { get; } public bool Success { get; } public string Message { get; } }

批量任务中,这种写法比“一个失败全部失败”更适合统计结果。

例子 10:文件读取失败也返回结果对象

await File.WriteAllTextAsync("a.txt", "A"); await File.WriteAllTextAsync("b.txt", "B"); string[] paths = { "a.txt", "missing.txt", "b.txt" }; Task<FileReadResult>[] tasks = paths async Task<FileReadResult> ReadFileSafeAsync(string path) { try { string text = await File.ReadAllTextAsync(path); return new FileReadResult(path, true, text); } catch (Exception ex) { return new FileReadResult(path, false, ex.Message); } } .Select(path => ReadFileSafeAsync(path)) .ToArray(); FileReadResult[] results = await Task.WhenAll(tasks); foreach (FileReadResult result in results) { Console.WriteLine($"{result.Path} | 成功: {result.Success} | {result.Message}"); } class FileReadResult { public FileReadResult(string path, bool success, string message) { Path = path; Success = success; Message = message; } public string Path { get; } public bool Success { get; } public string Message { get; } }

这个例子接近真实批量读取:每个文件都有自己的结果,最后统一输出。

异步异常常用写法速查

需求写法作用
捕获异步异常try { await WorkAsync(); } catch (...) { ... }await 处处理失败
捕获文件缺失catch (FileNotFoundException)文件不存在单独提示
捕获取消catch (OperationCanceledException)用户取消或超时
捕获兜底错误catch (Exception ex)未预料异常统一处理
批量任务等待await Task.WhenAll(tasks)等所有任务完成
单个任务转结果return new Result(false, ex.Message)一个失败不影响汇总
最终收尾finally { ... }恢复状态、释放资源

常见错误和修法

错误为什么错修法
只创建 Taskawait异常不会在当前位置处理对异步任务使用 await
catch错误原因丢失输出或记录 ex.Message
把取消当普通失败取消是用户或超时行为单独捕获 OperationCanceledException
Task.WhenAll 里一个失败导致整体失败没有给每个任务独立结果在单个任务内部捕获并返回结果对象
异常后界面状态不恢复收尾逻辑没执行把恢复状态放进 finally

小白重复敲写训练

异步异常必须把 try 放在 await 外面来练。

训练 1:捕获异步异常

try { async Task FailAsync() { await Task.Delay(200); throw new InvalidOperationException("异步任务失败"); } await FailAsync(); } catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }

第二遍删除 await,观察为什么处理方式会变化,再恢复。

训练 2:异步方法返回失败

try { async Task<int> DivideAsync(int a, int b) { await Task.Delay(100); return a / b; } Console.WriteLine(await DivideAsync(10, 0)); } catch (DivideByZeroException) { Console.WriteLine("除数不能为零"); }

改动任务:把除数改成 2,确认正常返回。

训练 3:多个任务中有一个失败

Task ok = Task.Delay(200); Task failed = Task.FromException(new Exception("第二个任务失败")); try { await Task.WhenAll(ok, failed); } catch (Exception ex) { Console.WriteLine(ex.Message); }

第三遍增加一个正常任务。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:异步异常处理。今天至少要掌握的 3 个点是:

1. 异步方法里的异常为什么会在 `await` 处出现 2. `try/catch` 应该包住哪一行代码 3. `Task.WhenAll` 中有任务失败时会发生什么

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:异步方法里的异常为什么会在 await 处出现。

上位机专项练习

异步通信失败要被捕获并转成用户看得懂的状态,不能让程序直接崩溃。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:捕获设备读取失败

class Program { static async Task<double> ReadAsync() { await Task.Delay(100); throw new IOException("串口断开"); } static async Task Main() { try { Console.WriteLine(await ReadAsync()); } catch (IOException ex) { Console.WriteLine($"读取失败: {ex.Message}"); } } }

运行结果或界面效果:

读取失败: 串口断开

改动任务: 把异常文字改成“网络超时”。

专项例子 2:用结果对象记录失败

record ReadResult(bool Success, double Value, string Error); class Program { static async Task<ReadResult> ReadAsync() { await Task.Delay(100); return new(false, 0, "CRC 校验失败"); } static async Task Main() { ReadResult result = await ReadAsync(); Console.WriteLine(result.Success ? $"值: {result.Value}" : result.Error); } }

运行结果或界面效果:

CRC 校验失败

改动任务: 返回成功结果 new(true, 25.6, string.Empty)。

专项例子 3:finally 恢复界面按钮

class Program { static async Task Main() { bool buttonEnabled = false; try { await Task.Delay(100); throw new Exception("设备离线"); } catch (Exception ex) { Console.WriteLine(ex.Message); } finally { buttonEnabled = true; } Console.WriteLine($"按钮可用: {buttonEnabled}"); } }

运行结果或界面效果:

设备离线 按钮可用: True

改动任务: 删除异常,确认 finally 仍然执行。

第四部分:作业完整答案

这一部分给出当天作业的完整答案。建议先照着敲一遍,再修改失败条件验证。

作业 1:异步读取必需文件

要求:

  1. ReadRequiredFileAsync
  2. 文件不存在时捕获 FileNotFoundException
  3. 输出明确提示。

完整答案

try { async Task<string> ReadRequiredFileAsync(string path) { return await File.ReadAllTextAsync(path); } string text = await ReadRequiredFileAsync("missing.txt"); Console.WriteLine(text); } catch (FileNotFoundException) { Console.WriteLine("文件不存在,读取失败"); }

作业 2:异步业务规则异常

要求:

  1. PayAsync
  2. 金额小于等于 0 时抛 ArgumentException
  3. 调用处捕获并输出。

完整答案

try { async Task PayAsync(string orderNo, decimal amount) { await Task.Delay(300); if (string.IsNullOrWhiteSpace(orderNo)) { throw new ArgumentException("订单号不能为空"); } if (amount <= 0) { throw new ArgumentException("支付金额必须大于 0"); } Console.WriteLine("支付成功"); } await PayAsync("SO-001", 0m); } catch (ArgumentException ex) { Console.WriteLine($"支付失败: {ex.Message}"); }

作业 3:批量任务失败

要求:

  1. 准备 5 个任务。
  2. 其中一个任务模拟失败。
  3. 每个任务返回 ProcessResult
  4. 最后输出每个任务的结果。

完整答案

int[] ids = { 1, 2, 3, 4, 5 }; Task<ProcessResult>[] tasks = ids async Task<ProcessResult> ProcessSafeAsync(int id) { try { await Task.Delay(300); if (id == 3) { throw new InvalidOperationException("任务 3 失败"); } return new ProcessResult(id, true, "成功"); } catch (Exception ex) { return new ProcessResult(id, false, ex.Message); } } .Select(id => ProcessSafeAsync(id)) .ToArray(); ProcessResult[] results = await Task.WhenAll(tasks); foreach (ProcessResult result in results) { Console.WriteLine($"{result.Id} | {result.Success} | {result.Message}"); } class ProcessResult { public ProcessResult(int id, bool success, string message) { Id = id; Success = success; Message = message; } public int Id { get; } public bool Success { get; } public string Message { get; } }

作业 4:取消和普通异常分开处理

要求:

  1. 创建一个 300 毫秒后取消的 token。
  2. 任务模拟等待 2 秒。
  3. 取消异常单独捕获。

完整答案

using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); try { async Task LoadAsync(CancellationToken token) { await Task.Delay(2000, token); Console.WriteLine("加载完成"); } await LoadAsync(cts.Token); } catch (OperationCanceledException) { Console.WriteLine("任务已取消"); } catch (Exception ex) { Console.WriteLine($"任务失败: {ex.Message}"); }

作业 5:批量读取文件并统计失败

要求:

  1. 创建两个存在的文件。
  2. 加入一个不存在的文件路径。
  3. 并发读取三个文件。
  4. 输出成功数和失败数。

完整答案

await File.WriteAllTextAsync("a.txt", "A"); await File.WriteAllTextAsync("b.txt", "B"); string[] paths = { "a.txt", "missing.txt", "b.txt" }; Task<FileReadResult>[] tasks = paths async Task<FileReadResult> ReadFileSafeAsync(string path) { try { string text = await File.ReadAllTextAsync(path); return new FileReadResult(path, true, text); } catch (Exception ex) { return new FileReadResult(path, false, ex.Message); } } .Select(path => ReadFileSafeAsync(path)) .ToArray(); FileReadResult[] results = await Task.WhenAll(tasks); int successCount = results.Count(result => result.Success); int failedCount = results.Length - successCount; Console.WriteLine($"成功: {successCount}"); Console.WriteLine($"失败: {failedCount}"); foreach (FileReadResult result in results) { Console.WriteLine($"{result.Path} | {result.Message}"); } class FileReadResult { public FileReadResult(string path, bool success, string message) { Path = path; Success = success; Message = message; } public string Path { get; } public bool Success { get; } public string Message { get; } }

本页最后要记住

  1. 异步方法内部异常会在 await 处重新抛出。
  2. try/catch 要包住 await
  3. 用户输入格式错误优先用 TryParse,不是全靠异常。
  4. 取消异常和普通异常要分开处理。
  5. Task.WhenAll 中任意任务失败,整体 await 会进入 catch。
  6. 批量业务更适合每个任务返回自己的成功失败结果。
  7. 不要空 catch 吞掉错误,至少要输出或记录原因。