Запускаем фоновые задачи в асинхронном web-приложении на Python3

newbie

Опубликован:  2020-07-07T06:31:25.169162Z
600

В этом обзоре поговорим о так называемых фоновых задачах, в английской интерпретации Background Tasks, и рассмотрим небольшой пример реализации двух фоновых задач в одном маленьком и уютном web приложении, построенном на базе асинхронного фреймворка Starlette. Я покажу начальную стадию конфигурации web-приложения, разработаю обрабатывающую запросы клиентов функцию представления — будущую главную страницу сайта, и в контексте запроса этой страницы запущу две отличающиеся друг от друга по своей природе фоновые задачи, затем запущу web-сервер, и покажу как всё это великолепие работает вместе.

Для этой простой демонстрации в моём распоряжении есть великолепный десктоп Debian bullseye — это тестовая ветка свободной операционной системы Debian, который располагает всеми необходимыми средствами и инструментами для реализации задуманного.

Писать код для этой демонстрации я буду в текстовом редакторе Kate, запускаю его, открываю встроенную консоль, создаю для своего проекта отдельную директорию и вхожу в неё.

yLBySFWpPq.png

Web-приложение обычно состоит из группы файлов, а каталог, в котором эта группа файлов хранится, обычно называют базовым каталогом приложения.

Для реализации задуманного мне понадобится web-фреймворк, в рамках этого обзора я буду использовать совершенно фантастически великолепный фреймворк Starlette, и его нужно установить. Создаю в базовом каталоге новое виртуальное окружение.

python3 -m venv venv

Ryyjf0Uu09.png

Как видно на снимке экрана выше, после выполнения команды в моём базовом каталоге появился каталог только что созданного виртуального окружения. Активирую это виртуальное окружение в текущей консоли.

source venv/bin/activate

F7pfoOSxSR.png

Следует обратить внимание, как изменилось приглашение командной строки, теперь префикс указывает на имя активного виртуального окружения. Замечательно, теперь я установлю в это виртуальное окружение все необходимые моему web-приложению компоненты. Для начала обновляю само виртуальное окружение.

pip install --upgrade wheel

wD603HYKUT.png

Устанавливаю Starlette и uvicorn.

pip install starlette uvicorn

RL6ID8AfLm.png

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

DEBUG=True

F4dn238b54.png

На текущем этапе разработки этого вполне достаточно. Создаю файл, в котором будет храниться код приложения, дам ему имя selfish.py. Открываю этот файл в текстовом редакторе и пишу следующий код.

import os

from datetime import datetime

from starlette.applications import Starlette
from starlette.config import Config
from starlette.responses import HTMLResponse
from starlette.routing import Route

base = os.path.dirname(__file__)
settings = Config(os.path.join(base, '.env'))


async def show_index(request):
    print(f'Response sent at {datetime.now()}')
    return HTMLResponse('<p>Hello, World!</p>')


app = Starlette(
    debug=settings.get('DEBUG', cast=bool),
    routes=[Route('/', show_index, name='index')])
app.config = settings

Вот так это выглядит в моём текстовом редакторе.

M7Yb38Y7jp.png

Здесь следует обратить внимание, что корутина show_index является так называемой функцией представления для главной страницы сайта, что соответствующим образом отражено в параметре route созданного экземпляра класса Starlette с именем app. Корутина show_index обрабатывает поступивший от клиента на заданный url-адрес HTTP-запрос, печатает на терминал дату и время, когда этот запрос обработан, и отдаёт клиенту HTTP-ответ заданной формы.

Чтобы запустить это приложение, мне потребуется асинхронный web-сервер uvicorn, который уже установлен в виртуальное окружение. Создаю в базовом каталоге приложения ещё один файл - runserver.py, и пишу в него следующий код.

import uvicorn

if __name__ == '__main__':
    uvicorn.run(
      'selfish:app', host='127.0.0.1',
      reload=True, port=5000, log_level='info')

W2EvVDMPhw.png

Перемещаюсь в окно встроенного в редактор терминала и запускаю приложение.

python runserver.py

Запускаю web-браузер и стучусь по адресу из первой строчки выхлопа в терминале.

tq7qypk0Es.png

Здесь следует обратить внимание на выхлоп в терминал, как видно на снимке экрана, при обработке запроса сервер вывел на экрана строку в полном соответствии с заданной в корутине show_index процедурой. Отлично, в своей минимальной конфигурации приложение заработало.

Теперь перейдём собственно к основной цели этой демонстрации — фоновым задачам. Для чего они нужны и когда используются? Вопрос хороший… Главная задача web-сервера — это обработка поступающих от клиентов HTTP-запросов и отправка клиентам созданных на основе этой обработки HTTP-ответов. При этом самое важное значение имеет длительность промежутка времени между поступившим запросом и отправленным сервером ответом, чем короче этот промежуток времени, тем более благоприятно воспринимается пользователями сайт, на котором наше web-приложение будет работать. Очень часто в этой жизни случается так, что при обработке поступившего на конкретный url-адрес HTTP-запроса web-сервер должен выполнить слишком много возложенных на него задач. Например: отослать пользователю сообщение по электронной почте, удалить с сервера альбом с хранящимися в нём фотографиями — всего 2817 штук, отформатировать текст, содержащий встроенный код с других сайтов, для чего эти сайты нужно посетить и получить с них этот код, и так далее. А что будет делать пользователь, пока сервер будет выполнять все эти задачи? Правильно, он уйдёт на другой сайт, потому что устанет ждать ответ от сервера, или заснёт на рабочем месте, в зависимости от темперамента пользователя. Так вот, чтобы такой каверзы в этой жизни не произошло, процедуры, на исполнение которых уходит длительный промежуток времени, обычно исполняют не в контексте запроса, а в фоне, на заднем плане, параллельно с исполнением породившей их функции представления.

