Skip to content

Commit

Permalink
函数是怎么实现的
Browse files Browse the repository at this point in the history
  • Loading branch information
satori1995 committed Dec 26, 2024
1 parent 92e5cd3 commit f190b76
Show file tree
Hide file tree
Showing 2 changed files with 273 additions and 33 deletions.
66 changes: 33 additions & 33 deletions src/64.虚拟机是如何捕获异常的?.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ dis.dis(compile(code_string, "exception", "exec"))
但我们知道无论是哪种情况,都要执行 finally,所以开头有两个 SETUP_FINALLY 指令,但为什么会有两个呢?因为在 Python 的异常处理机制中,<font color="blue">try-except-finally</font> 结构会被编译成两个嵌套的异常处理块:

+ 第一个 SETUP_FINALLY 是为了处理 finally 块,会把 finally 块的地址压入栈中,它确保无论 try 块中是否发生异常,finally 块中的代码都会被执行;
+ 第二个 SETUP_FINALLY 实际上是在处理 except 块,在 Python 的字节码层面,except 块也是通过 SETUP_FINALLY 来实现的
+ 第二个 SETUP_FINALLY 实际上是在处理 except 块,在 Python 的字节码层面,except 块也是通过 SETUP_FINALLY 实现的

我们来看一下 SETUP_FINALLY 指令都干了什么。

Expand Down Expand Up @@ -364,7 +364,7 @@ PyFrame_BlockSetup(PyFrameObject *f, int type, int handler, int level)
if (f->f_iblock >= CO_MAXBLOCKS)
Py_FatalError("XXX block stack overflow");
// 栈帧有一个 f_blockstack 字段,它是 PyTryBlock 类型的数组
// 当栈帧创建完毕之后,f_blockstack 的内存就已经申请好了
// 当栈帧创建完毕后,f_blockstack 的内存就已经申请好了
// 因此当需要创建 PyTryBlock 实例时,只需从 f_blockstack 里面获取即可
b = &f->f_blockstack[f->f_iblock++];
// 设置 PyTryBlock 实例的字段,这个结构体一会儿说
Expand All @@ -391,7 +391,7 @@ PyFrame_BlockSetup(PyFrameObject *f, int type, int handler, int level)

结果没有问题,当然啦,如果不是恶意代码,我个人认为不会存在嵌套层级如此之深的异常捕获。

然后再来看看里面的 f_iblock,它表示对应的 PyTryBlock 在 f_blockstack 数组中的索引。栈帧刚创建时,f_iblock 的值为 0,每当执行 SETUP_FINALLY 执行时,就会从 f_blockstack 数组中获取一个 PyTryBlock,然后 f_iblock 自增 1。那么 PyTryBlock 长什么样子呢?
然后再来看看里面的 f_iblock,它表示对应的 PyTryBlock 在 f_blockstack 数组中的索引。栈帧刚创建时,f_iblock 的值为 0,每当执行 SETUP_FINALLY 指令时,就会从 f_blockstack 数组中获取一个 PyTryBlock,然后 f_iblock 自增 1。那么 PyTryBlock 长什么样子呢?

~~~C
// Include/frameobject.h
Expand All @@ -402,14 +402,14 @@ typedef struct {
} PyTryBlock;
~~~

b_type 表示 block 的种类,因为存在着多种用途的 PyTryBlock 对象,除了 SETUP_FINALLY 之外还有 SETUP_WITH 等。然后在 PyFrame_BlockSetup 中我们看到它被设置成了参数 type,而参数 type 接收的就是当前虚拟机正在执行的字节码指令。因此 PyTryBlock 具有多种用途,具体用于哪种则基于字节码指令进行判断。
b_type 表示 block 的种类,因为存在多种用途的 PyTryBlock 对象,除了 SETUP_FINALLY 之外还有 SETUP_WITH 等。然后在 PyFrame_BlockSetup 中我们看到它被设置成了参数 type,而参数 type 接收的就是当前虚拟机正在执行的字节码指令。因此 PyTryBlock 具有多种用途,具体用于哪种则基于字节码指令进行判断。

b_handler 表示处理程序的字节码偏移量,即跳转目标,比如当前的 try 语句块,当 try 执行完了或者执行出错后,要跳转到什么位置呢?在 SETUP_FINALLY 指令中我们看到它被设置成了 <font color="blue">INSTR_OFFSET() + oparg</font>,所以它会跳转到 finally 或 except 所在的位置。

b_level 表示进入 try 语句块时的 STACK_LEVEL(),即运行时栈的深度,或者说当前运行时栈的元素个数,因为当发生异常时,要把运行时栈恢复到进入 try 语句块时的状态。

