Hacer una tarea asincrónica en Flask


96

Estoy escribiendo una aplicación en Flask, que funciona muy bien, excepto que WSGIes sincrónica y de bloqueo. Tengo una tarea en particular que llama a una API de terceros y esa tarea puede tardar varios minutos en completarse. Me gustaría hacer esa llamada (en realidad es una serie de llamadas) y dejar que se ejecute. mientras que el control se devuelve a Flask.

Mi vista se parece a:

@app.route('/render/<id>', methods=['POST'])
def render_script(id=None):
    ...
    data = json.loads(request.data)
    text_list = data.get('text_list')
    final_file = audio_class.render_audio(data=text_list)
    # do stuff
    return Response(
        mimetype='application/json',
        status=200
    )

Ahora, lo que quiero hacer es tener la línea

final_file = audio_class.render_audio()

ejecutar y proporcionar una devolución de llamada para que se ejecute cuando el método regrese, mientras que Flask puede continuar procesando solicitudes. Esta es la única tarea que necesito que Flask se ejecute de forma asincrónica, y me gustaría recibir algunos consejos sobre la mejor manera de implementar esto.

He mirado a Twisted y Klein, pero no estoy seguro de que sean exagerados, ya que tal vez Threading sería suficiente. ¿O tal vez el apio es una buena opción para esto?


Normalmente uso apio para esto ... podría ser excesivo, pero el enhebrado afaik no funciona bien en entornos web (iirc ...)
Joran Beasley

Correcto. Sí, solo estaba investigando a Celery. Podría ser un buen enfoque. ¿Fácil de implementar con Flask?
Darwin Tech

eh, también suelo usar un servidor de socket (flask-socketio) y sí, pensé que era bastante fácil ... la parte más difícil fue instalar todo
Joran Beasley

4
Recomendaría revisar esto . Este tipo escribe excelentes tutoriales para matraces en general, y este es excelente para comprender cómo integrar tareas asincrónicas en una aplicación de matraces.
atlspin

Respuestas:


100

Me gustaría utilizar apio para manejar la tarea asíncrona para usted. Deberá instalar un agente para que sirva como cola de tareas (se recomiendan RabbitMQ y Redis).

app.py:

from flask import Flask
from celery import Celery

broker_url = 'amqp://guest@localhost'          # Broker URL for RabbitMQ task queue

app = Flask(__name__)    
celery = Celery(app.name, broker=broker_url)
celery.config_from_object('celeryconfig')      # Your celery configurations in a celeryconfig.py

@celery.task(bind=True)
def some_long_task(self, x, y):
    # Do some long task
    ...

@app.route('/render/<id>', methods=['POST'])
def render_script(id=None):
    ...
    data = json.loads(request.data)
    text_list = data.get('text_list')
    final_file = audio_class.render_audio(data=text_list)
    some_long_task.delay(x, y)                 # Call your async task and pass whatever necessary variables
    return Response(
        mimetype='application/json',
        status=200
    )

Ejecute su aplicación Flask e inicie otro proceso para ejecutar su trabajador de apio.

$ celery worker -A app.celery --loglevel=debug

También me referiría a Miguel Gringberg escritura hasta una más en la guía de profundidad a la utilización de apio con el frasco.


34

El enhebrado es otra posible solución. Aunque la solución basada en apio es mejor para aplicaciones a escala, si no espera demasiado tráfico en el punto final en cuestión, el subproceso es una alternativa viable.

Esta solución se basa en la presentación PyCon 2016 Flask at Scale de Miguel Grinberg , específicamente en la diapositiva 41 de su plataforma de diapositivas. Su código también está disponible en github para aquellos interesados ​​en la fuente original.

Desde la perspectiva del usuario, el código funciona de la siguiente manera:

  1. Realiza una llamada al punto final que realiza la tarea de larga duración.
  2. Este punto final devuelve 202 Aceptado con un enlace para verificar el estado de la tarea.
  3. Las llamadas al enlace de estado devuelven 202 mientras la tarea aún se está ejecutando y devuelve 200 (y el resultado) cuando se completa la tarea.

Para convertir una llamada a la API en una tarea en segundo plano, simplemente agregue el decorador @async_api.

Aquí hay un ejemplo completo:

from flask import Flask, g, abort, current_app, request, url_for
from werkzeug.exceptions import HTTPException, InternalServerError
from flask_restful import Resource, Api
from datetime import datetime
from functools import wraps
import threading
import time
import uuid

tasks = {}

app = Flask(__name__)
api = Api(app)


