跳转至

27 异常(上):优雅高效地处理运行时发生的错误

你好,我是海纳。

到目前为止,我们的虚拟机已经具备很多功能了,比如控制流、函数、对象、模块系统都已经构建完了。在这些机制的基础之上,我们才能完成迭代这一个基本功能。

迭代是 Python 中非常重要的一个机制,在实现列表和字典的时候,我们花了很大的精力介绍它们的迭代器。实际上,在 Python 中,自定义类型也可以定义迭代器。这种自定义迭代器需要依赖很多重要的功能作为基础,到目前为止,我们还差一个功能尚未完成,就是异常。因为 Python 虚拟机使用 StopIteration 异常来标志迭代结束。

这节课,我们将重点实现异常的处理机制。异常的处理需要增加新的控制流处理方式,我们从最简单的情况开始,逐步实现它。

实现 finally 子句

我们先看一个例子。

def foo():
    try:
        print("hello")
        return
        print("hi")     # will not be executed
    finally:
        # will be executed
        print("world")

foo()

上述代码的执行结果是打印“hello”和“world”两个字符串。也就是说,return 语句(第 4 行)执行以后,函数并没有立即返回,而是打印完 “world”(第 8 行)以后才结束。

为了分析这个问题,我们还是得从字节码入手,再次使用 show_file 工具查看测试用例的字节码。

  2           0 SETUP_FINALLY           16 (to 18)

  3           2 LOAD_GLOBAL              0 (print)
              4 LOAD_CONST               2 ('hello')
              6 CALL_FUNCTION            1
              8 POP_TOP

  4          10 POP_BLOCK
             12 CALL_FINALLY             4 (to 18)
             14 LOAD_CONST               0 (None)
             16 RETURN_VALUE

  8     >>   18 LOAD_GLOBAL              0 (print)
             20 LOAD_CONST               1 ('world')
             22 CALL_FUNCTION            1
             24 POP_TOP
             26 END_FINALLY
             28 LOAD_CONST               0 (None)
             30 RETURN_VALUE

在 Python3 中,编译器前端做了很多优化工作,像打印字符串 hi 的操作位于 return 指令之后,所以它就是一段死代码,已经被编译器前端删除了。这一点和 Python2 很不一样,Python2 要自己处理 try 语句里的 continue 和 break 关键字,在 Python3 里,编译器在生成字节码的时候就已经做好了优化。

接下来,我们看一下 Python3 在处理异常时引入了哪些新的特性。

首先是 SETUP_FINALLY 这条指令,它的作用是创建一个 Block 对象,里面记录了 Block 开始时的栈状态,以及在这个 Block 发生了异常时应该跳转到哪条指令继续执行。

Block 对象中要记录类型、操作数栈深度和跳转目标三个参数,所以 Block 的定义就比较简单,它只不过是三个数字的集合而已。你可以看一下它的实现。

class Block {
public:
    unsigned char _type;
    unsigned int  _target;
    int  _level;

    Block() {
        _type = 0;
        _target = 0;
        _level = 0;
    }

    ~Block() {
        _type = 0;
        _target = 0;
        _level = 0;
    }

    Block(unsigned char b_type,
            unsigned int b_target,
            int b_level):
        _type(b_type),
        _target(b_target),
        _level(b_level) {
        }

    Block(const Block& b) {
        _type = b._type;
        _target = b._target;
        _level  = b._level;
    }
}

SETUP_FINALLY 的参数是一个跳转的相对地址,就像例子中当前 pc 是 2,参数是 16,这就意味着从偏移地址 2 到偏移地址 18(代码行号是 3 至 11),形成了一个新的 Block,这就是 Finally Block。

不管是以何种方式离开 Finally Block,都必须跳到偏移地址 18 处去执行,那里是真正的 finally 子句的逻辑。

在执行 SETUP_FINALLY 时,我们会把这个相对地址记录到 Block 对象的 _target 里。所以 SETUP_FINALLY 的实现就很简单了,你可以看一下它的具体代码。

// frameObject.cpp
BlockList* FrameObject::blocks() {
    if (!_blocks) {
        _blocks = new BlockList();
    }

    return _blocks;
}

