Сообщить об ошибке.

Использование async и await в приложении на Flask

Маршруты, обработчики ошибок, функции до и после запроса, а также функции освобождения ресурсов могут быть функциями сопрограмм, если Flask установлен с дополнительным параметром async (pip install flask [async]). Для этого требуется Python 3.7+, где доступен contextvars.ContextVar. Это позволяет определять представления с помощью async def и использовать await.

Синтаксис:

@app.route("/get-data")
async def get_data():
    data = await async_db_query(...)
    return jsonify(data)

Подключаемые представления на основе классов также поддерживают обработчики, реализованные как сопрограммы. Это относится к методу dispatch_request() в представлениях, которые наследуются от класса flask.views.View, а также ко всем обработчикам методов HTTP в представлениях, которые наследуются от класса flask.views.MethodView.

Использование async в Windows на Python 3.8

В Windows в Python 3.8 есть ошибка, связанная с модулем asyncio. Если столкнетесь с чем-то вроде ValueError: set_wakeup_fd, то обновите Python до версии 3.9.

Содержание:

Производительность.

Для асинхронных функций требуется цикл событий. Фреймворк Flask, как приложение WSGI, использует одну задачу/воркер для обработки одного цикла запроса/ответа. Когда запрос поступает в асинхронное представление, то Flask запускает цикл обработки событий в отдельном потоке, запускает там функцию представление, а затем возвращает результат.

Каждый запрос по-прежнему связывает одну задачу, даже для асинхронных представлений. Положительным моментом является то, что асинхронный код можно запускать в самом представлении, например, для выполнения нескольких одновременных запросов к базе данных и/или HTTP-запросов к внешнему API и т. д. НО количество запросов, которые веб-приложение может обрабатывать одновременно, останется прежним.

Использование async def по своей сути не быстрее, чем синхронный код. Асинхронный режим полезен при выполнении параллельных задач, связанных с вводом-выводом, но, вероятно, не улучшит задачи, связанные с процессором. Традиционные представления Flask по-прежнему подходят для большинства случаев использования, но поддержка async в Flask позволяет писать и использовать код, что раньше было невозможно изначально.

Фоновые задачи.

Асинхронные функции будут выполняться в цикле событий до тех пор, пока они не завершатся, после чего этот цикл событий остановится. Это означает, что любые дополнительные порожденные задачи, которые не были выполнены после завершения асинхронной функции, будут отменены. Поэтому нельзя запускать фоновые задачи, например, через asyncio.create_task.

Если необходимо использовать фоновые задачи, то лучше всего использовать очередь задач для запуска фоновой работы, а не создавать задачи в функции-представлении. Принимая сказанное во внимание, можно запускать задачи asyncio, обслуживая Flask с сервером ASGI и используя адаптер WsgiToAsgi, как описано в ASGI. Так как адаптер создаст непрерывный цикл обработки событий, то это точно будет работать.

Например:

Адаптер WsgiToAsgi интегрируется с циклом событий, используемым для Flask. Что бы использовать адаптер, необходимо обернуть приложение Flask:

from asgiref.wsgi import WsgiToAsgi
from flask import Flask

app = Flask(__name__)

...

asgi_app = WsgiToAsgi(app)

а затем запускать приложение asgi_app с сервером asgi, например, с помощью Hypercorn.

$ hypercorn module:asgi_app

Расширения Flask и асинхронность.

Расширения Flask, написанные до поддержки async во Flask, не предполагают асинхронных представлений. Если они предоставляют декораторы для добавления функциональности к представлениям, то они не будут работать с асинхронными представлениями, потому что они не будут ожидать функцию или быть ожидаемыми. Другие функции, которые они предоставляют, также не будут ожидаемыми и, вероятно, будут блокироваться при вызове в асинхронном представлении.

Авторы расширений могут поддерживать асинхронные функции, используя метод flask.Flask.ensure_sync(). Например, если расширение предоставляет декоратор для функции-представления, то необходимо добавить app.ensure_sync() перед вызовом функции-декоратора.

Асинхронный фреймворк Quart.

Поддержка async в Flask менее эффективна, чем фреймворки, которые изначально разрабатывались как async-first из-за способа реализации асинхронности. Если у вас в основном асинхронная кодовая база, то имеет смысл рассмотреть использования модуля Quart. Фреймворк Quart - это точная реализация Flask, основанная на стандарте ASGI вместо WSGI. Такая реализация позволяет ему обрабатывать множество одновременных запросов, длительных запросов и веб-сокетов, не требуя нескольких процессов или потоков.

