如何使用python Tornado服务器在请求中最佳地执行多处理?

时间:2013-03-13 01:18:44

标签: python multiprocessing tornado python-multithreading

我正在使用I / O非阻塞python服务器Tornado。我有一类GET个请求可能需要很长时间才能完成(想想在5-10秒的范围内)。问题是Tornado会阻止这些请求,以便后续的快速请求一直持续到慢速请求完成。

我查看了:https://github.com/facebook/tornado/wiki/Threading-and-concurrency并得出结论我想要#3(其他进程)和#4(其他线程)的某种组合。 #4本身有问题,当有另一个线程正在进行“重举”时,我无法获得可靠的控制回ioloop。 (我假设这是由于GIL以及heavy_lifting任务具有高CPU负载并且不断控制远离主ioloop的事实,但这是猜测)。

所以我一直在原型化如何通过在单独的进程中在这些缓慢的GET请求中执行“繁重的”任务来解决这个问题,然后在完成该过程的过程中将回调放回到Tornado ioloop中请求。这释放了ioloop以处理其他请求。

我创建了一个展示可能解决方案的简单示例,但我很想从社区获得反馈。

我的问题有两个方面:如何简化当前的方法?它可能存在哪些陷阱?

方法

  1. 利用Tornado的内置asynchronous装饰器,允许请求保持打开状态并继续ioloop。

  2. 使用python的multiprocessing模块为“繁重的”任务生成一个单独的进程。我首先尝试使用threading模块,但无法将任何可靠的放弃控制权交还给ioloop。 mutliprocessing似乎也会利用多核。

  3. 使用threading模块在​​主ioloop流程中启动“观察者”主题,该模块的工作是在完成“重吊”任务时查看multiprocessing.Queue的结果。这是必要的,因为我需要一种方法来知道重载任务已经完成,同时仍能通知ioloop这个请求现在已经完成。

  4. 确保'观察者'线程经常通过time.sleep(0)调用放弃对主ioloop循环的控制,以便继续处理其他请求。

  5. 当队列中有结果时,使用tornado.ioloop.IOLoop.instance().add_callback()从“观察者”线程添加一个回调,记录这是从其他线程调用ioloop实例的唯一安全方法。

  6. 请务必在回调中拨打finish()以完成请求并移交回复。

  7. 下面是一些显示此方法的示例代码。 multi_tornado.py是实现上述大纲的服务器,call_multi.py是一个示例脚本,它以两种不同的方式调用服务器来测试服务器。两个测试都会向服务器调用3个慢GET个请求,然后是20个快速GET请求。结果显示在打开和不打开线程的情况下运行。

    在使用“无线程”运行它的情况下,3个慢速请求阻塞(每个需要花费一点多秒才能完成)。 20个快速请求中的一些请求在ioloop中的一些慢速请求之间挤压(不完全确定如何发生 - 但可能是我在同一台机器上运行服务器和客户端测试脚本的工件)。这里的要点是所有快速请求都被不同程度地保留。

    如果在启用线程的情况下运行它,则20个快速请求立即全部完成,然后三个慢速请求在几乎同时完成,因为它们各自并行运行。这是期望的行为。三个慢速请求并行完成需要2.5秒 - 而在非线程情况下,三个慢速请求总共需要3.5秒。所以总体上加速了大约35%(我假设由于多核共享)。但更重要的是 - 快速请求立即以慢速列表处理。

    我对多线程编程没有太多经验 - 所以虽然这看起来很有用,但我很想学习:

    有没有更简单的方法来实现这一目标?在这种方法中潜藏着什么怪物?

    (注意:未来的权衡可能只是使用反向代理运行Tornado的更多实例,例如nginx进行负载平衡。无论我将使用负载均衡器运行多个实例 - 但我担心只是投掷硬件在这个问题上,因为似乎硬件在阻塞方面与问题直接相关。)

    示例代码

    multi_tornado.py (示例服务器):

    import time
    import threading
    import multiprocessing
    import math
    
    from tornado.web import RequestHandler, Application, asynchronous
    from tornado.ioloop import IOLoop
    
    
    # run in some other process - put result in q
    def heavy_lifting(q):
        t0 = time.time()
        for k in range(2000):
            math.factorial(k)
    
        t = time.time()
        q.put(t - t0)  # report time to compute in queue
    
    
    class FastHandler(RequestHandler):
        def get(self):
            res = 'fast result ' + self.get_argument('id')
            print res
            self.write(res)
            self.flush()
    
    
    class MultiThreadedHandler(RequestHandler):
        # Note:  This handler can be called with threaded = True or False
        def initialize(self, threaded=True):
            self._threaded = threaded
            self._q = multiprocessing.Queue()
    
        def start_process(self, worker, callback):
            # method to start process and watcher thread
            self._callback = callback
    
            if self._threaded:
                # launch process
                multiprocessing.Process(target=worker, args=(self._q,)).start()
    
                # start watching for process to finish
                threading.Thread(target=self._watcher).start()
    
            else:
                # threaded = False just call directly and block
                worker(self._q)
                self._watcher()
    
        def _watcher(self):
            # watches the queue for process result
            while self._q.empty():
                time.sleep(0)  # relinquish control if not ready
    
            # put callback back into the ioloop so we can finish request
            response = self._q.get(False)
            IOLoop.instance().add_callback(lambda: self._callback(response))
    
    
    class SlowHandler(MultiThreadedHandler):
        @asynchronous
        def get(self):
            # start a thread to watch for
            self.start_process(heavy_lifting, self._on_response)
    
        def _on_response(self, delta):
            _id = self.get_argument('id')
            res = 'slow result {} <--- {:0.3f} s'.format(_id, delta)
            print res
            self.write(res)
            self.flush()
            self.finish()   # be sure to finish request
    
    
    application = Application([
        (r"/fast", FastHandler),
        (r"/slow", SlowHandler, dict(threaded=False)),
        (r"/slow_threaded", SlowHandler, dict(threaded=True)),
    ])
    
    
    if __name__ == "__main__":
        application.listen(8888)
        IOLoop.instance().start()
    

    call_multi.py (客户测试人员):

    import sys
    from tornado.ioloop import IOLoop
    from tornado import httpclient
    
    
    def run(slow):
        def show_response(res):
            print res.body
    
        # make 3 "slow" requests on server
        requests = []
        for k in xrange(3):
            uri = 'http://localhost:8888/{}?id={}'
            requests.append(uri.format(slow, str(k + 1)))
    
        # followed by 20 "fast" requests
        for k in xrange(20):
            uri = 'http://localhost:8888/fast?id={}'
            requests.append(uri.format(k + 1))
    
        # show results as they return
        http_client = httpclient.AsyncHTTPClient()
    
        print 'Scheduling Get Requests:'
        print '------------------------'
        for req in requests:
            print req
            http_client.fetch(req, show_response)
    
        # execute requests on server
        print '\nStart sending requests....'
        IOLoop.instance().start()
    
    if __name__ == '__main__':
        scenario = sys.argv[1]
    
        if scenario == 'slow' or scenario == 'slow_threaded':
            run(scenario)
    

    测试结果

    通过运行python call_multi.py slow(阻止行为):

    Scheduling Get Requests:
    ------------------------
    http://localhost:8888/slow?id=1
    http://localhost:8888/slow?id=2
    http://localhost:8888/slow?id=3
    http://localhost:8888/fast?id=1
    http://localhost:8888/fast?id=2
    http://localhost:8888/fast?id=3
    http://localhost:8888/fast?id=4
    http://localhost:8888/fast?id=5
    http://localhost:8888/fast?id=6
    http://localhost:8888/fast?id=7
    http://localhost:8888/fast?id=8
    http://localhost:8888/fast?id=9
    http://localhost:8888/fast?id=10
    http://localhost:8888/fast?id=11
    http://localhost:8888/fast?id=12
    http://localhost:8888/fast?id=13
    http://localhost:8888/fast?id=14
    http://localhost:8888/fast?id=15
    http://localhost:8888/fast?id=16
    http://localhost:8888/fast?id=17
    http://localhost:8888/fast?id=18
    http://localhost:8888/fast?id=19
    http://localhost:8888/fast?id=20
    
    Start sending requests....
    slow result 1 <--- 1.338 s
    fast result 1
    fast result 2
    fast result 3
    fast result 4
    fast result 5
    fast result 6
    fast result 7
    slow result 2 <--- 1.169 s
    slow result 3 <--- 1.130 s
    fast result 8
    fast result 9
    fast result 10
    fast result 11
    fast result 13
    fast result 12
    fast result 14
    fast result 15
    fast result 16
    fast result 18
    fast result 17
    fast result 19
    fast result 20
    

    通过运行python call_multi.py slow_threaded(所需行为):

    Scheduling Get Requests:
    ------------------------
    http://localhost:8888/slow_threaded?id=1
    http://localhost:8888/slow_threaded?id=2
    http://localhost:8888/slow_threaded?id=3
    http://localhost:8888/fast?id=1
    http://localhost:8888/fast?id=2
    http://localhost:8888/fast?id=3
    http://localhost:8888/fast?id=4
    http://localhost:8888/fast?id=5
    http://localhost:8888/fast?id=6
    http://localhost:8888/fast?id=7
    http://localhost:8888/fast?id=8
    http://localhost:8888/fast?id=9
    http://localhost:8888/fast?id=10
    http://localhost:8888/fast?id=11
    http://localhost:8888/fast?id=12
    http://localhost:8888/fast?id=13
    http://localhost:8888/fast?id=14
    http://localhost:8888/fast?id=15
    http://localhost:8888/fast?id=16
    http://localhost:8888/fast?id=17
    http://localhost:8888/fast?id=18
    http://localhost:8888/fast?id=19
    http://localhost:8888/fast?id=20
    
    Start sending requests....
    fast result 1
    fast result 2
    fast result 3
    fast result 4
    fast result 5
    fast result 6
    fast result 7
    fast result 8
    fast result 9
    fast result 10
    fast result 11
    fast result 12
    fast result 13
    fast result 14
    fast result 15
    fast result 19
    fast result 20
    fast result 17
    fast result 16
    fast result 18
    slow result 2 <--- 2.485 s
    slow result 3 <--- 2.491 s
    slow result 1 <--- 2.517 s
    

