Python手動中斷(Ctrl-C)多線程程序

靈感來源依舊是爬蟲框架項目pycrawler,爬蟲作為子線程運行時不受鍵盤中斷信號影響,Ctrl-C無法終止整個爬蟲運行。另外的一個場景是多線程壓力測試,需要提前終止的情況下,Ctrl-C依舊不能終止整個程序。除了簡單粗暴的使用kill命令強行終止之外,本文將給出一個簡單可行的解決方案。
值得注意的一點是,Python2、3兩個版本在測試中的表現并不一致,所以使用兩個版本分別進行測試。
博客原文

測試環境

  • Python2 2.7.9
  • Python3 3.4.2
  • Mac OS X Yosemite 10.10.3

子線程類

import time
from threading import Thread


class CountDown(Thread):
    def __init__(self):
        super(CountDown, self).__init__()

    def run(self):
        num = 100
        print('slave start')
        for i in range(10, 0, -1):
            print('Num: {0}'.format(num/i))
            time.sleep(1)
        print('slave end')

失敗情況一

主線程代碼

if __name__ == '__main__':
    print('main start')
    CountDown().start()
    print('main end')

使用Python2進行測試,在運行結束之前手動終止:

main start
slave start
 main end
Num: 10
Num: 11
^CNum: 12
Num: 14
Num: 16
Num: 20
Num: 25
Num: 33
Num: 50
Num: 100
slave end
Exception KeyboardInterrupt in <module 'threading' from '/usr/local/Cellar/python/2.7.9/Frameworks/Python.framework/Versions/2.7/lib/python2.7/threading.pyc'> ignored

可以看到,運行到第三行時手動終止,主線程已經提前結束,子線程繼續執行直到結束,然后拋出未捕獲異常,值得注意的是,此時沒有Traceback輸出。
接下來使用Python3測試同樣的代碼:

main start
slave start
main end
Num: 10.0
Num: 11.11111111111111
Num: 12.5
^CException ignored in: <module 'threading' from '/usr/local/Cellar/python3/3.4.3/Frameworks/Python.framework/Versions/3.4/lib/python3.4/threading.py'>
Traceback (most recent call last):
  File "/usr/local/Cellar/python3/3.4.3/Frameworks/Python.framework/Versions/3.4/lib/python3.4/threading.py", line 1294, in _shutdown
    t.join()
  File "/usr/local/Cellar/python3/3.4.3/Frameworks/Python.framework/Versions/3.4/lib/python3.4/threading.py", line 1060, in join
    self._wait_for_tstate_lock()
  File "/usr/local/Cellar/python3/3.4.3/Frameworks/Python.framework/Versions/3.4/lib/python3.4/threading.py", line 1076, in _wait_for_tstate_lock
    elif lock.acquire(block, timeout):
KeyboardInterrupt

有趣的事情發生了,主線程依舊提前退出,子線程在手動終止后也被強行終止,雖然打印了Traceback信息,但和上例一樣依舊是Exception ignored。

失敗情況二

主線程代碼,現在調用join()方法使主線程等待子線程完成:

if __name__ == '__main__':
    print('main start')
    td = CountDown()
    td.start()
    td.join()
    print('main end')

同上,使用Python2進行測試:

main start
slave start
Num: 10
Num: 11
Num: 12
^CNum: 14
Num: 16
Num: 20
Num: 25
Num: 33
Num: 50
Num: 100
slave end
Traceback (most recent call last):
  File "multithread.py", line 23, in <module>
    td.join()
  File "/usr/local/Cellar/python/2.7.9/Frameworks/Python.framework/Versions/2.7/lib/python2.7/threading.py", line 949, in join
    self.__block.wait()
  File "/usr/local/Cellar/python/2.7.9/Frameworks/Python.framework/Versions/2.7/lib/python2.7/threading.py", line 340, in wait
    waiter.acquire()
KeyboardInterrupt

可以看出,主線程調用join()方法之后wait在一個鎖上,等待子線程退出。子線程退出后,主線程獲得鎖并響應中斷信號,拋出異常并打印信息,main end一行并沒有被打印。
然后使用Python3進行測試:

main start
slave start
Num: 10.0
Num: 11.11111111111111
Num: 12.5
^CTraceback (most recent call last):
  File "multithread.py", line 23, in <module>
    td.join()
  File "/usr/local/Cellar/python3/3.4.3/Frameworks/Python.framework/Versions/3.4/lib/python3.4/threading.py", line 1060, in join
    self._wait_for_tstate_lock()
  File "/usr/local/Cellar/python3/3.4.3/Frameworks/Python.framework/Versions/3.4/lib/python3.4/threading.py", line 1076, in _wait_for_tstate_lock
    elif lock.acquire(block, timeout):
KeyboardInterrupt
Num: 14.285714285714286
Num: 16.666666666666668
Num: 20.0
Num: 25.0
Num: 33.333333333333336
Num: 50.0
Num: 100.0
slave end

神奇的事情再次發生了,主線程等待子線程過程中響應了中斷信號,打印信息后退出,而此時子線程沒有受影響,繼續執行直到結束。

思考

從Python3的Traceback信息可以看出,即使沒有顯式調用join()方法,主線程執行完成后依然會自動調用一個join()邏輯(line 1294, in _shutdown),而且個方法對子線程的影響并不一致:顯式調用時子線程不受影響繼續執行;而自動調用時,子線程隨主線程一起退出。
根據Traceback信息查看Python3源代碼:

def _shutdown():
    # Obscure:  other threads may be waiting to join _main_thread.  That's
    # dubious, but some code does it.  We can't wait for C code to release
    # the main thread's tstate_lock - that won't happen until the interpreter
    # is nearly dead.  So we release it here.  Note that just calling _stop()
    # isn't enough:  other threads may already be waiting on _tstate_lock.
    tlock = _main_thread._tstate_lock
    # The main thread isn't finished yet, so its thread state lock can't have
    # been released.
    assert tlock is not None
    assert tlock.locked()
    tlock.release()
    _main_thread._stop()
    t = _pickSomeNonDaemonThread()
    while t:
        t.join()
        t = _pickSomeNonDaemonThread()
    _main_thread._delete()

def _pickSomeNonDaemonThread():
    for t in enumerate():
        if not t.daemon and t.is_alive():
            return t
    return None

從源代碼可以看出,主線程調用了_stop()方法,然后循環等待所有非daemon進程執行結束,最終調用_delete()方法結束運行。所以主線程雖然執行完了所有的代碼,但是其實并未真正退出,而是等待所有非daemon子線程全部執行完畢后才釋放資源退出程序(所有daemon線程也隨之被銷毀),這個過程中,主線程僅僅占有資源但并沒有執行邏輯(這里我的理解是,不會響應中斷信號)。
所以,得出結論:

  • 沒有調用join()的情況下,主線程退出執行邏輯但沒有釋放資源,且不響應中斷信號,此時中斷信號由子線程響應,于是在失敗情況一中,程序成功被終止。
  • 顯式調用join()的情況下,主線程沒有執行后續代碼,而是等待子線程釋放鎖,因此可以響應中斷信號,于是在失敗情況二中,主線程響應中斷信號并執行退出邏輯(進入上一種情況),子線程并未受影響,執行完畢后程序退出。

但是對于Python2和Python3之間的差別,我現在依舊沒有想明白,初步的想法是2和3對于異常的處理邏輯(或者說順序)不一致,導致2中所有的異常都在主線程真正退出時才被捕獲。打算去知乎問一下,之后會補上問題鏈接

解決方案

說了這么多終于到解決方案了。思路是:設置子線程為daemon線程,啟動子線程后主線程調用isAlive()方法手動模擬join過程。
代碼如下:

if __name__ == '__main__':
    print('main start')
    td = CountDown()
    td.setDaemon(True)
    td.start()
    try:
        while td.isAlive():
            pass
    except KeyboardInterrupt:
        print('stopped by keyboard')
    print('main end')

測試輸出(Python2、3執行結果一致):

main start
slave start
Num: 10
Num: 11
Num: 12
Num: 14
Num: 16
^Cstopped by keyboard
main end

此方案另一優點是主線程可以繼續執行之后的善后邏輯。

  • 感覺這種解決方案算是非主流小技巧了,我想了好久才想出來,具體應用中實不實用還不知道,畢竟現在接觸的項目都太小了。
  • 從這個例子看出,Python2和Python3之間除了官網提到的關鍵區別,還有很多細微的差別,這些差別對某些特定程序可能會產生一定影響,只能慢慢摸索著發現了。
  • 這篇文章算是多線程tricky技巧第一篇。多線程程序中另一個重要問題是主線程如何捕獲子線程產生的異常,我構思了一個方案,有時間測試一下。
  • 如果有哪些不對的地方或者有更好的解決方案,歡迎討論。
  • 謝謝~
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容