引
靈感來源依舊是爬蟲框架項目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技巧第一篇。多線程程序中另一個重要問題是主線程如何捕獲子線程產生的異常,我構思了一個方案,有時間測試一下。
- 如果有哪些不對的地方或者有更好的解決方案,歡迎討論。
- 謝謝~