web-dev-qa-db-ja.com

python Tornadoサーバーを使用してリクエスト内でマルチプロセッシングを最適に実行する方法は?

I/Oノンブロッキングpython server Tornadoを使用しています。完了までにかなりの時間がかかるGETリクエストのクラスがあります(範囲内で考えてください)問題は、Tornadoがこれらの要求をブロックするため、遅い要求が完了するまで後続の高速要求が保留されることです。

https://github.com/facebook/tornado/wiki/Threading-and-concurrency を見て、#3(他のプロセス)と#4の組み合わせが必要であるという結論に達しました(他のスレッド)。 #4自体に問題があり、「heavy_lifting」を実行している別のスレッドがあると、ioloopに信頼できる制御を戻すことができませんでした。 (これはGILと、heavy_liftingタスクのCPU負荷が高く、メインioloopから制御を引き離し続けているためだと思いますが、それは推測です)。

そのため、これらの遅いGETリクエスト内で「重いリフティング」タスクを別のプロセスで実行し、プロセスが完了してリクエストを完了するとコールバックをTornado ioloopに戻すことで、これを解決する方法のプロトタイプを作成しました。これにより、ioloopが解放され、他の要求を処理できます。

考えられる解決策を示す簡単な例を作成しましたが、コミュニティからフィードバックを得たいと思っています。

私の質問は2つあります:この現在のアプローチをどのように簡素化できますか?どのような落とし穴が潜在的に存在しますか?

アプローチ

  1. Tornadoのビルトインasynchronousデコレーターを使用すると、リクエストを開いたままにして、ioloopを続行できます。

  2. Pythonのmultiprocessingモジュールを使用して、「重いリフティング」タスク用に別のプロセスを作成します。最初にthreadingモジュールを使用しようとしましたが、ioloopに制御を確実に放棄することはできませんでした。 mutliprocessingもマルチコアを利用するようです。

  3. threadingモジュールを使用して、メインのioloopプロセスで「ウォッチャー」スレッドを開始します。これは、完了時に「重いリフティング」タスクの結果について_multiprocessing.Queue_を監視することです。これが必要だったのは、heavy_liftingタスクが完了したことを知りながら、この要求が終了したことをioloopに通知できるためです。

  4. 「ウォッチャー」スレッドがtime.sleep(0)呼び出しで頻繁に制御をメインioloopループに放棄するようにして、他のリクエストが引き続き容易に処理されるようにしてください。

  5. キューに結果がある場合、tornado.ioloop.IOLoop.instance().add_callback()を使用して「ウォッチャー」スレッドからコールバックを追加します。これは、他のスレッドからioloopインスタンスを呼び出す唯一の安全な方法として文書化されています。

  6. コールバックでfinish()を呼び出して、リクエストを完了し、応答を渡すようにしてください。

以下は、このアプローチを示すサンプルコードです。 _multi_tornado.py_は上記の概要を実装するサーバーであり、_call_multi.py_はサーバーをテストする2つの異なる方法でサーバーを呼び出すサンプルスクリプトです。両方のテストは、3つの遅いGET要求に続いて20の速いGET要求でサーバーを呼び出します。結果は、スレッドをオンにして実行した場合としない場合の両方で表示されます。

「スレッドなし」で実行する場合、3つの低速な要求ブロック(それぞれ完了までに1秒以上かかります)がブロックされます。 20の高速リクエストのうちのいくつかは、ioloop内のいくつかの低速リクエストの間に押し込まれます(その発生方法は完全にはわかりませんが、同じマシンでサーバーとクライアントの両方のテストスクリプトを実行しているアーティファクトである可能性があります)。ここでのポイントは、すべての高速リクエストがさまざまな程度まで保持されることです。

スレッドを有効にして実行する場合、20個の高速リクエストが最初にすべて完了し、3つの低速リクエストはそれぞれが並行して実行されるのとほぼ同時に完了します。これは望ましい動作です。 3つの遅いリクエストは、並行して完了するのに2.5秒かかりますが、非スレッドの場合、3つの遅いリクエストは合計で約3.5秒かかります。したがって、全体で約35%の速度が向上します(マルチコア共有によるものと思われます)。しかし、もっと重要なのは、高速な要求は、低速な要求の代わりにすぐに処理されたことです。

私はマルチスレッドプログラミングの経験があまりないので、ここでは一見うまく機能しているように見えますが、ここで学ぶことに興味があります。

これを達成する簡単な方法はありますか?このアプローチではどのモンスターが潜む可能性がありますか?

(注:将来のトレードオフは、ロードバランシングを行うnginxのようなリバースプロキシを使用してTornadoのインスタンスをさらに実行することです。ロードバランサーで複数のインスタンスを実行する場合でも、この問題でハードウェアを投げるハードウェアがブロッキングの点で問題に直接結合しているように見えるため)

サンプルコード

_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 __== "__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 __== '__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
_
46
Rocketman

multiprocessingの代わりに concurrent.futures.ProcessPoolExecutor を使用する場合、これは実際には非常に簡単です。 Tornadoのioloopはすでにconcurrent.futures.Futureをサポートしているので、すぐに一緒にプレイできます。 concurrent.futuresはPython 3.2+に含まれています。 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にはmultiprocessing.Poolよりも制限されたAPIがありますが、multiprocessing.Poolのより高度な機能が必要ない場合は、統合が非常に簡単なので使用する価値があります。

31
dano

_multiprocessing.Pool_はtornado I/Oループに統合できますが、少し面倒です。 _concurrent.futures_(詳細については 私のその他の答え を参照)を使用して、よりクリーンな統合を行うことができますが、Python 2.xおよび_concurrent.futures_バックポートをインストールできません。以下は、multiprocessingを使用して厳密に実行する方法です。

_multiprocessing.Pool.apply_async_メソッドと_multiprocessing.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 __== "__main__":
    pool = multiprocessing.Pool(multiprocessing.cpu_count())
    func = partial(async_run, worker)
    IOLoop().run_sync(func)
_

前述したように、これはmostの場合にうまく機能します。ただし、worker()が例外をスローした場合、callbackが呼び出されることはありません。つまり、_gen.Task_が終了せず、永遠にハングアップします。今、あなたの仕事がneverが例外をスローすることがわかっている場合(例えば、全体をtry/exceptでラップしたため) )、このアプローチを喜んで使用できます。ただし、ワーカーから例外をエスケープしたい場合、私が見つけた唯一の解決策は、いくつかのマルチプロセッシングコンポーネントをサブクラス化し、ワーカーサブプロセスが例外を発生させた場合でもcallbackを呼び出すようにすることでした:

_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 __== "__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 __== "__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
_

また、2つのcurl要求を同時に送信すると、サーバー側で非同期に処理されていることがわかります。

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

編集:

このコードは、すべての非同期_error_callback_メソッドに_multiprocessing.Pool_キーワード引数を導入するため、Python 3で簡単になります。これにより、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_で行う必要があるのは、callback kwargに加えて、_error_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 __== "__main__":
    pool = TornadoPool()
    app.listen(8888)
    IOLoop.instance().start()
_
16
dano

Getリクエストに時間がかかっている場合、竜巻は間違ったフレームワークです。

Nginxを使用して、高速取得を竜巻に、低速取得を別のサーバーにルーティングすることをお勧めします。

PeterBeには興味深い記事があり、複数のTornadoサーバーを実行し、そのうちの1つを長時間実行される要求を処理するための「遅いサーバー」に設定しています。 worrying-about-io-blocking

1
andy boot