Creazione di un'attività asincrona in Flask


96

Sto scrivendo un'applicazione in Flask, che funziona molto bene tranne che WSGIè sincrona e bloccante. Ho un'attività in particolare che richiama un'API di terze parti e il completamento di tale attività può richiedere diversi minuti. Vorrei fare quella chiamata (in realtà è una serie di chiamate) e lasciarla funzionare. mentre il controllo viene restituito a Flask.

La mia vista è simile 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
    )

Ora, quello che voglio fare è avere la linea

final_file = audio_class.render_audio()

run e fornire un callback da eseguire quando il metodo ritorna, mentre Flask può continuare a elaborare le richieste. Questa è l'unica attività di cui ho bisogno per eseguire Flask in modo asincrono e vorrei qualche consiglio su come implementarla al meglio.

Ho esaminato Twisted e Klein, ma non sono sicuro che siano eccessivi, perché forse Threading sarebbe sufficiente. O forse il sedano è una buona scelta per questo?


Di solito uso il sedano per questo ... potrebbe essere eccessivo ma il threading afaik non funziona bene negli ambienti web (iirc ...)
Joran Beasley

Destra. Sì, stavo solo indagando su Celery. Potrebbe essere un buon approccio. Facile da implementare con Flask?
Darwin Tech

heh tendo a usare anche un server socket (flask-socketio) e sì, pensavo fosse abbastanza facile ... la parte più difficile è stata installare tutto
Joran Beasley

4
Mi consiglia di controllare questo fuori. Questo ragazzo scrive ottimi tutorial per flask in generale, e questo è ottimo per capire come integrare attività asincrone in un'app flask.
atlspin

Risposte:


100

Vorrei utilizzare Celery per gestire l'attività asincrona per te. Dovrai installare un broker che funga da coda delle attività (si consigliano RabbitMQ e 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
    )

Esegui la tua app Flask e avvia un altro processo per eseguire il tuo lavoratore sedano.

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

Vorrei anche fare riferimento al di Miguel Gringberg write up per una più approfondita guida all'utilizzo di sedano con la boccetta.


34

La filettatura è un'altra possibile soluzione. Sebbene la soluzione basata su Celery sia migliore per le applicazioni su larga scala, se non ti aspetti troppo traffico sull'endpoint in questione, il threading è una valida alternativa.

Questa soluzione si basa sulla presentazione PyCon 2016 Flask at Scale di Miguel Grinberg , in particolare sulla diapositiva 41 nel suo mazzo di diapositive. Il suo codice è disponibile anche su GitHub per chi è interessato alla fonte originale.

Dal punto di vista dell'utente, il codice funziona come segue:

  1. Si effettua una chiamata all'endpoint che esegue l'attività a lunga esecuzione.
  2. Questo endpoint restituisce 202 Accettato con un collegamento per controllare lo stato dell'attività.
  3. Le chiamate al collegamento di stato restituiscono 202 mentre i taks sono ancora in esecuzione e restituiscono 200 (e il risultato) quando l'attività è completata.

Per convertire una chiamata API in un'attività in background, aggiungi semplicemente il decoratore @async_api.

Ecco un esempio 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)

Quando utilizzo questo codice, ricevo l'errore werkzeug.routing.BuildError: Impossibile creare l'URL per l'endpoint "gettaskstatus" con valori ["task_id"] Mi manca qualcosa?
Nicolas Dufaur

10

Puoi anche provare a usare multiprocessing.Processcon daemon=True; ilprocess.start() metodo non si blocca e puoi restituire immediatamente una risposta / stato al chiamante mentre la tua costosa funzione viene eseguita in background.

Ho riscontrato un problema simile mentre lavoravo con Falcon Framework e utilizzodaemon processo ha aiutato.

Dovresti fare quanto segue:

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

Dovresti ricevere immediatamente una risposta e, dopo 10 secondi, dovresti vedere un messaggio stampato nella console.

NOTA: tenere presente che ai daemonicprocessi non è consentito generare processi figlio.


asincrono è un certo tipo di concorrenza che non è né threading né multiprocessing. Il threading è tuttavia molto più vicino allo scopo come attività asincrona,
tortale

3
Non capisco il tuo punto. L'autore sta parlando di un'attività asincrona, che è l'attività che viene eseguita "in background", in modo tale che il chiamante non si blocchi finché non riceve una risposta. La generazione di un processo demone è un esempio di dove è possibile ottenere tale asincronismo.
Tomasz Bartkowiak,

cosa succede se l' /render/<id>endpoint si aspetta qualcosa come risultato my_func()?
Will Gu il

Ad esempio, puoi my_funcinviare risposta / heartbeat a qualche altro endpoint. Oppure puoi stabilire e condividere una coda di messaggi attraverso la quale puoi comunicare conmy_func
Tomasz Bartkowiak
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.