當前位置:
首頁 > 知識 > Python 信號機制的探索和思考

Python 信號機制的探索和思考

寫在前面

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

本文環境:

Ubuntu 16.04 (64 bit)

Python 3.6.2

使用的 C 源碼可以從 Python 官網 獲取。

起因

工作時用到了 celery 作為非同步任務隊列,為方便調試,我寫了一個腳本用以啟動/關閉 celery 主進程。代碼簡化後如下:

代碼啟動了 celery worker,並嘗試在捕獲到 異常時將其熱關閉。

初看上去沒什麼問題。然而實際測試時卻發生了十分詭異的事情:按下 後,程序偶爾會拋出這樣的異常: 。詭異之處有兩點:

這個結果大大出乎了我的意料。隨機性異常是眾多最難纏的問題之一,因為這常常意味著並發問題,涉及底層知識,病灶隱蔽,調試難度大,同時沒有有效的手段判斷問題是否徹底解決(可能只是降低了頻率)。

解決

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

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

多次嘗試後,出現了一條線索:有時候 這個字元串會被二次列印,時機不確定。這句話是 celery 將要熱關閉時的提示語,二次出現只可能是主進程收到了第二個信號。閱讀 celery 的文檔 可知, 和 信號可以引發熱關閉。回頭瀏覽我的代碼,其中只有一處發送了 信號( ),至於另一個神秘的信號,我懷疑是 。

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

信號不僅會發送到父進程,而是會發到整個進程組,默認情況下包括了所有子進程。也就是說——在攔截了 之後執行的 是多此一舉,因為 信號也會被發送至 celery 主進程,同樣會引起熱關閉。代碼稍作修改即可正常運行:

猜測

UNIX 信號處理是一個相當奇葩的過程——當進程收到一個信號時,內核會選擇一條線程(以一定的規則),中斷其當前控制流,將控制流強行轉給信號處理函數,待其執行完畢後再將控制流交還給原線程。時序圖如下:

由於控制流轉換髮生在同一條線程上,許多線程間同步機制會失效甚至報錯。因此信號處理函數的編寫要比線程函數更加嚴格,對同一個文件輸出是被禁止並且無解的,因為很可能會發生這樣的事情:

而且這個問題不能通過加鎖來解決(因為是在同一個線程中,會死鎖)。

因此,我猜測異常發生時的事件時序是這樣的:在 未執行完時中斷,又在信號處理函數中調用 print,觸發了重入檢測,引起 :

疑雲又起

不幸的是,我的猜想很快被推翻了。

在翻看 Python 模塊的官方文檔,我看到了如下敘述:

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 Handler 中的 並沒有在異常控制流中執行。那異常又是怎麼產生的呢?

文檔說 Python Signal Handler 會被延後至某個時機進行,但並沒有明示是什麼時候。對於這個疑問,這個提問的被採納回答 則斬釘截鐵地將其具體化到了"某兩個 Python 位元組碼之間"。

我們知道,Python 程序在執行前會被編譯成 Python 內定的位元組碼

(bytecode),Python 虛擬機實際執行的正是這些位元組碼。倘若該回答是正確的,則立即有如下推論:在處理信號的過程中,位元組碼具有原子性(atomic)。也就是說,主線程總是在兩個位元組碼之間決定是否轉移控制流, 而不會出現以下情況:

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

因此,除了在兩個位元組碼之間,應該還有其他時機喚起了 Python Signal Handler

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

深入源碼

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

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

位於 1511 行:

可見,這個函數便是非同步回調的最裡層,包含了執行 Python Signal Handler 的邏輯。

至此我們可以發現,整個 Python 中有兩個辦法可以喚起 Python Signal Handler,一個是調用 ,另一個是調用 。前者只是後者的簡單封包。