3 个答案:

答案 0 :(得分:30)

如果您愿意使用concurrent.futures.ProcessPoolExecutor代替multiprocessing,这实际上非常简单。 Tornado的ioloop已经支持concurrent.futures.Future,因此他们可以一起玩得很开心。 {3.2}包含concurrent.futureshas been backported to Python 2.x

以下是一个例子:

import time
from concurrent.futures import ProcessPoolExecutor
from tornado.ioloop import IOLoop
from tornado import gen

def f(a, b, c, blah=None):
    print "got %s %s %s and %s" % (a, b, c, blah)
    time.sleep(5)
    return "hey there"

@gen.coroutine
def test_it():
    pool = ProcessPoolExecutor(max_workers=1)
    fut = pool.submit(f, 1, 2, 3, blah="ok")  # This returns a concurrent.futures.Future
    print("running it asynchronously")
    ret = yield fut
    print("it returned %s" % ret)
    pool.shutdown()

IOLoop.instance().run_sync(test_it)

输出:

running it asynchronously
got 1 2 3 and ok
it returned hey there

ProcessPoolExecutor的API比multiprocessing.Pool更受限制,但如果您不需要multiprocessing.Pool更高级的功能,那么它值得使用,因为它集成了非常简单。

答案 1 :(得分:16)

