虚拟机是如何捕获异常的?

文摘   2024-11-11 10:36   北京  

楔子


上一篇文章我们介绍了 Python 的异常是怎么实现的,抛出异常这个动作在虚拟机层面上是怎样的一个行为,以及虚拟机在处理异常时候的栈帧展开行为。

既然虚拟机内建的异常处理动作我们已经了解了,那么接下来就看看异常捕获是如何实现的,还有它又是如何影响虚拟机的异常处理流程的。毕竟在大部分情况下,我们都不会将异常抛出去,而是将它捕获起来。



异常捕获语句


这里先来回顾一下异常捕获语句,首先一个完整的异常捕获语句如下。

try:
    pass
except IndexError as e:
    pass
except Exception as e:
    pass
else:
    pass
finally:
    pass 

情况可以分为以下几种:

1)如果 try 里面的代码在执行时没有出现异常,那么会执行 else ,然后执行 finally。

try:
    print("我是 try")
except Exception as e:
    print("我是 except")
else:
    print("我是 else")
finally:
    print("我是 finally")
"""
我是 try
我是 else
我是 finally
"""
    

2)如果 try 里面的代码在执行时出现异常了(异常会被设置在线程状态对象中),那么会依次判断 except(可以有多个)能否匹配发生的异常。如果某个 except 将异常捕获了,那么会将异常给清空,然后执行 finally。

try:
    raise IndexError("IndexError Occurred")
except ValueError as e:
    print("ValueError 匹配上了异常")
except IndexError as e:
    print("IndexError 匹配上了异常")
except Exception as e:
    print("Exception 匹配上了异常")
else:
    print("我是 else")
finally:
    print("我是 finally")
"""
IndexError 匹配上了异常
我是 finally
"""
   

except 子句可以有很多个,发生异常时会从上往下依次匹配。但是注意:多个 except 子句最多只有一个被执行,比如当前的 IndexError 和 Exception 都能匹配发生的异常,但只会执行匹配上的第一个 except 子句。

另外只要发生异常了,else 就不会执行了。不管 except 有没有将异常捕获到,都不会执行 else,因为 else 只有在 try 里面没有发生异常的时候才会执行。

3)如果 try 里面的代码在执行时出现异常了,但 except 没有将异常捕获掉,那么异常仍然被保存在线程状态对象中,然后执行 finally。如果 finally 子句中没有出现 return、break、continue 等关键字,再将异常抛出来。

try:
    raise IndexError("IndexError Occurred")
except ValueError:
    print("ValueError 匹配上了异常")
finally:
    print("我是 finally")
"""
我是 finally
Traceback (most recent call last):
  File "......", line 2, in <module>
    raise IndexError("IndexError Occurred")
IndexError: IndexError Occurred
"""

except 没有将异常捕获掉,所以执行完 finally 之后,异常又被抛出来了。但如果 finally 里面出现了 return、break、continue 等关键字,也不会抛出异常,而是将异常丢弃掉。

def f():
    try:
        raise IndexError("IndexError Occurred")
    except ValueError:
        print("ValueError 匹配上了异常")
    finally:
        print("我是 finally")
        return

f()
"""
我是 finally
"""


def g():
    for i in range(3):
        try:
            raise IndexError("IndexError Occurred")
        except ValueError:
            print("ValueError 匹配上了异常")
        finally:
            print(f"我是 finally,i = {i}")
            continue

g()
"""
我是 finally,i = 0
我是 finally,i = 1
我是 finally,i = 2
"""

由于 finally 里面出现了 return 和 continue,所以异常并没有发生,而是被丢弃掉了。这个特性相信有很多小伙伴之前还是没有发现的。

然后 try、except、else、finally 这几个关键字不需要同时出现,可以有以下几种组合。

try ... except

try ... finally

try ... except ... else

try ... except ... else ... finally

注意里面的 except,可以出现多次,但其它关键字在一个 try 语句内只能出现一次。



返回值问题


如果这几个关键字对应的代码块都指定了返回值,那么听谁的呢?下面解释一下。

def retval():
    try:
        return 123
    except Exception:
        return 456

print(retval())  # 123

由于没有发生异常,所以返回了 try 指定的返回值。

def retval():
    try:
        return 123
    except Exception:
        return 456
    else:
        return 789

print(retval())  # 123

虽然指定了 else,但是 try 里面已经执行 return 了,所以打印的仍是 try 的返回值。

def retval():
    try:
        1 / 0
        return 123
    except Exception:
        return 456

print(retval())  # 456

