本篇开始主要记录.net core的异常处理机制
处理意外情况的传统方式,通过函数返回值报告给调用函数,是否发生错误,并通过线程本地变量存储最后一次发生错误的原因。这样做会导致很多问题,例如:
.net core提供了新的机制,使用异常来报告错误,需要编译器和运行时的支持。编译器要帮助用户自动生成异常处理使用的数据,运行时需要读取与分析这些数据。
使用异常机制,用户通过抛出异常与捕捉传递错误,不要调用函数通过检查返回值来检测异常,并且还能包含更为详细的错误信息。
使用异常还有一个特征,就是如果不处理错误,异常会自动向上传递。
虽然异常很好用,但是异常发生时的处理成本很高,所以需要用户按需使用返回值,或者异常的方式处理异常。
提示:以下是本篇文章正文内容,下面案例可供参考
.net中异常处理由try,catch,when,finally组成,且支持多层嵌套。try块中的代码无论是否抛出异常,都会执行finally中的内容。
如果try中抛出了异常,.net运行时会根据catch指定的异常类型,以及when返回的结果找到对应的catch块,将抛出的异常对象传递给catch块进行处理,接着程序恢复运行。
.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代码发现:
上面提到异常的结构与异常表,下面记录异常将如何触发。
异常按触发方式分为:用户异常,硬件异常。
用户异常:程序代码主动请求.net运行时抛出的异常:
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
其中关键代码:
对应的关键汇编代码:
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异常处理的入口。
这些异常都会通过平台的异常处理机制,传递到.net内部的异常处理入口继续处理。
Jit编译器根据传入的IL代码自动插入检查错误并抛出异常的指令。
CPU执行指令出现异常后,由CPU通知操作系统,操作系统再通知进程触发异常。X86平台上出现硬件异常,CPU会查找操作系统预先注册的中断处理表,再由操作系统通知进程发生异常,通知方式因操作系统而不同
主流的.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,则这条指令就会触发异常。
.net会额外插入一条指令move eax,dword ptr[rcx]
在调用方法前,原理与上面相同。结论:访问对象字段时,如果字段偏移值小于一定值,则不需要出入指令检测,调用方法时总需要插入检测指令。
相关指令:idiv edx:eax,dword ptr[reloc classVar[0xffffffff]]
计算edx与eax寄存器组成的64位数值除以全局变量,这里后面的全局变量,还是本地变量,或者其他寄存器的值,都是根据用户程序决定的。
流程与上类似,抛出异常-操作系统捕捉-异常处理器-进程通知。需要注意的是浮点数的除法运算不会发生零除异常。
通过返回值报错错误时,错误逻辑处理嵌入在程序逻辑中。而通过抛出异常这种处理,需要在程序流程之外进行处理。
.net运行时,处理异常主要实现以下两个功能:
1.执行清理:调用沿途的finally块
2.恢复程序执行:调用对应的catch块并跳转到catch块后的代码。
实现的操作:
1.捕捉异常获取异常发生的位置
2.通过调用链跟踪获取抛出异常函数以及所有调用来源
3.获取元数据中的异常处理表(注:每个函数都有自己的异常处理表)
4.枚举异常处理表调用对应的finally块与catch块
通过汇编指令那一块内容可知,每个进程调用都有自己的栈空间,里面包含了函数调用的一套规则。利用这个规则,可以获取调用链。
抛出异常的位置实际上就是程序计数器的值(即当前指令地址),用户异常抛出位置在.net运行时内部,硬件异常,抛出位置等于执行失败指令地址,通常在托管代码中。
不同操作系统有不同的通知异常机制:
windows:用户异常直接到达异常处理入口,包装PEXCEPTION_RECORD,包含程序计数器的值与异常类型代码。硬件异常到达SEH处理器,PEXCEPTION_POINTERS包含PEXCEPTION_RECORD对象。
类Unix系统,用户异常使用C++抛出异常,包装成PEXCEPTION_RECORD,硬件异常通过信号处理器,根据上下文修改当前使用栈空间,然后根据信号类型生成PEXCEPTION_RECORD。
两者最终都会进入到统一的异常处理入口:ProcessCLRException中。
通过之前的x86汇编,通过eip程序计数器结合元数据,可以获得当前调用的函数,通过ebp+4的方式可以获得上一个函数返回地址,以及上一个函数的ebp备份地址,来获得调用链查找所有调用来源。
而X86-64不允许rbp寄存器保存进入函数时的栈顶位置,.net运行时借助函数对应的元数据计算函数帧大小,来找到上一个函数的返回地址。使用这种方式必须知道函数元数据,而.net允许托管函数与非托管函数互相调用。托管函数元数据,通过JIT编译器生成并由运行时管理,非托管函数如何处理呢?
.net托管线程对象,有一个列表用于专门记录托管函数与非托管函数之间的切换。调用链先枚举这个列表,再扫描栈内容,这样来跳过没有元数据,只查找栈中的托管函数来确定调用来源。
之前IL代码中已经看过IL的异常处理表的数据格式,在真实处理异常时,使用的是机器码对应的异常处理表,这些异常处理表,是在.net异常处理入口接收到操作系统传递给进程异常时,.net内部对异常处理所定义的结构。
主要步骤:
回滚调用链的过程:
移除函数帧,通过栈顶寄存器实现,而执行指令通过程序计数器实现,一旦移除相当于函数后的指令都不会再执行了,且此时的函数调用链丢失。
如果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");
}
具体的测试用例,就是循环执行,通过对比,使用返回值与抛出异常两种方式查看执行消耗的时间对比。之类就不具体放出来了,主要就是通过stopwatch类进行统计运行时长。
结论:异常发生频率越高,抛出异常消耗远大于返回错误放方式,如果异常发生频率较低,则两者消耗差不多。即:如果在函数使用不当,实现不正确,文件访问错误等低频可能发生的错误,使用异常抛出的方式处理,如果是高频的发生错误,或者错误由第三方来源触发,最好使用返回值错误方式处理异常。
本节主要介绍.NET中异常处理方式,流程,机制,以及各种异常触发方式;还介绍了异常处理表,调用链跟踪(X86-64的处理,非托管函数处理等),最后介绍了重新抛出异常的几种捕捉结果,以及最终抛出异常性能消耗,得出在低频错误应使用抛出异常的方式处理的结论。