叫我安不理

C#查漏补缺----Exception处理实现,无脑抛异常不可取

前言

环境:.NET 8.0
系统:Windows11
参考资料:CLR via C#, .Net Core底层入门
https://andreabergia.com/blog/2023/05/error-handling-patterns/

异常报告的四种方式

程序在执行过程中可能会遇到很多意外的情况,比如空指针,栈溢出等。当程序无法继续完成任务时,就应该抛出异常。
处理意外情况常规有四种做法:

  1. 通过方法的返回值报告错误
    处理是否发生错误,并通过线程本地变量储存最后一次发生错误的原因。这样做的好处是实现非常简单且开销很小,但会增加开发者的负担,并且容易因为开发者的疏忽而导致错误被忽略
FILE* fp = fopen("file.txt" , "w");
if (!fp) {
  // some error occurred
}
  1. 使用异常(Exception)来报告错误
    使用Exception来报告错误,可以减少代码量并标准化错误处理流程,但性能花销很大且需要编译器和runtime的支持

  2. 使用回调函数来报告错误
    在JavaScript领域非常常见的做法,使用回调。

const fs = require('fs');
fs.readFile('some_file.txt', (err, result) => {
  if (err) {
    console.error(err);
    return;
  }

  console.log(result);
});

但这种方法会导致一个问题"回调地狱"。相信写过JS的同学一定会对此深有感触

  1. 函数式语言
    例如Rust,F# 等语言。它们提供一种不同的思路
enum Result<S, E> {
  Ok(S),
  Err(E)
}
let result = match my_fallible_function() {
  Err(e) => return Err(e),
  Ok(some_data) => some_data,
};

类似一条轨道的分出了两个分支轨道,一个表示成功,一个表示失败。这种方法的优点是它使错误处理既明显又类型安全,因为编译器会确保处理每个可能的结果。

返回值报告错误与Exception报告错误的区别

在C语言中,代码会通过函数的返回值报告来判断是否发生错误,并且通过线程本地变量储存最后一次发生错误的原因,Windows系统的GetLaseError函数和类Unix系统的errno宏。这样做开销很小,但是会带来以下问题

  1. 增加代码量
    每次调用方法都需要显式检查返回值,容易被忽略
  2. 处理未标准化
    传递详细的错误信息需要自定义结构体
  3. 传递错误不容易
    如果错误无法在当前方法被处理,需要手动将错误继续传递到上层。容易被忽略
  4. 高耦合
    如果一个不会发生错误的方法,代码调整后,变得可能会发生错误。则需要修改所有调用该方法的代码

异常处理(Exception Handling)机制的出现解决了这些问题,异常独立处理。实现高内聚,低耦合。且如果不处理错误,错误会自动传递到上一层。
异常的好处在于,未处理的异常会造成程序终止,可以在测试期间提前发现问题。而不是等到部署之后还发生终止的情况。
但是异常处理也是有代价的

  1. 大量的bookkeeping代码,对代码的大小和时间造成负面影响
    非托管编译器必须生成代码来跟踪哪些对象被成功构造。编译器还必须生成代码,以便在一个异常被捕获到的时候,调用每个成功构造的对象的析构器。
  2. 线程切换
    线程要从用户态切到内核态,开销不会小。
  3. StackTrace
    这里面的值需要从当前异常的线程栈中去抓取调用栈,越深开销就越大。
    image

.NET 异常处理机制

用户异常与硬件异常

.NET中的异常可以按照触发方式分为用户异常和硬件异常,其中用户异常是程序代码主动抛出的异常,是最常见的异常。其操作流程如下
image
Q1:托管异常为什么不直接处理,而是要包装一层。再绕回来,这不是脱裤子放屁吗?
这么做的好处是,它们可以传递给同一个异常处理入口并共用相同的逻辑。

硬件异常是指CPU执行指令码出现异常后,由CPU通知操作系统,操作系统再通知进程触发的异常。常见的场景有调用null对象的方法,字段。以及整数除以0。其流程如下
image

异常处理表

.NET程序中的每个托管函数都会有对应的异常处理表(Execption Handling Table , EH Table),基础处理表记录了try,catch,finally的范围与它们的对应关系
C# 代码

    public class ExceptionEmample
    {
        public static void Example()
        {
			try
			{
                Console.WriteLine("Try outer");
				try
				{
                    Console.WriteLine("Try inner");
                }
				catch (Exception)
				{
                    Console.WriteLine("Catch Expception inner");
                }
            }
			catch (ArgumentException)
			{
                Console.WriteLine("Catch ArgumentException outer");
            }
            catch (Exception)
            {
                Console.WriteLine("Catch Exception outer");
            }
            finally
            {
                Console.WriteLine("Finally outer");
            }
        }
    }

