β

论 Python 装饰器控制函数 Timeout 的正确姿势

leejun_2005的个人页面 503 阅读

1、问题背景

之前在这篇《 Python RPC 远程调用脚本之 RPyC 实践 》中实现过一个小 Demo,通过 RPyC 可以实现一个简单的分布式程序,但是,有过开发经验的同学应该一眼就能看出这个程序有个致命缺陷:假如用户执行了一个非常耗时或者耗资源的程序,那客户端将永远无法获取结果甚至导致服务端直接宕掉,因此我们需要对命令的执行时长做出限制,引入 Timeout 机制增强程序健壮性和用户体验。

2、so easy:装饰器!

如果你恰好看过我之前的这篇《 深入浅出 Python 装饰器:16 步轻松搞定 Python 装饰器 》,那应该很自然的想到,Python 装饰器最适合这种业务场景了:对函数进行额外功能性包装,又不侵入主体业务逻辑。

Timeout 装饰器的代码如下:

# coding=utf-8
# 测试utf-8编码
import sys
 
reload(sys)
sys.setdefaultencoding('utf-8')
 
import signal, functools
 
 
class TimeoutError(Exception): pass
 
 
def timeout(seconds, error_message="Timeout Error: the cmd 30s have not finished."):
    def decorated(func):
        result = ""
 
        def _handle_timeout(signum, frame):
            global result
            result = error_message
            raise TimeoutError(error_message)
 
        def wrapper(*args, **kwargs):
            global result
            signal.signal(signal.SIGALRM, _handle_timeout)
            signal.alarm(seconds)
 
            try:
                result = func(*args, **kwargs)
            finally:
                signal.alarm(0)
                return result
            return result
 
        return functools.wraps(func)(wrapper)
 
    return decorated
 
 
@timeout(5)  # 限定下面的slowfunc函数如果在5s内不返回就强制抛TimeoutError Exception结束
def slowfunc(sleep_time):
    a = 1
    import time
    time.sleep(sleep_time)
    return a
 
 
# slowfunc(3) #sleep 3秒,正常返回 没有异常
 
 
print slowfunc(11)  # 被终止

测试用例也正常,但是把这个装饰器用在文初提到的 RPC 代码中时,抛了异常:

Traceback (most recent call last):
  File "exec_cmd.py", line 79, in <module>
    exec_cmd(cmd_str)
  File "exec_cmd.py", line 53, in exec_cmd
    results = pool.map(rpc_client, host_port_list)
  File "/opt/soft/python-2.7.10/lib/python2.7/multiprocessing/pool.py", line 251, in map
    return self.map_async(func, iterable, chunksize).get()
  File "/opt/soft/python-2.7.10/lib/python2.7/multiprocessing/pool.py", line 567, in get
    raise self._value
ValueError: signal only works in main thread
========= Remote Traceback (1) =========
Traceback (most recent call last):
  File "/opt/soft/python-2.7.10/lib/python2.7/site-packages/rpyc/core/protocol.py", line 305, in _dispatch_request
    res = self._HANDLERS[handler](self, *args)
  File "/opt/soft/python-2.7.10/lib/python2.7/site-packages/rpyc/core/protocol.py", line 535, in _handle_call
    return self._local_objects[oid](*args, **dict(kwargs))
  File "flumeFileMonitor_RPC_Server.py", line 39, in wrapper
    signal.signal(signal.SIGALRM, _handle_timeout)
ValueError: signal only works in main thread

为了更简单说明问题,我们把测试代码再简化下:

# coding=utf-8
#测试utf-8编码
from time import sleep, time
import sys, threading
reload(sys)
sys.setdefaultencoding('utf-8')
from multiprocessing.dummy import Pool as ThreadPool
@timeout(1)
def processNum(num):
    num_add = num + 1
    # results.append(str(threading.current_thread()) + ": " + str(num) + " → " + str(num_add))
    sleep(2)
    return str(threading.current_thread()) + ": " + str(num) + " → " + str(num_add)
def main():
    ts = time()
    pool = ThreadPool(4)
    results = pool.map(processNum, range(4))
    pool.close()
    pool.join()
    for _ in results:
        print _
    print("cost time is: {:.2f}s".format(time() - ts))
if __name__ == "__main__":
    main()