multiprocessing.Pool可以集成到tornado I / O循环中,但它有点乱。可以使用concurrent.futures进行更清晰的集成(有关详细信息,请参阅my other answer),但如果您遇到Python 2.x并且无法安装concurrent.futures backport,以下是严格使用multiprocessing

的方法

multiprocessing.Pool.apply_asyncmultiprocessing.Pool.map_async方法都有一个可选的callback参数,这意味着两者都可以插入tornado.gen.Task。因此,在大多数情况下,在子流程中异步运行代码就像这样简单:

import multiprocessing
import contextlib

from tornado import gen
from tornado.gen import Return
from tornado.ioloop import IOLoop
from functools import partial

def worker():
    print "async work here"

@gen.coroutine
def async_run(func, *args, **kwargs):
    result = yield gen.Task(pool.apply_async, func, args, kwargs)
    raise Return(result)

if __name__ == "__main__":
    pool = multiprocessing.Pool(multiprocessing.cpu_count())
    func = partial(async_run, worker)
    IOLoop().run_sync(func)

正如我所提到的,这适用于大多数案例。但是如果worker()抛出异常,则永远不会调用callback,这意味着gen.Task永远不会完成,并且您将永远挂起。现在,如果您知道您的工作永远不会抛出异常(例如,因为您将整个事件包裹在try / except中),您可以愉快地使用此功能做法。但是,如果你想让异常从你的worker中逃脱,我发现的唯一解决方案是将一些多处理组件子类化,并使它们调用callback,即使worker子进程引发了异常:

from multiprocessing.pool import ApplyResult, Pool, RUN
import multiprocessing
class TornadoApplyResult(ApplyResult):
    def _set(self, i, obj):
        self._success, self._value = obj 
        if self._callback:
            self._callback(self._value)
        self._cond.acquire()
        try:
            self._ready = True
            self._cond.notify()
        finally:
            self._cond.release()
        del self._cache[self._job]

class TornadoPool(Pool):
    def apply_async(self, func, args=(), kwds={}, callback=None):
        ''' Asynchronous equivalent of `apply()` builtin

        This version will call `callback` even if an exception is
        raised by `func`.

        '''
        assert self._state == RUN
        result = TornadoApplyResult(self._cache, callback)
        self._taskqueue.put(([(result._job, None, func, args, kwds)], None))
        return result
 ...

 if __name__ == "__main__":
     pool = TornadoPool(multiprocessing.cpu_count())
     ...

通过这些更改,gen.Task将返回异常对象,而不是gen.Task无限期挂起。我还更新了我的async_run方法,以便在返回异常时重新引发异常,并进行一些其他更改,以便为工作组子进程中引发的异常提供更好的回溯。这是完整的代码:

import multiprocessing
from multiprocessing.pool import Pool, ApplyResult, RUN
from functools import wraps

import tornado.web
from tornado.ioloop import IOLoop
from tornado.gen import Return
from tornado import gen

class WrapException(Exception):
    def __init__(self):
        exc_type, exc_value, exc_tb = sys.exc_info()
        self.exception = exc_value
        self.formatted = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))

    def __str__(self):
        return '\n%s\nOriginal traceback:\n%s' % (Exception.__str__(self), self.formatted)

class TornadoApplyResult(ApplyResult):
    def _set(self, i, obj):
        self._success, self._value = obj 
        if self._callback:
            self._callback(self._value)
        self._cond.acquire()
        try:
            self._ready = True
            self._cond.notify()
        finally:
            self._cond.release()
        del self._cache[self._job]   

class TornadoPool(Pool):
    def apply_async(self, func, args=(), kwds={}, callback=None):
        ''' Asynchronous equivalent of `apply()` builtin

        This version will call `callback` even if an exception is
        raised by `func`.

        '''
        assert self._state == RUN
        result = TornadoApplyResult(self._cache, callback)
        self._taskqueue.put(([(result._job, None, func, args, kwds)], None))
        return result

@gen.coroutine
def async_run(func, *args, **kwargs):
    """ Runs the given function in a subprocess.

    This wraps the given function in a gen.Task and runs it
    in a multiprocessing.Pool. It is meant to be used as a
    Tornado co-routine. Note that if func returns an Exception 
    (or an Exception sub-class), this function will raise the 
    Exception, rather than return it.

    """
    result = yield gen.Task(pool.apply_async, func, args, kwargs)
    if isinstance(result, Exception):
        raise result
    raise Return(result)