IL 代码

.method public hidebysig static void  Example() cil managed
{
  // Code size       96 (0x60)
  .maxstack  1
  IL_0000:  nop
  IL_0001:  nop
  IL_0002:  ldstr      "Try outer"
  IL_0007:  call       void [System.Console]System.Console::WriteLine(string)
  IL_000c:  nop
  IL_000d:  nop
  IL_000e:  ldstr      "Try inner"
  IL_0013:  call       void [System.Console]System.Console::WriteLine(string)
  IL_0018:  nop
  IL_0019:  nop
  IL_001a:  leave.s    IL_002c
  IL_001c:  pop
  IL_001d:  nop
  IL_001e:  ldstr      "Catch Expception inner"
  IL_0023:  call       void [System.Console]System.Console::WriteLine(string)
  IL_0028:  nop
  IL_0029:  nop
  IL_002a:  leave.s    IL_002c
  IL_002c:  nop
  IL_002d:  leave.s    IL_004f
  IL_002f:  pop
  IL_0030:  nop
  IL_0031:  ldstr      "Catch ArgumentException outer"
  IL_0036:  call       void [System.Console]System.Console::WriteLine(string)
  IL_003b:  nop
  IL_003c:  nop
  IL_003d:  leave.s    IL_004f
  IL_003f:  pop
  IL_0040:  nop
  IL_0041:  ldstr      "Catch Exception outer"
  IL_0046:  call       void [System.Console]System.Console::WriteLine(string)
  IL_004b:  nop
  IL_004c:  nop
  IL_004d:  leave.s    IL_004f
  IL_004f:  leave.s    IL_005f
  IL_0051:  nop
  IL_0052:  ldstr      "Finally outer"
  IL_0057:  call       void [System.Console]System.Console::WriteLine(string)
  IL_005c:  nop
  IL_005d:  nop
  IL_005e:  endfinally
  IL_005f:  ret
  IL_0060:  
  // Exception count 4
  .try IL_000d to IL_001c catch [System.Runtime]System.Exception handler IL_001c to IL_002c
  .try IL_0001 to IL_002f catch [System.Runtime]System.ArgumentException handler IL_002f to IL_003f
  .try IL_0001 to IL_002f catch [System.Runtime]System.Exception handler IL_003f to IL_004f
  .try IL_0001 to IL_0051 finally handler IL_0051 to IL_005f
} // end of method ExceptionEmample::Example

IL代码中最后4行就代表了方法的异常处理表,意义如下

  1. IL_000d to IL_001c 之间代码发生的Exception异常由IL_001c to IL_002c 之间的代码处理
  2. IL_0001 to IL_002f 之间发生的ArgumentException异常由IL_002f to IL_003f之间的代码处理
  3. IL_0001 to IL_002f 之间发生的Exception异常由IL_003f to IL_004f之间的代码处理
  4. IL_0001 to IL_0051 之间无论发生什么,结束后都要执行IL_0051 to IL_005f之间的代码

异常发生时,Runtime会检索EH Table是否存在,自上而下搜索第一个匹配项进行后续处理。
需要注意的是,一旦CLR找到catch块,就会先执行内层所有finally块中的代码,再等到当前catch块中的代码执行完毕finally才会执行

Q1:finally一定会执行吗?
常规情况下,finally是保证会执行的代码,但如果直接用win32函数TerminateThread杀死线程,或使用System.Environment的Failfast杀死进程,finally块不会执行。
Q2:先执行return还是先执行finally
C#代码

        public static int Example2()
        {
            try
            {
                return 100+100;
            }
            finally
            {
                Console.WriteLine("finally");
            }
        }

IL代码

.method public hidebysig static int32  Example2() cil managed
{
  // Code size       22 (0x16)
  .maxstack  1
  .locals init (int32 V_0)
  IL_0000:  nop
  IL_0001:  nop
  IL_0002:  ldc.i4.1  //将常量1压入Evaluation Stack
  IL_0003:  stloc.0   //从Evaluation Stack出栈,保存到序号为0的本地变量
  IL_0004:  leave.s   IL_0014 //退出代码保护区域,并跳转到指定内存区域IL_0014
  IL_0006:  nop
  IL_0007:  ldstr      "finally"
  IL_000c:  call       void [System.Console]System.Console::WriteLine(string)
  IL_0011:  nop
  IL_0012:  nop
  IL_0013:  endfinally
  IL_0014:  ldloc.0 //读取序号0的本地变量并存入Evaluation Stack
  IL_0015:  ret  //从方法返回,返回值从Evaluation Stack中获取
  IL_0016:  //继续执行IL_0006 to IL_0014之间的代码
  // Exception count 1
  .try IL_0001 to IL_0006 finally handler IL_0006 to IL_0014
} // end of method ExceptionEmample::Example2

如上所述,先执行return,再执行finally。

处理流程

具体来说,.NET Runtime 处理异常主要为以下四个操作

  1. 捕捉异常并抛出异常
  2. 通过调用链获取异常发生点与调用来源

在捕捉到异常后,Runtime会通过调用链跟踪所有调用来源,其原理是扫描方法的栈结构
但是面对非托管方法(没有元数据)与托管方法(有元数据)之间互相调用,如何实现跟踪呢?.NET的托管线程对象中有一个列表,专门记录托管方法与非托管方法之间的切换。调用链会先枚举这个列表,然后再扫描栈结构,这样就可以跳过没有元数据的非托管函数
image

  1. 获取函数元数据中的异常处理表

异常处理表同样在托管方法的元数据中

  1. 枚举异常处理表对应的cath块与finally块

获取到足够的信息后,Runtime开始从异常中恢复。遍历调用链,找到对应的catch块,回滚调用链,调用沿途的finally与最终的catch块

重新抛出异常

在从异常恢复的过程中,如果finally块或catch块的代码抛出异常,程序会再次进入异常处理入口。此时调用链会消失。

可以理解,再次进入异常处理入口,相当于重新走了一遍异常流程。而操作系统只会提供最后一次发生错误的信息(GetLaseError函数和类Unix系统的errno) 所以之前的调用链会消失。

        public static void ExceptionLinkDemo()
        {
            try
            {
                throw new Exception("");
            }
            catch (Exception e)
            {
                throw e;//重新抛出异常,调用链消失。
                throw;//相当于抛出原始来源的异常,调用链完整
                ExceptionDispatchInfo.Capture(e).Throw();//两者合一,不仅调用链完整,而且显示了重新抛出异常所在代码的调用链
            }
        }

CLS与非CLS异常(历史包袱)

在CLR的2.0版本之前,CLR只能捕捉CLS相容的异常。如果一个C#方法调用了其他编程语言写的方法,且抛出一个非CLS相容的异常。那么C#无法捕获到该异常。
在后续版本中,CLR引入了RuntimeWrappedException类。当非CLS相容的异常被抛出时,CLR会自动构造RuntimeWrappedException实例。使之与与CLS兼容

        public static void Example2()
        {
            try
            {

            }
            catch(Exception)
            {
                //c# 2.0之前这个块只能捕捉CLS相容的异常
            }
            catch
            {
                //这个块可以捕获所有异常
            }
        }

异常对性能的影响

.NET异常处理基于编译时生成的方法元数据,程序进入Try没有代价。只要不抛出异常,使用try-catch是没有性能影响的。
那么在抛出异常时,性能受到多大影响呢?
直接使用<.NET Core底层入门>书中的数据
image
按照我的理解,如果是频率很低一些边界性的错误,使用异常无伤大雅。如果是一次频次很高的业务异常,则需要考虑使用返回值报告错误来减少异常开销。

函数式编程 Result/Option 模式

面对高频次的报错,且对性能敏感的方法。我们要减少throw exception,毕竟throw一次就要再次进入异常处理入口。线程从用户态切到内核态,又从内核态切换到用户态。实在是划不来。
那么有什么折中的办法吗?答案是有!
我们可以使用F#/Rust的设计思路,使用函数式编程来帮助我们写更优雅的代码。
language-ext
Optional

        public static string GetUserNameById(int i)
        {
            if (i == default)
            {
                throw new ArgumentNullException(nameof(i));
            }
            // 操作db
            var userName = "lewis";

            return userName;
        }

如果此代码高频次throw,且对性能敏感。我们可以引入任何支持 Result/Option 模式的库。来协助我们改善代码。
伪代码如下

        public static Result<string> GetUserNameById(int i)
        {
            Result<string> result = null;
            if (i == default)
            {
                return new Result<string>(new ArgumentNullException(nameof(i)));
            }
            // 操作db
            var userName = "lewis";

            return new Result<string>(userName);
        }
		public static void Run(int userId)
        {
            var nameResult = GetUserNameById(userId);
            var userName= nameResult.Match(s =>
            {
                return s;
            }, exception =>
            {
                if (exception is ArgumentNullException)
                {
                    Console.WriteLine($"输入参数边界值异常:{exception.Message}");
                }
                else
                {
                    Console.WriteLine($"未处理异常:{exception.Message}");
                }

                return "";
            });
        }

posted on 2024-09-03 10:56  叫我安不理  阅读(35)  评论(0编辑  收藏  举报

导航