由于发生异常,所以返回了 except 指定的返回值。

def retval():
    try:
        1 / 0
        return 123
    except Exception:
        return 456
    else:
        return 789

print(retval())  # 456

一旦发生异常,else 就不可能执行,所以此时仍然返回 456。

def retval():
    try:
        return 123
    except Exception:
        return 456
    finally:
        pass

print(retval())  # 123

finally 永远会执行,但它没有指定返回值,所以此时返回的是 123。

def retval():
    try:
        return 123
    except Exception:
        return 456
    finally:
        return

print(retval())  # None

一旦 finally 中出现了 return,那么返回的都是 finally 指定的返回值。并且此时即便出现了没有捕获的异常,也不会报错,因为会将异常丢弃掉。

def retval():
    try:
        return 123
    except Exception:
        return 456
    finally:
        pass
    return 789

print(retval())  # 123

函数一旦 return,就表示要返回了,但如果这个 return 是位于出现了 finally 的异常捕获语句中,那么会先执行 finally,然后再返回。所以最后的 return 789 是不会执行的,因为已经出现 return 了,finally 执行完毕之后就直接返回了。

def retval():
    try:
        pass
    except Exception:
        return 456
    finally:
        pass
    return 789

print(retval())  # 789

没有异常,所以 except 里的 return 不会执行,而 try 和 finally 里面也没有 return,因此返回 789。

一个简单的异常捕获,总结起来还稍微有点绕呢。

从 Python 的层面理解完异常捕获之后,再来看看虚拟机是如何实现这一机制的?想要搞清楚这一点,还是得从字节码入手。




异常捕获对应的字节码


随便写一段代码,然后反编译一下。

import dis

code_string = """
try:
    raise Exception("抛出一个异常")
except Exception as e:
    print(e)
finally:
    print("我一定会被执行的")
"""


dis.dis(compile(code_string, "exception""exec"))

抛异常有两种方式,一种是虚拟机执行的时候出现错误而抛出异常,另一种是使用 raise 关键字手动抛出异常。这里我们就用第二种方式,来看一下反编译的结果(为了清晰,省略掉了源代码行号)。

      0 RESUME                   0

      2 NOP

      4 PUSH_NULL
      6 LOAD_NAME                0 (Exception)
      8 LOAD_CONST               0 ('抛出一个异常')
     10 CALL                     1
     18 RAISE_VARARGS            1
>>   20 PUSH_EXC_INFO

     22 LOAD_NAME                0 (Exception)
     24 CHECK_EXC_MATCH
     26 POP_JUMP_IF_FALSE       18 (to 64)
     28 STORE_NAME               1 (e)

     30 PUSH_NULL
     32 LOAD_NAME                2 (print)
     34 LOAD_NAME                1 (e)
     36 CALL                     1
     44 POP_TOP
     46 POP_EXCEPT
     48 LOAD_CONST               1 (None)
     50 STORE_NAME               1 (e)
     52 DELETE_NAME              1 (e)
     54 JUMP_FORWARD             8 (to 72)
>>   56 LOAD_CONST               1 (None)
     58 STORE_NAME               1 (e)
     60 DELETE_NAME              1 (e)
     62 RERAISE                  1

>>   64 RERAISE                  0
>>   66 COPY                     3
     68 POP_EXCEPT
     70 RERAISE                  1

>>   72 NOP

     74 PUSH_NULL
     76 LOAD_NAME                2 (print)
     78 LOAD_CONST               2 ('我一定会被执行的')
     80 CALL                     1
     88 POP_TOP
     90 RETURN_CONST             1 (None)
>>   92 PUSH_EXC_INFO
     94 PUSH_NULL
     96 LOAD_NAME                2 (print)
     98 LOAD_CONST               2 ('我一定会被执行的')
    100 CALL                     1
    108 POP_TOP
    110 RERAISE                  0
>>  112 COPY                     3
    114 POP_EXCEPT
    116 RERAISE                  1
ExceptionTable:
  4 to 18 -> 20 [0]
  20 to 28 -> 66 [1] lasti
  30 to 44 -> 56 [1] lasti
  46 to 54 -> 92 [0]
  56 to 64 -> 66 [1] lasti
  66 to 70 -> 92 [0]
  92 to 110 -> 112 [1] lasti

字节码指令还是比较多的,我们来分段解释。



try 子句的指令


try 子句的指令如下。

      6 LOAD_NAME                0 (Exception)
      8 LOAD_CONST               0 ('抛出一个异常')
     10 CALL                     1
     18 RAISE_VARARGS            1