Что было раньше? Раньше мы программировали web на блокирующих фреймворках типа Flask, Pyramid или Django. И с ними для реализации фоновых процессов мы использовали одно на всех специализированное решение — Celery. Надо понимать, что Celery это довольно объёмный пакет, в своё время только на чтение его документации у меня ушла чуть ли не целая неделя, потом я столкнулся со сложностями конфигурирования рабочего процесса Celery в приложении Flask, но героически их преодолел, а потом оказалось, что Celery самым наглым образом поглощает ресурсы моего достаточно слабенького VDS и в частности оперативную память, которой и так всегда мало. Поэтому теперь я читаю документацию на асинхронные web-фреймворки, лишь бы избавиться от Celery в своём проекте.

Итак фоновые задачи… Поскольку реальные прикладные задачи слишком осложнят эту демонстрацию, в её рамках я обойдусь простыми имитациями. Давайте посмотрим, как можно реализовать фоновую задачу в асинхронном программировании на Python3. И первое на чём стоит остановиться — это природа кода вашей фоновой задачи. Как известно, реализация той или иной задачи может быть выделена либо в обычную блокирующую функцию, либо в асинхронную функцию (корутину). У меня есть две фоновые задачи, в рамках которых процесс будет засыпать на заданный промежуток времени, а потом выводить на терминал строку с сообщением. Вот так они будут выглядеть в файле selfish.py.

def blocked_task(num, name):
    time.sleep(num)
    print(f'{name} at {datetime.now()}')


async def async_task(num, name):
    await asyncio.sleep(num)
    print(f'{name} at {datetime.now()}')

481sxbRgml.png

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

async def show_index(request):
    # обращаю внимание на последовательность процедур в теле функции
    # первая фоновая задача будет исполняться 45 секунд
    # и после этого выведет на экран терминала строку
    asyncio.ensure_future(async_task(45, 'Async task'))

    # вторая фоновая задача будет исполняться 25 секунд
    # и после этого выведет на экран терминала строку
    asyncio.get_running_loop().run_in_executor(
      None, functools.partial(blocked_task, 25, 'Blocked task'))

    # этот print появится на терминале первым
    print(f'Response sent at {datetime.now()}')
    return HTMLResponse('<p>Hello, World!</p>')

nWdxKyGSrn.png

Сохраняю все изменения в файл, иду в терминал и опять запускаю сервер.

python runserver.py

Обновляю в браузере страницу, обращаю внимание на скорость загрузки страницы, браузер мгновенно получает от сервера ответ, а в терминале появляется первый print, который по факту следует в конце функции представления.

xr7TCBtLJs.png

Жду следующего print-а, по текущему времени можно определить разницу между первым и вторым print-ами.

PNDWboeIMg.png

Жду ещё, следующий print появляется точно вовремя.

MwK5jAIsmk.png

Обращаю внимание на одну характерную деталь, порядок следования отладочных print-ов не соответствует порядку следования задающих их процедур в функции представления show_index, а это значит, что show_index, blocked_task и async_task исполняются параллельно и одновременно, а долгоиграющие blocked_task и async_task никак не мешают серверу отдать ответ клиенту молниеносно. Вот так всё просто в асинхронном мире, и теперь можно проститься с Celery навсегда. Прощай дорогой товарищ, мы будем вспоминать о тебе в приступах ностальгии, но скучать не будем.

На всякий случай представляю полный код selfish.py одним листингом:

import asyncio
import functools
import os
import time

from datetime import datetime

from starlette.applications import Starlette
from starlette.config import Config
from starlette.responses import HTMLResponse
from starlette.routing import Route

base = os.path.dirname(__file__)
settings = Config(os.path.join(base, '.env'))


def blocked_task(num, name):
    time.sleep(num)
    print(f'{name} at {datetime.now()}')


async def async_task(num, name):
    await asyncio.sleep(num)
    print(f'{name} at {datetime.now()}')


async def show_index(request):
    asyncio.ensure_future(async_task(45, 'Async task'))
    asyncio.get_running_loop().run_in_executor(
      None, functools.partial(blocked_task, 25, 'Blocked task'))
    print(f'Response sent at {datetime.now()}')
    return HTMLResponse('<p>Hello, World!</p>')


app = Starlette(
    debug=settings.get('DEBUG', cast=bool),
    routes=[Route('/', show_index, name='index')])
app.config = settings

Всё… цель демонстрации полностью достигнута.

Уважаемый читатель, вы наверно заметили, что заметки в блогах стали появляться крайне редко. Это потому, что я занят переездом с Flask на Starlette – а это довольно увлекательное и всепоглощающее занятие. Пожалуйста, не забывайте, что этому проекту нужна ваша помощь, будь то перевод на мой Яндекс-кошелёк любой приемлемой для вас суммы, или пара-тройка кликов по рекламным блокам в рамках сайта — я буду благодарен за любую оказанную проекту помощь. Не прощаюсь… :)

Комментарии: