C#查漏补缺----Exception处理实现,无脑抛异常不可取
前言
环境:.NET 8.0
系统:Windows11
参考资料:CLR via C#, .Net Core底层入门
https://andreabergia.com/blog/2023/05/error-handling-patterns/
异常报告的四种方式
程序在执行过程中可能会遇到很多意外的情况,比如空指针,栈溢出等。当程序无法继续完成任务时,就应该抛出异常。
处理意外情况常规有四种做法:
- 通过方法的返回值报告错误
处理是否发生错误,并通过线程本地变量储存最后一次发生错误的原因。这样做的好处是实现非常简单且开销很小,但会增加开发者的负担,并且容易因为开发者的疏忽而导致错误被忽略
FILE* fp = fopen("file.txt" , "w");
if (!fp) {
// some error occurred
}
-
使用异常(Exception)来报告错误
使用Exception来报告错误,可以减少代码量并标准化错误处理流程,但性能花销很大且需要编译器和runtime的支持 -
使用回调函数来报告错误
在JavaScript领域非常常见的做法,使用回调。
const fs = require('fs');
fs.readFile('some_file.txt', (err, result) => {
if (err) {
console.error(err);
return;
}
console.log(result);
});
但这种方法会导致一个问题"回调地狱"。相信写过JS的同学一定会对此深有感触
- 函数式语言
例如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宏。这样做开销很小,但是会带来以下问题
- 增加代码量
每次调用方法都需要显式检查返回值,容易被忽略 - 处理未标准化
传递详细的错误信息需要自定义结构体 - 传递错误不容易
如果错误无法在当前方法被处理,需要手动将错误继续传递到上层。容易被忽略 - 高耦合
如果一个不会发生错误的方法,代码调整后,变得可能会发生错误。则需要修改所有调用该方法的代码
异常处理(Exception Handling)机制的出现解决了这些问题,异常独立处理。实现高内聚,低耦合。且如果不处理错误,错误会自动传递到上一层。
异常的好处在于,未处理的异常会造成程序终止,可以在测试期间提前发现问题。而不是等到部署之后还发生终止的情况。
但是异常处理也是有代价的
- 大量的bookkeeping代码,对代码的大小和时间造成负面影响
非托管编译器必须生成代码来跟踪哪些对象被成功构造。编译器还必须生成代码,以便在一个异常被捕获到的时候,调用每个成功构造的对象的析构器。 - 线程切换
线程要从用户态切到内核态,开销不会小。 - StackTrace
这里面的值需要从当前异常的线程栈中去抓取调用栈,越深开销就越大。
.NET 异常处理机制
用户异常与硬件异常
.NET中的异常可以按照触发方式分为用户异常和硬件异常,其中用户异常是程序代码主动抛出的异常,是最常见的异常。其操作流程如下
Q1:托管异常为什么不直接处理,而是要包装一层。再绕回来,这不是脱裤子放屁吗?
这么做的好处是,它们可以传递给同一个异常处理入口并共用相同的逻辑。
硬件异常是指CPU执行指令码出现异常后,由CPU通知操作系统,操作系统再通知进程触发的异常。常见的场景有调用null对象的方法,字段。以及整数除以0。其流程如下
异常处理表
.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行就代表了方法的异常处理表,意义如下
- IL_000d to IL_001c 之间代码发生的Exception异常由IL_001c to IL_002c 之间的代码处理
- IL_0001 to IL_002f 之间发生的ArgumentException异常由IL_002f to IL_003f之间的代码处理
- IL_0001 to IL_002f 之间发生的Exception异常由IL_003f to IL_004f之间的代码处理
- 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 处理异常主要为以下四个操作
- 捕捉异常并抛出异常
- 通过调用链获取异常发生点与调用来源
在捕捉到异常后,Runtime会通过调用链跟踪所有调用来源,其原理是扫描方法的栈结构。
但是面对非托管方法(没有元数据)与托管方法(有元数据)之间互相调用,如何实现跟踪呢?.NET的托管线程对象中有一个列表,专门记录托管方法与非托管方法之间的切换。调用链会先枚举这个列表,然后再扫描栈结构,这样就可以跳过没有元数据的非托管函数
- 获取函数元数据中的异常处理表
异常处理表同样在托管方法的元数据中
- 枚举异常处理表对应的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底层入门>书中的数据
按照我的理解,如果是频率很低一些边界性的错误,使用异常无伤大雅。如果是一次频次很高的业务异常,则需要考虑使用返回值报告错误来减少异常开销。
函数式编程 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 "";
});
}
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】博客园携手 AI 驱动开发工具商 Chat2DB 推出联合终身会员
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Bogus:.NET的假数据生成利器
· 如何做好软件架构师
· 记录一次线上服务OOM排查
· 阿里云IP遭受DDOS攻击 快速切换IP实践
· itextpdf 找出PDF中 文字的坐标