void FrameObject::setup_block(unsigned char btype, unsigned int target, int level) {
    blocks()->add(Block(btype, target, level));
}

Block FrameObject::pop_block() {
    assert(_blocks && _blocks->length() > 0);
    return _blocks->pop();
}

// interpreter.cpp
void Interpreter::eval_frame() {
    ...
    while (_frame->has_more_codes()) {
        unsigned char op_code = _frame->get_op_code();
        ...
        switch (op_code) {
        ...
            case ByteCode::SETUP_FINALLY:
                _frame->setup_block(ByteCode::SETUP_FINALLY, 
                    _frame->get_pc() + op_arg, STACK_LEVEL());
                break;
        ...
        }
    }
}

如果在 Finally Block 的范围内出现了异常,虚拟机就会跳转到 Block 中所记录的 target 地址处执行。如果 Block 内的语句都正确执行了,虚拟机就会执行 CALL_FINALLY 指令,通过这条指令,控制流正常地进入 finally 子句执行。

CALL_FINALLY 指令是一个跳转指令,它会把当前指令的地址记录在栈上,等 finally 子句执行完以后再跳转回来继续执行,所以这条指令的行为和 CALL_FUNCTION 非常相似,这也是它被命名为 CALL 指令的原因。

有两种情况可以进入 finally 子句执行,一种是发生了异常通过 Finally Block 进入,另外一种是try 语句自然结束,通过 CALL_FINALLY 进入。

如果是因为异常而进入的,我们得把异常状态保存起来,以备后面真正执行 return 的时候再恢复出来。Python 字节码在设计的时候就明确规定了使用操作数栈保存状态,恢复状态使用 END_FINALLY,这条字节码所要求保存的状态是有格式的。

如果虚拟机执行时发生异常,操作数栈上需要保存异常对象、异常类型和 Traceback 对象。其中 Traceback 对象的作用是记录调用栈信息。

如果是通过 CALL_FINALLY 进入 finally 子句,我们要把当前指令地址保存到栈上。由于操作数栈里只能存放对象的引用,直接往里面放一个整数是不行的,必须进行强制类型转换,但这样的话,怎么才能区分这到底是一个异常对象还是一个整数呢?

还记得我们在实现自动内存管理的时候,要求所有对象都是 8 字节对齐吗?这样做可以保证任何一个对象指针的低三位都是 0,我们可以充分利用低三位进行类型标记。倒数第二位已经被我们拿来标记 forwarding 指针了,这里可以使用倒数第一位来标记这是一个直接整数而不是指针。

所以我们可以这样实现 CALL_FINALLY 指令:

// interpreter.cpp
void Interpreter::eval_frame() {
    ...
    while (_frame->has_more_codes()) {
        unsigned char op_code = _frame->get_op_code();
        ...
        switch (op_code) {
        ...
            case ByteCode::CALL_FINALLY:
                PUSH((HiObject*)((long long)(_frame->get_pc() << 1) | 0x1));
                _frame->set_pc(_frame->get_pc() + op_arg);
                break;
        ...
        }
    }
}

这条指令做了两件事情,一是把当前指令地址左移 1 位,然后与 0x1 做或操作,并把这个结果转换成 HiObject 指针存入操作数栈。与 0x1 做或操作可以保证最后一位一定是 1,通过这个标记位就可以区分普通的对象指针和指令地址了。

进入 finally 子句以后,就和平常执行普通逻辑是相同的,直到遇到那条 END_FINALLY,这提示我们 finally 所对应的逻辑已经执行完了,我们应该把进入 finally 子句之前的所有状态都拿回来了,包括返回值和解释器状态,这两个东西被我们放在操作数栈里了,现在再从栈里恢复就好了。

void Interpreter::eval_frame() {
    ...
    while (_frame->has_more_codes()) {
        unsigned char op_code = _frame->get_op_code();
        ...
        switch (op_code) {
        ...
            case ByteCode::BEGIN_FINALLY: {
                PUSH(nullptr);
                break;
            }

            // ...
            case ByteCode::END_FINALLY: {
                v = POP();
                long long t = (long long)v();
                if (t == 0) {
                    // do nothing.
                }
                else if (t & 0x1) {
                    _frame->set_pc(t >> 1);
                }
                else {
                }
                break;
            }
        }
    }
}

END_FINALLY 指令先从操作数栈上取出一个整数值(第 16 行),并判断这个值是不是 0。这个值是为了配合 BEGIN_FINALLY 指令而加的。BEGIN_FINALLY 是虚拟机为了平衡栈深度而引入的一个字节码,并不重要,它只是简单地向栈上放入一个 0 而已。

接下来,通过整数值的最后一个比特,判断它是一个地址还是一个对象指针。如果最后一个比特置位,说明这个值是一个地址,这正是 CALL_FINALLY 指令所存在栈上的地址,只需要把 frame 的 pc 值恢复为这个值就可以了(第 20 行至 22 行)。

如果这个值是普通的对象,就说明已经发生了异常。这时候需要将异常对象从栈上取出来,这里的具体实现先放一下,等异常对象实现完了以后,再来补全。

实现了这几个字节码,开头的那个例子就能正常执行了。因为这个例子中只有 finally 子句,并且不会发生异常。接下来,我们实现异常产生的过程。首先,我们要先搞明白异常对象是什么。

异常对象

在 Python 中,Exception 类代表异常,在 Python 编程中常见的异常类都是它的子类,比如 StopIteration 和 ZeroDivisionError 等。

首先,我们要在虚拟机中增加 Exception 类型。这一章开始之前,如果想要在虚拟机中增加一种内建类型,只能像整数类型、字符串类型等内建类型那样创建对象类,创建它所对应的 Klass,再维护 Klass 的各种继承关系等等,十分繁琐。

但在上一节课引入了模块以后,我们就有了第二种选择,可以在 builtin 模块中定义这些异常类。

# [lib/builtin.py]
class Exception(object):
    def __init__(self, *args):
        self.info = args
        self.__context__ = None
        self.__traceback__ = None

    def __repr__(self):
        return "Error"

class ZeroDivisionError(Exception):
    def __init__(self, *args):
        self.info = args

    def __repr__(self):
        return "divide by zero"

这里只用了 15 行代码,我们就搭建起了基本的异常对象结构了。可以看到使用 Python 来开发模块是非常简洁高效的,这也体现出了模块功能的巨大优势。

有了 Exception 的定义以后,我们来研究 Python 中的异常是如何产生,又是如何影响控制流的。

我们先看下面这个简短的例子。

try:
    1 / 0
except Exception as e:
    print(e)
finally:
    print("hello")

这个例子的逻辑是主动发起一个异常,然后通过 except 语句抓住这个异常,并把它赋值给变量 e。然后打印这个异常。这个例子涉及一些新的语法,我们通过查看它的字节码来研究它。

像以前一样,我们还是把它编译成 pyc 文件,并且使用 show_file 工具查看它的字节码。我把它的字节码放在下面,供你参考。

  1           0 SETUP_FINALLY           60 (to 62)
              2 SETUP_FINALLY           12 (to 16)

  2           4 LOAD_CONST               1 (1)
              6 LOAD_CONST               2 (0)
              8 BINARY_TRUE_DIVIDE
             10 POP_TOP
             12 POP_BLOCK
             14 JUMP_FORWARD            42 (to 58)

  3     >>   16 DUP_TOP
             18 LOAD_NAME                1 (Exception)
             20 COMPARE_OP              10 (exception match)
             22 POP_JUMP_IF_FALSE       56
             24 POP_TOP
             26 STORE_NAME               2 (e)
             28 POP_TOP
             30 SETUP_FINALLY           12 (to 44)

  4          32 LOAD_NAME                0 (print)
             34 LOAD_NAME                2 (e)
             36 CALL_FUNCTION            1
             38 POP_TOP
             40 POP_BLOCK
             42 BEGIN_FINALLY
        >>   44 LOAD_CONST               3 (None)
             46 STORE_NAME               2 (e)
             48 DELETE_NAME              2 (e)
             50 END_FINALLY
             52 POP_EXCEPT
             54 JUMP_FORWARD             2 (to 58)
        >>   56 END_FINALLY
        >>   58 POP_BLOCK
             60 BEGIN_FINALLY

  6     >>   62 LOAD_NAME                0 (print)
             64 LOAD_CONST               0 ('hello')
             66 CALL_FUNCTION            1
             68 POP_TOP
             70 END_FINALLY
             72 LOAD_CONST               3 (None)
             74 RETURN_VALUE

