Как стать автором
Обновить
281.08
Слёрм
Учебный центр для тех, кто работает в IT

Асинхронная обработка запросов в Python: необходимость или просто модное слово?

Время на прочтение8 мин
Количество просмотров2.5K

Все прекрасно понимают, что традиционное, классическое, «синхронное» программирование подразумевает пошаговое выполнение программного кода. Соответственно, каждый следующий шаг скрыт за пределами «видимости» до момента его выполнения. 

Из этого вытекает вполне логичная проблема — что делать, когда необходимо получить некие данные в процессе выполнения определенного блока кода или до? Тут на помощь нам и приходит асинхронная обработка запросов и асинхронное программирование в целом.

Потоки, Async и иже с ними

Потоки — это своеобразный элемент, часть «многопоточности», когда разные аспекты программы выполняются параллельно. Но в Python из-за GIL (Global Interpreter Lock) настоящей параллельности нет, потоки выполняются по очереди, особенно в CPU-bound задачах. Но для I/O-bound задач они могут ускорить выполнение, потому что пока один поток ждет, например, ответа от сети, другой может работать.

Теперь асинхронность. Это подход, где одна задача может приостановиться, пока ждет I/O, и позволить другой задаче выполниться. Здесь используется цикл событий (event loop), который управляет всеми основными процессами. Асинхронность хорошо подходит для I/O-bound задач, например, веб-запросов или работы с базами данных.

Отличия между ними, как можно понять, заключаются во многом. В первую очередь это касается ресурсоёмкости и производительности. Так, в сравнении получается, что для потоков актуально следующее:

  1. Для их создания потребуется больше ресурсов.

  2. Переключение между ними довольно накладно.

  3. Неплохо применяются только при решении задач, связанных с I/O (а именно при работе с файлами, сетью). Для остальных случаев не актуальны.

  4. При использовании имеется риск состояний гонки, обязательна синхронизация.

  5. Из-за параллелизма усложняется процесс отладки.

Асинхронность в данном случае:

  1. Корутины имеют минимум «веса», способны выполняться в рамках одного потока.

  2. Нет накладных ресурсов на переключение контекста.

  3. Подходят для высоконагруженных I/O-bound задач.

  4. Код остаётся последовательным, но требует применения async/await и, в ряде случаев, дополнительных библиотек (например, asyncio).

  5. При возникновении заблокированной корутины, весь event loop остановится.

Области применения

Представьте, что вы создали некий веб-сервис с сервером на Django, который последовательно способен обработать 100 запросов в секунду, а каждый ответ от БД требует ожидания около 200 мс.

Внезапно случилось нечто — единовременно к сервису стали обращаться тысячи пользователей со всего мира и из-за подобной нагрузки вместо мгновенных ответов на клиентские запросы пользователи получают лишь ошибки, бесконечную загрузку и прочее. Естественно, проблема заключается в том, что сервер обрабатывает все запросы последовательно. Расходуется огромное количество человеко-часов на ожидание и получение данных. В результате пользователи просто перестают пользоваться таким сервисом.

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

Асинхронность идеальна в случае разработки I/O приложений, где от задач зачастую ожидается какой-то внешний ресурс:

  • веб-серверы и API (FastAPI, aiohttp);

  • парсинг данных и скрейпинг;

  • микросервисы, взаимодействующие с базами данных.

CPU-зависимые задачи (например, сложные вычисления) выиграют от мультипроцессинга, так как Global Interpreter Lock оказывает своё влияние путём ограничения выполнения параллельных вычислений в одном потоке.

Разберём подробнее, как работает асинхронность, какие инструменты можно использовать и как избежать типовых ошибок при разработке.

Что это такое и с чем его едят?

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