可以看到报错是因为 signal 只能用在主线程中,不能用在多线程环境下的子线程中,而且 signal 只能用在 *nix 环境下,不能跨平台,看到这里,似乎这个问题又不那么容易解决了,看来咱们得另辟蹊径。

3、另辟蹊径:线程控制超时

大体逻辑如下:咱们启动新子线程执行指定的方法,主线程等待子线程的运行结果,若在指定时间内子线程还未执行完毕,则判断为超时,抛出超时异常,并杀掉子线程;否则未超时,返回子线程所执行的方法的返回值。但是python默认模块里是没有方法可以杀掉线程的,怎么办呢?发现有人已经实现了该KThread类,它继承了threading.Thread,并添加了kill方法,让我们能杀掉子线程。

先上代码,然后我会简述下 KThread类的设计思路:

from time import sleep, time
import sys, threading
class KThread(threading.Thread):
    """A subclass of threading.Thread, with a kill()
    method.
    Come from:
    Kill a thread in Python:
    http://mail.python.org/pipermail/python-list/2004-May/260937.html
    """
    def __init__(self, *args, **kwargs):
        threading.Thread.__init__(self, *args, **kwargs)
        self.killed = False
    def start(self):
        """Start the thread."""
        self.__run_backup = self.run
        self.run = self.__run  # Force the Thread to install our trace.
        threading.Thread.start(self)
    def __run(self):
        """Hacked run function, which installs the
        trace."""
        sys.settrace(self.globaltrace)
        self.__run_backup()
        self.run = self.__run_backup
    def globaltrace(self, frame, why, arg):
        if why == 'call':
            return self.localtrace
        else:
            return None
    def localtrace(self, frame, why, arg):
        if self.killed:
            if why == 'line':
                raise SystemExit()
        return self.localtrace
    def kill(self):
        self.killed = True
class Timeout(Exception):
    """function run timeout"""
def timeout(seconds):
    """超时装饰器,指定超时时间
    若被装饰的方法在指定的时间内未返回,则抛出Timeout异常"""
    def timeout_decorator(func):
        """真正的装饰器"""
        def _new_func(oldfunc, result, oldfunc_args, oldfunc_kwargs):
            result.append(oldfunc(*oldfunc_args, **oldfunc_kwargs))
        def _(*args, **kwargs):
            result = []
            new_kwargs = {  # create new args for _new_func, because we want to get the func return val to result list
                'oldfunc': func,
                'result': result,
                'oldfunc_args': args,
                'oldfunc_kwargs': kwargs
            }
            thd = KThread(target=_new_func, args=(), kwargs=new_kwargs)
            thd.start()
            thd.join(seconds)
            alive = thd.isAlive()
            thd.kill()  # kill the child thread
            if alive:
                # raise Timeout(u'function run too long, timeout %d seconds.' % seconds)
                try:
                    raise Timeout(u'function run too long, timeout %d seconds.' % seconds)
                finally:
                    return u'function run too long, timeout %d seconds.' % seconds
            else:
                return result[0]
        _.__name__ = func.__name__
        _.__doc__ = func.__doc__
        return _
    return timeout_decorator

然后根据上面的代码测试结果如下:

@timeout(1)
def processNum(num):
    num_add = num + 1
    # results.append(str(threading.current_thread()) + ": " + str(num) + " → " + str(num_add))
    sleep(2)
    return str(threading.current_thread()) + ": " + str(num) + " → " + str(num_add)
    
//
function run too long, timeout 1 seconds.
function run too long, timeout 1 seconds.
function run too long, timeout 1 seconds.
function run too long, timeout 1 seconds.
cost time is: 1.17s

看了代码咱们再来聊聊上述 KThread 的设计思路:

关键点在那个threading.settrace(self.globaltrace),它是用来设置跟踪调试threading。

看下threading.settrace文档。需要在线程调用run前设置好,threading.settrace只起一个中转作用,它会在线程运行前将self.globaltrace传给sys.settrace。

threading.settrace(func)

Set a trace function for all threads started from the threading module. The func will be passed to sys.settrace() for each thread, before its run() method is called.

New in version 2.3.

再看下sys.settrace的文档,英文文档说明有点长,参照上面代码看起来应没什么问题。

