概述
python是如何处理中断信号Singal的?
python中的异常(Exception)分为哪几种,不同Exception的继承关系?他们有什么不同?Error和Exception有区别吗?
发现问题
最近处理了一个线上问题,使用signal中的ALARM机制对程序
进行超时管理,超时会触发自定义的AirflowTaskTimeout
Exception(原理是在程序启动时注册一个SIGALRM(14)的中断回调方法,并定义在超时N秒后触发回调方法,在回调方法中抛出AirflowTaskTimeout
,然后捕获该异常进行程序退出的善后工作。详细情况请参看Apache-Airflow的TaskTimeout机制,TaskTimeout),但是由于程序
中引用的一个模块(elasticsearch-py
)捕获了所有Exception,然后抛出了一个新建的ConnectionError
异常(详细情况请看perform_request中的异常处理),导致上层程序无法捕捉到AirflowTaskTimeout
。由于Http请求耗时较长,终端信号大概率在这部分被处理,而这个方法默认会重试3次(见transport.py#310),最终结果就是,本来该因超时停止的程序,在接收到一次超时信号后会重新再执行一次,本来该结束的程序怎么也停不下来。
我的第一反应是:修改exception代码块中的程序,让AirflowTaskTimeout
能够以本来面目抛出。于是提出仅将网络请求相关的异常转化为ConnectionError
抛出,其它异常原样抛出。提了一个issue。
后来,又考虑到这么大范围的异常捕获不太妥,查看了elasticsearch-py
try代码块中代码可能抛出什么异常,发现其中可能的异常都继承自HttpError
,认为将异常捕捉范围缩小到HttpError
似乎更合理,线上的问题通过这种方式得到解决。于是提交了pr。
但是elasticsearch-py
的作者不认可这个修改,原因是:所修改的代码中的Transport
模块作为本库的基础,修改这里会造成严重后果(breaking change),Transport
模块应该只抛出TransportError
相关的异常。推荐使用内建的超时机制或者异步IO任务。
的确,如果上层有依赖Transport
的TransportError
的异常处理逻辑(见transport.py#316),势必会影响到原有的程序。但是,我仍然认为对未知异常的劫持是不道德可取的。
在后续的探索中发现,同样是向程序发送中断信号,kill -2 $PID
可以终止程序(这里的停止不能善后)。但是kill -14 $PID
就会因为AirflowTaskTimeout
被劫持而达不到停止程序的效果。
开始Signal和Exception的探索之旅…………
探索Python中的Exception
书接上文,kill -2 $PID
可以结束程序,kill -14 $PID
不能结束程序。原因肯定出现在对这两个信号的处理逻辑上。
信号值14就是前述的超时机制使用的信号SIGALRM,发送信号14不能结束程序的原因是AirflowTaskTimeout
被劫持导致后续的程序不能检测到有效的AirflowTaskTimeout
异常。
观察被kill -2 $PID
结束的程序,可以看到堆栈异常日志最后一行打印了KeyboardInterrupt
,这是一个python内建的异常类型,为什么它没有被劫持?
Traceback (most recent call last):
File "ep1.py", line 77, in
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的源码发现了其中的玄机,原来python中的异常都继承自BaseException
,而内建的异常中仅有4个异常直接继承自BaseException
,他们分别是:
SystemExit # 系统退出异常,~这么翻译不对吧?~
KeyboardInterrupt # 用户终止程序引发的异常,就是kill -2引发的,平常ctrl + c也是触发这个异常
GeneratorExit # 迭代类型结束时触发的异常,用来结束对迭代类型的for循环遍历
Exception # 其它所有内建异常,也是我们程序中创建新的异常的父类
后来又翻了一下《Python核心编程》,
BaseException
是在python2.5之后才引入了,目的就是为了区分SystemExit
、KeyboardInterrupt
和其他异常的捕获。
这么一看,一切都清晰了,程序中异常捕捉虽然广泛,但是仅捕捉了Exception
一族的异常。而KeyboardInterrupt
集成自BaseException
,他和Exception
不是子孙关系。所以except Exception as e:
语句不能捕获KeyboardInterrupt
(劫持失败~)。
Error
和Exception
有什么区别吗?个人觉得就是名称的区别,可能Error
代表的异常更严重。
- python内建异常的继承关系
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
是通过什么方式触发的KeyboardInterrupt
异常?这一部分我还没弄清楚,以下叙述有个人猜测部分,欢迎指正、讨论。
查看python源码(C部分)发现,_signal.py
文件中有一个常量SIG_DFL
(used to refer to the system default handler),这是一个默认的信号处理handler,是这个默认的handler抛出了
KeyboardInterrupt`异常吗,在哪里?
实验过程用的代码
后面发现还可以通过修改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()
参考
- [1] Python源码剖析—信号处理机制