Day 5 - 异步异常处理
建议用时:240-300 分钟
你将学会什么
- 异步方法里的异常为什么会在
await处出现 try/catch应该包住哪一行代码Task.WhenAll中有任务失败时会发生什么- 批量任务如何做到“一个失败不影响全部结果”
- 取消异常和普通异常应该怎么区分
- 为什么不能空
catch把错误吞掉
异常处理不是把错误藏起来。好的异常处理要做到:程序不直接崩溃、错误原因可定位、用户提示能理解、后续状态能收尾。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
今天只抓住 3 件事
- 异步异常通常在
await那一行重新抛出。 - 批量任务不能让一个失败毁掉全部结果,要能记录每个任务状态。
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 内部会记录多个异常。
阶段学习先记住:
- 任意一个任务失败,
await WhenAll会进入catch。 - 批量业务里,常常需要每个任务自己捕获错误并返回失败结果。
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:异步文件读取异常
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 { ... } | 恢复状态、释放资源 |
常见错误和修法
| 错误 | 为什么错 | 修法 |
|---|---|---|
只创建 Task 不 await | 异常不会在当前位置处理 | 对异步任务使用 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:异步读取必需文件
要求:
- 写
ReadRequiredFileAsync。 - 文件不存在时捕获
FileNotFoundException。 - 输出明确提示。
完整答案
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:异步业务规则异常
要求:
- 写
PayAsync。 - 金额小于等于 0 时抛
ArgumentException。 - 调用处捕获并输出。
完整答案
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:批量任务失败
要求:
- 准备 5 个任务。
- 其中一个任务模拟失败。
- 每个任务返回
ProcessResult。 - 最后输出每个任务的结果。
完整答案
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:取消和普通异常分开处理
要求:
- 创建一个 300 毫秒后取消的 token。
- 任务模拟等待 2 秒。
- 取消异常单独捕获。
完整答案
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:批量读取文件并统计失败
要求:
- 创建两个存在的文件。
- 加入一个不存在的文件路径。
- 并发读取三个文件。
- 输出成功数和失败数。
完整答案
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; }
}本页最后要记住
- 异步方法内部异常会在
await处重新抛出。 try/catch要包住await。- 用户输入格式错误优先用
TryParse,不是全靠异常。 - 取消异常和普通异常要分开处理。
Task.WhenAll中任意任务失败,整体 await 会进入 catch。- 批量业务更适合每个任务返回自己的成功失败结果。
- 不要空
catch吞掉错误,至少要输出或记录原因。