前言

环境:.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. 【SpringBoot】07 探索配置方式 Part3 多环境配置

    1.按多个Profile文件来配置 SpringBoot默认会使用第一个 我们可以在默认的application.properties中设置激活哪种环境配置 profile的命名规则 2.按Yml可以 ...

  2. Parallel and Sequential Data Structures and Algorithms

    并串行 从零开始考前突击并串行数据结构与算法 强烈建议和原教材参照着看 Introduction 本书的要点 定义问题 不同的算法解决 设计抽象数据类型和相应的数据结构实现 分析比较算法和数据类型的代 ...

  3. 美国小伙: "American Guy: Only communism can save America!"

    视频地址: https://www.youtube.com/watch?v=Y_WQnXFh8ss 2024大选在即,又是拜登对阵特朗普的旧日重现.在角逐谁的对手反对者更多的畸形内耗中,有一个名为 M ...

  4. 大语言模型(LLM)运行报错:module ‘streamlit‘ has no attribute ‘chat_message‘

    参考: https://blog.csdn.net/weixin_45748921/article/details/134645308 问题在于版本不匹配,深究一下为什么各个版本软件不匹配,发现原因是 ...

  5. 外观很好看的云盘:一款开源的云盘系统 —— kodcloud

    看到一款外观十分好看的开源云盘系统: 下载地址: https://kodcloud.com/explorer/download/ 开源地址: https://github.com/kalcaddle ...

  6. AI未来应用的新领域:具有领域知识的专属智能拼音输入法 —— 医生专属的智能输入法

    本人上个月去辽宁中医看了些小毛病,在和医生交流的时候随便小聊一下,其中一个主要的话题就是"医生是否需要练习五笔".众所周知,医生的主要工作是看病,而需要使用输入法打字写病历只是看病 ...

  7. 如何为anaconda配置动态链接库——ERROR: compiler_compat/ld: cannot find

    现在为python编译lib库的环境主要是使用anaconda,而之前往往都是使用自编译python环境,然后使用Linux的系统lib环境,但是现在由于都是使用anaconda环境来编译python ...

  8. 从零到一:用Go语言构建你的第一个Web服务

    使用Go语言从零开始搭建一个Web服务,包括环境搭建.路由处理.中间件使用.JSON和表单数据处理等关键步骤,提供丰富的代码示例. 关注TechLead,复旦博士,分享云服务领域全维度开发技术.拥有1 ...

  9. 03-canvas线条属性

    1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="U ...

  10. Maven经验分享(一)安装部署 转

    Maven安装部署 1.安装 在安装Maven之前,首先要确认你已经正确安装了JDK.Maven可以运行在JDK 1.4及以上的版本上.本书的所有样例都基于JDK 5及以上版本 目录下的安装包,直接a ...