web-dev-qa-db-fra.com

Comment effectuer au mieux le Multiprocessing dans les requêtes avec le serveur python Tornado?

J'utilise le serveur non-bloquant d'E/S python serveur Tornado. J'ai une classe de demandes GET qui peuvent prendre un temps considérable à compléter (pensez dans la plage Le problème est que Tornado bloque ces requêtes afin que les requêtes rapides suivantes soient bloquées jusqu'à la fin de la requête lente.

J'ai regardé: https://github.com/facebook/tornado/wiki/Threading-and-concurrency et je suis arrivé à la conclusion que je voulais une combinaison de # 3 (autres processus) et # 4 (autres fils). Le # 4 seul a eu des problèmes et je n'ai pas pu retrouver un contrôle fiable sur l'ioloop quand il y avait un autre thread faisant le "heavy_lifting". (Je suppose que cela était dû au GIL et au fait que la tâche heavy_lifting a une charge CPU élevée et continue à éloigner le contrôle de l'ioloop principal, mais c'est une supposition).

J'ai donc fait un prototype de la façon de résoudre ce problème en effectuant des tâches de "levage de charges lourdes" au sein de ces requêtes lentes GET dans un processus distinct, puis en remettant un rappel dans l'ioloop Tornado lorsque le processus est terminé pour terminer la requête. Cela libère l'ioloop pour gérer d'autres demandes.

J'ai créé un exemple simple démontrant une solution possible, mais je suis curieux d'obtenir les commentaires de la communauté à ce sujet.

Ma question est double: comment simplifier cette approche actuelle? Quels pièges existent potentiellement avec elle?

L'approche

  1. Utilisez le décorateur asynchronous intégré à Tornado qui permet à une demande de rester ouverte et à l'ioloop de continuer.

  2. Générez un processus distinct pour les tâches de "levage de charges lourdes" à l'aide du module multiprocessing de python. J'ai d'abord essayé d'utiliser le module threading mais je n'ai pas pu obtenir de retour fiable de contrôle vers l'ioloop. Il semble également que mutliprocessing profiterait également des multicœurs.

  3. Démarrez un thread 'observateur' dans le processus ioloop principal en utilisant le module threading à qui il revient de surveiller un multiprocessing.Queue Pour les résultats de la tâche de "levage de charges lourdes" à la fin. Cela était nécessaire car j'avais besoin d'un moyen de savoir que la tâche heavy_lifting s'était terminée tout en étant en mesure d'aviser l'ioloop que cette demande était maintenant terminée.

  4. Assurez-vous que le thread "observateur" abandonne souvent le contrôle de la boucle ioloop principale avec des appels time.sleep(0) afin que les autres requêtes continuent d'être traitées facilement.

  5. Lorsqu'il y a un résultat dans la file d'attente, ajoutez un rappel à partir du thread "observateur" en utilisant tornado.ioloop.IOLoop.instance().add_callback() qui est documenté comme étant le seul moyen sûr d'appeler des instances ioloop à partir d'autres threads.

  6. Assurez-vous ensuite d'appeler finish() dans le rappel pour terminer la demande et remettre une réponse.

Voici un exemple de code illustrant cette approche. multi_tornado.py Est le serveur implémentant le plan ci-dessus et call_multi.py Est un exemple de script qui appelle le serveur de deux manières différentes pour tester le serveur. Les deux tests appellent le serveur avec 3 requêtes lentes GET suivies de 20 requêtes rapides GET. Les résultats sont affichés pour l'exécution avec et sans le filetage activé.

Dans le cas de l'exécuter sans "threading", le bloc de 3 requêtes lentes (chacune prenant un peu plus d'une seconde pour terminer). Quelques-unes des 20 requêtes rapides se faufilent entre certaines des requêtes lentes au sein de l'ioloop (je ne sais pas vraiment comment cela se produit - mais cela pourrait être un artefact que j'exécute à la fois le script de test du serveur et du client sur la même machine). Le point ici étant que toutes les demandes rapides sont bloquées à des degrés divers.

Dans le cas de son exécution avec le threading, les 20 requêtes rapides sont toutes terminées en premier immédiatement et les trois requêtes lentes se terminent à peu près en même temps par la suite, car elles ont chacune été exécutées en parallèle. C'est le comportement souhaité. Les trois requêtes lentes prennent 2,5 secondes pour se terminer en parallèle - alors que dans le cas non threadé, les trois requêtes lentes prennent environ 3,5 secondes au total. Il y a donc environ 35% d'accélération globale (je suppose en raison du partage multicœur). Mais plus important encore - les demandes rapides ont été immédiatement traitées dans le cas des demandes lentes.