@app.before_first_request
def before_first_request():
    """Start a background thread that cleans up old tasks."""
    def clean_old_tasks():
        """
        This function cleans up old tasks from our in-memory data structure.
        """
        global tasks
        while True:
            # Only keep tasks that are running or that finished less than 5
            # minutes ago.
            five_min_ago = datetime.timestamp(datetime.utcnow()) - 5 * 60
            tasks = {task_id: task for task_id, task in tasks.items()
                     if 'completion_timestamp' not in task or task['completion_timestamp'] > five_min_ago}
            time.sleep(60)

    if not current_app.config['TESTING']:
        thread = threading.Thread(target=clean_old_tasks)
        thread.start()


def async_api(wrapped_function):
    @wraps(wrapped_function)
    def new_function(*args, **kwargs):
        def task_call(flask_app, environ):
            # Create a request context similar to that of the original request
            # so that the task can have access to flask.g, flask.request, etc.
            with flask_app.request_context(environ):
                try:
                    tasks[task_id]['return_value'] = wrapped_function(*args, **kwargs)
                except HTTPException as e:
                    tasks[task_id]['return_value'] = current_app.handle_http_exception(e)
                except Exception as e:
                    # The function raised an exception, so we set a 500 error
                    tasks[task_id]['return_value'] = InternalServerError()
                    if current_app.debug:
                        # We want to find out if something happened so reraise
                        raise
                finally:
                    # We record the time of the response, to help in garbage
                    # collecting old tasks
                    tasks[task_id]['completion_timestamp'] = datetime.timestamp(datetime.utcnow())

                    # close the database session (if any)

        # Assign an id to the asynchronous task
        task_id = uuid.uuid4().hex

        # Record the task, and then launch it
        tasks[task_id] = {'task_thread': threading.Thread(
            target=task_call, args=(current_app._get_current_object(),
                               request.environ))}
        tasks[task_id]['task_thread'].start()

        # Return a 202 response, with a link that the client can use to
        # obtain task status
        print(url_for('gettaskstatus', task_id=task_id))
        return 'accepted', 202, {'Location': url_for('gettaskstatus', task_id=task_id)}
    return new_function


class GetTaskStatus(Resource):
    def get(self, task_id):
        """
        Return status about an asynchronous task. If this request returns a 202
        status code, it means that task hasn't finished yet. Else, the response
        from the task is returned.
        """
        task = tasks.get(task_id)
        if task is None:
            abort(404)
        if 'return_value' not in task:
            return '', 202, {'Location': url_for('gettaskstatus', task_id=task_id)}
        return task['return_value']


class CatchAll(Resource):
    @async_api
    def get(self, path=''):
        # perform some intensive processing
        print("starting processing task, path: '%s'" % path)
        time.sleep(10)
        print("completed processing task, path: '%s'" % path)
        return f'The answer is: {path}'


api.add_resource(CatchAll, '/<path:path>', '/')
api.add_resource(GetTaskStatus, '/status/<task_id>')


if __name__ == '__main__':
    app.run(debug=True)

Cuando uso este código, aparece el error werkzeug.routing.BuildError: No se pudo construir la URL para el punto final 'gettaskstatus' con valores ['task_id'] ¿Me estoy perdiendo algo?
Nicolas Dufaur

10

También puede intentar usar multiprocessing.Processcon daemon=True; el process.start()método no se bloquea y puede devolver una respuesta / estado inmediatamente a la persona que llama mientras su costosa función se ejecuta en segundo plano.

Experimenté un problema similar mientras trabajaba con el marco de Falcon y el daemonproceso me ayudó.

Debería hacer lo siguiente:

from multiprocessing import Process

@app.route('/render/<id>', methods=['POST'])
def render_script(id=None):
    ...
    heavy_process = Process(  # Create a daemonic process with heavy "my_func"
        target=my_func,
        daemon=True
    )
    heavy_process.start()
    return Response(
        mimetype='application/json',
        status=200
    )

# Define some heavy function
def my_func():
    time.sleep(10)
    print("Process finished")

Debería obtener una respuesta de inmediato y, después de 10 segundos, debería ver un mensaje impreso en la consola.

NOTA: Tenga en cuenta que los daemonicprocesos no pueden generar procesos secundarios.


asincrónico es un cierto tipo de concurrencia que no es ni subprocesamiento ni multiprocesamiento. Threading es, sin embargo, mucho más cerca de propósito como tarea asíncrona,
Tortal

3
No entiendo tu punto. El autor habla de una tarea asincrónica, que es la tarea que se ejecuta "en segundo plano", de modo que la persona que llama no se bloquea hasta que obtiene una respuesta. Generar un proceso demoníaco es un ejemplo de dónde se puede lograr tal sincronismo.
Tomasz Bartkowiak

¿Qué /render/<id>pasa si endpoint espera algo como resultado de my_func()?
Will Gu

Puede my_funcenviar respuesta / latidos a algún otro punto final, por ejemplo. O puede establecer y compartir una cola de mensajes a través de la cual puede comunicarse conmy_func
Tomasz Bartkowiak
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.