可能超出很多人的意料,短短的 6 行 Python 代码,竟然生成了这么多的字节码。虽然字节码比较长,但是这里面并没有我们还没有实现的字节码。我们先来解释一下这里的每一行字节码的意义。

第一个 SETUP_FINALLY,会创建一个 Finally Block,而且目标地址是偏移为 62 的地方(第 36 行),正好对应了 Python 源码里的 finally 子句。

第二个 SETUP_FINALLY,也创建了一个 Finally Block,而且目标地址是偏移为 16 的地方(第 11 行),对应了 Python 源码里的 except 子句。这就意味着,一旦 try 语句中出现了异常,虚拟机就会先跳到 except 语句里处理。

偏移地址为 8 的那个除法指令(第 6 行),会产生一个除零异常。一旦解释器发现某条指令发生异常了,就会从 Block 列表里取出最后一项,并跳转到它的目标地址,也就是偏移地址为 16 的地方。

这个时候,虚拟机会假设操作数栈上已经存储了三个对象,分别是异常的类型对象、异常的实例对象和异常发生时的栈帧,也就是 Traceback 对象。

偏移地址为 20 的那条 COMPARE_OP 指令(第 13 行),是为了比较异常的类型与目标类型是否匹配。这里是与 Exception 类型进行匹配。由于第 6 行的除法指令会产生一个 ZeroDivisionError,而 ZeroDivisionError 是 Exception 的子类,所以这里的匹配是会成功的。如果匹配成功了,就会打印异常对象 e。

偏移地址 52 的指令是 POP_EXCEPT,这条指令的作用和 END_FINALLY 指令的作用比较像,它是从栈上将异常对象都恢复到解释器的状态里。这里我们先不详细解释这个过程。等到上面提到的机制都实现完了,你会对这条字节码有更深入的理解。

分析完这段字码的基本含义以后,我们来实现相关的数据结构。

处理异常

首先,我们在解释器里添加 4 个域用来记录异常发生时的状态,第一个域是 _exception_class,记录异常的类型,第二个域是 _pending_exception,记录异常的实例,第三个域是 _trace_back,记录异常发生时的栈帧,还有一个是 _int_status,记录解释器的状态。

然后,我们在 Integer 的 div 方法中来处理除 0 异常。

// hiInteger.cpp
HiObject* IntegerKlass::div(HiObject* x, HiObject* y) {
    int ix = x->as<HiInteger>()->value();
    int iy = y->as<HiInteger>()->value();

    if (iy == 0) {
        Interpreter::get_instance()->set_error_str(ST(div_zero));
        return nullptr;
    }

    return new HiInteger(ix / iy);
}

// interpreter.cpp
void Interpreter::set_error_str(HiString* name) {
    Handle<HiObject*> exc = _builtins->get(name);
    assert(exc != Universe::HiNone);
    set_error_object(exc, nullptr, nullptr);
    _int_status = IS_EXCEPTION;
}

void Interpreter::set_error_object(HiObject* raw_exc, HiObject* raw_val, HiObject* raw_tb) {
    normalize_errors(&raw_exc, &raw_val, &raw_tb);

    _pending_exception = raw_val;
    _exception_class = raw_exc;
    _trace_back = raw_tb;

    _pending_exception->setattr(ST(context), _old_exception);
    _int_status = IS_EXCEPTION;
}

void Interpreter::normalize_errors(HiObject** raw_exc, HiObject** raw_val, HiObject** raw_tb) {
    assert(raw_exc != nullptr);

    Handle<HiObject*> exc(*raw_exc);
    Handle<HiObject*> val(*raw_val);
    Handle<HiObject*> tb(*raw_tb);

    if (tb == nullptr) {
        tb = Traceback::new_instance();
    }

    if (exc->klass() == TypeKlass::get_instance()) {
        val = exc->call(nullptr, nullptr);
    }
    else {
        val = exc;
        exc = val->klass()->type_object();
    }

    val->setattr(ST(tb), tb);

    // 可能已经发生过GC了,所以要把地址重新刷新一下
    *(raw_exc) = exc();
    *(raw_val) = val();
    *(raw_tb) = tb();
}

