异常编译代码分析
异常编译代码分析
https://lowlevelbits.org/compiling-ruby-part-5/
调用堆栈、堆栈帧和程序计数器
在程序执行期间,机器维护指向正在执行的指令的指针。它被称为程序计数器(或指令指针)。
当调用一个方法时,程序计数器被设置为被调用函数(被调用者)上的第一条指令。一旦子方法完成执行,程序就需要知道如何返回调用站点。
该信息通常使用调用堆栈的概念来维护。
请考虑下面的程序及其右侧的调用堆栈。
调用堆栈由堆栈帧组成。每当调用函数时,都会创建一个新的堆栈帧并将其推送到堆栈上。当被调用的函数返回时,堆栈帧将弹出。
在每一点上,调用堆栈都表示实际的堆栈跟踪。
调用堆栈的最顶部表示整个文件的范围,后面是第一个函数的堆栈帧,后面是第二个函数,依此类推。在Ruby中,top函数/文件作用域被简称为top。
现在,假设想将一些信息从第二个函数传递到顶部。发生了一些错误或异常情况,此特定程序状态需要一些特殊处理。
有几种有限的方法来处理这种情况:要么返回一些特殊的值up(因此,调用堆栈上的每个函数都应该知道这一点),要么可以使用一些全局变量与调用方通信(例如,C中的errno),这会再次通过调用堆栈污染业务逻辑。
更优雅地处理这个问题的一种方法是使用特定的语言结构——异常。
可以抛出/引发异常,然后在顶部添加特殊处理,而不是污染整个调用堆栈,如下图所示:
堆叠放卷
现在,问题是:如何实现这个功能?为了回答这个问题,了解需要发生什么!
在调用顶部的第一个函数之前,程序处于某种特定状态。现在,程序在第二个函数中的提升错误行附近处于另一个特定状态。
需要以某种方式恢复第一次调用之前的状态,并在顶部的救援之后继续执行(通过相应地更改程序计数器)。
从概念上讲,可以在调用第一个方法并稍后恢复它之前保存机器状态。问题是存储整个机器的状态过于昂贵,并且节省了超出所需的成本,从而增加了开销。
相反,可以将维护程序的责任推给实际的程序开发人员。
大多数语言都提供了处理此问题的有用功能:
Ruby有明确的确保块
Java有明确的finally语句
C++具有RAII和隐式析构函数
(C有setjmp/longjmp,但只讨论有用的特性)
以下是Ruby的工作原理。
每当抛出异常时,程序都会爬上调用堆栈,并从这些终结器执行代码,直到到达异常处理程序。
此过程称为堆栈放卷。
这里是一个更新的例子,在堆栈展开期间显式恢复状态。
如果不执行来自确保块的代码,假设的锁将永远不会被释放,从而以可怕的方式破坏程序。
从块返回
return语句的行为不同,这取决于它们所属的词法范围。
有一个小谜题。
将在屏幕上打印的内容:
Ruby中的异常
现在,可以讨论Ruby中不同类型的异常。有三种不同的类型:
实际引发的异常
break语句
return声明
break和return语句在Procs上下文中使用时都具有特殊意义。
用例子详细说明这三个方面。
正常例外情况
实际的异常会爬上堆栈,调用终结器直到找到异常处理程序。
这些都是大家熟悉的正常例外情况。
return是从块内调用的。您可能期望x*4从块返回,但它是从封闭函数(词法范围)返回的。
返回x*4将从f而不是从块返回。
代码打印
2: 8
而不是
1: 8
2:42
breaks
与返回类似,breaks允许从封闭函数返回,但方式略有不同。
这是这里最复杂的例子。
top调用循环函数并将块传递给它。块只是引擎盖下的另一个函数;在这里作为__anonymous_block单独呈现。
Runtime为循环创建一个新的堆栈帧,并将其放在调用堆栈上。
循环调用传递的块(__anonymous_block)。
Runtime为__anonymous_block创建新的堆栈帧,并将其放在堆栈上。
__anonymous_block递增i,检查是否相等,然后返回到循环,没有什么特别的。
Runtime从调用堆栈中删除__anonymous_block堆栈帧。
循环堆栈帧保留在调用堆栈上,while true的下一次迭代再次调用__anonymous_block。
Runtime为__anonymous_block创建新的堆栈帧,并将其放在堆栈上。
__anonymous_block递增i,检查是否相等,并调用break。
break启动堆栈展开并从封闭函数(循环)返回。请参见虚线。
循环返回,从而在为true时绕过无休止循环。
break构造实际上等效于以下代码:
实施
上面描述的所有语言构造(块中的异常、返回和中断)的行为都类似:它们展开堆栈(在向上的过程中调用终结器)并在某个定义明确的点停止。
它们在原始mruby运行时中的实现略有不同。尽管如此,还是将它们全部作为异常来实现,返回和中断是特殊的异常:需要携带一个值并存储关于在哪里停止解除过程的信息。
考虑以下示例:
救援和确保后的障碍物称为着陆垫。
此示例有两种着陆台:捕获(救援)和清理(确保)。捕获是“有条件的”着陆台:只有当异常类型与其类型匹配时,才会执行捕获。注意最后一个救援:它没有附加任何类型,所以它只会捕获任何异常。
相反,清理是无条件的——它们将始终运行,但也会将异常转发到调用堆栈上的下一个函数。
这个例子中的另一个重要细节是第二个救援:它使用函数参数作为类型。也就是说,着陆台的类型只有在运行时才知道,它可以是任何东西。
例如,在C++中,所有的catch类型都必须预先知道,并且编译器会发出特殊的运行时类型信息(RTTI)。再说一遍,IMO,它应该是编译时间类型信息,但它是C++…
因此,Ruby虚拟机总是进入每个着陆台。对于捕获,它首先(在运行时!)检查异常类型是否与平台的类型匹配,如果匹配,则将异常标记为捕获,并继续执行平台。
如果异常类型不匹配,则会立即重新抛出异常,以便下一个着陆台可以尝试接住它。
MLIR
描述一下如何在MLIR级别建模异常的,但这需要更多的时间,原因有几个:
由于异常的工作方式(即,一些寄存器必须溢出堆栈),最初立即构建SSA的方法不起作用,所以方言发生了一些变化,需要对它们进行一些清理
目前对它们建模的方式更像是一种破解,只是因为有某些约定,所以它还不是一个可靠的模型
添加了JIT支持(对Kernel.eval),并需要在那里进行一些调整,以使异常在实时评估中起作用
参考文献链接
https://lowlevelbits.org/compiling-ruby-part-5/