Также можно запустить приложение Flask, написанное по асинхронной схеме с gevent или eventlet, для достижения преимущества обработки асинхронных запросов. Эти библиотеки исправляют низкоуровневые функции Python для достижения цели асинхронной обработки. Решение о том, следует ли использовать Flask, Quart или что-то еще, зависит от понимания конкретных потребностей разрабатываемого веб-проекта.

Асинхронный запуск синхронного приложения Flask.

Хотя технически это не новая функция, НО многие разработчики не знают, что Flask можно развернуть с помощью асинхронных рабочих процессов. В частности, выполнение в цикле событий, который дает результат при вводе-выводе, подобно тому, как asyncio предоставляет стандартную библиотеку.

Использование асинхронного рабочего процесса по любому улучшит производительность, так как веб-приложения часто выполняют много параллельных операций ввода-вывода (ожидание подключения по сети, ожидание подключения и выполнения запроса к БД, ожидание чтения templantes с диска и т.д.). Для этих целей можно использовать асинхронные модули gevent или eventlet совместно с gunicorn.

Нашей команде нравится использовать асинхронные процессы gevent или eventlet в сочетании с несколькими процессами gunicorn. Другими словами, gunicorn обеспечивает многопроцессорность, а gevent/eventlet асинхронность на каждый процесс gunicorn.

Установим в виртуальное окружение, в котором стоит Flask пакет gunicorn с поддержкой gevent:

# активируем виртуальное окружение 
$ source .venv/bin/activate
# ставим `gunicorn` с поддержкой `gevent`
$ python3 -m pip install gunicorn[gevent]
# или с поддержкой `eventlet`
$ python3 -m pip install gunicorn[eventlet]

Затем, в качестве ГРУБОГО ЭКСПЕРИМЕНТА, можно запустить приложение (например, server.py) с несколькими рабочими процессами gunicorn и/или асинхронными рабочими процессами gevent:

# server.py
from flask import Flask
from time import sleep

app = Flask(__name__)

@app.route("/")
def hello():
    # замедляем ответ сервера, это грубая
    # эмуляция ожидания различных соединений.
    sleep(0.5)
    return "Hello World!"

if __name__ == "__main__":
    app.run()

ВАЖНО! В случае запуска приложения Flask через gunicorn + gevent/eventlet, количество асинхронных потоков на процесс подбираются индивидуально под конкретное приложение в зависимости от его "ТЯЖЕСТИ" и характеристик сервера. НЕ НАДО масштабировать количество воркеров до ожидаемого количества клиентов. Модулю gunicorn требуется всего 4–12 рабочих процессов для обработки сотен тысяч запросов в секунду. В противном случае, чрезмерное количество асинхронных воркеров могут легко положить выделенный сервер из за чрезмерной нагрузки на процессор при средней посещаемости ресурса (20 - 30 тыс. уникальных посетителя, включая роботов). И вообще, документация модуля gunicorn не советует запускать его через gevent или eventlet без какой либо необходимости (если приложение отвечает на запросы без задержек)

Выдержка из документации gunicorn:

Класс воркера sync, используемый по умолчанию, будет обрабатывать большинство обычных типов рабочих нагрузок. Доступные асинхронные рабочие процессы в gunicorn основаны на Greenlets (через модули eventlet и gevent). Greenlets - это реализация совместной многопоточности для Python. В общем, приложение должно иметь возможность использовать эти рабочие классы без каких-либо изменений. Например, для полной поддержки Greenlets может потребоваться адаптация приложений.

При использовании модулей, например, gevent и psycopg имеет смысл убедиться, что модуль psycogreen установлен и настроен. Другие приложения могут быть вообще несовместимы, поскольку они, например, полагаются на исходное непропатченное поведение.

То есть, если приложение запускается на асинхронной основе, то и все связанные с ним службы (например, база данных) должны поддерживать асинхронность.

Запуск Flask c Gunicorn: только многопроцессорность.

Запускаем gunicorn с двумя рабочими синхронными процессами (многопроцессорность). Будем считать эту схему эталонной.

$ gunicorn server:app -w 2
[39391] [INFO] Starting gunicorn 20.1.0
[39391] [INFO] Listening at: http://127.0.0.1:8000 (39391)
# Используется синхронный вариант ответа 
[39391] [INFO] Using worker: sync
# и 2 процесса приложения
[39392] [INFO] Booting worker with pid: 39392
[39394] [INFO] Booting worker with pid: 39394