在做除法之前,先检查除数是不是 0, 如果是 0,则产生一个除零异常(第 6 到 8 行)。

通过调用 set_error_str 从内建模块中找到 ZeroDivisionError,把这个类所对应的类型对象赋给_exception_class。并且把 _int_status 设成了 IS_EXCEPTION,代码指示了当前有一个要处理的异常。

调用 set_error_obj 时还要再使用 normalize_erros 方法对异常对象进行一次标准化处理。因为有些时候,用户的代码里并不是按照异常类型、异常对象、Traceback 对象这样的顺序传入的,所以要再对异常对象进行处理,以保证它们的顺序。normalize_erros 的逻辑相对比较简单,这里就不再解释了。

第二步,当我们结束了 eval_frame 的那个巨大的 switch 之后,进入了非 IS_OK 的状态,就要根据 Block 的情况做一些处理了。这意味着异常处理的时机正是在两条字节码执行的中间。

void Interpreter::eval_frame() {
    ...
    while (_frame->has_more_codes()) {
    unsigned char op_code = _frame->get_op_code();
        ...
        switch (op_code) {
        ...
        }

error_handling:
        while (_int_status != IS_OK && _frame->blocks()->length() > 0) {
            Block b = _frame->blocks()->pop();

            if (b._type == ByteCode::EXCEPT_HANDLER) {
                assert(STACK_LEVEL() >= b._level + 3);
                while (STACK_LEVEL() > b._level + 3) POP();

                _exception_class = POP();
                _pending_exception = POP();
                _trace_back = POP();

                continue;
            }

            while (STACK_LEVEL() > b._level) {
                POP();
            }

            if (b._type == ByteCode::SETUP_FINALLY) {
                _frame->setup_block(ByteCode::EXCEPT_HANDLER, -1, STACK_LEVEL());
                PUSH(_trace_back);
                PUSH(_pending_exception);
                PUSH(_exception_class);

                normalize_errors(&_trace_back, &_pending_exception, &_exception_class);

                PUSH(_trace_back);
                PUSH(_pending_exception);
                PUSH(_exception_class);

                _old_exception = _pending_exception;

                _trace_back = nullptr;
                _pending_exception = nullptr;
                _exception_class = nullptr;
                _frame->_pc = b._target;;
                _int_status = IS_OK;

                break;
            }

        } // end of while

        if (_int_status == IS_EXCEPTION && _frame->blocks()->length() == 0) {
            _trace_back->as<Traceback>()->record_frame(_frame);

            if (_frame->is_first_frame() ||
                    _frame->is_entry_frame())
                return;
            leave_frame();
            goto error_handling;
        }
    }
}

这份代码看上去有点复杂,我们慢慢看。

如果发生了异常,解释器就会跳转到 error_handling 处继续执行,可以想象,在 try 语句中发生了异常,解释器会因为 SETUP_FINALLY 这个 Block 而执行到第 29 行。

这时只需要把这个 Block 取出来,再重新设置 EXCEPT_HANDLER 这个 Block。这个 Block 的作用是告诉解释器,当前已经是在异常处理的流程中,如果此时再发生异常就得从栈上恢复异常状态(第 14 至 23 行)。

参考我们这节课的例子,也就是说当解释器执行 except 语句时,Block Stack 上还有两个 Block,分别是一个 SETUP_FINALLY 和一个 EXCEPT_HANDLER。如果在 EXCEPT_HANDLER 的范围内再次发生异常,那就只能从栈上将最早的那个异常恢复出来。然后再由解释器决定是应该需要转向 finally 子句执行,还是直接从函数中退出(第 54 至 62 行)。

如果当前的 Block 是 Finally Block(第 29 行),就要把异常相关的对象都保存到栈里(第 30 至 49 行),这个顺序是不能乱的,因为后面的字节码执行依赖于这个顺序。

回到这节课例子里的字节码,我们通过 SETUP_FINALLY Block 的 target 属性,就跳到了第 11 行继续执行,这里已经是 except 子句了。

DUP_TOP 的作用是将栈顶元素复制一份,通过刚刚的分析我们可以知道,栈顶现在存的是异常的类型对象,这主要是为了接下来的对比。我们看一下下面这个例子。