分析下上面的代码:

    def start(self):
        threading.settrace(self.globaltrace) #线程运行前设置跟踪过程self.globaltrace
        threading.Thread.start(self)#运行线程
    def globaltrace(self,frame,why,arg):
        if why=='call': #将会调用一个子过程
            return self.localtrace #返回调用子过程的跟踪过程self.localtrace,并使用子过程跟踪过程self.localtrace跟踪子过程运行
        else:
            return None
    def localtrace(self,frame,why,arg):
        if self._willKill and why=='line': #self._willKill自己设置的中断标识,why为跟踪的事件,其中line为执行一行或多行python代码
            raise SystemExit() #当中断标识为True及将会执行下一行python代码时,使用SystemExit()中断线程
        return self.localtrace

这就是中断线程的整个过程。只是在线程每执行一行代码将都检查一下中断标识,如果需要中断则返回,否则继续执行。

4、缺陷

5、函数超时在多线程场景下 2 个常见误区

尤其是 join(timeout) 方法里的 timeout 很容易让初学者误解,以为调用了  join(n) 就是 n 秒后线程超时结束

咱们先看下文档:

When the timeout argument is present and not None, it should be a floating point number specifying a timeout for the operation in seconds (or fractions thereof). As join() always returns None, you must call isAlive() after join() to decide whether a timeout happened – if the thread is still alive, the join() call timed out.

可以看到其实 timeout 只是将主线程阻塞,它只告诉join等待子线程运行多久,如果超时后,主线程和子线程还是各自向下继续运行,因此你必须调用 isAlive() 来决定是否超时发生——如果子线程还活着, 表示本次 join() 调用超时了。

假设有 10 个线程,每个线程业务逻辑是 sleep 3s,现在需要总体控制在 2s 内执行完毕,很多初学者可能写出这样的代码:

for i in range(10):
    t = ThreadTest(i)
    thread_arr.append(t)
for i in range(10):
    thread_arr[i].start()
for i in range(10):
    thread_arr[i].join(2)

其实最后你会发现,这段代码会耗时 20s,因为每个 join(2) 都是顺序执行的,而且没有真正的超时结束功能。

还是上一份完整的代码供大家测试学习使用吧:

# coding=utf-8
# 测试utf-8编码
from time import sleep, time
import sys, threading
from Queue import Queue
from threading import Thread
reload(sys)
sys.setdefaultencoding('utf-8')
def processNum(num):
    num_add = num + 1
    sleep(3)
    print str(threading.current_thread()) + ": " + str(num) + " → " + str(num_add)
class ProcessWorker(Thread):
    def __init__(self, queue):
        Thread.__init__(self)
        self.queue = queue
    def run(self):
        while True:
            num = self.queue.get()
            processNum(num)
            self.queue.task_done()
thread_arr = []
def main():
    ts = time()
    queue = Queue()
    for x in range(10):
        worker = ProcessWorker(queue)
        worker.daemon = True
        worker.start()
        thread_arr.append(worker)
    for num in range(10):
        queue.put(num)
    # queue.join()
    for _ in thread_arr:
        _.join(2)
    print("cost time is: {:.2f}s".format(time() - ts))
if __name__ == "__main__":
    main()

好了,今天就先聊到这儿吧,多线程是个永恒的话题,路漫漫其修远兮~

Refer:

[1] 深入浅出 Python 装饰器:16 步轻松搞定 Python 装饰器

http://my.oschina.net/leejun2005/blog/477614?fromerr=rNBm9BiN#OSC_h2_23

[2] Python RPC 远程调用脚本之 RPyC 实践

http://my.oschina.net/leejun2005/blog/471624

[3] Python tips: 超时装饰器, @timeout decorator

http://www.cnblogs.com/fengmk2/archive/2008/08/30/python_tips_timeout_decorator.html

http://bacspot.dip.jp/virtual_link/root/home/kishima/Debian/BACFlex/IVCH/BACFlex_pypage/var_pypage/pub/agent/KThread.py

[4] 可中止的線程

https://sites.google.com/site/mypynotes/skill/cankillthread

[5] Python模块学习:threading 多线程控制和处理

http://python.jobbole.com/81546/

[6] 一文学会Python多线程编程

http://bit.ly/2c8LF2R

[7] 一文学会Python多进程编程

http://bit.ly/2bLVznb

作者:leejun_2005的个人页面
xrzs的博客
原文地址:论 Python 装饰器控制函数 Timeout 的正确姿势, 感谢原作者分享。