def handle_exceptions(func):
    """ Raise a WrapException so we get a more meaningful traceback"""
    @wraps(func)
    def inner(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception:
            raise WrapException()
    return inner

# Test worker functions
@handle_exceptions
def test2(x):
    raise Exception("eeee")

@handle_exceptions
def test(x):
    print x
    time.sleep(2)
    return "done"

class TestHandler(tornado.web.RequestHandler):
    @gen.coroutine
    def get(self):
        try:
            result = yield async_run(test, "inside get")
            self.write("%s\n" % result)
            result = yield async_run(test2, "hi2")
        except Exception as e:
            print("caught exception in get")
            self.write("Caught an exception: %s" % e)
        finally:
            self.finish()

app = tornado.web.Application([
    (r"/test", TestHandler),
])

if __name__ == "__main__":
    pool = TornadoPool(4)
    app.listen(8888)
    IOLoop.instance().start()

以下是客户的行为方式:

dan@dan:~$ curl localhost:8888/test
done
Caught an exception: 

Original traceback:
Traceback (most recent call last):
  File "./mutli.py", line 123, in inner
    return func(*args, **kwargs)
  File "./mutli.py", line 131, in test2
    raise Exception("eeee")
Exception: eeee

如果我同时发送两个curl请求,我们可以看到它们在服务器端异步处理:

dan@dan:~$ ./mutli.py 
inside get
inside get
caught exception inside get
caught exception inside get

修改

请注意,使用Python 3时,此代码变得更简单,因为它为所有异步error_callback方法引入了multiprocessing.Pool关键字参数。这使得与Tornado集成更加容易:

class TornadoPool(Pool):
    def apply_async(self, func, args=(), kwds={}, callback=None):
        ''' Asynchronous equivalent of `apply()` builtin

        This version will call `callback` even if an exception is
        raised by `func`.

        '''
        super().apply_async(func, args, kwds, callback=callback,
                            error_callback=callback)

@gen.coroutine
def async_run(func, *args, **kwargs):
    """ Runs the given function in a subprocess.

    This wraps the given function in a gen.Task and runs it
    in a multiprocessing.Pool. It is meant to be used as a
    Tornado co-routine. Note that if func returns an Exception
    (or an Exception sub-class), this function will raise the
    Exception, rather than return it.

    """
    result = yield gen.Task(pool.apply_async, func, args, kwargs)
    raise Return(result)

除了apply_async kwarg之外,我们在被覆盖的error_callback中需要做的就是使用callback关键字参数调用父级。无需覆盖ApplyResult

我们可以通过在TornadoPool中使用MetaClass来获得更好的功能,以允许直接调用其*_async方法,就像它们是协程一样:

import time
from functools import wraps
from multiprocessing.pool import Pool

import tornado.web
from tornado import gen
from tornado.gen import Return
from tornado import stack_context
from tornado.ioloop import IOLoop
from tornado.concurrent import Future

def _argument_adapter(callback):
    def wrapper(*args, **kwargs):
        if kwargs or len(args) > 1:
            callback(Arguments(args, kwargs))
        elif args:
            callback(args[0])
        else:
            callback(None)
    return wrapper

def PoolTask(func, *args, **kwargs):
    """ Task function for use with multiprocessing.Pool methods.

    This is very similar to tornado.gen.Task, except it sets the
    error_callback kwarg in addition to the callback kwarg. This
    way exceptions raised in pool worker methods get raised in the
    parent when the Task is yielded from.

    """
    future = Future()
    def handle_exception(typ, value, tb):
        if future.done():
            return False
        future.set_exc_info((typ, value, tb))
        return True
    def set_result(result):
        if future.done():
            return
        if isinstance(result, Exception):
            future.set_exception(result)
        else:
            future.set_result(result)
    with stack_context.ExceptionStackContext(handle_exception):
        cb = _argument_adapter(set_result)
        func(*args, callback=cb, error_callback=cb)
    return future

def coro_runner(func):
    """ Wraps the given func in a PoolTask and returns it. """
    @wraps(func)
    def wrapper(*args, **kwargs):
        return PoolTask(func, *args, **kwargs)
    return wrapper

class MetaPool(type):
    """ Wrap all *_async methods in Pool with coro_runner. """
    def __new__(cls, clsname, bases, dct):
        pdct = bases[0].__dict__
        for attr in pdct:
            if attr.endswith("async") and not attr.startswith('_'):
                setattr(bases[0], attr, coro_runner(pdct[attr]))
        return super().__new__(cls, clsname, bases, dct)

class TornadoPool(Pool, metaclass=MetaPool):
    pass

# Test worker functions
def test2(x):
    print("hi2")
    raise Exception("eeee")

def test(x):
    print(x)
    time.sleep(2)
    return "done"

class TestHandler(tornado.web.RequestHandler):
    @gen.coroutine
    def get(self):
        try:
            result = yield pool.apply_async(test, ("inside get",))
            self.write("%s\n" % result)
            result = yield pool.apply_async(test2, ("hi2",))
            self.write("%s\n" % result)
        except Exception as e:
            print("caught exception in get")
            self.write("Caught an exception: %s" % e)
            raise
        finally:
            self.finish()

app = tornado.web.Application([
    (r"/test", TestHandler),
])

if __name__ == "__main__":
    pool = TornadoPool()
    app.listen(8888)
    IOLoop.instance().start()

答案 2 :(得分:1)

如果您的获取请求花了那么长时间,那么龙卷风是错误的框架。

我建议您使用nginx将快速获取路由到龙卷风,将较慢的路由器路由到不同的服务器。

PeterBe有一篇有趣的文章,他运行多个Tornado服务器并将其中一个设置为处理长时间运行请求的“慢速”服务器,请参阅:worrying-about-io-blocking我会尝试这种方法。