1. 异步方法和异常
我们习惯了如果代码里发生了不好的事(比如除以零或尝试访问不存在的文件),会抛出异常,我们可以用 try-catch 捕获。顺序执行且在同一线程里时一切简单。但一旦出现异步,世界就像外太空:异常可能在远离预期的位置“出现”,甚至根本被忽略。
原因在于异步方法通常返回一个任务(Task),这个任务的执行会在方法返回之后继续。异常可能在主线程“放手”之后发生。所以把 try-catch 包在调用周围并不总像在同步代码里那样起作用。
举个简单例子。假设我们的小程序里有这么个异步方法:
// 我们程序片段:异步计算“发送报告”
public async Task SendReportAsync()
{
// 这里可能有网络调用或文件访问
await Task.Delay(100);
throw new InvalidOperationException("发送报告时出错!");
}
下面是我们如何调用它:
SendReportAsync();
Console.WriteLine("继续工作...");
可视化
flowchart TD
Start["主线程"]
Call[/"调用 SendReportAsync()"/]
Continue["工作继续..."]
Exception["异常在 Task 中发生"]
Unhandled["错误未被处理!"]
Start --> Call --> Continue
Call -.- Exception --> Unhandled
结论: 如果异步方法返回 Task,而你没有等待任务完成(既没有 await 也没有 .Wait()),异常会被“忽略”。在最好的情况下,运行时会在日志里写类似 “Task 中未处理的异常” 的东西。最坏的情况——你完全丢失了错误,需要很久才能找出“神秘的 bug”来源。
2. 如何在异步代码里正确捕获异常?
使用 await + try-catch
看一个正确的做法:
try
{
await SendReportAsync(); // 等待 Task 完成
Console.WriteLine("报告发送成功!");
}
catch (Exception ex)
{
Console.WriteLine($"哎呀!出错了: {ex.Message}");
}
这是怎么工作的? 当你在异步方法调用前加上 await,C# 会把你的方法拆成两部分:await 之前和之后。如果在异步部分发生异常,它会在写有 await 的地方“抛出来”,你可以用常规的 try-catch 捕获它。
示例(应用场景)
在我们的演示中给发送报告流程加上错误处理:
public async Task StartReportProcessAsync()
{
try
{
await SendReportAsync();
Console.WriteLine("报告发送成功!");
}
catch (Exception ex)
{
Console.WriteLine($"发送报告时出错: {ex.Message}");
}
}
并这样调用:
await StartReportProcessAsync();
.Wait(), .Result — 不是最优但在控制台可行
有时特别是在控制台应用里,你不能在顶层使用 await(老版本 C#,Main 方法)。这时只能用同步等待,比如 .Wait() 或 .Result。
try
{
SendReportAsync().Wait();
}
catch (AggregateException aggEx)
{
foreach (var ex in aggEx.InnerExceptions)
Console.WriteLine($"错误: {ex.Message}");
}
为什么要这么做? .Wait() 和 .Result 会把原始异常包到 AggregateException 里。这个容器可能包含一个或多个异常,所以需要用循环把内部异常拆开。想了解更多 AggregateException 的细节请看 官方文档。
重要!
在现代 .NET 版本(从 C# 7.1 起)你可以把 Main 声明为异步并在入口直接使用 await:
static async Task Main(string[] args)
{
await StartReportProcessAsync();
}
3. “fire-and-forget” 任务里的异常
如果你启动一个异步方法,不等待它完成,也不保存任务引用,会发生什么?
SendReportAsync(); // “忘记” 这任务了
这种情况下的问题是:任务中发生的异常没人处理。有时(取决于宿主和设置)应用可能会崩溃,有时只是记录个警告。这不是 C# 的 bug,而是任务工作机制的结果。
怎么做才对?
- 理想情况:如果不能保证任务不会崩溃,就别用“fire-and-forget”。
- 如果异步方法确实需要以“fire-and-forget” 方式运行,那就在方法内部显式处理错误。
public async Task SendReportSafeAsync()
{
try
{
await Task.Delay(100);
throw new InvalidOperationException("发送时出错!");
}
catch (Exception ex)
{
// 记录或处理错误
Console.WriteLine($"[日志] 异常: {ex.Message}");
}
}
// 调用
SendReportSafeAsync();
通用建议: 如果任务没人跟踪并且你不使用 await,一定要把异步方法体包在 try-catch 里。这样你不会丢失错误,至少能把它记录下来。
4. 异常与并行任务:Task.WhenAll 等
在真实应用中经常需要同时启动多个相互独立的异步任务并等待它们完成。比如同时给多个收件人发送报告:
var tasks = new List<Task>
{
SendReportAsync(),
SendReportAsync(),
SendReportAsync()
};
await Task.WhenAll(tasks);
如果一个(或多个)任务抛了异常,会发生什么?
如何捕获这些错误?
当用 await 等待 Task.WhenAll(tasks) 时——如果至少有一个任务以错误结束,await 会抛出第一个出现错误的任务的异常(通常不会被包成 AggregateException)。
但有个细节: 如果有多个任务失败,可能会抛出包含多个内部异常的 AggregateException。
try
{
await Task.WhenAll(tasks);
}
catch (Exception ex)
{
// 如果是 AggregateException — 拆开它
if (ex is AggregateException agg)
{
foreach (var inner in agg.InnerExceptions)
Console.WriteLine($"任务中的错误: {inner.Message}");
}
else
{
Console.WriteLine($"错误: {ex.Message}");
}
}
对单个任务用 await 时,异常一般不会被包成 AggregateException。但对 WhenAll 来说,它就有可能出现!
5. 异步委托和错误处理
在有 UI 的程序(WPF、WinForms、ASP.NET)里事件处理器常写成异步 lambda。如果这样的处理器里异常“冒出来”,结果取决于 UI 框架:应用可能崩溃,也可能吞掉错误。
建议
在异步委托内部总是使用 try-catch:
button.Click += async (sender, args) =>
{
try
{
await SendReportAsync();
}
catch (Exception ex)
{
MessageBox.Show($"错误: {ex.Message}");
}
};
GO TO FULL VERSION