C# 异常(Exception)处理避坑指南

文章目录

  • [1. 异常的本质与开销](#1. 异常的本质与开销)
  • [2. 异步环境中的异常 (Async/Await)](#2. 异步环境中的异常 (Async/Await))
    • [2.1. 异步异常的"生存周期"](#2.1. 异步异常的“生存周期”)
    • [2.2. 两种常见的异步异常类型](#2.2. 两种常见的异步异常类型)
      • [2.2.1 原始异常 (Unwrapped Exception)](#2.2.1 原始异常 (Unwrapped Exception))
      • [2.2.2 聚合异常 (AggregateException)](#2.2.2 聚合异常 (AggregateException))
    • [2.3. 异步异常处理的"死劫":async void](#2.3. 异步异常处理的"死劫":async void)
    • [2.4. 总结](#2.4. 总结)
    • [2.5. 异常在异步流中的传递过程](#2.5. 异常在异步流中的传递过程)
  • [3. 异常类](#3. 异常类)
  • [4. 什么是异常过滤器(Exception Filters)?](#4. 什么是异常过滤器(Exception Filters)?)
  • [5. 如何创建自定义异常?](#5. 如何创建自定义异常?)
  • [6. AggregateException的作用是什么?](#6. AggregateException的作用是什么?)
  • [7. 全局异常监控](#7. 全局异常监控)
  • 8.异常流转逻辑图
  • [9. 易混淆](#9. 易混淆)
    • [9.1 不要吞掉异常 (Don't Swallow Exceptions)](#9.1 不要吞掉异常 (Don't Swallow Exceptions))
    • [9.2 针对具体的异常捕获](#9.2 针对具体的异常捕获)
    • [9.3 使用内联初始化的异常信息](#9.3 使用内联初始化的异常信息)
    • [9.4 总结](#9.4 总结)

1. 异常的本质与开销

程序中的运行时错误,它违反一个系统约束或应用程序约束,或出现了在正常操作时未预料的情形。

异常在 .NET 中不是简单的"错误标记",它是一个重量级对象

  • 堆栈回溯(Stack Trace):throw 发生时,运行时(CLR)必须暂停线程,爬取当前的调用堆栈以填充 StackTrace 属性。这是一个极其耗费 CPU 资源的操作。
  • 第一性原理: 异常应该只用于不可预见的错误(Unexceptional cases)。如果一个错误是可以通过代码逻辑预判的(例如:输入验证、尝试解析数字),则不应使用异常。

遵循 Tester-Doer 模式Try-Parse 模式

csharp 复制代码
// 差:依赖异常控制流程
try
{
  int.Parse(input);
}
catch
{
  ...
}

// 优:性能更好
if (int.TryParse(input, out var result))
{
  ...
}

1.1.try语句

指明被异常保护的代码块,并提供代码以处理异常。try由三部分组成:try...catch finally.

try:包含正被异常保护的代码

catch:含有一个或多个catch子句,这些是处理异常的代码块,也叫做异常处理程序

finally:含有在所有情况下都要被执行的代码,无论有没有异常发生

1.2.try-catch-finally

  • 正常流程:try→finally
  • 异常流程:try→匹配的 catch→finally
  • 特点:finally必定执行(即使 try/catch 里有 return),用于释放资源

这是 C# 基础中的经典逻辑。即使你在 trycatch 块里写了 returnfinally 也一定会执行。

  1. 执行 try:运行业务逻辑。
  2. 发生异常 :立即中断 try 中剩余的代码,寻找匹配的 catch
  3. 执行 catch:处理异常逻辑。
  4. 执行 finally :无论是否发生异常,无论是否 return,最后都会执行(用于释放资源)。

注意 :如果在 finally 块执行前程序被强行终止(如 Environment.FailFast() 或断电),它才不会执行。

finally 块

无论是否发生异常,finally 都会执行。它是释放非托管资源(如句柄、数据库连接)的最后防线。

using 声明

现代 C# 推荐使用 using 模式,它在编译时会被自动转化为 try-finally 并调用 Dispose() 方法。

csharp 复制代码
// 推荐写法 (C# 8.0+)
using var reader = new StreamReader("config.json");
string content = reader.ReadToEnd();
// 作用域结束时自动释放

1.3.throw ex vs. throw

  • throw ex:重置异常的调用栈(丢失原始错误位置),重置堆栈轨迹。它会告诉系统异常是从这一行开始的,你会丢失导致异常的原始位置信息。
  • throw:保留原始调用栈(包含错误发生的代码行),保持堆栈轨迹。它会保留原始异常发生的所有信息,是生产环境中最推荐的做法。
  • 建议:重新抛出异常用throw;,不用throw ex;关乎你调试代码的效率。

throw ex:重新开始 (Restart)

当你写 throw ex; 时,CLR(公共语言运行时)会认为你正在抛出一个全新 的异常。它会清除掉 ex 对象中原有的堆栈信息,并从当前这一行代码开始重新记录。

  • 后果: 你在日志里只能看到错误发生在 catch 块所在的函数,而无法知道 try 块里到底是哪个深层方法触发了问题。

throw:接力传递 (Preserve)

当你只写 throw; 时,CLR 会识别出这是一个"重新抛出"指令。它会保持 ex 对象内部的堆栈帧(Stack Frame)不动,仅仅是把这个异常继续往上传递。

  • 后果: 堆栈信息完整,包含异常最初发生的具体文件名和行号。

异常抛出

可以使用throw语句显式引发一个异常,语法如下:

csharp 复制代码
class Program
{
    static void Main(string[] args)
    {
        // 带对象的异常抛出
        string s = null;
        Myclass.PrintArg(s);
        Myclass.PrintArg("hello world");
        Myclass01.PrintArg(s);
        Console.ReadKey();

    }
}

class Myclass
{
    public static void PrintArg(string arg)
    {
        try
        {
            if(arg==null)
            {
                ArgumentNullException myEx = new ArgumentNullException("arg");
                throw myEx;
            }
        }
        catch (ArgumentNullException e)
        {
            Console.WriteLine("Message:{0}", e.Message);
        }
    }
}

class Myclass01
{
    public static void PrintArg(string arg)
    {
        try
        {
            try
            {
                if(arg == null)
                {
                    ArgumentNullException myEx = new ArgumentNullException("arg");
                    throw myEx;
                }
                Console.WriteLine(arg);
            }
            catch(ArgumentNullException e)
            {
                Console.WriteLine("Inner catch:{0}", e.Message);
                throw;//重新抛出异常,没有附加参数
            }
        }
        catch
        {
            Console.WriteLine("Outer catch:Handling an Exception.");
        }
    }
}

场景模拟

假设有一个三层调用:Main -> Service -> Repository(报错点)。

使用throw ex

你的日志或调试器会显示:

异常:System.Exception: 数据库连接失败 位置:在 MyProject.Service.UpdateData() 的第 45 行 <-- 这是 catch 块的位置

使用throw

你的日志或调试器会显示:

异常:System.Exception: 数据库连接失败 位置:在 MyProject.Repository.ExecuteSql() 的第 120 行 <-- 这是真正的报错点 在 MyProject.Service.UpdateData() 的第 42 行

总结

默认原则: 永远优先使用 throw;

唯一使用 throw ex; 的理由: 如果你故意想隐藏底层实现细节(例如编写一个面向外部客户端的 SDK,不希望暴露内部混淆后的堆栈),但这种情况极少见。

防御性排查: 在生产环境中,结合 log4netSerilog 记录异常时,即使你用了 throw;,也请确保记录的是 ex.ToString() 而不仅仅是 ex.Message。因为 ToString() 会自动包含完整的堆栈追踪。

1.4.ExceptionDispatchInfo (特殊场景)

你可能会遇到一种特殊情况:你捕获了异常,做了一些异步操作,然后想在另一个线程稍后的时间抛出它,且仍要保留堆栈。

这时候 throw; 只能在 catch 块内使用,而 ExceptionDispatchInfo 可以打破这个限制:

csharp 复制代码
using System.Runtime.ExceptionServices;

ExceptionDispatchInfo? capturedException = null;

try {
    // 某些逻辑
}
catch (Exception ex) {
    // 捕获并保存
    capturedException = ExceptionDispatchInfo.Capture(ex);
}

// ... 离开 catch 块后,在其他地方重新抛出
capturedException?.Throw(); // 效果等同于 throw; 完美保留堆栈

2. 异步环境中的异常 (Async/Await)

在异步编程(Async/Await)中,异常的处理不再是简单的"发生即捕获",而是涉及到一个包装 (Wrapping)与解包(Unwrapping)的过程。

在多线程或异步编程中,异常的处理逻辑会发生变化。

  • Task 包装:async 方法中抛出的异常会被捕获并存储在返回的 Task 对象中。
  • AggregateException: 当使用 Task.WaitAll 或并发任务时,多个异常会被聚合进 AggregateException (聚合异常)。你需要使用 Flatten() 方法将其展开。

2.1. 异步异常的"生存周期"

当你在一个 async 方法中使用 throw 时,异常不会立即中断程序,而是被转译为 Task 的一种状态。

  • 捕获阶段: 异步状态机(Async State Machine)会捕获方法体内的任何未处理异常。
  • 存储阶段: 该异常被封装在返回的 Task 对象中,此时 Task.IsFaulted 属性为 true
  • 唤醒阶段: 只有当你 await 这个 Task 时,异常才会被重新抛出(Re-throw),进入你的 try-catch 逻辑。

2.2. 两种常见的异步异常类型

2.2.1 原始异常 (Unwrapped Exception)

当你使用 await 关键字时,.NET 会自动帮你"解包"。即使 Task 中可能包含多个异常(例如并发任务),await 也只会抛出第一个捕获到的异常。

csharp 复制代码
try
{
    await SomeAsyncMethod(); // 这里抛出的是原始异常,如 FileNotFoundException
}
catch (FileNotFoundException ex)
{
    // 直接针对业务异常处理
}

2.2.2 聚合异常 (AggregateException)

当你使用同步阻塞方式(如 .Result.Wait())或者处理多个并发任务(如 Task.WhenAll)时,异常会以 AggregateException 的形式出现。

  • Flatten() 方法: 聚合异常可能嵌套。Flatten() 会将嵌套的异常树"拍平"成一个简单的列表,方便遍历。
  • Handle() 方法: 允许你针对特定类型的异常进行处理,如果不符合条件,则继续向上传递。
csharp 复制代码
var task1 = Task.Run(() => throw new InvalidOperationException());
var task2 = Task.Run(() => throw new AccessViolationException());

Task allTasks = Task.WhenAll(task1, task2);

try
{
    allTasks.Wait(); // 同步等待会触发 AggregateException
}
catch (AggregateException aes)
{
    // 使用 Flatten 拍平嵌套,并处理
    foreach (var ex in aes.Flatten().InnerExceptions)
    {
        Console.WriteLine($"捕获到:{ex.GetType().Name}");
    }
}

2.3. 异步异常处理的"死劫":async void

这是异步编程中的禁忌。绝对不要在非事件处理程序(Event Handler)中使用 async void

  • 无法捕获: 因为 async void 没有返回 Task 对象,调用者无法 await 它,异常会直接抛给 SynchronizationContext(同步上下文)。
  • 结果: 这通常会导致整个进程崩溃(Process Crash),且你的全局 try-catch 根本抓不到它。

2.4. 总结

  1. 优先使用 await 避免使用 .Result.Wait(),这样可以处理干净的原始异常,而不是复杂的 AggregateException
  2. 始终监听 Task 如果你发起了一个异步任务但没有 await 它(Fire-and-Forget),请确保内部有完善的日志记录,或者挂载 ContinueWith 来处理异常。
  3. 取消操作的异常: 当任务因 CancellationToken 取消时,会抛出 OperationCanceledException。在异步链路中,应将其视为正常流程的一部分,而不是致命错误。
  4. Task.WhenAll 的陷阱: 即使 WhenAll 包含 10 个失败的任务,await 也只抛出第一个。如果需要获取全部错误,请检查 Task.Exception.InnerExceptions 集合。

2.5. 异常在异步流中的传递过程

3. 异常类

BCL定义了许多类,每一个类代表一个指定的异常类型,当一个异常发生时,CLR:

  • 创建该类型的异常对象
  • 寻找适当的catch子句以处理 它

所有异常类都从根本上派生自system.exception类,异常继承层次如下

异常类类型包括:基类:System.Exception;

系统级异常:System.SystemException;

应用程序级异常:System.ApplicationException。

System.SystemException派生的异常类型:

System.AccessViolationException 在试图读写受保护内存时引发的异常。
System.ArgumentException 在向方法提供的其中一个参数无效时引发的异常。
System.Collections.Generic.KeyNotFoundException 指定用于访问集合中元素的键与集合中的任何键都不匹配时所引发的异常。
System.IndexOutOfRangeException 访问数组时,因元素索引超出数组边界而引发的异常。
System.InvalidCastException 因无效类型转换或显示转换引发的异常。
System.InvalidOperationException 当方法调用对于对象的当前状态无效时引发的异常。
System.InvalidProgramException 当程序包含无效Microsoft中间语言(MSIL)或元数据时引发的异常,这通常表示生成程序的编译器中有bug。
System.IO.IOException 发生I/O错误时引发的异常。
System.NotImplementedException 在无法实现请求的方法或操作时引发的异常。
System.NullReferenceException 尝试对空对象引用进行操作时引发的异常。
System.OutOfMemoryException 没有足够的内存继续执行程序时引发的异常。
System.StackOverflowException 挂起的方法调用过多而导致执行堆栈溢出时引发的异常。

System.ArgumentException派生的异常类型:

System.ArgumentNullException 当将空引用传递给不接受它作为有效参数的方法时引发的异常。
System.ArgumentOutOfRangeException 当参数值超出调用的方法所定义的允许取值范围时引发的异常。

System.ArithmeticException派生的异常类型:

System.DivideByZeroException 试图用零除整数值或十进制数值时引发的异常。
System.NotFiniteNumberException 当浮点值为正无穷大、负无穷大或非数字(NaN)时引发的异常。
System.OverflowException 在选中的上下文中所进行的算数运算、类型转换或转换操作导致溢出时引发的异常。

System.IOException派生的异常类型:

System.IO.DirectoryNotFoundException 当找不到文件或目录的一部分时所引发的异常。
System.IO.DriveNotFoundException 当尝试访问的驱动器或共享不可用时引发的异常。
System.IO.EndOfStreamException 读操作试图超出流的末尾时引发的异常。
System.IO.FileLoadException 当找到托管程序却不能加载它时引发的异常。
System.IO.FileNotFoundException 试图访问磁盘上不存在的文件失败时引发的异常。
System.IO.PathTooLongException 当路径名或文件名超过系统定义的最大长度时引发的异常。

其他常用异常类型:

ArrayTypeMismatchException 试图在数组中存储错误类型的对象。
BadImageFormatException 图形的格式错误。
DivideByZeroException 除零异常。
DllNotFoundException 找不到引用的dll。
FormatException 参数格式错误。
MethodAccessException 试图访问私有或者受保护的方法。
MissingMemberException 访问一个无效版本的dll。
NotSupportedException 调用的方法在类中没有实现。
PlatformNotSupportedException 平台不支持某个特定属性时抛出该错误。
ArgumentNullException 参数为空异常。在方法入口处防御性编程的最爱。
OperationCanceledException 操作取消异常。配合 CancellationToken (取消令牌) 使用。
TimeoutException 超时异常。用于网络或硬件 I/O 响应超时。
ObjectDisposedException 对象已释放异常。访问已经 Dispose 的对象时触发。

4. 什么是异常过滤器(Exception Filters)?

这是 C# 6.0 引入的黑科技,使用 when 关键字。

它允许你在进入 catch之前 先判断条件。如果条件不成立,异常会继续向上传递,而不会进入catch 块。

  • 优势:它不会"展开(Unwind)"堆栈。这意味着如果在过滤器里截获并调试,你可以看到异常发生时的最原始现场。
csharp 复制代码
try
{
    // 业务代码
}
catch (HttpException ex) when (ex.StatusCode == 404)
{
    // 只有 404 错误才进这里
}
catch (HttpException ex) when (ex.StatusCode == 500)
{
    // 500 错误进这里
}
  • 定义:catch 后加when(条件),仅当条件为 true 时捕获异常
  • 示例:
csharp 复制代码
catch (FileNotFoundException ex) when (ex.FileName.Contains("config")){
  /* 仅捕获文件名含config的文件不存在异常 */
}
  • 作用:更精准地捕获特定场景的异常

5. 如何创建自定义异常?

根据微软标准,自定义异常应该继承自 Exception 类,并遵循命名规范(以 Exception 结尾)。

csharp 复制代码
[Serializable] // 建议标记为可序列化
public class InsufficientFundsException : Exception
{
    public decimal CurrentBalance { get; }

    public InsufficientFundsException() { }

    public InsufficientFundsException(string message, decimal balance)
        : base(message)
    {
        CurrentBalance = balance;
    }

    // 建议至少提供三个标准构造函数
    public InsufficientFundsException(string message, Exception inner)
        : base(message, inner) { }
}
  • 步骤:
    • 继承 Exception 类
    • 实现构造函数(无参 / 带消息 / 带消息和内部异常)
  • 示例:
csharp 复制代码
public class MyException : Exception
{
  public MyException() : base() { }
  public MyException(string message) : base(message)
  {

  }
  public MyException(string message, Exception inner) : base(message, inner)
  {

  }
}

自定义的异常类派生自ApplicationException

csharp 复制代码
class Program
{
    static void Main(string[] args)
    {
        // 用户自定义的异常类是派生自ApplicationException类
        Temperture temp = new Temperture();
        try
        {
            temp.showTemp();
        }
        catch(TempIsZeroException e)
        {
            Console.WriteLine("TempIsZeroException:{0}", e.Message);
        }
        Console.ReadKey();

    }
}

// 创建自定义异常
public class TempIsZeroException:ApplicationException
{
    public TempIsZeroException(string message):base(message)
    {

    }
}

public class Temperture
{
    int temperature = 0;
    public void showTemp()
    {
        if(temperature==0)
        {
            throw (new TempIsZeroException("Zero Temperature found"));
        }
        else
        {
            Console.WriteLine("Temperature:{0}", temperature);
        }
    }
}

6. AggregateException的作用是什么?

这是并行编程(TPL/PLINQ)中的专有异常。

当你使用 Task.WaitAll 或并行循环时,可能多个任务同时抛出多个不同的异常。AggregateException 就像一个容器,把所有发生的异常打包在一起。

  • 处理方式 :你可以使用 Flatten() 方法将嵌套的异常展平,或使用 Handle() 过滤掉你关注的异常。
  • 用于 **并行操作(如 Task.WhenAll)**中,包装多个异常(因并行任务可能抛出多个异常)
  • 用法:通过ex.InnerExceptions获取所有异常
  • 示例:
csharp 复制代码
try
{
  await Task.WhenAll(task1, task2);
}
catch (AggregateException ex)
{
  foreach(var innerEx in ex.InnerExceptions)
  {
    /* 处理每个异常 */
  }
}

7. 全局异常监控

为了防止程序崩溃,需要在应用级别设置"最后一道防线"。

机制 适用范围
AppDomain.CurrentDomain.UnhandledException 捕获所有未处理的异常,通常用于记录日志并退出程序。
TaskScheduler.UnobservedTaskException 捕获那些从未被等待(await)的 Task 中发生的异常。

AppDomain.CurrentDomain.UnhandledException

AppDomain.CurrentDomain.UnhandledException 事件在 应用程序域 层面捕获所有未被处理的异常,无论这些异常发生在 UI 线程还是后台线程。当一个异常未被任何 try-catch 块捕获,并且传播到其所在线程的顶层时,这个事件就会触发。

  • 作用范围: 整个应用程序,包括所有线程(UI 和后台)。
  • 线程: 跨线程。它能捕获后台线程(如 TaskThread)中发生的未处理异常。
  • 用途: 这是一个 最后的防线。通常用于记录异常信息并优雅地关闭应用程序,防止程序崩溃。

Current.DispatcherUnhandledException

Current.DispatcherUnhandledException 事件在 WPF 应用 层面捕获所有未被处理的异常,但仅限于 UI 线程 。它是 System.Windows.Application 类的一部分。当 UI 线程中的代码抛出一个未被捕获的异常时,这个事件就会触发。

  • 作用范围: 整个 WPF 应用程序,但仅限于 UI 线程。
  • 线程: UI 线程。它 不会 捕获后台线程的异常。
  • 用途: 主要用于处理 UI 相关的异常。你可以在这里显示一个友好的错误信息给用户,或者记录异常然后决定是否继续运行程序。

Dispatcher.CurrentDispatcher.UnhandledException

Dispatcher.CurrentDispatcher.UnhandledException 事件在 特定线程的调度器 层面捕获未处理的异常。每个线程都有一个 Dispatcher 对象,它负责管理该线程的消息队列和工作项。这个事件只处理在其 关联线程 上发生的未捕获异常。

  • 作用范围: 仅限于其关联的特定线程。
  • 线程: 单个线程,通常是 UI 线程(因为 CurrentDispatcher 多数情况下指的是主 UI 线程的调度器)。
  • 用途: 当你需要为 某个特定的 UI 线程 (比如一个独立的、由 Dispatcher 管理的辅助 UI 线程)处理异常时,它会非常有用。

这三个事件共同构成了 WPF 异常处理的层次结构,从特定线程到整个应用程序。

事件 作用范围 线程 触发时机
AppDomain.CurrentDomain.UnhandledException 整个应用域 所有线程(UI 和后台) 最后一个捕获点,程序即将崩溃
Current.DispatcherUnhandledException 整个 WPF 应用 仅 UI 线程 UI 线程发生未捕获异常
Dispatcher.CurrentDispatcher.UnhandledException 特定线程 仅其关联线程 线程调度器发生未捕获异常

在实际开发中,通常会同时订阅这三个事件,以确保在任何情况下都能捕获并处理异常,提高应用的健壮性。

8.异常流转逻辑图

9. 易混淆

9.1 不要吞掉异常 (Don't Swallow Exceptions)

除非你明确知道吞掉异常后的补救措施,否则绝对不要写空的 catch {}。这会让 Bug 变成无声的杀手。

9.2 针对具体的异常捕获

永远不要在业务代码的底层直接 catch (Exception ex)。这会捕获包括 OutOfMemoryException (内存溢出) 在内的所有严重错误,而你的程序通常无法从这些错误中恢复。

9.3 使用内联初始化的异常信息

在 C# 10 以后,推荐使用 CallerArgumentExpression 来增强参数检查的异常信息:

csharp 复制代码
public void ProcessData(string data)
{
    ArgumentNullException.ThrowIfNull(data); // 简洁且性能高
}

9.4 总结

  • 特定的捕获 (Specific Catch): 永远优先捕获具体的异常(如 FileNotFoundException),最后才考虑捕获 Exception
  • 不要"吞掉"异常: 严禁编写空的 catch {}。如果不打算处理,请让它崩溃或记录日志。
  • 使用 Try-Parse 模式: 对于高频转换(如字符串转数字),使用 int.TryParse 以获得百倍以上的性能提升。
  • 防御性编程: 在方法入口处检查参数(如 ArgumentNullException.ThrowIfNull(arg)),比在逻辑深处抛出异常更易维护。
  • 记录上下文: 在捕获异常时,记录当时的参数值、用户 ID 等上下文信息,而不仅仅是堆栈信息。
相关推荐
步步为营DotNet2 小时前
剖析.NET 11 中 Native AOT 在高性能客户端应用的极致实践
.net
soragui3 小时前
【Python】第 4 章:Python 数据结构实现
数据结构·windows·python
橘子编程4 小时前
操作系统原理:从入门到精通全解析
java·linux·开发语言·windows·计算机网络·面试
步步为营DotNet5 小时前
深度探索.NET Aspire在云原生应用性能与安全加固的创新实践
安全·云原生·.net
武藤一雄5 小时前
WPF中ViewModel之间的5种通讯方式
开发语言·前端·microsoft·c#·wpf
雨浓YN5 小时前
OPC UA 通讯开发笔记 - 基于Opc.Ua.Client
笔记·c#
我是唐青枫6 小时前
C#.NET TPL Dataflow 深入解析:数据流管道、背压控制与实战取舍
c#·.net
程序员大辉6 小时前
Win11精简版的天花板:Windows X-Lite 26H1 V3完整安装教程,老电脑也能装
windows·电脑
熊明才7 小时前
PM2 服务器服务运维入门指南
运维·服务器·windows