一個 Reentrant Error 引起的對 Python 信號機制的探索和思考

寫在前面

前幾天工做時遇到了一個匪夷所思的問題。通過幾回嘗試後問題得以解決,但問題產生的緣由卻仍使人費解。查找 SO 無果,我決定翻看 Python 的源碼。斷斷續續地研究了幾天,終於恍然大悟。撰此文以記。html

本文環境:python

  • Ubuntu 16.04 (64 bit)segmentfault

  • Python 3.6.2安全

使用的 C 源碼能夠從 Python 官網 獲取。數據結構

原由

工做時用到了 celery 做爲異步任務隊列,爲方便調試,我寫了一個腳本用以啓動/關閉 celery 主進程。代碼簡化後以下:多線程

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>'>。詭異之處有兩點:異步

  • 異常發生的時機有隨機性async

  • 異常的 traceback 指向 celery 包,也就是說這是在 celery 主進程內部發生的異常ide

這個結果大大出乎了個人意料。隨機性異常是衆多最難纏的問題之一,由於這經常意味着併發問題,涉及底層知識,病竈隱蔽,調試難度大,同時沒有有效的手段判斷問題是否完全解決(可能只是下降了頻率)。

解決

異常信息中有兩個詞很關鍵:reentrantstdoutreentrant call 說明有一個不可重入的函數被遞歸調用了;stdout 則指明瞭發生的地點和時機。初步能夠斷定:因爲某種緣由,有兩股控制流在同時操控 stdout

「可重入」是什麼?根據 Wikipedia 的定義:若是一個子程序能在執行時被中斷並在以後被正確地、安全地喚起,它就被稱爲可重入的。依賴於全局數據的過程是不可重入的,如 printf(依賴於全局文件描述符)、malloc(依賴與和堆相關的一系列數據結構)等函數。須要注意的是,可重入性(reentrant)與 線程安全性(thread-safe)並不等價,甚至不存在包含關係,Wikipedia 中給出了相關的反例。

屢次嘗試後,出現了一條線索:有時候 worker: Warm shutdown (MainProcess) 這個字符串會被二次打印,時機不肯定。這句話是 celery 將要熱關閉時的提示語,二次出現只多是主進程收到了第二個信號。閱讀 celery 的文檔 可知,SIGINTSIGTERM 信號能夠引起熱關閉。回頭瀏覽個人代碼,其中只有一處發送了 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 主進程,一樣會引發熱關閉。代碼稍做修改便可正常運行:

# ...
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)。也就是說,主線程老是在兩個字節碼之間決定是否轉移控制流, 而 不會 出現如下狀況:

這很顯然與個人程序結果相悖:printprint 所調用的 io.BufferedWriter.writeio.BufferedWriter.flush 都是用純 C 代碼編寫的,對其的調用只消耗一條字節碼(CALL_FUNCTIONCALL_FUNCTION_KW),在信號中斷的影響下,這幾個函數仍保持原子性,在時序圖上互不重疊,更不會發生重入。

所以,除了在兩個字節碼之間,應該還有其餘時機喚起了 Python Signal Handler

至此,問題已觸及 Python 的地板了,需向更底層挖掘才能找到答案。

深刻源碼

信號註冊邏輯位於 Modules/signalmodule.c 文件中。 313 行的 signal_handler 是信號處理函數的最外層包裝,由系統調用 signalsigaction 註冊至內核,並在信號發生時被內核回調,是異常控制流的入口。signal_handler 主要調用了 239 行處的 trip_signal 函數,其中有這樣一段代碼:

Handlers[sig_num].tripped = 1;

if (!is_tripped) {
    is_tripped = 1;
    Py_AddPendingCall(checksignals_witharg, NULL);
}

這段代碼即是文檔中所說的邏輯:作標記並延後 Python Signal Handler。其中 checksignals_witharg 即爲被延後調用的函數,位於 192 行,核心代碼只有一句:

static int
checksignals_witharg(void * unused)
{
    return PyErr_CheckSignals();
}

PyErr_CheckSignals 位於 1511 行:

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 間接調用。仔細觀察能夠發現,它們都有着類似的結構:

ENTER_BUFFERED(self)
// ...
PyErr_CheckSignals();
// ...
LEAVE_BUFFERED(self)

ENTER_BUFFERED 是一個宏,會嘗試申請無阻塞線程鎖以保證函數不會被重入:

#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 中儘可能都使用 可重入的 的函數,以免奇怪的問題。可重入性能夠從文檔獲知,也能夠結合定義由源碼推斷出來。

  • 有疑問,翻源碼。人會說謊,代碼不會。

相關文章
相關標籤/搜索