CodeGym /课程 /C# SELF /错误处理总策略: Task

错误处理总策略: Taskasync/ awaitAggregateException

C# SELF
第 61 级 , 课程 4
可用

1. 介绍

写“永不崩溃”的程序就像相信 bug 会怕你的 IDE。实际上,代码越复杂(尤其是在多线程和异步场景),bug 越会搞花样,异常也越难处理。如果忽视线程和任务里的异常,很容易出现资源泄露、死锁、数据丢失,或者在运行好几个小时后突然崩溃。

这节课把在 C# 多线程和异步编程中处理异常的最佳实践组合起来。你会学到如何正确捕获异常,如何拆解同时发生的“一堆”错误(比如 AggregateException),怎么处理那些被“丢掉”的任务,以及为什么忽略异常既危险又没意义。

为什么事情没那么简单

  • 发生错误的线程可能和你放 try-catch 的线程不一样。
  • 异步任务不会马上抛出异常——它们会被“打包”,在 await(或同步等待)时才处理。
  • 当同时处理多个任务(比如 Task.WhenAll)时,可能会有多个错误——需要把它们都考虑进去。
  • fire-and-forget 类型的操作可能会完全“丢掉”异常,因为没有显式的处理者。

这个特性就是执行上下文分离。把程序想象成有好几个场地的马戏团:一个地方起火,别的地方不一定立刻能看到。重要的是学会正确追踪并扑灭这些“火”。

2. 任务里的异常: TaskTask<TResult>

任务如何报告错误

当任务里发生未处理异常时,它不会马上“抛”出来。任务会变成 Faulted,异常保存在内部。你可以通过以下方式获取它:

  • await 等待完成(或通过 task.Wait()/task.Result —— 但最好别这么做);
  • 检查属性 task.Exception —— 那里会是一个 AggregateException

示例


// 有错误的异步方法
async Task FailAsync()
{
    await Task.Delay(100);
    throw new InvalidOperationException("出现了问题");
}

async Task MainAsync()
{
    try
    {
        await FailAsync();
    }
    catch (Exception ex)
    {
        Console.WriteLine($"错误: {ex.Message}");
    }
}

如果不加 try-catch,程序会崩溃。如果加了 —— 异常会被正确捕获,即便错误发生在别的任务里。

AggregateException:批量错误

在用 await Task.WhenAll(tasks) 时,来自多个任务的错误会被收集到一个 AggregateException(在它的 InnerExceptions 里)。


async Task MultiFailAsync()
{
    Task t1 = Task.Run(() => throw new InvalidOperationException("错误 1"));
    Task t2 = Task.Run(() => throw new ArgumentException("错误 2"));
    try
    {
        await Task.WhenAll(t1, t2);
    }
    catch (Exception ex)
    {
        if (ex is AggregateException agg)
        {
            foreach (var e in agg.InnerExceptions)
                Console.WriteLine($"异常: {e.Message}");
        }
        else
        {
            Console.WriteLine($"单个错误: {ex.Message}");
        }
    }
}

注意:使用 await Task.WhenAll(tasks) 时,.NET 会“展开” AggregateException,在 catch 中你会拿到“第一个”异常。完整列表可以通过 task.Exception.InnerExceptions 获取(如果任务以错误结束)。

3. “Fire and Forget”:为什么不能直接忘了任务

“启动然后忘记”常常会导致隐蔽的失败。示例:


Task.Run(() => { throw new Exception("哐!"); }); // 错误飞到空处,没人观察到。

现代运行时会把错误保存在任务中,并且如果异常未被观察,可能会导致进程终止。最好的做法是保存任务引用和/或订阅事件 TaskScheduler.UnobservedTaskException

怎么办才对?

  • 保存任务引用,以便等待它完成并处理错误;
  • 对于 fire‑and‑forget,在委托内部放上处理器。

Task.Run(() => {
    try 
    {
        // 可能抛出异常的代码
    }
    catch (Exception ex)
    {
        // 记录、通知,但不要把错误放到外面去
        Console.WriteLine($"fire-and-forget 中的错误: {ex.Message}");
    }
});