Тестируем при помощи утилиты ab из пакета apache2-utils. Выставляем 50 запросов и 10 одновременных подключений.

$ ab -r -n 50 -c 10 http://127.0.0.1:8000/
# Server Software:        gunicorn
# Server Hostname:        127.0.0.1
# Server Port:            8000
# ...
# Time taken for tests:   13.070 seconds
# ...
# Requests per second:    3.83 [#/sec] (mean)
# Time per request:       2614.073 [ms] (mean)
# Time per request:       261.407 [ms] (mean, across all concurrent requests)
# ...

При работе приложения по схеме "Gunicorn с двумя рабочими процессами" получаем 3.83 запроса в секунду, длительность теста составила 13.070 секунды. Фиксируем расход памяти и нагрузку на процессор.

Запуск Flask c Gunicorn: процессы + gevent.

Запускаем gunicorn с двумя рабочими процессами, плюс 50 асинхронных gevent процессов на синхронный процесс gunicorn (50 * 2 = 100).

$ gunicorn server:app -w 2 -k gevent --worker-connections 100
[40739] [INFO] Starting gunicorn 20.1.0
[40739] [INFO] Listening at: http://127.0.0.1:8000 (40739)
# Теперь используется `gevent` 
[40739] [INFO] Using worker: gevent
[40740] [INFO] Booting worker with pid: 40740
[40741] [INFO] Booting worker with pid: 40741

Тестируем при помощи утилиты ab из пакета apache2-utils. Выставляем 50 запросов и 10 одновременных подключений.

$ ab -r -n 50 -c 10 http://127.0.0.1:8000/
# Server Software:        gunicorn
# Server Hostname:        127.0.0.1
# Server Port:            8000
# ...
# Time taken for tests:   3.034 seconds
# ...
# Requests per second:    16.48 [#/sec] (mean)
# Time per request:       606.827 [ms] (mean)
# Time per request:       60.683 [ms] (mean, across all concurrent requests)
# ...

При работе приложения по схеме "2 процесса gunicorn + 100 асинхронных gevent" получаем 16.48 запроса в секунду, длительность теста составила 3.034 секунды. При этом потребление оперативной памяти снизилось на 1/3 (меньше экземпляров приложений стоят в очереди на обработку), но возросла нагрузка на процессор из за того что процессы приложения стали обрабатываться без ожидающих операций.

Вывод: Схема "Gunicorn + gevent" увеличила производительность приложения в 5 раз + снижение потребления ОЗУ.

Дополнительно можно посмотреть материал "Разворачиваем Flask + Nginx + Gunicorn + Gevent на VDS".

Запуск Flask c Gunicorn: процессы + потоки.

И для чистоты эксперимента запустим gunicorn с двумя рабочими процессами, плюс 50 потоков на синхронный процесс gunicorn (50 * 2 = 100):

$ gunicorn server:app -w 2 --threads 100
[40739] [INFO] Starting gunicorn 20.1.0
[40739] [INFO] Listening at: http://127.0.0.1:8000 (40739)
# Теперь используется `gthread`
[40739] [INFO] Using worker: gthread
[40740] [INFO] Booting worker with pid: 40740
[40741] [INFO] Booting worker with pid: 40741

Тестируем при помощи утилиты ab из пакета apache2-utils. Условия тестирования не меняем.

$ ab -r -n 50 -c 10 http://127.0.0.1:8000/
# Server Software:        gunicorn
# Server Hostname:        127.0.0.1
# Server Port:            8000
# ...
# Time taken for tests:   3.079 seconds
# ...
# Requests per second:    16.24 [#/sec] (mean)
# Time per request:       615.857 [ms] (mean)
# Time per request:       61.586 [ms] (mean, across all concurrent requests)
# ...

При работе приложения по схеме "2 процесса gunicorn + 100 потоков" получаем 16.24 запроса в секунду, длительность теста составила 3.079 секунды (скорость обработки практически не изменилась). При этом потребление оперативной памяти незначительно выросло, но очень сильно возросла нагрузка на процессор (почти в 3 раза по сравнении с первой схемой запуска) из за переключений, вызванных GIL.

Многопоточную схему запуска практиковать не рекомендуют из за большого потребления ресурсов сервера, связанного с GIL.

Вот и все...