寫在前面
前幾天工作時遇到了一個匪夷所思的問題。經過幾次嘗試後問題得以解決,但問題產生的原因卻仍令人費解。查詢 SO 無果,我決定翻看 Python 的原始碼。斷斷續續地研究了幾天,終於恍然大悟。撰此文以記。
本文環境:
- Ubuntu 16.04 (64 bit)
- Python 3.6.2
使用的 C 原始碼可以從 Python 官網 獲取。
起因
工作時用到了 celery 作為非同步任務佇列,為方便除錯,我寫了一個指令碼用以啟動/關閉 celery 主程式。程式碼簡化後如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import sys import subprocess # ... celery_process = subprocess.Popen( ['celery', '-A', 'XXX', 'worker'], stdout=subprocess.PIPE, stderr=sys.stderr ) try: # Start and wait for server process except KeyboardInterrupt: # Ctrl + C pressed celery_process.terminate() celery_process.wait() |
程式碼啟動了 celery worker,並嘗試在捕獲到 KeyboardInterrupt 異常時將其熱關閉。
初看上去沒什麼問題。然而實際測試時卻發生了十分詭異的事情:按下 Ctrl+C 後,程式 偶爾 會丟擲這樣的異常:RuntimeError: reentrant call inside <_io.BufferedWriter name='<stdout>’>。詭異之處有兩點:
- 異常發生的時機有隨機性
- 異常的 traceback 指向 celery 包,也就是說這是在 celery 主程式內部發生的異常
這個結果大大出乎了我的意料。隨機性異常是眾多最難纏的問題之一,因為這常常意味著併發問題,涉及底層知識,病灶隱蔽,除錯難度大,同時沒有有效的手段判斷問題是否徹底解決(可能只是降低了頻率)。
解決
異常資訊中有兩個詞很關鍵:reentrant 和 stdout。reentrant call 說明有一個不可重入的函式被遞迴呼叫了;stdout 則指明瞭發生的地點和時機。初步可以判定:由於某種原因,有兩股控制流在同時操控 stdout。
“可重入”是什麼?根據 Wikipedia 的定義:如果一個子程式能在執行時被中斷並在之後被正確地、安全地喚起,它就被稱為可重入的。依賴於全域性資料的過程是不可重入的,如 printf(依賴於全域性檔案描述符)、malloc(依賴與和堆相關的一系列資料結構)等函式。需要注意的是,可重入性(reentrant)與 執行緒安全性(thread-safe)並不等價,甚至不存在包含關係,Wikipedia 中給出了相關的反例。
多次嘗試後,出現了一條線索:有時候 worker: Warm shutdown (MainProcess) 這個字串會被二次列印,時機不確定。這句話是 celery 將要熱關閉時的提示語,二次出現只可能是主程式收到了第二個訊號。閱讀 celery 的文件 可知,SIGINT 和 SIGTERM 訊號可以引發熱關閉。回頭瀏覽我的程式碼,其中只有一處傳送了 SIGTERM 訊號(celery_process.terminate()),至於另一個神祕的訊號,我懷疑是 SIGINT。
SO 一下,結果印證了我的猜想:
If you are generating the SIGINT with Ctrl-C on a Unix system, then the signal is being sent to the entire process group.
— via StackOverflow
SIGINT 訊號不僅會傳送到父程式,而是會發到整個程式組,預設情況下包括了所有子程式。也就是說——在攔截了 KeyboardInterrupt 之後執行的 celery_process.terminate() 是多此一舉,因為 SIGINT 訊號也會被髮送至 celery 主程式,同樣會引起熱關閉。程式碼稍作修改即可正常執行:
1 2 3 4 5 6 7 8 9 10 11 12 |
# ... try: # Start and wait for server process except KeyboardInterrupt: # Ctrl + C pressed pass else: # Signal SIGTERM if no exception raised celery_process.terminate() finally: # Wait for it to avoid it becoming orphan celery_process.wait() |
猜測
UNIX 訊號處理是一個相當奇葩的過程——當程式收到一個訊號時,核心會選擇一條執行緒(以一定的規則),中斷其當前控制流,將控制流強行轉給訊號處理函式,待其執行完畢後再將控制流交還給原執行緒。時序圖如下:
由於控制流轉換髮生在同一條執行緒上,許多執行緒間同步機制會失效甚至報錯。因此訊號處理函式的編寫要比執行緒函式更加嚴格,對同一個檔案輸出是被禁止並且無解的,因為很可能會發生這樣的事情:
而且這個問題不能通過加鎖來解決(因為是在同一個執行緒中,會死鎖)。
因此,我猜測異常發生時的事件時序是這樣的:在 print 未執行完時中斷,又在訊號處理函式中呼叫 print,觸發了重入檢測,引起 RuntimeError:
疑雲又起
不幸的是,我的猜想很快被推翻了。
在翻看 Python signal 模組的官方文件,我看到了如下敘述:
A Python signal handler does not get executed inside the low-level (C) signal handler. Instead, the low-level signal handler sets a flag which tells the virtual machine to execute the corresponding Python signal handler at a later point(for example at the next bytecode instruction).
— via Python Documentation
也就是說,Python 中使用 signal.signal 註冊的訊號處理函式並不會在收到訊號時立即執行,而只是簡單做一個標記,將其延遲至之後的某個時機。這麼做可以儘量快地結束異常控制流,減少其對被阻斷程式的影響。
這番表述可以說是推翻了我的猜想,因為 Signal Handler 中的 print 並沒有在異常控制流中執行。那異常又是怎麼產生的呢?
文件說 Python Signal Handler 會被延後至某個時機進行,但並沒有明示是什麼時候。對於這個疑問,這個提問的被採納回答 則斬釘截鐵地將其具體化到了“某兩個 Python 位元組碼之間”。
我們知道,Python 程式在執行前會被編譯成 Python 內定的位元組碼
(bytecode),Python 虛擬機器實際執行的正是這些位元組碼。倘若該回答是正確的,則立即有如下推論:在處理訊號的過程中,位元組碼具有原子性(atomic)。也就是說,主執行緒總是在兩個位元組碼之間決定是否轉移控制流, 而 不會 出現以下情況:
這很顯然與我的程式結果相悖:print 與 print 所呼叫的 io.BufferedWriter.write 和 io.BufferedWriter.flush 都是用純 C 程式碼編寫的,對其的呼叫只消耗一條位元組碼(CALL_FUNCTION 或 CALL_FUNCTION_KW),在訊號中斷的影響下,這幾個函式仍保持原子性,在時序圖上互不重疊,更不會發生重入。
因此,除了在兩個位元組碼之間,應該還有其他時機喚起了 Python Signal Handler。
至此,問題已觸及 Python 的地板了,需向更底層挖掘才能找到答案。
深入原始碼
訊號註冊邏輯位於 Modules/signalmodule.c 檔案中。 313 行的 signal_handler 是訊號處理函式的最外層包裝,由系統呼叫 signal 或 sigaction 註冊至核心,並在訊號發生時被核心回撥,是異常控制流的入口。signal_handler 主要呼叫了 239 行處的 trip_signal 函式,其中有這樣一段程式碼:
1 2 3 4 5 6 |
Handlers[sig_num].tripped = 1; if (!is_tripped) { is_tripped = 1; Py_AddPendingCall(checksignals_witharg, NULL); } |
這段程式碼便是文件中所說的邏輯:做標記並延後 Python Signal Handler。其中 checksignals_witharg 即為被延後呼叫的函式,位於 192 行,核心程式碼只有一句:
1 2 3 4 5 |
static int checksignals_witharg(void * unused) { return PyErr_CheckSignals(); } |
PyErr_CheckSignals 位於 1511 行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
int PyErr_CheckSignals(void) { int i; PyObject *f; if (!is_tripped) return 0; #ifdef WITH_THREAD if (PyThread_get_thread_ident() != main_thread) return 0; #endif is_tripped = 0; if (!(f = (PyObject *)PyEval_GetFrame())) f = Py_None; for (i = 1; i < NSIG; i++) { if (Handlers[i].tripped) { PyObject *result = NULL; PyObject *arglist = Py_BuildValue("(iO)", i, f); Handlers[i].tripped = 0; if (arglist) { result = PyEval_CallObject(Handlers[i].func, arglist); Py_DECREF(arglist); } if (!result) return -1; Py_DECREF(result); } } return 0; } |
可見,這個函式便是非同步回撥的最裡層,包含了執行 Python Signal Handler 的邏輯。
至此我們可以發現,整個 Python 中有兩個辦法可以喚起 Python Signal Handler,一個是呼叫 checksignals_witharg,另一個是呼叫 PyErr_CheckSignals。前者只是後者的簡單封包。
checksignals_witharg 在 Python 原始碼中只出現了一次(不包括定義,下同),沒有被直接呼叫的跡象。但需要注意的是,checksignals_witharg 曾被當做 Py_AddPendingCall 的引數,Py_AddPendingCall 所做的工作時將其加入到一個全域性佇列中。與之對應的出隊操作是 Py_MakePendingCalls,位於 Python/ceval.c 的 464 行。此函式會間接呼叫 checksignals_witharg,在 Python 原始碼中被呼叫了 3 次:
- Modules/_threadmodule.c 52 行的 acquire_timed
- Modules/main.c 310 行的 run_file
- Python/ceval.c 722 行的 _PyEval_EvalFrameDefault
值得注意的是,_PyEval_EvalFrameDefault 是一個長達 2600 多行的狀態機,是解析位元組碼的核心邏輯所在。此處呼叫出現於狀態機主迴圈開始處——這印證了上面回答中的部分說法,即 Python 會在兩個位元組碼中間喚起 Python Signal Hanlder。
而 PyErr_CheckSignals 在 Python 原始碼中出現了 80 多處,遍佈 Python 的各個模組中——這說明該回答的另一半說法是錯誤的:除了在兩個位元組碼之間,Python 還可能在其他角落喚起 Python Signal Handler。其中有兩處值得注意,它們都位於 Modules/_io/bufferedio.c 中:
- 1884 行的 _bufferedwriter_flush_unlocked
- 1939 行的 _io_BufferedWriter_write_impl
這兩個函式是 io.BufferedWriter 類的底層實現,會被 print 間接呼叫。仔細觀察可以發現,它們都有著相似的結構:
1 2 3 4 5 |
ENTER_BUFFERED(self) // ... PyErr_CheckSignals(); // ... LEAVE_BUFFERED(self) |
ENTER_BUFFERED 是一個巨集,會嘗試申請無阻塞執行緒鎖以保證函式不會被重入:
1 2 3 4 |
#define ENTER_BUFFERED(self) \ ( (PyThread_acquire_lock(self->lock, 0) ? \ 1 : _enter_buffered_busy(self)) \ && (self->owner = PyThread_get_thread_ident(), 1) ) |
至此,真相已經大白了。
真相
當訊號中斷髮生在 _bufferedwriter_flush_unlocked 或 _io_BufferedWriter_write_impl 中時,這兩個函式中的 PyErr_CheckSignals 會直接喚起 Python Signal Handler,而此時由 ENTER_BUFFERED 上的鎖尚未解開,若 Python Signal Handler 中又有 print 函式呼叫,則會導致再次 ENTER_BUFFERED 上鎖失敗,從而丟擲異常。時序圖如下:
思考
為什麼不將 Python Signal Handler 呼叫的地點統一在一個地方,而是散佈在程式的各處呢?閱讀相關程式碼,我認為有兩點原因:
- 訊號中斷會使某些系統呼叫行為異常,從而使系統呼叫的呼叫者不知如何處理,此時需要呼叫 Signal Handler 進行可能的狀態恢復。一個例子是 write 系統呼叫,訊號中斷會導致資料部分寫回,與此相關的一大批 I/O 函式(包括出問題的 _bufferedwriter_flush_unlocked 和 _io_BufferedWriter_write_impl)便只能相應地呼叫 PyErr_CheckSignals。
- 某些函式需要做計算密集型任務,為了防止 Python Signal Handler 的呼叫被過長地延後(其實主要是為了及時響應鍵盤中斷,防止程式無法從前臺結束),必須適時地檢查並呼叫 Python Signal Handler。一個例子是 Objects/longobject.c 中的諸函式,longobject.c 定義了 Python 特有的無限長整型,其相關的運算可能耗時相當長,必須做這樣的處理。
總結
- Python Signal Handler 的呼叫會被延後,但時機不止在兩個位元組碼之間,而是可能出現在任何地方。
- 由於第一條,Python Signal Handler 中儘量都使用 可重入的 的函式,以避免奇怪的問題。可重入性可以從文件獲知,也可以結合定義由原始碼推斷出來。
- 有疑問,翻原始碼。人會說謊,程式碼不會。