Je n'ai pas beaucoup d'expérience avec la programmation multithread - donc bien que cela fonctionne apparemment ici, je suis curieux d'apprendre:

Existe-t-il un moyen plus simple d'accomplir cela? Quels monstres peuvent se cacher dans cette approche?

(Remarque: Un compromis futur pourrait être d'exécuter simplement plus d'instances de Tornado avec un proxy inverse comme nginx effectuant l'équilibrage de charge. Peu importe ce que je vais exécuter plusieurs instances avec un équilibreur de charge - mais je suis préoccupé par le simple fait de lancer du matériel à ce problème car il semble que le matériel soit si directement lié au problème en termes de blocage.)

Exemple de code

multi_tornado.py (exemple de serveur):

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 (testeur client):

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)

Résultats de test

En exécutant python call_multi.py slow (Le comportement de blocage):

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

En exécutant python call_multi.py slow_threaded (Le comportement souhaité):

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

Si vous êtes prêt à utiliser concurrent.futures.ProcessPoolExecutor au lieu de multiprocessing, c'est en fait très simple. L'ioloop de Tornado prend déjà en charge concurrent.futures.Future, donc ils joueront bien ensemble hors de la boîte. concurrent.futures est inclus dans Python 3.2+, et a été rétroporté vers Python 2.x .

Voici un exemple:

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)

Production:

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

ProcessPoolExecutor possède une API plus limitée que multiprocessing.Pool, mais si vous n'avez pas besoin des fonctionnalités plus avancées de multiprocessing.Pool, ça vaut le coup car l'intégration est tellement plus simple.

31
dano

multiprocessing.Pool Peut être intégré dans la boucle d'E/S tornado, mais c'est un peu compliqué. Une intégration beaucoup plus propre peut être effectuée en utilisant concurrent.futures (Voir mon autre réponse pour plus de détails), mais si vous êtes bloqué sur Python 2.x et ne peut pas installer le backport concurrent.futures, voici comment vous pouvez le faire strictement en utilisant multiprocessing:

Les méthodes multiprocessing.Pool.apply_async Et multiprocessing.Pool.map_async Ont toutes deux un paramètre callback facultatif, ce qui signifie que les deux peuvent potentiellement être connectées à un tornado.gen.Task. Donc, dans la plupart des cas, l'exécution de code de manière asynchrone dans un sous-processus est aussi simple que cela:

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)

Comme je l'ai mentionné, cela fonctionne bien dans les cas la plupart. Mais si worker() lève une exception, callback n'est jamais appelé, ce qui signifie que gen.Task Ne se termine jamais, et vous vous bloquez pour toujours. Maintenant, si vous savez que votre travail jamais lèvera une exception (parce que vous avez enveloppé le tout dans un try/except, par exemple), vous peut volontiers utiliser cette approche. Cependant, si vous souhaitez laisser des exceptions s'échapper de votre travailleur, la seule solution que j'ai trouvée a été de sous-classer certains composants de multitraitement et de les faire appeler callback même si le sous-processus de travail a déclenché une exception:

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())
     ...

Avec ces modifications, l'objet d'exception sera renvoyé par le gen.Task, Plutôt que par le gen.Task Suspendu indéfiniment. J'ai également mis à jour ma méthode async_run Pour relancer l'exception lorsqu'elle est retournée et j'ai apporté d'autres modifications pour fournir de meilleurs retraits pour les exceptions levées dans les sous-processus de travail. Voici le code complet:

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()

Voici comment cela se comporte pour le client:

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

Et si j'envoie deux requêtes curl simultanées, nous pouvons voir qu'elles sont gérées de manière asynchrone côté serveur:

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

Modifier:

Notez que ce code devient plus simple avec Python 3, car il introduit un argument de mot clé error_callback Dans toutes les méthodes asynchrones multiprocessing.Pool. Cela facilite son intégration avec Tornade:

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)

Tout ce que nous devons faire dans notre apply_async Surchargé est d'appeler le parent avec l'argument de mot clé error_callback, En plus du callback kwarg. Pas besoin de remplacer ApplyResult.

Nous pouvons devenir encore plus sophistiqués en utilisant une MetaClass dans nos TornadoPool, pour permettre à ses méthodes *_async D'être appelées directement comme si elles étaient des coroutines:

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

Si vos demandes d'obtention prennent autant de temps, la tornade n'est pas le bon cadre.

Je vous suggère d'utiliser nginx pour acheminer les accès rapides à la tornade et les plus lents vers un autre serveur.

PeterBe a un article intéressant où il exécute plusieurs serveurs Tornado et définit l'un d'entre eux comme "le plus lent" pour gérer les requêtes de longue durée voir: souci-sur-io-blocking J'essaierais cette méthode .

1
andy boot