.net core底层入门学习笔记(六-异常)

.net core底层入门学习笔记(六)

本篇开始主要记录.net core的异常处理机制



前言

处理意外情况的传统方式,通过函数返回值报告给调用函数,是否发生错误,并通过线程本地变量存储最后一次发生错误的原因。这样做会导致很多问题,例如:

  • 每次调用函数都要检查返回值,容易忽略
  • 详细的错误信息需要自定义结构体,且很难阅读
  • 如果当前调用函数无法处理异常,则要向上传递,传递过程冗长且传递处理很麻烦
  • 函数重构后,异常信息结构变动,很多调用代码都要相应修改

.net core提供了新的机制,使用异常来报告错误,需要编译器和运行时的支持。编译器要帮助用户自动生成异常处理使用的数据,运行时需要读取与分析这些数据。
使用异常机制,用户通过抛出异常与捕捉传递错误,不要调用函数通过检查返回值来检测异常,并且还能包含更为详细的错误信息。
使用异常还有一个特征,就是如果不处理错误,异常会自动向上传递。
虽然异常很好用,但是异常发生时的处理成本很高,所以需要用户按需使用返回值,或者异常的方式处理异常。


提示:以下是本篇文章正文内容,下面案例可供参考

一、.net中的异常

.net中异常处理由try,catch,when,finally组成,且支持多层嵌套。try块中的代码无论是否抛出异常,都会执行finally中的内容。
如果try中抛出了异常,.net运行时会根据catch指定的异常类型,以及when返回的结果找到对应的catch块,将抛出的异常对象传递给catch块进行处理,接着程序恢复运行。

.net中异常结构保存方式

.net程序中,每个托管函数都有对应的异常处理表,异常处理表记录了try,catch,catch过滤器(when),以及finally块的范围与他们的对应关系。

class  ExceptionExample
    {

        public static void example()
        {
            try
            {
                Console.WriteLine("try outer");
                try
                {
                    Console.WriteLine("try inner");
                }
                catch (Exception e)
                {
                    Console.WriteLine("catch inner");
                    throw;
                }
            }
            catch (ArgumentException e)
            {
                Console.WriteLine("catch ArgumentException outer");
            }
            catch (Exception e)
            {
                Console.WriteLine("catch Exception outer");
            }
            finally
            {
                Console.WriteLine("finally outer");
            }
        }
        
        
        
    }
class private auto ansi beforefieldinit
  dotnet_test.ExceptionExample
    extends [System.Runtime]System.Object
{

  .method public hidebysig static void
    example() cil managed
  {
    .maxstack 1
    .locals init (
      [0] class [System.Runtime]System.Exception e,
      [1] class [System.Runtime]System.ArgumentException e_V_1,
      [2] class [System.Runtime]System.Exception e_V_2
    )

    // [66 9 - 66 10]
    IL_0000: nop
    .try
    {
      .try
      {

        // [68 13 - 68 14]
        IL_0001: nop

        // [69 17 - 69 48]
        IL_0002: ldstr        "try outer"
        IL_0007: call         void [System.Console]System.Console::WriteLine(string)
        IL_000c: nop
        .try
        {

          // [71 17 - 71 18]
          IL_000d: nop

          // [72 21 - 72 52]
          IL_000e: ldstr        "try inner"
          IL_0013: call         void [System.Console]System.Console::WriteLine(string)
          IL_0018: nop

          // [73 17 - 73 18]
          IL_0019: nop
          IL_001a: leave.s      IL_002b
        } // end of .try
        catch [System.Runtime]System.Exception
        {

          // [74 17 - 74 36]
          IL_001c: stloc.0      // e

          // [75 17 - 75 18]
          IL_001d: nop

          // [76 21 - 76 54]
          IL_001e: ldstr        "catch inner"
          IL_0023: call         void [System.Console]System.Console::WriteLine(string)
          IL_0028: nop

          // [77 21 - 77 27]
          IL_0029: rethrow
        } // end of catch

        // [79 13 - 79 14]
        IL_002b: nop
        IL_002c: leave.s      IL_004e
      } // end of .try
      catch [System.Runtime]System.ArgumentException
      {

        // [80 13 - 80 40]
        IL_002e: stloc.1      // e_V_1

        // [81 13 - 81 14]
        IL_002f: nop

        // [82 17 - 82 68]
        IL_0030: ldstr        "catch ArgumentException outer"
        IL_0035: call         void [System.Console]System.Console::WriteLine(string)
        IL_003a: nop

        // [83 13 - 83 14]
        IL_003b: nop
        IL_003c: leave.s      IL_004e
      } // end of catch
      catch [System.Runtime]System.Exception
      {

        // [84 13 - 84 32]
        IL_003e: stloc.2      // e_V_2

        // [85 13 - 85 14]
        IL_003f: nop

        // [86 17 - 86 60]
        IL_0040: ldstr        "catch Exception outer"
        IL_0045: call         void [System.Console]System.Console::WriteLine(string)
        IL_004a: nop

        // [87 13 - 87 14]
        IL_004b: nop
        IL_004c: leave.s      IL_004e
      } // end of catch

      IL_004e: leave.s      IL_005e
    } // end of .try
    finally
    {

      // [89 13 - 89 14]
      IL_0050: nop

      // [90 17 - 90 52]
      IL_0051: ldstr        "finally outer"
      IL_0056: call         void [System.Console]System.Console::WriteLine(string)
      IL_005b: nop

      // [91 13 - 91 14]
      IL_005c: nop
      IL_005d: endfinally
    } // end of finally

    // [92 9 - 92 10]
    IL_005e: ret

  } // end of method ExceptionExample::example

  .method public hidebysig specialname rtspecialname instance void
    .ctor() cil managed
  {
    .maxstack 8

    IL_0000: ldarg.0      // this
    IL_0001: call         instance void [System.Runtime]System.Object::.ctor()
    IL_0006: nop
    IL_0007: ret

  } // end of method ExceptionExample::.ctor
} // end of class dotnet_test.ExceptionExample