4. 线程 (Thread) 里的错误:“外面捕不到”吗?

新开一个 Thread 里的异常无法在外面用 try-catch 捕获 —— 只能在线程体内处理。


var thread = new Thread(() =>
{
    try
    {
        throw new Exception("线程中的错误");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"在线程内捕获到错误: {ex.Message}");
    }
});
thread.Start();

如果不放处理器,异常只会结束那个线程(如果它是后台线程:thread.IsBackground = true)。对于非后台线程,未处理的异常可能导致整个进程终止。总是在线程内放 try-catch

如何从线程返回结果和错误?

  • 使用队列/集合来传递结果/错误;
  • 事件模型;
  • 最好转用任务 — 用 Task 更方便处理错误。

5. 并行循环:特殊方式捕获错误

在并行循环中,不同分支的错误会被聚合成 AggregateException


try
{
    Parallel.For(0, 5, i =>
    {
        if (i % 2 == 0)
            throw new Exception($"第 {i} 次迭代出错");
    });
}
catch (AggregateException ex)
{
    foreach (var e in ex.InnerExceptions)
        Console.WriteLine($"[并行循环] 错误: {e.Message}");
}

如果想记录局部失败并继续其它分支,就在每个分支里放单独的 try-catch

6. 取消任务时的错误处理

通过 CancellationToken 取消时,约定会抛出 OperationCanceledException —— 这不是故障,而是正常的停止。


async Task DoWorkAsync(CancellationToken token)
{
    for (int i = 0; i < 10; i++)
    {
        token.ThrowIfCancellationRequested();
        await Task.Delay(100);
    }
}

// 在代码某处:
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
cts.Cancel(); // 大约 200 ms 后

try
{
    await task;
}
catch (OperationCanceledException)
{
    Console.WriteLine("操作已取消!");
}

除了 ThrowIfCancellationRequested() 外,很多支持 token 的方法(比如 Task.Delay, HttpClient.GetAsync)也会自己抛出 OperationCanceledException。确认你的 API 是否支持取消。

7. 实用细节

通用做法

  • 在异步方法的顶层放 try-catch —— 这样能捕到“逃跑”的错误。
  • 不要忽略任务:保存引用、等待完成、记录错误。
  • 对并行操作(Task.WhenAll / Parallel.ForEach)要考虑 AggregateException
  • 把取消和故障区分开:单独捕获 OperationCanceledException
  • 记录错误,尤其是那些“不杀死应用”的安静错误。
  • 保存细节:记录所有内层异常,而不仅仅是第一个。
  • 在本地处理错误:如果不知道该怎么办,至少记录下来。

在多线程和异步代码中应该在哪里捕获异常

flowchart TD
    A[主线程 / UI] -->|启动任务| B[Task/async]
    B -->|任务内部| C[在异步方法内 try/catch]
    B -->|在主线程 await| D[在 await 周围 try/catch]
    A -->|启动 Thread| E[Thread]
    E -->|内部| F[在线程内 try/catch]
    B -->|多个任务| G[Task.WhenAll / Parallel.ForEach]
    G -->|错误| H[AggregateException]

8. 常见错误和踩坑

错误 #1:.Result.Wait() 等待任务。可能会死锁和/或得到意外的 AggregateException

错误 #2: 启动 fire‑and‑forget 而没有在内部放 try-catch —— 任务可能悄悄挂掉,没法诊断。

错误 #3: 忽略并行循环中的错误 —— 部分工作没做,你却浑然不觉。

错误 #4: 不区分取消(OperationCanceledException)和真实错误。

错误 #5: 只记录多个任务中的第一个错误 —— 其它错误被“埋”起来了。

错误 #6: 为所有任务复用同一个 Exception 对象 —— 每个错误都该有自己的实例。

错误 #7: UI 线程没有异常处理 —— 后台错误没被注意到,界面表现得很怪。

1
调查/小测验
经典Thread中的异常第 61 级,课程 4
不可用
经典Thread中的异常
异步代码中的错误处理
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION