概述
python是如何處理中斷信號Singal的?
python中的異常(Exception)分為哪幾種,不同Exception的繼承關(guān)系?他們有什么不同?Error和Exception有區(qū)別嗎?
發(fā)現(xiàn)問題
最近處理了一個線上問題,使用signal中的ALARM機制對程序進行超時管理,超時會觸發(fā)自定義的AirflowTaskTimeoutException(原理是在程序啟動時注冊一個SIGALRM(14)的中斷回調(diào)方法,并定義在超時N秒后觸發(fā)回調(diào)方法,在回調(diào)方法中拋出AirflowTaskTimeout,然后捕獲該異常進行程序退出的善后工作。詳細情況請參看Apache-Airflow的TaskTimeout機制,TaskTimeout),但是由于程序中引用的一個模塊(elasticsearch-py)捕獲了所有Exception,然后拋出了一個新建的ConnectionError異常(詳細情況請看perform_request中的異常處理),導致上層程序無法捕捉到AirflowTaskTimeout。由于Http請求耗時較長,終端信號大概率在這部分被處理,而這個方法默認會重試3次(見transport.py#310),最終結(jié)果就是,本來該因超時停止的程序,在接收到一次超時信號后會重新再執(zhí)行一次,本來該結(jié)束的程序怎么也停不下來。
我的第一反應是:修改exception代碼塊中的程序,讓AirflowTaskTimeout能夠以本來面目拋出。于是提出僅將網(wǎng)絡請求相關(guān)的異常轉(zhuǎn)化為ConnectionError拋出,其它異常原樣拋出。提了一個issue。
后來,又考慮到這么大范圍的異常捕獲不太妥,查看了elasticsearch-pytry代碼塊中代碼可能拋出什么異常,發(fā)現(xiàn)其中可能的異常都繼承自HttpError,認為將異常捕捉范圍縮小到HttpError似乎更合理,線上的問題通過這種方式得到解決。于是提交了pr。
但是elasticsearch-py的作者不認可這個修改,原因是:所修改的代碼中的Transport模塊作為本庫的基礎(chǔ),修改這里會造成嚴重后果(breaking change),Transport模塊應該只拋出TransportError相關(guān)的異常。推薦使用內(nèi)建的超時機制或者異步IO任務。
的確,如果上層有依賴Transport的TransportError的異常處理邏輯(見transport.py#316),勢必會影響到原有的程序。但是,我仍然認為對未知異常的劫持是不道德可取的。
在后續(xù)的探索中發(fā)現(xiàn),同樣是向程序發(fā)送中斷信號,kill -2 $PID可以終止程序(這里的停止不能善后)。但是kill -14 $PID就會因為AirflowTaskTimeout被劫持而達不到停止程序的效果。
開始Signal和Exception的探索之旅…………
探索Python中的Exception
書接上文,kill -2 $PID可以結(jié)束程序,kill -14 $PID不能結(jié)束程序。原因肯定出現(xiàn)在對這兩個信號的處理邏輯上。
信號值14就是前述的超時機制使用的信號SIGALRM,發(fā)送信號14不能結(jié)束程序的原因是AirflowTaskTimeout被劫持導致后續(xù)的程序不能檢測到有效的AirflowTaskTimeout異常。
觀察被kill -2 $PID結(jié)束的程序,可以看到堆棧異常日志最后一行打印了KeyboardInterrupt,這是一個python內(nèi)建的異常類型,為什么它沒有被劫持?
Traceback (most recent call last):
File "ep1.py", line 77, in <module>
do_query()
File "ep1.py", line 67, in do_query
long_time_request()
File "ep1.py", line 59, in long_time_request
time.sleep(60) # DO A LONG TIME QUERY
KeyboardInterrupt
進一步查看python的源碼發(fā)現(xiàn)了其中的玄機,原來python中的異常都繼承自BaseException,而內(nèi)建的異常中僅有4個異常直接繼承自BaseException,他們分別是:
SystemExit # 系統(tǒng)退出異常,~這么翻譯不對吧?~
KeyboardInterrupt # 用戶終止程序引發(fā)的異常,就是kill -2引發(fā)的,平常ctrl + c也是觸發(fā)這個異常
GeneratorExit # 迭代類型結(jié)束時觸發(fā)的異常,用來結(jié)束對迭代類型的for循環(huán)遍歷
Exception # 其它所有內(nèi)建異常,也是我們程序中創(chuàng)建新的異常的父類
后來又翻了一下《Python核心編程》,
BaseException是在python2.5之后才引入了,目的就是為了區(qū)分SystemExit、KeyboardInterrupt和其他異常的捕獲。
這么一看,一切都清晰了,程序中異常捕捉雖然廣泛,但是僅捕捉了Exception一族的異常。而KeyboardInterrupt集成自BaseException,他和Exception不是子孫關(guān)系。所以except Exception as e:語句不能捕獲KeyboardInterrupt(劫持失敗~)。
Error和Exception有什么區(qū)別嗎?個人覺得就是名稱的區(qū)別,可能Error代表的異常更嚴重。
- python內(nèi)建異常的繼承關(guān)系
BaseException
+-- SystemExit
+-- KeyboardInterrupt
+-- GeneratorExit
+-- Exception
+-- StopIteration
+-- StopAsyncIteration
+-- ArithmeticError
| +-- FloatingPointError
| +-- OverflowError
| +-- ZeroDivisionError
+-- AssertionError
+-- AttributeError
+-- BufferError
+-- EOFError
+-- ImportError
| +-- ModuleNotFoundError
+-- LookupError
| +-- IndexError
| +-- KeyError
+-- MemoryError
+-- NameError
| +-- UnboundLocalError
+-- OSError
| +-- BlockingIOError
| +-- ChildProcessError
| +-- ConnectionError
| | +-- BrokenPipeError
| | +-- ConnectionAbortedError
| | +-- ConnectionRefusedError
| | +-- ConnectionResetError
| +-- FileExistsError
| +-- FileNotFoundError
| +-- InterruptedError
| +-- IsADirectoryError
| +-- NotADirectoryError
| +-- PermissionError
| +-- ProcessLookupError
| +-- TimeoutError
+-- ReferenceError
+-- RuntimeError
| +-- NotImplementedError
| +-- RecursionError
+-- SyntaxError
| +-- IndentationError
| +-- TabError
+-- SystemError
+-- TypeError
+-- ValueError
| +-- UnicodeError
| +-- UnicodeDecodeError
| +-- UnicodeEncodeError
| +-- UnicodeTranslateError
+-- Warning
+-- DeprecationWarning
+-- PendingDeprecationWarning
+-- RuntimeWarning
+-- SyntaxWarning
+-- UserWarning
+-- FutureWarning
+-- ImportWarning
+-- UnicodeWarning
+-- BytesWarning
+-- ResourceWarning
探索Python中Singal
kill -2 $PID是通過什么方式觸發(fā)的KeyboardInterrupt異常?這一部分我還沒弄清楚,以下敘述有個人猜測部分,歡迎指正、討論。
查看python源碼(C部分)發(fā)現(xiàn),_signal.py文件中有一個常量SIG_DFL(used to refer to the system default handler),這是一個默認的信號處理handler,是這個默認的handler拋出了KeyboardInterrupt`異常嗎,在哪里?
實驗過程用的代碼
后面發(fā)現(xiàn)還可以通過修改Airflow中的AirflowTaskTimeout,讓他繼承自BaseException,這樣就無懼try except Exception的劫持了。
import time
import signal
import logging
logging.basicConfig()
logger = logging.getLogger(__name__)
class MyTimeout(Exception):
"""
我們自定義的timeout異常
"""
pass
# class MyTimeout(BaseException):
# """
# 我們自定義的timeout異常,基于BaseException
# """
# pass
class LibaryDeifinedExcepiton(Exception):
"""
模塊自定義的異常
"""
pass
class timeout(object):
"""
To be used in a ``with`` block and timeout its content.
"""
def __init__(self, seconds=1, error_message='Timeout'):
self.seconds = seconds
self.error_message = error_message
self.log = logger
def handle_timeout(self, signum, frame):
self.log.error("Process timed out")
raise MyTimeout(self.error_message)
def __enter__(self):
try:
signal.signal(signal.SIGALRM, self.handle_timeout)
signal.alarm(self.seconds)
except ValueError as e:
self.log.warning("timeout can't be used in the current context")
self.log.exception(e)
def __exit__(self, type, value, traceback):
try:
signal.alarm(0)
except ValueError as e:
self.log.warning("timeout can't be used in the current context")
self.log.exception(e)
def long_time_request():
try:
time.sleep(60) # DO A LONG TIME QUERY
except Exception as e:
raise LibaryDeifinedExcepiton('N/A', str(e), e)
def do_query():
try:
with timeout(30):
long_time_request()
print("after request")
except Exception as e:
print("got excepiton %s"%e)
if isinstance(e, MyTimeout):
print("got MyTimeout exception")
# Do sth you want, when MyTimeout happened.
else:
print("not a MyTimeout exception but a %s"%type(e))
def do_query_2():
try:
with timeout(30):
long_time_request()
print("after request")
except MyTimeout as e: # 捕捉超時異常,做善后工作
print("got MyTimeout excepiton %s"%e)
# Do sth you want, when MyTimeout happened.
if __name__ == "__main__":
do_query()
# do_query_2()