通过ILDASM工具查看:

.method public hidebysig static void  example() cil managed
{
  // Code size       97 (0x61)
  .maxstack  1
  .locals init (int32 V_0,
           class [System.Runtime]System.Exception V_1,
           class [System.Runtime]System.ArgumentException V_2,
           class [System.Runtime]System.Exception V_3)
  IL_0000:  nop
  IL_0001:  ldc.i4.5
  IL_0002:  stloc.0
  IL_0003:  nop
  IL_0004:  ldstr      "try outer"
  IL_0009:  call       void [System.Console]System.Console::WriteLine(string)
  IL_000e:  nop
  IL_000f:  nop
  IL_0010:  ldstr      "try inner"
  IL_0015:  call       void [System.Console]System.Console::WriteLine(string)
  IL_001a:  nop
  IL_001b:  nop
  IL_001c:  leave.s    IL_002d
  IL_001e:  stloc.1
  IL_001f:  nop
  IL_0020:  ldstr      "catch inner"
  IL_0025:  call       void [System.Console]System.Console::WriteLine(string)
  IL_002a:  nop
  IL_002b:  rethrow
  IL_002d:  nop
  IL_002e:  leave.s    IL_0050
  IL_0030:  stloc.2
  IL_0031:  nop
  IL_0032:  ldstr      "catch ArgumentException outer"
  IL_0037:  call       void [System.Console]System.Console::WriteLine(string)
  IL_003c:  nop
  IL_003d:  nop
  IL_003e:  leave.s    IL_0050
  IL_0040:  stloc.3
  IL_0041:  nop
  IL_0042:  ldstr      "catch Exception outer"
  IL_0047:  call       void [System.Console]System.Console::WriteLine(string)
  IL_004c:  nop
  IL_004d:  nop
  IL_004e:  leave.s    IL_0050
  IL_0050:  leave.s    IL_0060
  IL_0052:  nop
  IL_0053:  ldstr      "finally outer"
  IL_0058:  call       void [System.Console]System.Console::WriteLine(string)
  IL_005d:  nop
  IL_005e:  nop
  IL_005f:  endfinally
  IL_0060:  ret
  IL_0061:  
  // Exception count 4
  .try IL_000f to IL_001e catch [System.Runtime]System.Exception handler IL_001e to IL_002d
  .try IL_0003 to IL_0030 catch [System.Runtime]System.ArgumentException handler IL_0030 to IL_0040
  .try IL_0003 to IL_0030 catch [System.Runtime]System.Exception handler IL_0040 to IL_0050
  .try IL_0003 to IL_0052 finally handler IL_0052 to IL_0060
} // end of method ExceptionExample::example