在 Python 源碼中只出現了一次(不包括定義,下同),沒有被直接調用的跡象。但需要注意的是, 曾被當做 的參數, 所做的工作時將其加入到一個全局隊列中。與之對應的出隊操作是 ,位於 的 464 行。此函數會間接調用 ,在 Python 源碼中被調用了 3 次:

52 行的

310 行的

722 行的

值得注意的是, 是一個長達 2600 多行的狀態機,是解析位元組碼的核心邏輯所在。此處調用出現於狀態機主循環開始處——這印證了上面回答中的部分說法,即 Python 會在兩個位元組碼中間喚起 Python Signal Hanlder。

而 在 Python 源碼中出現了 80 多處,遍布 Python 的各個模塊中——這說明該回答的另一半說法是錯誤的:除了在兩個位元組碼之間,Python 還可能在其他角落喚起 Python Signal Handler。其中有兩處值得注意,它們都位於 中:

這兩個函數是 類的底層實現,會被 間接調用。仔細觀察可以發現,它們都有著相似的結構:

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

至此,真相已經大白了。

真相

當信號中斷髮生在 或 中時,這兩個函數中的 會直接喚起 Python Signal Handler,而此時由 上的鎖尚未解開,若 Python Signal Handler 中又有 函數調用,則會導致再次 上鎖失敗,從而拋出異常。時序圖如下:

思考

為什麼不將 Python Signal Handler 調用的地點統一在一個地方,而是散布在程序的各處呢?閱讀相關代碼,我認為有兩點原因:

信號中斷會使某些系統調用行為異常,從而使系統調用的調用者不知如何處理,此時需要調用 Signal Handler 進行可能的狀態恢復。一個例子是 系統調用,信號中斷會導致數據部分寫回,與此相關的一大批 I/O 函數(包括出問題的 和 )便只能相應地調用 。

某些函數需要做計算密集型任務,為了防止 Python Signal Handler 的調用被過長地延後(其實主要是為了及時響應鍵盤中斷,防止程序無法從前台結束),必須適時地檢查並調用 Python Signal Handler。一個例子是 中的諸函數, 定義了 Python 特有的無限長整型,其相關的運算可能耗時相當長,必須做這樣的處理。

總結

Python Signal Handler 的調用會被延後,但時機不止在兩個位元組碼之間,而是可能出現在任何地方。

由於第一條,Python Signal Handler 中盡量都使用可重入的的函數,以避免奇怪的問題。可重入性可以從文檔獲知,也可以結合定義由源碼推斷出來。

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

題圖:pexels,CC0 授權。

點擊展開全文

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 編程派 的精彩文章:

王者榮耀英雄在創業公司都打什麼位置?
印象筆記 SDK 踩坑記
Python 爬蟲:王者榮耀那些事!
自動化替換 Markdown 中的本地圖片引用
Django 如何實現全文檢索?

TAG:編程派 |

您可能感興趣

PHP的session的實現機制
OpenStack-Neutron的資源隔離機制
基於注意力機制的深度網路HydraPlus-Net
Spring AOP 的實現機制
乾貨 | NLP中的self-attention【自-注意力】機制
阿里開源富容器引擎 PouchContainer的network 連接機制
《Science Advances》Highlights重磅!應用賽特SE-iFISH揭秘乳腺癌轉移機制
劫持者可以繞過 Active Directory 控制機制
RocketMQ底層通信機制
SpringCloud如何實現Eureka集群、HA機制?
react的更新機制
android 結合源碼深入剖析AsyncTask機制原理
python greenlet 背景介紹與實現機制
VR遊戲《Apex Construct》更新帶來競爭機制
Nature Communications 最新研究揭示調控種子休眠和萌發的新機制
研究揭示亞細胞核結構nuclear speckle在mRNA出核中的功能與機制
Python 和 Ruby 的分代垃圾回收機制
Android 消息機制—ThreadLocal
令人恐懼的固態硬碟:SSD和Bitlocker 加密機制被破解!
《Artifact》中的「回城」機制