異常是怎么實現(xiàn)的?虛擬機是如何將異常拋出去的?
楔子
程序在運行的過程中,總是會不可避免地產(chǎn)生異常,此時為了讓程序不中斷,必須要將異常捕獲掉。如果能提前得知可能會發(fā)生哪些異常,建議使用精確捕獲,如果不知道會發(fā)生哪些異常,則使用 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,方法的所有者還可以聲明自己會拋出什么異常,然后調(diào)用者對異常進行處理。
在 Java 程序啟動時,拋出大量異常都是司空見慣的事情,并在相應的調(diào)用堆棧中將信息完整地記錄下來。至此,Java 的異常不再是異常,而是一種很普遍的結(jié)構(gòu),從良性到災難性都有所使用,異常的嚴重性由調(diào)用者來決定。
雖然在 Python 里面,異常還沒有達到像 Java 異常那么高的地位,但使用頻率也是很高的,下面我們就來剖析一下異常是怎么實現(xiàn)的?
異常的本質(zhì)是什么
Python 解釋器 = Python 編譯器 + Python 虛擬機,所以異??梢杂删幾g器拋出,也可以由虛擬機剖出。如果是編譯器拋出的異常,那么基本上都是 SyntaxError,即語法錯誤。
try:
>>>
except Exception as e:
print(e)
比如上面這段代碼,你會發(fā)現(xiàn)異常捕獲根本沒用,因為這是編譯階段就發(fā)生的錯誤,而異常捕獲是在運行時進行的。當然語法不對屬于低級錯誤,所以不會留到運行時。
然后是運行時產(chǎn)生的異常:
try:
1 / 0
except ZeroDivisionError:
print("Division by zero")
像這種語法正確,但程序執(zhí)行時因邏輯出現(xiàn)問題而導致的異常,是可以被捕獲的。對于我們來說,關注的顯然是運行時產(chǎn)生的隱藏,比如 TypeError、IndexError 等等。
那么問題來了,異常本質(zhì)上是什么呢?我們以列表為例,看看 IndexError 是怎么產(chǎn)生的。
lst = [1, 2, 3]
print(lst[3])
"""
IndexError: list index out of range
"""
列表的最大索引是 2,但我們訪問了索引為 3 的元素,虛擬機就知道不能再執(zhí)行下去了,否則會訪問非法內(nèi)存。因此虛擬機的做法是:輸出異常信息,結(jié)束進程。我們通過源碼來驗證一下:
圖片
在獲取列表元素時發(fā)現(xiàn)索引不合法,就知道要拋出 IndexError 了,會將異常寫入到標準錯誤輸出當中,并返回 NULL。正常情況下,返回值應該指向一個合法的對象,如果為 NULL,證明出現(xiàn)異常了。
此時虛擬機會將回溯棧里的異常拋出來(就是我們在控制臺看到的那一抹鮮紅),然后結(jié)束進程,這就是異常的本質(zhì)。當然異常也是一個 Python 對象,虛擬機在退出前,會寫入到 stderr 中。
異常寫入的一些 C API
當我們用 C 編寫 Python 擴展時,如果想設置異常的話,該怎么做呢?首先設置異常之前,我們要知道有哪些異常。在 pyerrors.h 中,虛擬機內(nèi)置了大量的異常,另外 Python 一切皆對象,因此異常也是一個對象。
圖片
有了異常之后,怎么寫入呢?關于異常寫入,底層也提供了相應的 C API。
圖片
相關的 API 有很多,我們來解釋一下。
"""
PyErr_SetNone:設置異常,不包含提示信息。
PyErr_SetObject:設置異常,包含提示信息(Python 字符串)。
PyErr_SetString:設置異常,包含提示信息(C 字符串)。
PyErr_Occurred:檢測回溯棧中是否有異常產(chǎn)生。
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 等等,最終都會調(diào)用 PyErr_Restore
void
PyErr_Restore(PyObject *type, PyObject *value, PyObject *traceback)
{
// 獲取線程狀態(tài)對象
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);
// 調(diào)用 _PyErr_SetRaisedException
_PyErr_SetRaisedException(tstate, value);
Py_DECREF(type);
}
void
_PyErr_SetRaisedException(PyThreadState *tstate, PyObject *exc)
{
// 線程狀態(tài)對象的 current_exception 字段,負責保存當前異常
PyObject *old_exc = tstate->current_exception;
// 將它設置為 exc
tstate->current_exception = exc;
Py_XDECREF(old_exc);
}
我們再來看看 PyThreadState 對象,它是與線程相關的,但它只是線程信息的一個抽象描述,而真實的線程及狀態(tài)肯定是由操作系統(tǒng)來維護和管理的。
因為虛擬機在運行的時候總需要另外一些與線程相關的狀態(tài)和信息,比如是否發(fā)生了異常等等,這些信息顯然操作系統(tǒng)是沒有辦法提供的。而 PyThreadState 對象正是 Python 為線程準備的、在虛擬機層面保存線程狀態(tài)信息的對象(后面簡稱線程狀態(tài)對象、或者線程對象)。
當前活動線程(OS 原生線程)對應的 PyThreadState 對象可以通過 PyThreadState_GET 獲得,在得到了線程狀態(tài)對象之后,就將異常信息存放在里面。
關于線程相關的內(nèi)容,后續(xù)會詳細說。
traceback 是什么?
程序產(chǎn)生的異常會被記錄在線程狀態(tài)對象當中,現(xiàn)在可以回頭看看,在跳出了分派字節(jié)碼指令的代碼塊之后,發(fā)生了什么動作。
在 ceval.c 里面有一個 _PyEval_EvalFrameDefault 函數(shù),負責執(zhí)行字節(jié)碼指令。該函數(shù)內(nèi)部有一個代碼塊,包含了每個指令的處理邏輯,執(zhí)行完畢后會跳出代碼塊。
圖片
但跳出代碼塊的原因有兩種:
- 執(zhí)行完所有的字節(jié)碼指令之后正常跳出;
- 發(fā)生異常后跳出;
那么虛擬機如何區(qū)分是哪一種呢?很簡單,通過 error 標簽實現(xiàn),注意代碼塊里面有一個 error 標簽。
圖片
如果在執(zhí)行指令的時候出現(xiàn)了異常,那么會跳轉(zhuǎn)到 error 這里,否則會跳轉(zhuǎn)到其它地方。
另外當出現(xiàn)異常時,會在線程狀態(tài)對象中將異常信息記錄下來,包括異常類型、異常值、回溯棧(traceback),這個 traceback 就是在 error 標簽中調(diào)用 PyTraceBack_Here 創(chuàng)建的。
另外可能有人不清楚 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
"""
這是腳本運行時產(chǎn)生的錯誤輸出,我們看到了函數(shù)調(diào)用的信息:比如在源代碼的哪一行調(diào)用了哪一個函數(shù),那么這些信息是從何而來的呢?沒錯,顯然是 traceback 對象。
虛擬機在處理異常的時候,會創(chuàng)建 traceback 對象,在該對象中記錄棧幀的信息。虛擬機利用該對象來將棧幀鏈表中每一個棧幀的狀態(tài)進行可視化,可視化的結(jié)果就是上面輸出的異常信息。
而且我們發(fā)現(xiàn)輸出的信息也是一個鏈狀的結(jié)構(gòu),因為每一個棧幀都會對應一個 traceback 對象,這些 traceback 對象之間也會組成一個鏈表。
所以當虛擬機開始處理異常的時候,它首先的動作就是創(chuàng)建 traceback 對象,用于記錄異常發(fā)生時活動棧幀的狀態(tài)。創(chuàng)建方式是通過 PyTraceBack_Here 函數(shù),它接收一個棧幀作為參數(shù)。
// Python/traceback.c
int
PyTraceBack_Here(PyFrameObject *frame)
{
// 獲取當前的異常對象
PyObject *exc = PyErr_GetRaisedException();
assert(PyExceptionInstance_Check(exc));
// 拿到當前異常的 traceback
PyObject *tb = PyException_GetTraceback(exc);
// 創(chuàng)建新的 traceback 對象,并和舊的 traceback 對象組成鏈表
PyObject *newtb = _PyTraceBack_FromFrame(tb, frame);
Py_XDECREF(tb);
if (newtb == NULL) {
_PyErr_ChainExceptions1(exc);
return -1;
}
// 將新的 traceback 對象交給線程狀態(tài)對象
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 也是一個鏈表結(jié)構(gòu)。其實 traceback 對象的鏈表結(jié)構(gòu)跟棧幀對象的鏈表結(jié)構(gòu)是同構(gòu)的、或者說一一對應的,即一個棧幀對象對應一個 traceback 對象。
traceback 創(chuàng)建
在 PyTraceBack_Here 函數(shù)中我們看到它是通過 _PyTraceBack_FromFrame 創(chuàng)建的,那么秘密就隱藏在這個函數(shù)中。
// Python/traceback.c
PyObject*
_PyTraceBack_FromFrame(PyObject *tb_next, PyFrameObject *frame)
{
assert(tb_next == NULL || PyTraceBack_Check(tb_next));
assert(frame != NULL);
// 獲取最近一條執(zhí)行完畢的字節(jié)碼指令的偏移量
int addr = _PyInterpreterFrame_LASTI(frame->f_frame) * sizeof(_Py_CODEUNIT);
// 創(chuàng)建 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 對象申請內(nèi)存
tb = PyObject_GC_New(PyTracebackObject, &PyTraceBack_Type);
if (tb != NULL) {
// 設置屬性
tb->tb_next = (PyTracebackObject*)Py_XNewRef(next);
// 注意 traceback 內(nèi)部還保存了棧幀對象
// 所以在 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 對象建立了聯(lián)系,當然還有最后執(zhí)行完畢時的字節(jié)碼偏移量、以及在源代碼中對應的行號。
棧幀展開
traceback 的創(chuàng)建我們知道了,那么它和棧幀對象是怎么聯(lián)系起來的呢?我們還以之前的代碼為例,來解釋一下。
def h():
1 / 0
def g():
h()
def f():
g()
f()
當執(zhí)行到函數(shù) h 的 1 / 0 這行代碼時,底層會執(zhí)行 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) {
// ...
// 對于除法運算,指令參數(shù) 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 函數(shù)
// 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)
// 最終會執(zhí)行 (&PyLong_Type) -> tp_as_methods -> nb_true_divide
// 即 long_true_divice 函數(shù),看一下它的邏輯
// 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;
}
由于除數(shù)為 0,因此會通過 PyErr_SetString 設置一個異常進去,最終將異常類型、異常值、以及 traceback 保存到線程狀態(tài)對象中。但此時 traceback 實際上是為空的,因為目前還沒有涉及到 traceback 的創(chuàng)建,那么它是什么時候創(chuàng)建的呢?繼續(xù)往下看。
由于出現(xiàn)了異常,那么 long_true_divide 會返回NULL。
圖片
當返回值為 NULL 時,虛擬機就意識到發(fā)生異常了,這時候會跳轉(zhuǎn)到 pop_2_error 標簽。
圖片
當出現(xiàn)除零錯誤時,運行時棧里面還有兩個元素,所以跳轉(zhuǎn)到 pop_2_error。將棧里的兩個元素彈出之后,進入 error 標簽。
在里面會先取出線程狀態(tài)對象中已有的 traceback 對象(此時為空),然后以函數(shù) h 的棧幀為參數(shù),創(chuàng)建一個新的 traceback 對象,將兩者通過 tb_next 關聯(lián)起來。最后,再替換掉線程狀態(tài)對象里面的 traceback 對象。
在虛擬機意識到有異常拋出,并創(chuàng)建了 traceback 之后,它會在當前棧幀中尋找 try except 語句,來執(zhí)行開發(fā)人員指定的捕捉異常動作。如果沒有找到,那么虛擬機將退出當前的活動棧幀,并沿著棧幀鏈回退到上一個棧幀(這里是函數(shù) g 的棧幀),在上一個棧幀中尋找 try except 語句。
就像我們之前說的,函數(shù)調(diào)用會創(chuàng)建棧幀,當函數(shù)執(zhí)行完畢或者出現(xiàn)異常時,會回退到上一級棧幀。一層一層創(chuàng)建、一層一層返回。至于回退的這個動作,則是在 _PyEval_EvalFrameDefault 的最后完成。
圖片
當出現(xiàn)異常時,虛擬機會進入 exception_unwind 標簽尋找異常捕獲邏輯,相關細節(jié)下一篇文章再說,這里就讓它拋出去。然后來到 exit_unwind 標簽,將當前線程狀態(tài)對象中的活動棧幀,設置為上一級棧幀,從而完成棧幀回退的動作。
當棧幀回退時,會進入函數(shù) g 的棧幀,由于返回值為 NULL,所以知道自己調(diào)用的函數(shù) h 的內(nèi)部發(fā)生異常了(否則返回值一定會指向一個合法的 PyObject),那么繼續(xù)尋找異常捕獲語句。
對于當前這個例子來說,顯然是找不到的,于是會從線程狀態(tài)對象中取出已有的 traceback 對象(函數(shù) h 的棧幀對應的 traceback)。然后以函數(shù) g 的棧幀為參數(shù),創(chuàng)建新的 traceback 對象,再將兩者通過 tb_next 關聯(lián)起來,并重新設置到線程狀態(tài)對象中。
異常會沿著棧幀鏈進行反向傳播,函數(shù) h 出現(xiàn)的異常被傳播到了函數(shù) g 中,顯然接下來函數(shù) g 要將異常傳播到函數(shù) f 中。因為函數(shù) g 在無法捕獲異常時,那么返回值也是 NULL,而函數(shù) f 看到返回值為 NULL 時,同樣會去尋找異常捕獲語句。但是找不到,于是會從線程狀態(tài)對象中取出已有的 traceback 對象(此時是函數(shù) g 的棧幀對應的 traceback),然后以函數(shù) f 的棧幀為參數(shù),創(chuàng)建新的 traceback 對象,再將兩者通過 tb_next 關聯(lián)起來,并重新設置到線程狀態(tài)對象中。
最后再傳播到模塊對應的棧幀中,如果還無法捕獲發(fā)生的異常,那么虛擬機就要將異常拋出來了。
這個沿著棧幀鏈不斷回退的過程我們稱之為棧幀展開,在棧幀展開的過程中,虛擬機不斷地創(chuàng)建與各個棧幀對應的 traceback,并將其鏈接成鏈表。
圖片
由于沒有異常捕獲,那么接下來會調(diào)用 PyErr_Print。然后在 PyErr_Print 中,虛擬機取出維護的 traceback 鏈表,并進行遍歷,將里面的信息逐個輸出到 stderr 當中,最終就是我們在 Python 中看到的異常信息。
并且打印順序是:.py文件、函數(shù)f、函數(shù)g、函數(shù)h。因為每一個棧幀對應一個 traceback,而棧幀又是往后退的,因此顯然會從 .py文件對應的 traceback 開始打印,然后通過 tb_next 找到函數(shù)f 對應的 traceback,依次下去。當異常信息全部輸出完畢之后,解釋器就結(jié)束運行了。
因此從鏈路的開始位置到結(jié)束位置,將整個調(diào)用過程都輸出出來,可以很方便地定位問題出現(xiàn)在哪里。
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 一直在更新(因為要對整個調(diào)用鏈路進行追蹤),但是異常類型和異常值始終是不變的,就是函數(shù) h 中拋出的 ZeroDivisionError: division by zero。
小結(jié)
以上就是虛擬機拋異常的過程,異常在 Python 里面也是一個對象,和其它的實例對象并無本質(zhì)區(qū)別。
exc = StopIteration("迭代結(jié)束了")
print(exc.value) # 我是一個異常
print(exc.args) # ('迭代結(jié)束了',)
exc = IndexError("索引越界了")
print(exc.args) # ('索引越界了',)
exc = Exception("不知道是啥異常,總之出問題了")
print(exc.args) # ('不知道是啥異常,總之出問題了',)
# 異常都有一個 args 屬性,以元組的形式保存?zhèn)鬟f的參數(shù)
所謂拋出異常,就是將錯誤信息輸出到 stderr 中,然后停止進程。并且除了虛擬機內(nèi)部會拋出異常之外,我們還可以使用 raise 關鍵字手動引發(fā)一個異常。
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 一個異常。