~~~python
# 假设此时运行是时栈深度是 n
# 假设此时运行时栈的深度是 n
try: # SETUP_FINALLY 执行时 b_level = n
... # 执行内部逻辑
raise Exception("error") # 在这里不幸发生异常,那么要将栈恢复到 b_level 记录的深度 n
Expand All @@ -430,7 +430,7 @@ except Exception:

![](./images/207.png)

取出两块 PyTryBlock,第一块对应 finally,会无条件执行,第二块对应 except,异常捕获的时候用。至于具体做的稍后再说,我们先回到抛异常的地方看看。
取出两块 PyTryBlock,第一块对应 finally,会无条件执行,第二块对应 except,异常捕获的时候用。至于具体怎么做的稍后再说,我们先回到抛异常的地方看看。

~~~C
// 加载 <class 'Exception'>
Expand All @@ -453,7 +453,7 @@ case TARGET(RAISE_VARARGS): {
// raise 一个异常有三种方式
// 1)重新抛出当前异常,只写一个 raise 即可,此时 oparg = 0
// 2)抛出指定异常:raise exc,此时 oparg = 1
// 3)抛出执行异常并指定原因:raise exc from cause,此时 oparg = 2
// 3)抛出指定异常并指定原因:raise exc from cause,此时 oparg = 2
// 所以当前的 oparg = 1
switch (oparg) {
case 2:
Expand Down Expand Up @@ -489,8 +489,8 @@ exception_unwind:
PyTryBlock *b = &f->f_blockstack[--f->f_iblock];
// EXCEPT_HANDLER 定义在 opcode.h 中,值为 257,用于异常处理
// 但比较特殊的是,它不是一个指令,这么做的原因后续会看到
if (b->b_type == EXCEPT_HANDLER) {
// 但比较特殊的是,它不是一个指令,至于相关细节后续会看到
if (b->b_type == EXCEPT_HANDLER) { // 如果两者相等的话
// 这是一个宏,所做的事情如下
// 从线程状态对象中拿到 exc_info,这个和 python 里的 sys.exc_info() 等价
// 然后从栈顶弹出 exc_type、exc_value、exc_traceback,并设置在 exc_info 中
Expand Down Expand Up @@ -558,7 +558,7 @@ exception_unwind:
PUSH(tb);
PUSH(val);
PUSH(exc);
// 绝对跳转,跳转偏移量为 handler 的指令
// 绝对跳转,跳转到偏移量为 handler 的指令
JUMPTO(handler);
/* Resume normal execution */
goto main_loop;
Expand All @@ -573,9 +573,9 @@ assert(retval == NULL);
assert(_PyErr_Occurred(tstate));
~~~

我们看到虚拟机调用 PUSH 将旧异常和新异常的 exc_traceback、exc_value、exc_type 分别压入运行时栈中,并且知道此时开发者已经为异常处理做好了准备,所以接下来的异常处理工作,则需要交给开发者指定的代码来解决。于是内部调用了 `JUMPTO(b->b_handler)`将虚拟机将要执行的下一条指令设置为异常处理代码编译后所得到的第一条字节码指令
我们看到虚拟机调用 PUSH 将旧异常和新异常的 exc_traceback、exc_value、exc_type 分别压入运行时栈中,并且知道此时开发者已经为异常处理做好了准备,所以接下来的异常处理工作,则需要交给开发者指定的代码来解决。于是内部调用了 `JUMPTO(b->b_handler)`将虚拟机要执行的下一条指令设置为异常处理代码编译后所得到的第一条字节码指令

因为第一个弹出的是 PyTryBlock 的 b_handler 为 16,那么虚拟机将要执行的下一条指令就是偏移量为 16 的那条指令,而这条指令就是 DUP_TOP,异常处理代码对应的第一条字节码指令
因为第一个弹出的 PyTryBlock 的 b_handler 为 16,那么虚拟机将要执行的下一条指令就是偏移量为 16 的指令,而这条指令就是 DUP_TOP。

~~~C
// 在上面的 exception_unwind 标签中调用了 6 个 PUSH
Expand Down Expand Up @@ -603,12 +603,12 @@ assert(_PyErr_Occurred(tstate));
// 目前栈里面有 6 个元素,栈顶元素是新异常的 exc_type
// 所以 POP_TOP 之后,栈顶元素就变成了新异常的 exc_value
24 POP_TOP
// 弹出栈顶的 exc_value,赋值给变量 e,导致 except Exception as e 完成
// 弹出栈顶的 exc_value,赋值给变量 e,到此 except Exception as e 完成
// 我们看到这个过程其实也是一个变量赋值,字节码为 STORE_NAME
26 STORE_NAME 2 (e)
// 继续弹出栈顶元素,此时是新异常的 exc_traceback
28 POP_TOP
// 这里为啥又出现一个 SETUP_FINALLY 指令很简单
// 这里为啥又出现一个 SETUP_FINALLY 指令很简单
/*
try:
pass
Expand All @@ -625,53 +625,53 @@ assert(_PyErr_Occurred(tstate));
finally:
del e
*/
// 所以这里会多出一个 SETUP_FINALLY,因为内部有嵌套了一个 try ... finally
// 所以这里会多出一个 SETUP_FINALLY,因为内部又嵌套了一个 try ... finally
// 至于这么做的原因,我们稍后解释
30 SETUP_FINALLY 12 (to 44)
// 以下四条指令对应 except 子句内的 print(e)
32 LOAD_NAME 0 (print)
34 LOAD_NAME 2 (e)
36 CALL_FUNCTION 1
38 POP_TOP
// except 子句执行完毕,调用 POP_BLOCK
// except 子句里的代码执行完毕,调用 POP_BLOCK
// 该指令内部会交还 PyTryBlock,然后 f_iblock--
// 注意:这一步交换的 PyTryBlock,是 except 内部的 finally 对应的 PyTryBlock
// 注意:这一步交还的 PyTryBlock,是 except 内部的 finally 对应的 PyTryBlock
40 POP_BLOCK
// BEGIN_FINALLY 会往栈顶 PUSH 一个 NULL 进去,表示 finally 的开始
// BEGIN_FINALLY 会往栈顶 PUSH 一个 NULL 进去,标识 finally 的开始
// 注意:这个 finally 是 except 子句内部的 finally,是解释器自动生成的
42 BEGIN_FINALLY
// 将 e 赋值为 None,然后将 e 删除掉
>> 44 LOAD_CONST 2 (None)
46 STORE_NAME 2 (e)
48 DELETE_NAME 2 (e)
50 END_FINALLY
// 到此整个 except 执行完毕,执行 POP_EXCEPT,交还对应的 PyTryBlock
// 然后在该指令内部还会恢复之前保存的异常状态,我们上面看到,解析往运行时栈里面推了 6 个元素
// 到此整个 except 执行完毕,然后执行 POP_EXCEPT,交还对应的 PyTryBlock
// 然后在该指令内部还会恢复之前保存的异常状态,我们上面看到,解释器往运行时栈里面推了 6 个元素
// 前 3 个已经被弹出了,还剩下旧异常的 exc_type、exc_value、exc_traceback
// 那么将它们继续弹出,赋值给 exc_info 里面的字段,相当于恢复成之前的异常状态
52 POP_EXCEPT
// 跳转到第 58 条指令
54 JUMP_FORWARD 2 (to 58)
~~~

上面的指令有点多,我们再单独解释一下,首先在 exception_unwind 标签内部获取 `tstate->exc_info` 的三个字段,它们是旧异常的 exc_type、exc_value、exc_traceback,然后压入运行时栈。然后通过 _PyErr_Fetch 获取 tstate 里面的新异常的 exc_type、exc_value、exc_traceback,用它们更新 `tstate->exc_info`,然后再压入运行时栈。
上面的内容有点多,我们再单独解释一下,首先在 exception_unwind 标签内部获取 `tstate->exc_info` 的三个字段,它们是旧异常的 exc_type、exc_value、exc_traceback,然后压入运行时栈。再通过 _PyErr_Fetch 获取 tstate 里面的新异常的 exc_type、exc_value、exc_traceback,用它们更新 `tstate->exc_info`,然后再压入运行时栈。

所以此时运行时栈里面有 6 个元素。

![](./images/208.png)

然后使用 JUMPTO 跳转到偏移量为 `b->b_handler` 的指令,对于当前来说就是 except 子句对应的指令。
然后使用 JUMPTO 跳转到偏移量为 `b->b_handler` 的指令,对于当前来说就是 except 子句对应的指令,然后执行

+ 首先通过 DUP_TOP 指令将栈顶元素拷贝一份并入栈
+ 通过 DUP_TOP 将栈顶元素拷贝一份并入栈
+ 通过 LOAD_NAME 将 except 子句指定的异常类型入栈。
+ 通过 COMPARE_OP 将栈顶进行比较,将比较结果入栈。
+ 通过 COMPARE_OP 将栈顶的两个元素弹出,进行比较,将比较结果入栈。
+ 再通过 POP_JUMP_IF_FALSE 将比较结果弹出,进行判断,由于新异常的 exc_type 和 except 子句指定的异常类型都是 Exception,所以结果为 True。

我们画张图,展示一下运行时栈的变化过程。

![](./images/209.png)

然后是 30 SETUP_FINALLY,原因我们也解释了。
然后是 30 SETUP_FINALLY,至于为什么会多出这条指令,原因我们也解释了。

~~~Python
e = 2.71
Expand All @@ -698,7 +698,7 @@ except Exception as e:
del e
~~~

所以会又产生一个 SETUP_FINALLY 指令,至于解释器这么做的动机我们稍后解释。
所以又产生了一个 SETUP_FINALLY 指令,至于解释器这么做的动机我们稍后解释。

然后偏移量为 32、34、36、38 的指令就无需解释了,这几条指令执行完毕后,except 语句块里的代码就执行完了。然后执行 40 POP_BLOCK,它会交还当前 except 内部的 finally 语句块对应的 PyTryBlock,然后将 f_iblock 自减 1。

Expand Down Expand Up @@ -774,7 +774,7 @@ case TARGET(POP_EXCEPT): {
74 RETURN_VALUE
~~~

因此在异常机制的实现中,最重要的就是线程状态以及栈帧对象中 f_blockstack 里存放的 PyTryBlock 对象。首先根据线程状态可以判断当前是否发生了异常,而 PyTryBlock 对象则告诉虚拟机,开发者是否为异常设置了except 和 finally,虚拟机异常处理的流程就是在线程所处的状态和 PyTryBlock 的共同作用下完成的。
因此在异常机制的实现中,最重要的就是线程状态以及栈帧对象的 f_blockstack 字段里面存放的 PyTryBlock 对象。首先根据线程状态可以判断当前是否发生了异常,而 PyTryBlock 对象则告诉虚拟机,开发者是否为异常设置了 except 和 finally,虚拟机异常处理的流程就是在线程所处的状态和 PyTryBlock 的共同作用下完成的。

当然啦,我们这里分析的是异常能捕获的情况,如果不能捕获呢?具体细节可以自己写段代码测试一下,这里我们直接画张图总结一下。

Expand Down Expand Up @@ -818,13 +818,13 @@ Traceback (most recent call last):
File "/Users/.../main.py", line 2, in h
1 / 0

# 函数 h 的 traceback -> tb_next 为 None,证明错误是发生在函数 h 中
# 在模块中调用函数 f 相当于导火索,然后一层一层输出,最终定位到函数 h
# 然后再将之前设置在线程状态对象中的异常类型和异常值打印出来即可
# 函数 h 的 traceback->tb_next 为 None,证明错误是发生在函数 h 中
# 而在模块中调用函数 f 相当于导火索,然后一层一层输出,最终定位到函数 h
# 最后再将之前设置在线程状态对象中的异常类型和异常值打印出来即可
ZeroDivisionError: division by zero
~~~

模块中调用了函数 f,函数 f 调用了函数 g,函数 g 调用了函数 h。然后在函数 h 中执行出错了,但又没有异常捕获,那么会将执行权交给函数 g 对应的栈帧,但是函数 g 也没有异常捕获,那么再将执行权交给函数 f 对应的栈帧。所以调用的时候栈帧一层一层创建,当执行完毕或者出现异常时,栈帧再一层层回退。
模块中调用了函数 f,函数 f 调用了函数 g,函数 g 调用了函数 h。然后在函数 h 中执行出错了,但又没有异常捕获,那么会将执行权交给函数 g 对应的栈帧,但是函数 g 也没有异常捕获,那么再将执行权交给函数 f 对应的栈帧。所以调用的时候,栈帧一层层创建,当执行完毕或者出现异常时,栈帧再一层层回退。

![](./images/211.png)

Expand All @@ -851,7 +851,7 @@ except Exception as e:
# 而如果不 del e 的话,那么异常对象不会被销毁
# 此外还有一个原因,通过 __traceback__ 可以拿到当前的回溯栈,即 traceback 对象
print(e.__traceback__) # <traceback object at 0x104a98b80>
# 而 traceback 对象保存当前的栈帧,然后栈帧又保存了包含变量 e 的名字空间
# 而 traceback 对象保存了当前的栈帧,然后栈帧又保存了包含变量 e 的名字空间
print(e.__traceback__.tb_frame.f_locals["e"] is e) # True
# 相信你能猜到这会带来什么后果,没错,就是循环引用
# 因此在 except 结束时会隐式地 del e
Expand All @@ -867,7 +867,7 @@ NameError: name 'e' is not defined

## 小结

本篇文章我们就分析了异常捕获的实现原理,总的来说并不难。另外在更高的版本中,所有的信息会静态保存在一张异常跳转表中,速度会更快。当然啦,在不报错时,异常捕获对程序性能没有任何影响,所以放心使用。
本篇文章我们就分析了异常捕获的实现原理,总的来说并不难。另外在更高的版本中,所有的信息会静态保存在一张异常跳转表中,速度会更快。当然啦,在没有报错时,异常捕获对程序性能没有任何影响,所以放心使用。

----

Expand Down
Loading

0 comments on commit f190b76

Please sign in to comment.