对比C#代码与对应的IL代码发现:

  • try catch finally,会被转化成try{try{}catch{}}finally{}结构,意义是一样的。
  • 本地变量表中,包含了几个异常需要的本地变量
  • 在dotpeek中查看的,与在ildasm中查看的不一致,因为一般编译器会隐藏异常处理表的信息,并生成结构更好理解的IL代码,在ildasm中取消expand try/catch选项,则可以直接看到异常表的信息,如上图中的最下面几行IL代码
  • 异常处理表中,规定了异常捕捉的范围,异常捕捉的类型,以及异常处理的指令范围
  • 发生异常时,.net运行时会按顺序检索异常处理表,找到对应的且合适的catch
  • 实际运行.net程序时,JIT编译器会把IL代码转换为机器码,将异常处理表生成机器码对应的异常处理表

二、用户异常的触发

上面提到异常的结构与异常表,下面记录异常将如何触发。

1.用户异常

异常按触发方式分为:用户异常,硬件异常。
用户异常:程序代码主动请求.net运行时抛出的异常:

  • 使用IL代码中的throw指令抛出异常对象
  • 调用.net内部函数抛出异常,如:托管堆分配对象,内存不足;转换对象类型不匹配等
  • JIT编译时,自动插入的异常代码,如:checked块内执行的算术运算,会插入溢出检测异常,数组访问插入数组索引越界异常等
1.通过throw抛出异常:
public static void Example(){
	throw new ArgumentException("Something is wrong!");
}

生成的IL代码如下:

 .method public hidebysig static void
    example() cil managed
  {
    .maxstack 8

    // [99 9 - 99 10]
    IL_0000: nop

    // [100 13 - 100 64]
    IL_0001: ldstr        "something is wrong!"
    IL_0006: newobj       instance void [System.Runtime]System.ArgumentException::.ctor(string)
    IL_000b: throw

  } // end of method ExceptionThrowExample::example

其中关键代码:

  • newobj指令,实例化异常对象
  • throw,抛出异常实例

对应的关键汇编代码:

call CORINFO_HELP_NEWSFAST     //.net内部函数从托管堆分配对象
move rsi,rax				//将上面函数得到的对象地址,分配到rsi寄存器中
.....省略字符串常量的获取与参数设置
call CORINFO_HELP_STRCNS	//.net内部函数获取字符串
move rdx,rax				//设置字符串为第二个参数
move rcx,rsi				//设置第一个参数为异常对象地址
call System.ArgumentException:.ctor(ref):this		//调用异常对象的构造函数,并使用上面的参数
call CORINFO_HELP_THROW		//.net内部函数,抛出异常

上面就是最简单的使用throw指令抛出异常的流程。注意几个内部函数的使用,这些函数是.net运行时提供给托管代码调用的内部函数,使用原生代码编写,这些函数本身可能会抛出原生异常,抛出的异常会通过不同平台的异常处理机制,传递到.net内部的异常处理入口,包装为托管异常,交给托管代码处理。
而使用throw指令抛出的异常,会先包装成原生异常,然后调用不同平台操作系统函数抛出原生异常,此时原生异常与原生代码抛出的异常,走统一逻辑最后都交给.net内部的ProcessCLRException函数,这个函数就是.net异常处理的入口。

2. 调用.net内部函数抛出异常

这些异常都会通过平台的异常处理机制,传递到.net内部的异常处理入口继续处理。

3. 编译器自动插入的抛出异常代码

Jit编译器根据传入的IL代码自动插入检查错误并抛出异常的指令。

2.硬件异常

CPU执行指令出现异常后,由CPU通知操作系统,操作系统再通知进程触发异常。X86平台上出现硬件异常,CPU会查找操作系统预先注册的中断处理表,再由操作系统通知进程发生异常,通知方式因操作系统而不同

1. 访问null对象的字段抛出异常