try:
   1 / 0
except StopIteration as e:
    print("stop iteration")
except ZeroDivisionError as e:
    print("zero division")

这个例子的执行结果是打印 zero division,而不会打印 stop iteration。这是因为实际的异常类型是 ZeroDivisionError ,与第 3 行的 StopIteration 不匹配。

这里匹配操作所使用的字节码就是 COMPARE_OP。这个字节码我们已经很熟悉了,大于、小于、in、is 等比较操作都是依赖于这个字节码的,不同的比较操作,对应的参数是不同的,异常匹配所对应的字节操作参数是 10。

判断异常是否匹配,只需要检查实际发生的异常的类型是不是目标异常的子类型。我们可以这样实现 exception match 操作:

void Interpreter::eval_frame() {
    ...
    while (_frame->has_more_codes()) {
        unsigned char op_code = _frame->get_op_code();
        ...
        switch (op_code) {
        ...
            case ByteCode::COMPARE_OP:
                w = POP();
                v = POP();

                switch(op_arg) {
                ...
                case ByteCode::EXC_MATCH: {
                    if (v->as<HiTypeObject>()->mro()->index(w) >= 0) {
                        PUSH(HI_TRUE);
                    }
                    else {
                        PUSH(HI_FALSE);
                    }
                    break;
                }
                ...
                }
        ...
        }
    }
}

COMPARE_OP 指令对实际发生的异常的类型和目标类型进行比较,如果目标类型是实际异常类型的父类(第 15 行),就说明匹配成功了,如果没有找到,就说明匹配失败。

好了,到这里我们就完成了 except 语句的功能。下一节课我们会继续介绍在发生异常的情况下, finally 子句的处理流程。

总结

这节课我们先从 finally 子句开始,讲解了在正常情况下控制流是如何在执行完 try 语句之后再跳入 finally 子句的。

在未发生异常的情况下,Python 主要通过 CALL_FINALLY 字节码转入 finally 子句执行。当 finally 子句的字节码执行完以后,就会通过 END_FINALLY 再转回到原 block 中继续执行。这就像是发生了一次短的跳转而已。

在发生了异常的情况下,解释器就需要通过 SETUP_FINALLY Block 跳转入 finally 子句执行。在跳转进入之前,解释器会把异常对象放入栈上,以便于 finally 子句正常执行。当 finally 子句执行完以后,END_FINALLY 会把异常状态全部恢复,以便于解释器带着异常状态退出当前函数栈帧。

在这个过程中,我们演示了如何通过 Python 源码定义异常对象。使用 Python 定义异常对象,无疑比使用 C++ 在虚拟机中定义高效了很多。

最后,我们又介绍了 except 语句匹配异常对象所使用的指令。从而完成了处理异常对象的基本流程。下节课,我们再来实现 END_FINALLY 和 POP_EXCEPT 等字节码,从而实现退出栈帧、清理异常对象的能力。这样异常处理的全部功能才算完成。

思考题

不同虚拟机在处理异常时使用的数据结构各不相同,在这节课的基础上,你也可以进一步研究JVM是如何处理异常的。欢迎你把你的研究成果分享到评论区,也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!

精选留言(4)
  • 冯某 👍(1) 💬(0)

    这节有点难懂

    2024-12-11

  • ifelse 👍(0) 💬(0)

    学习打卡

    2024-11-11

  • Geek_3d35fd 👍(0) 💬(0)

    cpython中POP_EXCEPT字节码实现是pop block之后就直接continue,执行紧挨着的下一个字节码了

    2024-10-05

  • Geek_3d35fd 👍(0) 💬(0)

    看代码有一个疑惑, 很长字节码部分执行到POP_EXCEPT弹出栈上异常对象,设置执行器状态是异常,会触发pop Block(type=setup_finally),重置状态为IS_OK,然后执行finally部分代码,改部分代码执行完紧接着执行END_FINALLY字节码,会重新设置状态为IS_EXCEPTION, 弹完BLOCK后状态始终是IS_EXCEPTION, 这样异常并没有捕获接着逐渐向上抛出了,正常应该在本函数捕获了啊

    2024-10-05