Python中的Singal和Exception

概述

python是如何处理中断信号Singal的?

python中的异常(Exception)分为哪几种,不同Exception的继承关系?他们有什么不同?Error和Exception有区别吗?

发现问题

最近处理了一个线上问题,使用signal中的ALARM机制对程序进行超时管理,超时会触发自定义的AirflowTaskTimeoutException(原理是在程序启动时注册一个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-pytry代码块中代码可能抛出什么异常,发现其中可能的异常都继承自HttpError,认为将异常捕捉范围缩小到HttpError似乎更合理,线上的问题通过这种方式得到解决。于是提交了pr。

但是elasticsearch-py的作者不认可这个修改,原因是:所修改的代码中的Transport模块作为本库的基础,修改这里会造成严重后果(breaking change),Transport模块应该只抛出TransportError相关的异常。推荐使用内建的超时机制或者异步IO任务。

的确,如果上层有依赖TransportTransportError的异常处理逻辑(见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之后才引入了,目的就是为了区分SystemExitKeyboardInterrupt和其他异常的捕获。

这么一看,一切都清晰了,程序中异常捕捉虽然广泛,但是仅捕捉了Exception一族的异常。而KeyboardInterrupt集成自BaseException,他和Exception不是子孙关系。所以except Exception as e:语句不能捕获KeyboardInterrupt(劫持失败~)。

ErrorException有什么区别吗?个人觉得就是名称的区别,可能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源码剖析—信号处理机制

你可能感兴趣的:(Python中的Singal和Exception)