原创 古明地觉 2024-11-07 10:12 北京
楔子
程序在运行的过程中,总是会不可避免地产生异常,此时为了让程序不中断,必须要将异常捕获掉。如果能提前得知可能会发生哪些异常,建议使用精确捕获,如果不知道会发生哪些异常,则使用 Exception 兜底。
另外异常也可以用来传递信息,比如生成器。
def gen(): yield 1 yield 2 return "result"g = gen()next(g)next(g)try: next(g)except StopIteration as e: print(f"返回值: {e.value}") # 返回值: result
如果想要拿到生成器的返回值,我们需要让它抛出 StopIteration,然后进行捕获,再通过 value 属性拿到返回值。所以,Python 是将生成器的返回值封装到了异常里面。之所以举这个例子,目的是想说明,异常并非是让人嗤之以鼻的东西,它也可以作为信息传递的载体。特别是在 Java 语言中,引入了 checked exception,方法的所有者还可以声明自己会抛出什么异常,然后调用者对异常进行处理。
在 Java 程序启动时,抛出大量异常都是司空见惯的事情,并在相应的调用堆栈中将信息完整地记录下来。至此,Java 的异常不再是异常,而是一种很普遍的结构,从良性到灾难性都有所使用,异常的严重性由调用者来决定。
虽然在 Python 里面,异常还没有达到像 Java 异常那么高的地位,但使用频率也是很高的,下面我们就来剖析一下异常是怎么实现的?
异常的本质是什么
Python 解释器 = Python 编译器 + Python 虚拟机,所以异常可以由编译器抛出,也可以由虚拟机剖出。如果是编译器抛出的异常,那么基本上都是 SyntaxError,即语法错误。
try: >>>except Exception as e: print(e)
比如上面这段代码,你会发现异常捕获根本没用,因为这是编译阶段就发生的错误,而异常捕获是在运行时进行的。当然语法不对属于低级错误,所以不会留到运行时。
然后是运行时产生的异常:
try: 1 / 0except ZeroDivisionError: print("Division by zero")
像这种语法正确,但程序执行时因逻辑出现问题而导致的异常,是可以被捕获的。对于我们来说,关注的显然是运行时产生的隐藏,比如 TypeError、IndexError 等等。
那么问题来了,异常本质上是什么呢?我们以列表为例,看看 IndexError 是怎么产生的。
lst = [1, 2, 3]print(lst[3])"""IndexError: list index out of range"""
列表的最大索引是 2,但我们访问了索引为 3 的元素,虚拟机就知道不能再执行下去了,否则会访问非法内存。因此虚拟机的做法是:输出异常信息,结束进程。我们通过源码来验证一下:
在获取列表元素时发现索引不合法,就知道要抛出 IndexError 了,会将异常写入到标准错误输出当中,并返回 NULL。正常情况下,返回值应该指向一个合法的对象,如果为 NULL,证明出现异常了。
此时虚拟机会将回溯栈里的异常抛出来(就是我们在控制台看到的那一抹鲜红),然后结束进程,这就是异常的本质。当然异常也是一个 Python 对象,虚拟机在退出前,会写入到 stderr 中。
异常写入的一些 C API
当我们用 C 编写 Python 扩展时,如果想设置异常的话,该怎么做呢?首先设置异常之前,我们要知道有哪些异常。在 pyerrors.h 中,虚拟机内置了大量的异常,另外 Python 一切皆对象,因此异常也是一个对象。
有了异常之后,怎么写入呢?关于异常写入,底层也提供了相应的 C API。
相关的 API 有很多,我们来解释一下。
"""PyErr_SetNone:设置异常,不包含提示信息。PyErr_SetObject:设置异常,包含提示信息(Python 字符串)。PyErr_SetString:设置异常,包含提示信息(C 字符串)。PyErr_Occurred:检测回溯栈中是否有异常产生。PyErr_Clear:将回溯栈中的异常清空,相当于 Python 的异常捕获。PyErr_Fetch:将回溯栈中的异常清空,同时拿到它的 exc_type、exc_value、exc_tb。PyErr_Restore:基于 exc_type、exc_value、exc_tb 设置异常。"""
我们以 PyErr_Restore 为例,看看异常的具体设置过程。
// Python/errors.c// PyErr_SetObject、PyErr_SetString 等等,最终都会调用 PyErr_RestorevoidPyErr_Restore(PyObject *type, PyObject *value, PyObject *traceback){ // 获取线程状态对象 PyThreadState *tstate = _PyThreadState_GET(); _PyErr_Restore(tstate, type, value, traceback);}void_PyErr_Restore(PyThreadState *tstate, PyObject *type, PyObject *value, PyObject *traceback){ // 对 type、value、traceback 做一些检测 // ... PyObject *old_traceback = ((PyBaseExceptionObject *)value)->traceback; ((PyBaseExceptionObject *)value)->traceback = traceback; Py_XDECREF(old_traceback); // 调用 _PyErr_SetRaisedException _PyErr_SetRaisedException(tstate, value); Py_DECREF(type);}void_PyErr_SetRaisedException(PyThreadState *tstate, PyObject *exc){ // 线程状态对象的 current_exception 字段,负责保存当前异常 PyObject *old_exc = tstate->current_exception; // 将它设置为 exc tstate->current_exception = exc; Py_XDECREF(old_exc);}
我们再来看看 PyThreadState 对象,它是与线程相关的,但它只是线程信息的一个抽象描述,而真实的线程及状态肯定是由操作系统来维护和管理的。
因为虚拟机在运行的时候总需要另外一些与线程相关的状态和信息,比如是否发生了异常等等,这些信息显然操作系统是没有办法提供的。而 PyThreadState 对象正是 Python 为线程准备的、在虚拟机层面保存线程状态信息的对象(后面简称线程状态对象、或者线程对象)。
当前活动线程(OS 原生线程)对应的 PyThreadState 对象可以通过 PyThreadState_GET 获得,在得到了线程状态对象之后,就将异常信息存放在里面。
关于线程相关的内容,后续会详细说。
traceback 是什么?
程序产生的异常会被记录在线程状态对象当中,现在可以回头看看,在跳出了分派字节码指令的代码块之后,发生了什么动作。
在 ceval.c 里面有一个 _PyEval_EvalFrameDefault 函数,负责执行字节码指令。该函数内部有一个代码块,包含了每个指令的处理逻辑,执行完毕后会跳出代码块。
但跳出代码块的原因有两种:
执行完所有的字节码指令之后正常跳出;
发生异常后跳出;
那么虚拟机如何区分是哪一种呢?很简单,通过 error 标签实现,注意代码块里面有一个 error 标签。
如果在执行指令的时候出现了异常,那么会跳转到 error 这里,否则会跳转到其它地方。
另外当出现异常时,会在线程状态对象中将异常信息记录下来,包括异常类型、异常值、回溯栈(traceback),这个 traceback 就是在 error 标签中调用 PyTraceBack_Here 创建的。
另外可能有人不清楚 traceback 是做什么的,我们举个 Python 的例子。
def h(): 1 / 0def g(): h()def f(): g()f()"""Traceback (most recent call last): File "/Users/.../main.py", line 10, in <module> f() File "/Users/.../main.py", line 8, in f g() File "/Users/.../main.py", line 5, in g h() File "/Users/.../main.py", line 2, in h 1 / 0ZeroDivisionError: division by zero"""
这是脚本运行时产生的错误输出,我们看到了函数调用的信息:比如在源代码的哪一行调用了哪一个函数,那么这些信息是从何而来的呢?没错,显然是 traceback 对象。
虚拟机在处理异常的时候,会创建 traceback 对象,在该对象中记录栈帧的信息。虚拟机利用该对象来将栈帧链表中每一个栈帧的状态进行可视化,可视化的结果就是上面输出的异常信息。
而且我们发现输出的信息也是一个链状的结构,因为每一个栈帧都会对应一个 traceback 对象,这些 traceback 对象之间也会组成一个链表。所以当虚拟机开始处理异常的时候,它首先的动作就是创建 traceback 对象,用于记录异常发生时活动栈帧的状态。创建方式是通过 PyTraceBack_Here 函数,它接收一个栈帧作为参数。
// Python/traceback.cintPyTraceBack_Here(PyFrameObject *frame){ // 获取当前的异常对象 PyObject *exc = PyErr_GetRaisedException(); assert(PyExceptionInstance_Check(exc)); // 拿到当前异常的 traceback PyObject *tb = PyException_GetTraceback(exc); // 创建新的 traceback 对象,并和旧的 traceback 对象组成链表 PyObject *newtb = _PyTraceBack_FromFrame(tb, frame); Py_XDECREF(tb); if (newtb == NULL) { _PyErr_ChainExceptions1(exc); return -1; } // 将新的 traceback 对象交给线程状态对象 PyException_SetTraceback(exc, newtb); Py_XDECREF(newtb); // 重新设置异常 PyErr_SetRaisedException(exc); return 0;}// Python/errors.cPyErr_GetRaisedException(void){ PyThreadState *tstate = _PyThreadState_GET(); return _PyErr_GetRaisedException(tstate);}PyObject *_PyErr_GetRaisedException(PyThreadState *tstate) { // 返回当前的异常 PyObject *exc = tstate->current_exception; tstate->current_exception = NULL; return exc;}
那么这个 traceback 对象究竟长什么样呢?
// Include/cpython/traceback.htypedef struct _traceback PyTracebackObject;struct _traceback { PyObject_HEAD PyTracebackObject *tb_next; PyFrameObject *tb_frame; int tb_lasti; int tb_lineno;};
里面有一个 tb_next,所以很容易想到 traceback 也是一个链表结构。其实 traceback 对象的链表结构跟栈帧对象的链表结构是同构的、或者说一一对应的,即一个栈帧对象对应一个 traceback 对象。
traceback 创建
在 PyTraceBack_Here 函数中我们看到它是通过 _PyTraceBack_FromFrame 创建的,那么秘密就隐藏在这个函数中。
// Python/traceback.cPyObject*_PyTraceBack_FromFrame(PyObject *tb_next, PyFrameObject *frame){ assert(tb_next == NULL || PyTraceBack_Check(tb_next)); assert(frame != NULL); // 获取最近一条执行完毕的字节码指令的偏移量 int addr = _PyInterpreterFrame_LASTI(frame->f_frame) * sizeof(_Py_CODEUNIT); // 创建 traceback return tb_create_raw((PyTracebackObject *)tb_next, frame, addr, -1);}static PyObject *tb_create_raw(PyTracebackObject *next, PyFrameObject *frame, int lasti, int lineno){ PyTracebackObject *tb; if ((next != NULL && !PyTraceBack_Check(next)) || frame == NULL || !PyFrame_Check(frame)) { PyErr_BadInternalCall(); return NULL; } // 为 traceback 对象申请内存 tb = PyObject_GC_New(PyTracebackObject, &PyTraceBack_Type); if (tb != NULL) { // 设置属性 tb->tb_next = (PyTracebackObject*)Py_XNewRef(next); // 注意 traceback 内部还保存了栈帧对象 // 所以在 Python 中,except Exception as e 之后 // 可以通过 e.__traceback__.tb_frame 获取栈帧 tb->tb_frame = (PyFrameObject*)Py_XNewRef(frame); tb->tb_lasti = lasti; tb->tb_lineno = lineno; // 加入 GC 追踪, 参与垃圾回收 PyObject_GC_Track(tb); } return (PyObject *)tb;}
tb_next 将两个 traceback 连接了起来,不过这个和栈帧的 f_back 正好相反,f_back 指向的是上一个栈帧,而 tb_next 指向的是下一个 traceback。另外在 traceback 中,还通过 tb_frame 字段和对应的 PyFrameObject 对象建立了联系,当然还有最后执行完毕时的字节码偏移量、以及在源代码中对应的行号。
栈帧展开
traceback 的创建我们知道了,那么它和栈帧对象是怎么联系起来的呢?我们还以之前的代码为例,来解释一下。
def h(): 1 / 0def g(): h()def f(): g()f()
当执行到函数 h 的 1 / 0 这行代码时,底层会执行 BINARY_OP 指令。
// Include/opcode.h#define NB_ADD 0#define NB_AND 1#define NB_FLOOR_DIVIDE 2#define NB_LSHIFT 3#define NB_MATRIX_MULTIPLY 4#define NB_MULTIPLY 5#define NB_REMAINDER 6#define NB_OR 7#define NB_POWER 8#define NB_RSHIFT 9#define NB_SUBTRACT 10#define NB_TRUE_DIVIDE 11// ...// Python/generated_cases.c.hTARGET(BINARY_OP) { // ... // 对于除法运算,指令参数 oparg 的值是 11 res = binary_ops[oparg](lhs, rhs); // ...}// Python/ceval.cstatic const binaryfunc binary_ops[] = { [NB_ADD] = PyNumber_Add, [NB_AND] = PyNumber_And, [NB_FLOOR_DIVIDE] = PyNumber_FloorDivide, // ... [NB_RSHIFT] = PyNumber_Rshift, [NB_SUBTRACT] = PyNumber_Subtract, [NB_TRUE_DIVIDE] = PyNumber_TrueDivide, // ...};// 毫无疑问,binary_ops[11] 会得到 PyNumber_TrueDivide 函数// Objects/abstract.cPyObject *PyNumber_TrueDivide(PyObject *v, PyObject *w){ return binary_op(v, w, NB_SLOT(nb_true_divide), "/");}#define NB_SLOT(x) offsetof(PyNumberMethods, x)// 最终会执行 (&PyLong_Type) -> tp_as_methods -> nb_true_divide// 即 long_true_divice 函数,看一下它的逻辑// Objects/longobject.cstatic PyObject *long_true_divide(PyObject *v, PyObject *w){ // ... a_size = _PyLong_DigitCount(a); b_size = _PyLong_DigitCount(b); negate = (_PyLong_IsNegative(a)) != (_PyLong_IsNegative(b)); if (b_size == 0) { PyErr_SetString(PyExc_ZeroDivisionError, "division by zero"); goto error; } // ... success: return PyFloat_FromDouble(negate ? -result : result); underflow_or_zero: return PyFloat_FromDouble(negate ? -0.0 : 0.0); overflow: PyErr_SetString(PyExc_OverflowError, "integer division result too large for a float"); error: return NULL;}
由于除数为 0,因此会通过 PyErr_SetString 设置一个异常进去,最终将异常类型、异常值、以及 traceback 保存到线程状态对象中。但此时 traceback 实际上是为空的,因为目前还没有涉及到 traceback 的创建,那么它是什么时候创建的呢?继续往下看。
由于出现了异常,那么 long_true_divide 会返回NULL。
当返回值为 NULL 时,虚拟机就意识到发生异常了,这时候会跳转到 pop_2_error 标签。
当出现除零错误时,运行时栈里面还有两个元素,所以跳转到 pop_2_error。将栈里的两个元素弹出之后,进入 error 标签。
在里面会先取出线程状态对象中已有的 traceback 对象(此时为空),然后以函数 h 的栈帧为参数,创建一个新的 traceback 对象,将两者通过 tb_next 关联起来。最后,再替换掉线程状态对象里面的 traceback 对象。
在虚拟机意识到有异常抛出,并创建了 traceback 之后,它会在当前栈帧中寻找 try except 语句,来执行开发人员指定的捕捉异常动作。如果没有找到,那么虚拟机将退出当前的活动栈帧,并沿着栈帧链回退到上一个栈帧(这里是函数 g 的栈帧),在上一个栈帧中寻找 try except 语句。
就像我们之前说的,函数调用会创建栈帧,当函数执行完毕或者出现异常时,会回退到上一级栈帧。一层一层创建、一层一层返回。至于回退的这个动作,则是在 _PyEval_EvalFrameDefault 的最后完成。
当出现异常时,虚拟机会进入 exception_unwind 标签寻找异常捕获逻辑,相关细节下一篇文章再说,这里就让它抛出去。然后来到 exit_unwind 标签,将当前线程状态对象中的活动栈帧,设置为上一级栈帧,从而完成栈帧回退的动作。
当栈帧回退时,会进入函数 g 的栈帧,由于返回值为 NULL,所以知道自己调用的函数 h 的内部发生异常了(否则返回值一定会指向一个合法的 PyObject),那么继续寻找异常捕获语句。对于当前这个例子来说,显然是找不到的,于是会从线程状态对象中取出已有的 traceback 对象(函数 h 的栈帧对应的 traceback)。然后以函数 g 的栈帧为参数,创建新的 traceback 对象,再将两者通过 tb_next 关联起来,并重新设置到线程状态对象中。
异常会沿着栈帧链进行反向传播,函数 h 出现的异常被传播到了函数 g 中,显然接下来函数 g 要将异常传播到函数 f 中。因为函数 g 在无法捕获异常时,那么返回值也是 NULL,而函数 f 看到返回值为 NULL 时,同样会去寻找异常捕获语句。但是找不到,于是会从线程状态对象中取出已有的 traceback 对象(此时是函数 g 的栈帧对应的 traceback),然后以函数 f 的栈帧为参数,创建新的 traceback 对象,再将两者通过 tb_next 关联起来,并重新设置到线程状态对象中。最后再传播到模块对应的栈帧中,如果还无法捕获发生的异常,那么虚拟机就要将异常抛出来了。
这个沿着栈帧链不断回退的过程我们称之为栈帧展开,在栈帧展开的过程中,虚拟机不断地创建与各个栈帧对应的 traceback,并将其链接成链表。
并且打印顺序是:.py文件、函数f、函数g、函数h。因为每一个栈帧对应一个 traceback,而栈帧又是往后退的,因此显然会从 .py文件对应的 traceback 开始打印,然后通过 tb_next 找到函数f 对应的 traceback,依次下去。当异常信息全部输出完毕之后,解释器就结束运行了。
因此从链路的开始位置到结束位置,将整个调用过程都输出出来,可以很方便地定位问题出现在哪里。
Traceback (most recent call last): File "/Users/.../main.py", line 10, in <module> f() File "/Users/.../main.py", line 8, in f g() File "/Users/.../main.py", line 5, in g h() File "/Users/.../main.py", line 2, in h 1 / 0ZeroDivisionError: division by zero
另外,虽然 traceback 一直在更新(因为要对整个调用链路进行追踪),但是异常类型和异常值始终是不变的,就是函数 h 中抛出的 ZeroDivisionError: division by zero。
小结
以上就是虚拟机抛异常的过程,异常在 Python 里面也是一个对象,和其它的实例对象并无本质区别。
exc = StopIteration("迭代结束了")print(exc.value) # 我是一个异常print(exc.args) # ('迭代结束了',)exc = IndexError("索引越界了")print(exc.args) # ('索引越界了',)exc = Exception("不知道是啥异常,总之出问题了")print(exc.args) # ('不知道是啥异常,总之出问题了',)# 异常都有一个 args 属性,以元组的形式保存传递的参数
所谓抛出异常,就是将错误信息输出到 stderr 中,然后停止进程。并且除了虚拟机内部会抛出异常之外,我们还可以使用 raise 关键字手动引发一个异常。
def judge_score(score: int): if score > 100 or score < 0: raise ValueError("Score must be between 0 and 100")
站在虚拟机的角度,score 取任何值都是合理的,但对于我们来说,希望 score 位于 0 ~ 100。那么当 score 不满足 0 ~ 100 时,可以手动 raise 一个异常。