6 LOAD_NAME 指令会将 <class 'Exception'> 压入运行时栈。8 LOAD_CONST 指令会将字符串常量压入运行时栈。然后 10 CALL 指令将运行时栈里的元素弹出,进行调用。可以看到不管是调用函数,还是调用类,执行的都是 CALL 指令,然后将返回值(这里就是 Exception 对象,即异常)压入栈中。

接着执行 18 RAISE_VARARGS,这是一条新指令,看一下它的逻辑。

TARGET(RAISE_VARARGS) {
    PyObject **args = (stack_pointer - oparg);
    #line 606 "Python/bytecodes.c"
    PyObject *cause = NULL, *exc = NULL;
    switch (oparg) {
    case 2:
        cause = args[1];
        /* fall through */
    case 1:
        exc = args[0];
        /* fall through */
    case 0:
        // 调用 do_raise 设置异常
        if (do_raise(tstate, exc, cause)) {
            assert(oparg == 0);
            monitor_reraise(tstate, frame, next_instr-1);
            goto exception_unwind;
        }
        break;
    default:
        _PyErr_SetString(tstate, PyExc_SystemError,
                         "bad RAISE_VARARGS oparg");
        break;
    }
    if (true) { STACK_SHRINK(oparg); goto error; }
    #line 912 "Python/generated_cases.c.h"
}

因为 RAISE_VARARGS 指令的参数是 1,所以 case 1 成立,于是将异常从运行时栈中弹出,并赋值给变量 exc,然后调用 do_raise 函数。

在 do_raise 中,最终会调用之前说过的 PyErr_Restore 函数,将异常对象存储到当前的线程状态对象中,然后跳转到标签为 exception_unwind 的地方开始异常捕获。