主流的.net运行时检查对象是否为null,不使用分支判断,交由硬件异常触发。.net中未插入引及期货你 用类型的分支判断,是因为.net使用对象频率很高,插入这样的代码会使得机器码变得非常庞大,且添加过多的分支,会影响cpu流水线以及分支预测的效率。
具体表现:move ecx,dword ptr[rcx + 8]假设rcx寄存器是某个对象obj的地址,8是其内部字段的偏移值地址,4是字段大小,如果这个时候obj对象为null,那么rcx就是0,这条指令会从地址8读取数据。虚拟内存8所在虚拟页没有对应的物理页,CPU通知操作系统发生硬件异常(缺页中断),操作系统判断改进程是否分配过该虚拟内存页,由于太低的地址页不允许进程申请,所以发生内存访问异常。
在windows上会检查进程是否注册SEH异常处理器,如果已经注册则调用异常处理器处理这个异常,没有则结束进程运行。
如果访问一个很大的偏移值字段,就有可能获取到非正常内存数据,这个时候.net会自动插入一条指令检查cmp dword prt[rcx],ecx,直接从rcx地址读取数据,不使用偏移值,如果obj还是为null,则这条指令就会触发异常。

2. 调用null对象方法抛出异常

.net会额外插入一条指令move eax,dword ptr[rcx]在调用方法前,原理与上面相同。结论:访问对象字段时,如果字段偏移值小于一定值,则不需要出入指令检测,调用方法时总需要插入检测指令。

3. 对整数进行零除抛出异常

相关指令:idiv edx:eax,dword ptr[reloc classVar[0xffffffff]]计算edx与eax寄存器组成的64位数值除以全局变量,这里后面的全局变量,还是本地变量,或者其他寄存器的值,都是根据用户程序决定的。
流程与上类似,抛出异常-操作系统捕捉-异常处理器-进程通知。需要注意的是浮点数的除法运算不会发生零除异常。

三、异常处理实现

1.异常处理过程

通过返回值报错错误时,错误逻辑处理嵌入在程序逻辑中。而通过抛出异常这种处理,需要在程序流程之外进行处理。
.net运行时,处理异常主要实现以下两个功能:
1.执行清理:调用沿途的finally块
2.恢复程序执行:调用对应的catch块并跳转到catch块后的代码。
实现的操作:
1.捕捉异常获取异常发生的位置
2.通过调用链跟踪获取抛出异常函数以及所有调用来源
3.获取元数据中的异常处理表(注:每个函数都有自己的异常处理表)
4.枚举异常处理表调用对应的finally块与catch块

2.捕捉异常并获取抛出异常的位置

通过汇编指令那一块内容可知,每个进程调用都有自己的栈空间,里面包含了函数调用的一套规则。利用这个规则,可以获取调用链。
抛出异常的位置实际上就是程序计数器的值(即当前指令地址),用户异常抛出位置在.net运行时内部,硬件异常,抛出位置等于执行失败指令地址,通常在托管代码中。
不同操作系统有不同的通知异常机制:
windows:用户异常直接到达异常处理入口,包装PEXCEPTION_RECORD,包含程序计数器的值与异常类型代码。硬件异常到达SEH处理器,PEXCEPTION_POINTERS包含PEXCEPTION_RECORD对象。
类Unix系统,用户异常使用C++抛出异常,包装成PEXCEPTION_RECORD,硬件异常通过信号处理器,根据上下文修改当前使用栈空间,然后根据信号类型生成PEXCEPTION_RECORD。
两者最终都会进入到统一的异常处理入口:ProcessCLRException中。

3.通过调用链跟踪获取抛出异常函数与所有调用来源

通过之前的x86汇编,通过eip程序计数器结合元数据,可以获得当前调用的函数,通过ebp+4的方式可以获得上一个函数返回地址,以及上一个函数的ebp备份地址,来获得调用链查找所有调用来源。
而X86-64不允许rbp寄存器保存进入函数时的栈顶位置,.net运行时借助函数对应的元数据计算函数帧大小,来找到上一个函数的返回地址。使用这种方式必须知道函数元数据,而.net允许托管函数与非托管函数互相调用。托管函数元数据,通过JIT编译器生成并由运行时管理,非托管函数如何处理呢?
.net托管线程对象,有一个列表用于专门记录托管函数与非托管函数之间的切换。调用链先枚举这个列表,再扫描栈内容,这样来跳过没有元数据,只查找栈中的托管函数来确定调用来源。

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

之前IL代码中已经看过IL的异常处理表的数据格式,在真实处理异常时,使用的是机器码对应的异常处理表,这些异常处理表,是在.net异常处理入口接收到操作系统传递给进程异常时,.net内部对异常处理所定义的结构。

5.枚举异常处理表,找到catch与finally

