Главная проблема новичков в асинхронном программировании на Python

Аватарка пользователя Андрей Баранов

Объясняем, в чем проблема асинхронного программирования на Python, и как она касается синтаксиса высокоуровневой концепции async и await.

Асинхронность в Python должен знать почти каждый разработчик на Python, который хочет быть вовлечён в коммерческую разработку. Как минимум это касается веб-программирования, где очень важна скорость отклика приложения (сервиса). Вообще, асинхронность в Python – это отдельный мир, сильно отличающийся от синхронного Python. Наличие одного событийного цикла уже вносит много изменений в привычный порядок вещей. Существует много интересных и одновременно спорных вещей, суть которых скрыт в самой концепции асинхронности.

Дисклеймер: статья ориентирована на тех, кто хорошо знаком с Python и знает основы асинхронности. Здесь не будут разбираться до мельчайших деталей понятия, используемые в статье для различных пояснений и т.п.

Немного о себе

На данный момент я работаю junior-разработчиком в отечественной IT-компании над сервисом, полностью построенном на aiohttp. Это моя первая работа, да и работаю я сравнительно недолго, так что я не претендую на звание гуру асинхронного программирования. Однако определённый опыт у меня имеется, и хочется поделиться им с читателем.

Источник информации

Тут я порекомендую YouTube-канал Олега Молчанова, на котором есть отличный цикл видео про асинхронность в Python. Именно после его просмотра ко мне пришло базовое понимание данной тематики. Также у него есть небольшой курс на Boosty, в котором, кстати, рассматривается проблема, о которой тут пойдёт речь. Однако ввиду того, что этот контент является платным, я принял решение рассказать о ней тут. Эта проблема будет рассматриваться под призмой моего опыта, так что, если у кого-то есть чем поделиться, жду обратной связи в комментариях.

Непосредственно к проблеме

Итак, наступило время понять, о чём же могут думать не так новички об асинхронности в великом и могучем… Python. А проблема состоит в высокоуровневой концепции async и await. И, таким образом, она разделяется на 2 подпроблемы, качающиеся каждой синтаксической конструкции соответственно.

Что не так с пониманием async

Тут всё просто. Многие думают, что, написав async def, код в функции автоматически становится асинхронным. Это не так. Если этот код спроектирован как синхронный, он таковым и останется. Так, у нас есть две такие функции:

			import asyncio
import time


async def do_something() -> None:
    time.sleep(1)
    print("some result")


async def pseudo_async_sleep(timeout: int) -> None:
    time.sleep(timeout)


async def main() -> None:
    coros = [
        pseudo_async_sleep(5),
        do_something(),
    ]
    await asyncio.gather(*coros)

if __name__ == "__main__":
    start = time.time()
    asyncio.run(main())
    print(time.time() - start)
		

Если запустить его через интерпретатор Python версии от 3.5, мы убедимся, что код выполнился линейно (время выполнения, почти равное 6 секундам, говорит об этом).

Важно помнить, что аббревиатура “asyncio” означает “асинхронный ввод-вывод”. Но даже если эти операции есть в коде, как в нашем случае (time.sleep симулирует операцию ввода), недостаточно написать async def. Помимо этого, необходимо использовать асинхронный аналог функции. Например, для time.sleep это asyncio.sleep.

Если бы мы использовали асинхронный аналог паузы в программе, время выполнения бы было равно величине самой большой паузы, а именно: 5 секундам. Попробуйте поменять time.sleep на await asyncio.sleep, и убедитесь в этом сами.

Тогда зачем нужен async, если его добавление, на первый взгляд, ничего не делает? На самом деле, делает. Под капотом у такой функции происходит процесс, инициализирующий из неё корутину, которую впоследствии можно будет поместить в специальный класс Task, который, в свою очередь, будет помещён цикл событий.

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

Краткий вывод по async

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

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

Что не так с пониманием await

Эта проблема, по моему мнению, больше вводит в заблуждение, чем первая, и сейчас я постараюсь объяснить, почему. Но сперва скажу, в чём она заключается: многим новичкам может показаться, что await переключает контроль выполнения задач (корутин) в событийный цикл. На самом деле, await просто делегирует определённые действия в другую корутину, а переключением контроля выполнения занимается yield, приостанавливающий выполнение корутины и отдающий какое-то значение.

Не надо бояться слова “делегировать”. Оно означает передача ответственности за какие-либо действия кому-то другому. В программировании это означает, что мы можем поместить определённую часть кода в отдельную функцию, и вызывать её из другой функции, таким образом, делегируя определённые действия в неё. Такой же принцип и с корутинами и генераторами.

Давайте убедимся в этом на практике:

			import asyncio
import time


async def print_something(value: str, timeout: int) -> None:
    await _pseudo_async_sleep(timeout)
    print(value, end=" ")


async def _pseudo_async_sleep(timeout: int) -> None:
    time.sleep(timeout)


async def main() -> None:
    coros = [
        print_something("hello", 1),
        print_something("world", 3),
        print_something("!", 2),
    ]
    await asyncio.gather(*coros)

if __name__ == "__main__":
    start = time.time()
    asyncio.run(main())
    print(time.time() - start)
		

Если бы await отдавал контроль выполнения событийному циклу, программа напечатала бы “hello ! world”, но тут код выполнился последовательно (по порядку, указанному в gather). Всё потому, что await просто вызывает корутину, “проваливается” внутрь её. Стоит отметить, что выполнение корутины, из которой производится await, приостанавливает своё выполнение, ожидая (“wait” с английского), когда выполнится “дочерняя” кореутина. А вот в корутине уже могут быть yield, которые дают шанс выполниться другим задачам в событийном цикле.

Опять же, поменяв time.sleep на асинхронный аналог, всё встанет на свои места. И тут важно понять: в своей реализации asyncio.sleep использует yield, и именно он является главным фактором в той магии, которая сокращает время выполнения программы и конкурентного выполнения корутин.

Краткий вывод по await

Всё просто. Проводя аналогии с синхронным Python, если корутина – это функция, то await – это вызов функции (эквивалентно скобкам после названия функции). Всё, ничего более. За конкурентность отвечают другие языковые конструкции.

Время выводов

В некоторых материалах и видео на YouTube я наблюдал следующие тезисы: “Если ты не знаешь, как писать асинхронный код, просто ставь везде async и await”. И я скажу так: если нужно написать единичный скрипт на Python и это вообще не является твоей профильной сферой, то можно не погружаться в подробности и делать согласно этому тезису. Однако в остальных случаях на такой идеологии далеко не уедешь. Важно понимать, как работает асинхронность под капотом хотя бы на концептуальном уровне, чтобы уметь проектировать асинхронные программы. А async/await – это просто высокоуровневые обёртки, которыми легко пользоваться тем, кто понимает, как они работают. Я постарался раскрыть главную проблему новичков в этом направлении, поверхностно разобрав каждую конструкцию. Надеюсь, что эта статья помогла разобраться в этой концепции и что я направил вас в нужное русло в плане развития навыков в асинхронном программировании на Python. Всем удачи и спасибо за то, что прочитали до конца!

Для начинающих
Python
1666