exception_unwind:
{
    // INSTR_OFFSET 是一个宏,定义在 Python/ceval_macros.h 中
    // #define INSTR_OFFSET() ((int)(next_instr - _PyCode_CODE(frame->f_code)))
    int offset = INSTR_OFFSET()-1;
    int level, handler, lasti;
    // 查询 co_exceptiontable,即异常处理表
    // 当 try 里面产生异常时,那么必须跳转到相应的 except 或 finally 里面
    // 在 Python 3.10 以及之前的版本,这个机制是通过引入一个独立的动态栈,跟踪 try 语句块实现的
    // 但从 3.11 开始,动态栈被替换成了静态表,即异常处理表,由 co_exceptiontable 字段维护
    // 并且表在编译期间就静态生成了,是一段字节序列,里面包含了 try / except / finally 信息
    // 当代码在执行过程中出现异常时,解释器会查询这张表,寻找与之匹配的 except / finall 块
    if (get_exception_handler(frame->f_code, offset, &level, &handler, &lasti) == 0
    {
        // No handlers, so exit.
        // ...
        // 如果 get_exception_handler 返回值等于 0,说明没有找到
        // 那么跳转到 exit_unwind 标签,退出当前栈帧
        goto exit_unwind;
    }
    // 否则说明找到了,那么要进行跳转,而跳转地址保存在 handler 中
    assert(STACK_LEVEL() >= level);
    // ...
    // 获取 tstate->current_exception,即当前线程状态对象保存的异常
    PyObject *exc = _PyErr_GetRaisedException(tstate);
    // 压入栈中
    PUSH(exc);
    // 跳转到指定指令,即 except / finally 块对应的指令
    JUMPTO(handler);
    if (monitor_handled(tstate, frame, next_instr, exc) < 0) {
        goto exception_unwind;
    }
    /* Resume normal execution */
    DISPATCH();
}

该指令执行后,异常会被压入栈中,虚拟机也知道该跳转到什么地方了。



except 子句的指令


try 子句的指令我们说完了,再来看看 except 子句。

>>   20 PUSH_EXC_INFO
     22 LOAD_NAME                0 (Exception)
     24 CHECK_EXC_MATCH
     26 POP_JUMP_IF_FALSE       18 (to 64)
     28 STORE_NAME               1 (e)

     30 PUSH_NULL
     32 LOAD_NAME                2 (print)
     34 LOAD_NAME                1 (e)
     36 CALL                     1
     44 POP_TOP
     46 POP_EXCEPT
     48 LOAD_CONST               1 (None)
     50 STORE_NAME               1 (e)
     52 DELETE_NAME              1 (e)
     54 JUMP_FORWARD             8 (to 72)

首先执行 20 PUSH_EXC_INFO 指令,内部逻辑如下。

TARGET(PUSH_EXC_INFO) {
    // RAISE_VARARGS 指令将异常设置在了线程状态对象中
    // 然后跳转到 exception_unwind 标签,将异常压入运行时栈
    PyObject *new_exc = stack_pointer[-1];
    PyObject *prev_exc;
    #line 2543 "Python/bytecodes.c"
    /* tstate->current_exception 表示当前存储的异常
     * tstate->exc_info 是一个结构体,相当于对异常做了一个封装
     *
     * typedef struct _err_stackitem {
     *     PyObject *exc_value;
     *     struct _err_stackitem *previous_item;
     * } _PyErr_StackItem;
     */

    _PyErr_StackItem *exc_info = tstate->exc_info;
    // exc_info->exc_value 还是之前存储的异常
    if (exc_info->exc_value != NULL) {
        prev_exc = exc_info->exc_value;
    }
    else {
        prev_exc = Py_None;
    }
    assert(PyExceptionInstance_Check(new_exc));
    // 将 exc_info->exc_value 替换为新产生的异常
    exc_info->exc_value = Py_NewRef(new_exc);
    #line 3584 "Python/generated_cases.c.h"
    // 此时栈里面有两个元素,从栈底到栈顶分别是旧异常、新异常
    STACK_GROW(1);
    stack_pointer[-1] = new_exc;
    stack_pointer[-2] = prev_exc;
    DISPATCH();
}

该指令做的事情是将旧异常和新异常压入运行时栈。

22 LOAD_NAME 加载 Exception,然后执行 24 CHECK_EXC_MATCH,逻辑如下。

TARGET(CHECK_EXC_MATCH) {
    // 获取栈顶元素,由于源码中是 except Exception as e
    // 所以会得到上一条 LOAD_NAME 指令压入的 class Exception
    PyObject *right = stack_pointer[-1];
    // 执行 exception_unwind 标签内的逻辑时压入的异常
    PyObject *left = stack_pointer[-2];
    PyObject *b;
    #line 2122 "Python/bytecodes.c"
    // ...
    // 判断异常对象和指定的异常类能否匹配
    int res = PyErr_GivenExceptionMatches(left, right);
    #line 2981 "Python/generated_cases.c.h"
    Py_DECREF(right);
    #line 2130 "Python/bytecodes.c"
    b = res ? Py_True : Py_False;
    #line 2985 "Python/generated_cases.c.h"
    // 将栈顶元素用 res 替换掉,此时栈里面有两个元素
    // 从栈底到栈顶分别是:旧异常对象,新异常对象,布尔值(异常是否可以被捕获)
    stack_pointer[-1] = b;
    DISPATCH();
}

26 POP_JUMP_IF_FALSE 会弹出栈顶元素,如果为 False,说明异常无法被捕获,那么要跳转到下一个 except 或者 finally。如果可以被捕获,那么执行 28 STORE_NAME,再将栈里的异常对象弹出,赋值给变量 e。

到此 except Exception as e 这一行语句便已经完成,至于接下来的 4 条指令应该不需要解释了。

很好理解,这 4 条就是 print(e) 对应的指令,然后执行 46 POP_EXCEPT,逻辑如下。

TARGET(POP_EXCEPT) {
    // 此时运行时栈里面还剩下一个旧异常
    PyObject *exc_value = stack_pointer[-1];
    #line 930 "Python/bytecodes.c"
    _PyErr_StackItem *exc_info = tstate->exc_info;
    // 更新引用计数
    Py_XSETREF(exc_info->exc_value, exc_value);
    #line 1274 "Python/generated_cases.c.h"
    STACK_SHRINK(1);
    DISPATCH();
}

但是接下来的几条指令是干嘛的,估计有人会感到困惑。

这几条指令的具体作用,我们稍后解释。



异常跳转表


finally 子句对应的指令比较简单,我们就不看了。相比之前版本,3.12 的异常捕获变得简单许多,因为相关信息都静态化了。在以前的版本中是使用 SETUP_FINALLY 等指令来处理异常,而 3.12 换成了更高效的异常表结构,类似于 Java 的异常表。

我们来看一下异常表的结构,它由 PyCodeObject 对象的 co_exceptiontable 字段负责维护。

4 to 18 -> 20 

表示 try 子句内部对应偏移量为 4 ~ 18 的指令,并且如果出现异常,那么跳转到偏移量为 20 的指令。

20 to 28 -> 66 

偏移量为 20 ~ 28 的指令对应 except 子句本身,如果执行出错,跳转到偏移量为 66 的指令去清理异常。

30 to 44 -> 56

偏移量为 30 ~ 44 的指令对应 except 子句内部的处理逻辑,如果执行出错则跳转到第 56 条指令。

注意里面的 DELETE_NAME,它是 del 语句对应的指令。所以跳转之后的这几条指令,负责删除变量 e,怎么理解呢?我们举个例子。

e = 2.71

try:
    raise Exception("xx")
except Exception as e:
    pass

print(e)
"""
NameError: name 'e' is not defined
"""

奇怪,为什么在外面打印变量 e 报错了呢?其实 Python 会对 except 子句内部做一些处理,以上代码最终会变成下面这个样子。

e = 2.71

try:
    raise Exception("xx")
except Exception as e:
    e = None
    try:
        pass
    finally:
        del e
finally:
    print(e)

至于这么做的原因,稍后解释。

46 to 54 -> 92

del e 相关指令,但它对应的是存在 finally 的情况,删除之后会跳转到偏移量为 92 的指令。

56 to 64 -> 66

del e 相关指令,对应不存在 finally 的情况。

66 to 70 -> 92

异常清理相关指令。

92 to 110 -> 112

finally 子句对应的指令。

我们看到  try / except / finally 块的范围信息、异常处理的起始位置、需要执行的清理操作都被静态化了,在编译阶段就已经确定,所以性能方面比之前要更高。并且当 try 子句内的代码没有出现错误时,和不使用异常捕获之间基本没有性能差异。

总之 Python 中一旦出现异常了,那么会将异常类型、异常值、异常回溯栈设置在线程状态对象中,然后栈帧一步一步地回退,寻找异常捕获代码(从内向外)。如果退到了模块级别还没有发现异常捕获,那么从外向内打印 traceback 中的信息,当走到最内层的时候再将线程中设置的异常类型和异常值打印出来。

def h():
    1 / 0

def g():
    h()

def f():
    g()

f()

# traceback 回溯栈
Traceback (most recent call last):
  # 打印模块的 traceback
  # 并提示:发生错误是因为在第 10 行调用了 f()
  File "/Users/.../main.py", line 10in <module>
    f()

  # 打印函数 f 的 traceback
  # 并提示:发生错误是因为在第 8 行调用了 g()
  File "/Users/.../main.py", line 8in f
    g()

  # 打印函数 g 的 traceback
  # 并提示:发生错误是因为在第 5 行调用了 h()
  File "/Users/.../main.py", line 5in g
    h()

  # 打印函数 h 的 traceback
  # 并提示:发生错误是因为在第 2 行执行了 1 / 0
  File "/Users/.../main.py", line 2in h
    1 / 0

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

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

因此栈帧的遍历顺序是从函数 h 到模块,traceback 的遍历顺序是从模块到函数 h



为什么要执行 del


前面说了,在 except 语句块内,如果将异常赋给了某个变量,那么 except 结束时会将变量删掉。

e = 2.71

def get_e():
    return e

try:
    raise Exception("我要引发异常了")
except Exception as e:
    # 因为 except Exception as e 位于全局作用域
    # 所以执行完之后,全局变量 e 就被修改了
    print(get_e())  # 我要引发异常了
    # 但是在最后还会隐式地执行 del e,那为什么要这么做呢?
    # 因为 except 子句结束后,变量 e 指向的异常对象就没用了
    # 而如果不 del e 的话,那么异常对象不会被销毁
    # 此外还有一个原因,通过 __traceback__ 可以拿到当前的回溯栈,即 traceback 对象
    print(e.__traceback__)  # <traceback object at 0x104a98b80>
    # 而 traceback 对象保存当前的栈帧,然后栈帧又保存了包含变量 e 的名字空间
    print(e.__traceback__.tb_frame.f_locals["e"is e)  # True
    # 相信你能猜到这会带来什么后果,没错,就是循环引用
    # 因此在 except 结束时会隐式地 del e

# 显然当 except 结束后,全局变量 e 就无法访问了
print(e)
"""
NameError: name 'e' is not defined
"""

所以在附加了回溯信息的情况下,它们会形成堆栈帧的循环引用,在下一次垃圾回收执行之前,会使所有变量都保持存活。



小结


本篇文章我们就分析了异常捕获的实现原理,总的来说并不难,因为所有的信息都静态保存在了异常跳转表(简称异常表)中。并且在不报错时,异常捕获对程序性能没有任何影响,所以放心使用

古明地觉的编程教室
Python、Rust 程序猿,你感兴趣的内容我都会写,点个关注吧(#^.^#)
 最新文章