主要步骤:

  1. 遍历调用链,找到对应的catch块(只要catch块与抛出的异常类型对应,则终止查找,因为已经找到可以处理异常的代码块了)
  2. 回滚调用链,调用沿途的finally块与最终的catch块

回滚调用链的过程:

  1. 调整栈顶,移除抛出异常的函数帧
  2. 调用调用链中函数的finally块(如果有的话),如果不是对应的catch块,则移除此函数帧
  3. 一直执行2,直到发现对应的函数帧中有指定的catch块,执行catch块内容(其实就是跳转到catch块指令),执行完毕后,再次跳转到catch后续代码指令地址。

移除函数帧,通过栈顶寄存器实现,而执行指令通过程序计数器实现,一旦移除相当于函数后的指令都不会再执行了,且此时的函数调用链丢失。

6.重新抛出的异常

如果finally块或catch块代码抛出异常,程序会再次进入异常处理流程,此时调用链已经丢失。如果是有意抛出异常,则需要使用无参数throw或ExceptionDispatchInfo.Capture(ex).Throw函数,相当于承接了上一次的异常,这样原始来源的异常也可以传递到上层函数。

namespace dotnet_test
{
    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                A();
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
            }
        }

        private static void C()
        {
            throw new Exception("aaa");
        }
        
        private static void B()
        {
            C();
        }
        
        private static void A()
        {
            try
            {
                B();
            }
            catch (Exception e)
            {
               1. throw e;
               2. throw;
               3. ExceptionDispatchInfo.Capture(e).Throw();
               4. throw new Exception("bbb");
				
            }
        }
        

    }

    
}
System.Exception: aaa
   at dotnet_test.Program.A() in H:\dotnet_test\Program.cs:line 38
   at dotnet_test.Program.Main(String[] args) in H:\dotnet_test\Program.cs:line
12

System.Exception: aaa
   at dotnet_test.Program.C() in H:\dotnet_test\Program.cs:line 22
   at dotnet_test.Program.B() in H:\dotnet_test\Program.cs:line 27
   at dotnet_test.Program.A() in H:\dotnet_test\Program.cs:line 34
   at dotnet_test.Program.Main(String[] args) in H:\dotnet_test\Program.cs:line 12

System.Exception: aaa
   at dotnet_test.Program.C() in H:\dotnet_test\Program.cs:line 23
   at dotnet_test.Program.B() in H:\dotnet_test\Program.cs:line 28
   at dotnet_test.Program.A() in H:\dotnet_test\Program.cs:line 35
--- End of stack trace from previous location ---
   at dotnet_test.Program.A() in H:\dotnet_test\Program.cs:line 39
   at dotnet_test.Program.Main(String[] args) in H:\dotnet_test\Program.cs:line 13

System.Exception: bbb
   at dotnet_test.Program.A() in H:\dotnet_test\Program.cs:line 39
   at dotnet_test.Program.Main(String[] args) in H:\dotnet_test\Program.cs:line 13

以上四种方式抛出的结果,显而易见,ExceptionDispatchInfo.Capture(e).Throw()能够最大程度的保留所有信息。这里如果A的捕捉类型改为其他,则A无法捕捉到相应的异常,但是依然会调用A里面的finally内容。所以得出结论:try用于捕捉异常,catch用于处理异常,finally无论是否捕捉到都会执行。

catch (FormatException e)
            {
                throw new Exception("bbb");
                ;
            }
            finally
            {
                Console.WriteLine("123");
            }

7.异常处理对于性能影响

具体的测试用例,就是循环执行,通过对比,使用返回值与抛出异常两种方式查看执行消耗的时间对比。之类就不具体放出来了,主要就是通过stopwatch类进行统计运行时长。

结论:异常发生频率越高,抛出异常消耗远大于返回错误放方式,如果异常发生频率较低,则两者消耗差不多。即:如果在函数使用不当,实现不正确,文件访问错误等低频可能发生的错误,使用异常抛出的方式处理,如果是高频的发生错误,或者错误由第三方来源触发,最好使用返回值错误方式处理异常。

总结

本节主要介绍.NET中异常处理方式,流程,机制,以及各种异常触发方式;还介绍了异常处理表,调用链跟踪(X86-64的处理,非托管函数处理等),最后介绍了重新抛出异常的几种捕捉结果,以及最终抛出异常性能消耗,得出在低频错误应使用抛出异常的方式处理的结论。

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值