Python能否实现超时,以及一些关于线程知识的复习

最近在试着写一个简单的分布式数据库(主要是受了OceanBase的影响),其中日志系统打算基于Raft来写。Raft的算法中有很多场景是需要基于超时来实现的,比如选举超时、心跳维护超时等,因此自然就想试着写一个通用的超时模块。这篇文章就来说说小秦在实现超时时候遇到的困难以及一些感想。(注意,Raft的超时可以通过socket.settimeout来设置,这里讨论的超时只的是针对一般方法的超时)

简单的说,我的目的(最初)是实现这么一个装饰器timeout:

@timeout(5)
def foo():
    print 'in foo'

对于下面的代码:

if __name__ == '__main__':
    try:
        foo()
    except TimeoutError as e:
        print 'timeout'

如果foo执行的时间超过了5秒,那么foo会的抛出异常。

现在来看看下面的几个方法能否实现:

1.signal
很自然的想法是使用信号量来实现我们的timeout,一个例子可以参考:http://stackoverflow.com/questions/492519/timeout-on-a-python-function-call。代码如下(非装饰器):

def timeout(func, args=(), kwargs={}, timeout_duration=1, default=None):
    import signal

    class TimeoutError(Exception):
        pass

    def handler(signum, frame):
        raise TimeoutError()

    # set the timeout handler
    signal.signal(signal.SIGALRM, handler) 
    signal.alarm(timeout_duration)
    try:
        result = func(*args, **kwargs)
    except TimeoutError as exc:
        result = default
    finally:
        signal.alarm(0)

    return result

这里通过SIGALRM来实现超时。但是这里有两个问题:第一,windows下不支持SIGALRM。第二,这种超时只能用在单线程中。

为什么只有单线程可以使用这种方式呢?这个问题其实可以转变为信号量在Python线程中是如何被处理的。首先顺便补充下Python线程的基本知识(可以参考《Python源码剖析》),Python的线程的调度由解释器的计数器决定,解释器每次执行一个指令就对计数器加1,计数器的值达到某个值后就会进行python线程的切换。python的线程对应的就是操作系统的线程,只是每个线程要执行前都必须获取到一把GIL。在计数器达到某个值后,解释器会的释放释放当前线程的GIL,底层操作对应的线程会的去争抢GIL。争抢到GIL的线程(也就是python线程)可以继续执行代码,然后重复这个过程。

既然python的线程是基于操作系统的线程,那么信号量的接收方式是不是也是由操作系统决定的呢?答案是no。对于python来说,其强制只有主线程可以接收信号量(https://docs.python.org/2/library/signal.html#module-signal),(对于信号量的知识可以参考《UNIX环境高级编程》):

only the main thread can set a new signal handler, and the main thread will be the only one to receive signals (this is enforced by the Python signal module, even if the underlying thread implementation supports sending signals to individual threads). This means that signals can’t be used as a means of inter-thread communication.

说了这么多我们现在明白了,python的多线程虽然是基于操作系统的,但是解释器强制只有主线程能接收信号。因此对于下面的代码:

import threading
import signal
import time

def foo():
    try:
        while True:
            time.sleep(10)
    except Exception as e:
        print 'in foo'
        print e

def handler(signum, frame):
    raise Exception('TimeoutError')

if __name__ == '__main__':
    t = threading.Thread(target=foo)
    t.start()
    while True:
        signal.signal(signal.SIGALRM, handler) 
        signal.alarm(1)

        try:
            time.sleep(10)
        except Exception as e:
            print 'in mian'
            print e

其输出永远是:

in mian
TimeoutError
in mian
TimeoutError
in mian
TimeoutError
in mian
TimeoutError
in mian
TimeoutError
in mian
TimeoutError
in mian
TimeoutError
in mian
TimeoutError
in mian
TimeoutError
in mian
TimeoutError

既然只有主线程可以收到signal的信号量,那么对于多线程环境来说,通过signal来实现timeout就是不可能的了。

2.启动一个监听线程
监听线程来实现timeout的原始想法很简单,当需要记录超时信息的时候,我们启动一个单独的线程来sleep具体的时间。如果这个时间结束后我们监视的线程还没有结束那么我们就kill对应的线程。所以我们只要找到kill线程的方法就好啦~~~但是,python没有提供这么个方法=-=。

和上面一样,这个问题现在转变成了“为什么python不提供kill线程的方法呢?”。看了http://eli.thegreenplace.net/2011/08/22/how-not-to-set-a-timeout-on-a-computation-in-python 这篇文章后就知道,kill一个线程是一种很不好的编程习惯。如果被kill的线程持有临界资源,那么当它被kill后临界资源就不能被释放,整个代码可能就都无法运行下去了。按照我们之前分析的,python线程对应的操作系统如果被kill了,可能GIL都来不及释放,那么我们的python程序就会hang在那了。

这时可能有人要问了,如果python线程异常终止,会不会来不及释放GIL而使得python程序hang住?或者换个问题,如果子线程抛出了异常,我们的python代码会怎样?来看下这个代码:

import threading
import time

def error_method():
    time.sleep(5)
    raise Exception('Error Thread is over')

def good_method():
    while True:
        time.sleep(2)
        print 'in good thread'

if __name__ == '__main__':
    t = threading.Thread(target=error_method)
    t.start()
    t = threading.Thread(target=good_method)
    t.start()

    time.sleep(3)
    raise Exception('Main thread is died')

其输出为:

in good thread
Traceback (most recent call last):
  File "z.py", line 20, in <module>
    raise Exception('Main thread is died')
Exception: Main thread is died
in good thread
Exception in thread Thread-1:
Traceback (most recent call last):
  File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/threading.py", line 810, in __bootstrap_inner
    self.run()
  File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/threading.py", line 763, in run
    self.__target(*self.__args, **self.__kwargs)
  File "z.py", line 6, in error_method
    raise Exception('Error Thread is over')
Exception: Error Thread is over

in good thread
in good thread
in good thread
in good thread
in good thread
in good thread
in good thread
in good thread

可以看到,一个线程的异常并不会影响其他线程,其线程所在的解释器会的捕获到异常。main线程结束后,只要其还有不是daemon的子线程则整个程序中活着的线程就会继续运行下去。

在http://www.cnblogs.com/fengmk2/archive/2008/08/30/python_tips_timeout_decorator.html 这个文章中找到了一个比较少见的实现kill线程的方法,但是由于上面我们说的原因,不推荐使用。

3.多进程
多进程可以一定程度的解决临界问题,但是在需要共享数据结构的时候,其编程复杂度会上升很多。因此也不是一个值得推荐的通用的方法。

4.eventlet
eventlet有对应的超时方法,但前提是代码将cpu让出。如果不让出那么timeout是不会生效的。

好了,我们现在来总结下:对于单线程来说,通过signal可以做到超时,但是这是一种不好的编程习惯,无论是单线程还是多线程,最好的方式是让代码自己感知到自己超时来实现退出。就如之前在分析Heat代码时看到其stack的创建的超时例子一样,较好的方式是:

while not timeout():
    do_sth()

如果在一些测试用例中必须使用超时,那么对于测试,小秦能想到的最好的方式就是通过多进程的方式来实现。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*