Основная концепция асинхронного программирования крутится вокруг 2 ключевых слов — async и await. Всё взаимодействие с параллельной обработкой напрямую завязано на них:

  1. Async – применяется непосредственно для того, чтобы указать, какая функция является асинхронной. В результате её работы появляется отдельный объект, который именуется корутиной (некая функция, способная как останавливаться на время, так и вновь начинать работать). Все реализуется посредством await.

  2. Await, в свою очередь, позволяет установить «таймер ожидания», приостановить выполнение некоторых действий до завершения асинхронной операции.

В примере, который рассмотрен ниже, метод main отмечен, как асинхронный. Внутри него записан await, результатом которого станет остановка выполнения кода на секунду. При этом остальная часть программы остановлена не будет, соответственно, её элементы будут продолжать работать в фоне. Работу можно остановить посредством ключевого слова sleep, ну или в случае, если будет достигнут необходимый результат программы:

import asyncio
async def main():
print ("Hello")
await asyncio.sleep(1)
print ("World")
asyncio.run(main())

Но нам интереснее, чтобы ряд задач выполнялся параллельно. Поэтому следует расширить пример и использовать ещё одно ключевое слово, которое как раз и отвечает за подобную реализацию:

import asyncio
async def say_hi():
await asyncio.sleep(1)
print ("Hello")
async def say_hi2():
await asyncio.sleep(2)
print ("My")
async def say_hi3():
await asyncio.sleep(3)
print ("Friend")
async def main():
await asyncio.gather(say_hi(), say_hi2(), say_hi3())
asyncio.run(main())

В данном случае asyncio.gather() отвечает за одновременное выполнение сразу нескольких операций. Несмотря на то, что функции будут выполняться в один и тот же момент времени, первой завершится say_hi(), поскольку задержка выполнения равна 1 мс.

Асинхронность в Python построена вокруг event loop (цикла событий). Это механизм, который непосредственно отвечает за выполнение операций конкретным образом:

  1. Event Loop следит за состоянием задач (корутин) — ожидают они I/O или готовы к выполнению.

  2. Когда задача встречает await, она приостанавливается, и управление передается другой задаче.

  3. Как только операция I/O завершена (например, пришел ответ от сервера), event loop возобновляет выполнение задачи.

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

Полезные для работы библиотеки и инструменты

Выше был рассмотрен пример работы с asyncio — базовой библиотекой. Она обладает достаточно мощными инструментами, позволяющими взаимодействовать с потоками, создавать и управлять выполнением асинхронных задач.

Asyncio.gather() была рассмотрена выше. Следом взглянем на асинхронные генераторы, который открывают возможность для создания итераторов. Такие итераторы, в свою очередь, будут способны использовать функцию await уже внутри себя, что особенно актуально при работе с потоками информации, поступающими асинхронно.

Реализация генератора довольно проста в начальном своем выражении и выглядит, как async def async_generator(). Далее в генераторе важно прописать итератор, например, for x in range(3), а также не забыть об await asyncio.sleep(5) и завершить все yield x. В методе main генератор также прописывается, а именно: async for value in async_generator().

В данном примере реализована задержка в 5 мс., соответственно, результат работы итератора будет возвращен с её учетом. Сам же цикл async for будет заниматься обработкой полученных запросов по мере их появления.

Среди других, не менее полезных библиотек, нельзя не отметить следующие:

  1. aiohttp – HTTP-клиент и сервер для обработки асинхронных запросов.

  2. asyncpg — асинхронный драйвер для PostgreSQL.

  3. FastAPI — современный фреймворк с поддержкой асинхронных эндпоинтов.

  4. httpx — альтернатива requests с асинхронной поддержкой.

Особенности асинхронного подхода: безопасность и нюансы

Самое первое, о чем следует сказать, так это «состояние гонки». Такое состояние возникает, когда в реализации используются общие переменные. В таком случае одновременно несколько частей кода могут постараться выполнять операции чтения или записи относительно одной и той же переменной. 

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

Примечательно, что существует и несколько типов такого состояния гонки, но это отдельная тема. Продемонстрировать данное состояние можно следующим образом:

from threading import Thread
from time import sleep


count = 0
def increase(by):
global count




local_count = count
local_count += by


sleep(0.1)


count = local_count
print(f' Итоговый результат count: {count}')

# создаем нужные потоки

t1 = Thread(target=increase, args=(10,))
t2 = Thread(target=increase, args=(20,))

# непосредственный запуск

t1.start()
t2.start()

# ожидание окончания выполнения вызванных потоков

t1.join()
t2.join()
print(f' Итоговый результат: {count}')

Во избежание подобного состояния можно воспользоваться встроенным классом Lock. У его экземпляра может быть 2 состояния — заблокированное или разблокированное. Реализация выглядит следующим образом: lock = Lock().

При разработке важно учитывать, что lock изначально находится в разблокированном состоянии. Оно может измениться в случае, если вы укажете обратное. Соответственно, для блокировки применяется метод acquire(), а разблокировка объекта класса выполняется посредством метода release().

Дополнительно следует отметить, что любая синхронная операция (например, time.sleep() или вызов из библиотеки requests) заблокирует весь event loop. Поэтому всегда используйте асинхронные аналоги.

Нередко новички при реализации асинхронной обработки запросов забывают об await, что в итоге приводит к созданию такой корутины, которая никогда не выполнится. Одной из распространённых проблем является смешивание циклов, в частности, синхронного и асинхронного. Как итог, event loop блокируется. Чтобы решить такую проблему, необходимо неким образом «запаковать» синхронный вызов, что делается посредством asyncio.run_in_executor.

Среди распространённых проблем, с которыми может столкнуться разработчик следует отметить также:

  1. Игнорирование ограничений — по сути получается своеобразный DDoS самого себя. В итоге велика вероятность не только сильного замедления работы, но и банального получения бана IP.

  2. Не стоит забывать о замене psycopg2 на asyncpg в случае, если того требуют обстоятельства. 

  3. Обработка исключений требуется не только при последовательной работе с кодом, но и при разработке асинхронных функций. Сервер в итоге может вовсе упасть без логов. К тому же, исключения в задачах, которые не были привязаны к await, будут игнорироваться.

Если подытожить все вышесказанное, то следует всегда заменять блокирующие вызовы на их асинхронные разновидности. Не забывайте об ограничении параллелизма (семафоры, пулы). Логируйте ошибки и обрабатывайте возможные исключения.

Стоит ли игра свеч?

Асинхронная обработка запросов в Python — мощный инструмент, но не панацея. Она отлично подходит для I/O задач, но требует пересмотра архитектуры приложения и осторожности в деталях.

Плюсы:

  1. Высокая производительность при работе с сетью или диском.

  2. Экономия ресурсов сервера.

Минусы:

  1. Определённая сложность отладки.

  2. Не все поддерживает асинхронность.

Что дальше?

  1. Изучите официальную документацию.

  2. Поэкспериментируйте с асинхронными фреймворками (FastAPI, aiohttp).

  3. Проверьте свой код под нагрузкой с помощью инструментов для нагрузочного тестирования (wrk2, locust).

Если ваш проект страдает от медленных I/O-операций — асинхронность может стать спасением. Начните с малого: перепишите «узкие» места, используйте современные библиотеки и следите за результатами.

Больше о том, как работать с запросами в Python, можете узнать на курсах Слёрма «Python-разработчик» и «Python для инженеров». 

На курсе «Python-разработчик» вы освоите базу по Python и получите новую профессию junior-разработчика, а на курсе «Python для инженеров» сможете прокачать навыки программирования и научиться автоматизировать рабочие процессы и создавать кастомные решения для управления инфраструктурой.

Теги:
Хабы:
+10
Комментарии4

Публикации

Информация

Сайт
to.slurm.io
Дата регистрации
Дата основания
Численность
51–100 человек
Местоположение
Россия
Представитель
Антон Скобин