前言

环境:.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

    这里面的值需要从当前异常的线程栈中去抓取调用栈,越深开销就越大。

.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行就代表了方法的异常处理表,意义如下

  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的托管线程对象中有一个列表,专门记录托管方法与非托管方法之间的切换。调用链会先枚举这个列表,然后再扫描栈结构,这样就可以跳过没有元数据的非托管函数

  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底层入门>书中的数据



按照我的理解,如果是频率很低一些边界性的错误,使用异常无伤大雅。如果是一次频次很高的业务异常,则需要考虑使用返回值报告错误来减少异常开销。

函数式编程 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 "";
});
}

C#查漏补缺----Exception处理实现,无脑抛异常不可取的更多相关文章

  1. 《CSS权威指南》基础复习+查漏补缺

    前几天被朋友问到几个CSS问题,讲道理么,接触CSS是从大一开始的,也算有3年半了,总是觉得自己对css算是熟悉的了.然而还是被几个问题弄的"一脸懵逼"... 然后又是刚入职新公司 ...

  2. js基础查漏补缺(更新)

    js基础查漏补缺: 1. NaN != NaN: 复制数组可以用slice: 数组的sort.reverse等方法都会改变自身: Map是一组键值对的结构,Set是key的集合: Array.Map. ...

  3. Entity Framework 查漏补缺 (一)

    明确EF建立的数据库和对象之间的关系 EF也是一种ORM技术框架, 将对象模型和关系型数据库的数据结构对应起来,开发人员不在利用sql去操作数据相关结构和数据.以下是EF建立的数据库和对象之间关系 关 ...

  4. 2019Java查漏补缺(一)

    看到一个总结的知识: 感觉很全面的知识梳理,自己在github上总结了计算机网络笔记就很累了,猜想思维导图的方式一定花费了作者很大的精力,特共享出来.原文:java基础思维导图 自己学习的查漏补缺如下 ...

  5. 【spring源码分析】IOC容器初始化——查漏补缺(四)

    前言:在前几篇查漏补缺中,其实我们已经涉及到bean生命周期了,本篇内容进行详细分析. 首先看bean实例化过程: 分析: bean实例化开始后 注入对象属性后(前面IOC初始化十几篇文章). 检查激 ...

  6. Django 查漏补缺

    Django 查漏补缺 Django  内容回顾: 一. Http 请求本质: 网络传输,运用socket Django程序: socket 服务端 a. 服务端监听IP和端口 b. 浏览器发送请求 ...

  7. Java查漏补缺(3)(面向对象相关)

    Java查漏补缺(3) 继承·抽象类·接口·静态·权限 相关 this与super关键字 this的作用: 调用成员变量(可以用来区分局部变量和成员变量) 调用本类其他成员方法 调用构造方法(需要在方 ...

  8. Java基础查漏补缺(2)

    Java基础查漏补缺(2) apache和spring都提供了BeanUtils的深度拷贝工具包 +=具有隐形的强制转换 object类的equals()方法容易抛出空指针异常 String a=nu ...

  9. CSS基础面试题,快来查漏补缺

    本文大部分问题来源:50道CSS基础面试题(附答案),外加一些面经. 我对问题进行了分类整理,并给了自己的回答.大部分知识点都有专题链接(来源于本博客相关文章),用于自己前端CSS部分的查漏补缺.虽作 ...

  10. Go语言知识查漏补缺|基本数据类型

    前言 学习Go半年之后,我决定重新开始阅读<The Go Programing Language>,对书中涉及重点进行全面讲解,这是Go语言知识查漏补缺系列的文章第二篇,前一篇文章则对应书 ...

随机推荐

  1. 15、SpringMVC之常用组件及执行流程

    15.1.常用组件 15.1.1. DispatcherServlet DispatcherServlet 是前端控制器,由框架提供,不需要工程师开发: 作用:统一处理请求和响应,整个流程控制的中心, ...

  2. 植物大战僵尸杂交版v2.1整合包全解锁+高清工具

    植物大战僵尸杂交版v2.1整合包全解锁+高清工具   引言 <植物大战僵尸>作为一款经典的塔防游戏,自2009年发布以来,就以其独特的游戏机制和幽默的风格赢得了全球玩家的喜爱.随着游戏的不 ...

  3. 从baselines库的common/vec_env/vec_normalize.py看reinforcement learning算法中的reward shape方法

    参考前文:https://www.cnblogs.com/devilmaycry812839668/p/15889282.html 2.  REINFORCE算法实际代码中为什么会对一个episode ...

  4. JavaScript中的包装类型详解

    JavaScript中的包装类型详解 在 JavaScript 中,我们有基本类型和对象类型两种数据类型. 基本类型包括 String,Number,Boolean,null,undefined 和 ...

  5. 禅道项目管理系统权限绕过漏洞(QVD-2024-15263)

    本文所涉及的任何技术.信息或工具,仅供学习和参考之用,请勿将文章内的相关技术用于非法目的,如有相关非法行为与文章作者无关.请遵守<中华人民共和国网络安全法>. 1. 概述 1.1 基本信息 ...

  6. MYSQL——mysql检索不包含字母U的数据

    2024/07/09 1. NOT LIKE 2. IS NOT.<>.!= 3. NOT IN 如题,正确答案如下: SELECT * FROM your_table_name WHER ...

  7. Linux 常见编辑器

    命令行编辑器 Vim Linux 上最出名的编辑器当属 Vim 了.Vim 由 Vi 发展而来,Vim 的名字意指 Vi IMproved,表示 Vi 的升级版.Vim 对于新手来说使用比较复杂,不过 ...

  8. el-popover - 问题

    背景:elemet - ui和vue , el-table中使用了 el-popover , el-popover 中使用了form, 每编辑一行数据,点击编辑按钮,出现el-popover弹窗,页面 ...

  9. Element Plus使用

    目录 Element Plus快速入门 常用组件 Element:是饿了么团队研发的,基于 Vue 3,面向设计师和开发者的组件库. 组件:组成网页的部件,例如 超链接.按钮.图片.表格.表单.分页条 ...

  10. 在一个简单的pwn题目中探究执行系统调用前堆栈的对齐问题

    题目介绍:在输入AAAAAAAAAAAAAAAAAAAAAAAAA后,程序会打开一个shell,这是为什么?字符串中的A能否更换为@? 1.程序接收输入AAAAAAAAAAAAAAAAAAAAAAAA ...