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?
Utilisez le décorateur asynchronous
intégré à Tornado qui permet à une demande de rester ouverte et à l'ioloop de continuer.
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.
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.
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.
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.
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.)
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)
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
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.
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()
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 .