异常是怎么实现的?虚拟机是如何将异常抛出去的?

文摘   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 / 0
except ZeroDivisionError:
    print("Division by zero")

像这种语法正确,但程序执行时因逻辑出现问题而导致的异常,是可以被捕获的。对于我们来说,关注的显然是运行时产生的隐藏,比如 TypeError、IndexError 等等。

那么问题来了,异常本质上是什么呢?我们以列表为例,看看 IndexError 是怎么产生的。

lst = [123]
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_Restore
void
PyErr_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 / 0

def 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 / 0
ZeroDivisionError: division by zero
"""

这是脚本运行时产生的错误输出,我们看到了函数调用的信息:比如在源代码的哪一行调用了哪一个函数,那么这些信息是从何而来的呢?没错,显然是 traceback 对象。

虚拟机在处理异常的时候,会创建 traceback 对象,在该对象中记录栈帧的信息。虚拟机利用该对象来将栈帧链表中每一个栈帧的状态进行可视化,可视化的结果就是上面输出的异常信息。

而且我们发现输出的信息也是一个链状的结构,因为每一个栈帧都会对应一个 traceback 对象,这些 traceback 对象之间也会组成一个链表。


所以当虚拟机开始处理异常的时候,它首先的动作就是创建 traceback 对象,用于记录异常发生时活动栈帧的状态。创建方式是通过 PyTraceBack_Here 函数,它接收一个栈帧作为参数。

// Python/traceback.c
int
PyTraceBack_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.c
PyErr_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.h
typedef 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.c
PyObject*
_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 / 0

def 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.h
TARGET(BINARY_OP) {
    // ...
    // 对于除法运算,指令参数 oparg 的值是 11
    res = binary_ops[oparg](lhs, rhs);
    // ...
}


// Python/ceval.c
static 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.c
PyObject *
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.c
static 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,并将其链接成链表。


由于没有异常捕获,那么接下来会调用 PyErr_Print。然后在 PyErr_Print 中,虚拟机取出维护的 traceback 链表,并进行遍历,将里面的信息逐个输出到 stderr 当中,最终就是我们在 Python 中看到的异常信息。


并且打印顺序是:.py文件函数f函数g函数h。因为每一个栈帧对应一个 traceback,而栈帧又是往后退的,因此显然会从 .py文件对应的 traceback 开始打印,然后通过 tb_next 找到函数f 对应的 traceback,依次下去。当异常信息全部输出完毕之后,解释器就结束运行了。


因此从链路的开始位置到结束位置,将整个调用过程都输出出来,可以很方便地定位问题出现在哪里。

Traceback (most recent call last):
  File "/Users/.../main.py", line 10in <module>
    f()
  File "/Users/.../main.py", line 8in f
    g()
  File "/Users/.../main.py", line 5in g
    h()
  File "/Users/.../main.py", line 2in h
    1 / 0
ZeroDivisionError: 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 一个异常。

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