1. 程式人生 > >【Python筆記】Python多執行緒程序如何正確響應Ctrl-C以實現優雅退出

【Python筆記】Python多執行緒程序如何正確響應Ctrl-C以實現優雅退出

相信用C/C++寫過服務的同學對通過響應Ctrl-C(訊號量SIG_TERM)實現多執行緒C程序的優雅退出都不會陌生,典型的實現偽碼如下:

#include <signal.h>

int main(int argc, char * argv[]) 
{
    // 1. do some init work 
    ... init() ...

    // 2. install signal handler, take SIGINT as  example install:
    struct sigaction action;
    memset(&action, 0, sizeof(struct sigaction));
    action.sa_handler = term;
    sigaction(SIGTERM, &action, NULL
); // 3. create thread(s) pthread_create(xxx); // 4. wait thread(s) to quit pthread_join(thrd_id); return 0; }

主要步驟概括如下:
1)設定SIG_TERM訊號量的處理函式
2)建立子執行緒
3)主執行緒通過pthread_join阻塞
4)主執行緒收到Ctrl-C對應的SIG_INT訊號後,訊號處理函式被呼叫,該函式通常會設定子執行緒的退出flag變數
5)子執行緒的spin-loop通常會檢測flag變數,當flag指示退出時機已到時,子執行緒break其loop
6)待子執行緒釋放資源退出後,主執行緒的pthread_join()結束阻塞返回,主執行緒退出,程序銷燬

然而,如果用Python多執行緒庫(threading或thread)實現一個與上述偽碼流程相似的多執行緒模組時,新手很容易犯錯,導致程序啟動後,Ctrl-C不起作用,甚至kill也結束不了程序,必須kill -9強殺才行。

下面用例項來說明。

常見錯誤1:試圖捕獲Ctrl-C的KeyboardInterrupt異常實現程序退出,示例偽碼如下:

def main():
    try:
        thread1.start() 

        thread1.join()
    except KeyboardInterrupt:
        print "Ctrl-c pressed ..."
sys.exit(1)

上面的偽碼在主執行緒中建立子執行緒後呼叫join主動阻塞,預期的行為是按下Ctrl-C時,程序能捕獲到鍵盤中斷異常。然而,根據Python thread文件Caveats部分的說明,”Threads interact strangely with interrupts: the KeyboardInterrupt exception will be received by an arbitrary thread. (When the signal module is available, interrupts always go to the main thread.)”,所以,上面偽碼由於沒有import signal模組,鍵盤中斷只能由主執行緒接收,而主執行緒被thread.join()阻塞導致無法響應中斷訊號,最終的結果是程序無法通過Ctrl-C結束。

解決方法:
呼叫thread.join()時,傳入timeout值並在spin-loop做isAlive檢測,示例如下:

def main():
    try:
        thread1.start() 
        ## thread is totally blocking e.g. while (1)
        while True:
            thread1.join(2)
            if not thread1.isAlive:
                break
    except KeyboardInterrupt:
        print "Ctrl-c pressed ..."
        sys.exit(1)

上面的方案可以實現用Ctrl-C退出程序的功能,不過,如Python Issue1167930中有人提到的,帶timeout的join()呼叫會由於不斷的polling帶來額外的CUP開銷。

常見錯誤2:註冊SIG_INT或SIG_TERM訊號處理函式,試圖通過捕獲Ctrl-C或kill對應的訊號來銷燬執行緒、退出主程序。示例如下:

#/bin/env python
#-*- encoding: utf-8 -*-

import sys
import time
import signal
import threading


def signal_handler(sig, frame):
    g_log_inst.get().warn('Caught signal: %s, process quit now ...', sig)
    sys.exit(0)


def main():
    ## install signal handler, for gracefully shutdown
    signal.signal(signal.SIGTERM, signal_handler)
    signal.signal(signal.SIGINT, signal_handler)

    ## start thread
    thrd = threading.Tread(target = thread_routine)
    thrd.start()

    ## block主執行緒,否則主執行緒直接退出會導致其建立的子執行緒出現未定義行為
    ## 具體可以參考python thread文件Caveats部分的說明。
    thrd.join()

if '__main__' == __name__:
    main()

如果執行上述偽碼,你會發現Ctrl-C和kill均無法結束這個多執行緒的Python程序,那麼,問題出在哪裡呢?

根據Python signal文件,我們需要牢記”Some general rules for working with signals and their handlers“,其中,跟本文最相關的第7條摘出如下:

Some care must be taken if both signals and threads are used in the same program. The fundamental thing to remember in using signals and threads simultaneously is: always perform signal() operations in the main thread of execution. Any thread can perform an alarm(), getsignal(), pause(), setitimer() or getitimer(); only the main thread can set a new signal handler, and the main thread will be the only one to receive signals (this is enforced by the Python signal module, even if the underlying thread implementation supports sending signals to individual threads). This means that signals can’t be used as a means of inter-thread communication. Use locks instead.

如上所述,當signal和threading模組同時出現時,所有的訊號處理函式都只能由主執行緒來設定,且所有的訊號均只能由主執行緒來接收,而主執行緒呼叫thrd.join()後處於阻塞狀態。
所以,Ctrl-C或kill不起作用的原因與前面列出的常見錯誤1類似,解決方法也類似,這裡不贅述。

參考資料

============== EOF =============