Асинхронный код на Python: синтаксис и особенности
hard

Асинхронный код на Python: синтаксис и особенности

Асинхронность — это когда функции выполняются вместе, но не одновременно

Чтобы сделать Python-код быстрее, продвинутые программисты пишут его так, чтобы параллельно могло работать сразу несколько операций. Это называется асинхронный код, и сегодня мы потренируемся с ним работать — посмотрим, что для этого нужно и что будет уметь такой код.

Разница между синхронным и асинхронным кодом

Сегодня разберём главный синтаксис асинхронного Python-программирования.

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

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

Асинхронный код выглядит как обычный код Python, но в нём используются дополнительные функции. Для их работы нужна дополнительная библиотека — встроенный модуль asyncio.

Как технически работает асинхронный код

Процессор с несколькими ядрами может обрабатывать несколько операций одновременно. Но Python запрещает так делать в своих скриптах: в него даже встроен механизм глобальной блокировки интерпретатора (GIL), который разрешает выполнять только одну операцию в каждый момент. Зачем так сделано — отдельный вопрос, который мы лучше разберём в отдельной статье, а пока вернёмся к коду.

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

Как технически работает асинхронный код

Ждущие ответа операции называются IO-bound-операциями. Их скорость ограничена скоростью подсистемы ввода-вывода.

Чтобы переключать активность программы между разными функциями, в Python существует специальный планировщик Event loop. В современном асинхронном синтаксисе он обычно создаётся автоматически при запуске, а всё планирование настраивается в самих функциях.

Главные термины асинхронного программирования с asyncio

Чтобы дальше объяснять всё было проще, нам понадобятся три новых термина асинхронного программирования.

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

Приостановка корутин — необходимый этап запуска корутин, который обозначается ключевым словом await. Если есть async-функция, для неё обязательно должно быть await-ожидание. Проще говоря, мы не можем запустить асинхронную функцию, пока не скажем ей, в какой момент ей нужно будет остановиться (например, когда она получит определённый ответ от сервера).

Планировщик event loop, внутри которого происходит запуск и остановка асинхронных функций. Event loop включается при запуске асинхронной программы командой asyncio.run(), где в скобках нужно написать асинхронную функцию для запуска. 

❌ Асинхронные функции: как делать неправильно

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

 import asyncio 
 #создаём корутину
async def first_cor():
   print('Первая корутина')
# выводим в консоли объект корутины
print(first_cor())
asyncio.run()

Если запустить код, вместо вывода слов «Первая корутина» в консоли мы получим два сообщения:

<coroutine object first_cor at 0x10182e440>

RuntimeWarning: coroutine 'first_cor' was never awaited

Первое сообщение — результат работы команды print(first_cor()). Мы заглянули объект асинхронной функции и увидели, что он относится к типу coroutine.

А вот второе сообщение означает, что корутина не может работать без ключевого слова await. Если его не указать — код будет исполняться не так, как нам нужно.

Основные и дополнительные корутины

Теперь шагнём дальше и напишем код, в котором будет уже несколько корутин, которые работают одновременно.

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

import asyncio
 #создаём корутину, которая имитирует IO-операцию
async def fetch_data(wait_time):
   print('Корутина с IO-операцией начала работу')
   # asyncio.sleep имитирует асинхронное ожидание на внешней системе,
   # мы можем ждать сразу несколько таких операций параллельно
   await asyncio.sleep(wait_time)
   # отчитываемся о результате
   print('Данные загружены из внешней системы загружены')
   # отдаём результат запроса дальше в программу
   return {'Данные': 'Полученные данные из единственной корутины без номера'}

Это типичная задача на ожидание ответа: функция отправила запрос, получила результат через несколько секунд и доложила об этом в event loop. А планировщик уже сам распределит, какой код продолжит работать дальше. 

Идём дальше: в качестве основной функции мы создадим другую корутину:

# создаём корутину с основной логикой
async def main():
   print('*Запущена основная корутина*\n')
   # запускаем корутину с IO-операцией
   task = fetch_data(3)
   # на этом месте выполнение будет приостановлено, пока не будет выполнена функция fetch_data
   result = await task
   # смотрим, какой результат вернула корутина с IO-операцией
   print('Полученный результат: ', result)
   print('\n*Основная корутина закончила работать*')
# запускаем код
asyncio.run(main()

В основной корутине мы запустили дополнительную, сказали ей ждать 3 секунды и запустили выполнение. Как только мы написали await task, event loop передал управление процессом в корутину fetch_data и продолжил выполнение только после того, как оттуда пришёл ответ:

Основные и дополнительные корутины

Как синтаксис await влияет на порядок выполнения

Чтобы понять, от чего зависит порядок выполнения асинхронных функций, сделаем так: поменяем местами последние три строки. Теперь последняя строка с выводом текста Основная корутина закончила работать будет стоять до result = await task. Поэтому основная функция будет работать в такой последовательности:

  1. Сначала мы получим сообщение о запуске.
  2. Затем создадим объект второй корутины.
  3. После этого мы получим сообщение об окончании работы основной корутины, которое переставили наверх.
  4. Следующей строкой запустим созданную корутину fetch_data(). Она выполнит написанный в ней код и вернёт результат.
  5. Последним выводом на экран будут результаты выполненной функции fetch_data().

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

Вот что получилось в итоге:

# создаём корутину с основной логикой
async def main():
   print('*Запущена основная корутина*\n')
   # запускаем корутину с IO-операцией
   task = fetch_data(3)
   print('\n*Основная корутина закончила работать*')
   # на этом месте выполнение будет приостановлено, пока не будет выполнена функция fetch_data
   result = await task
   # смотрим, какой результат вернула корутина с IO-операцией
   print('Полученный результат: ', result)

Смотрите: корутина начинает работать только по ключевому слову await. Когда мы создаём её объект строкой task = fetch_data(3), мы просто объявляем переменную. Работать корутина начинает только на строке result = await task.

Как синтаксис await влияет на порядок выполнения

Как превратить асинхронный код в синхронный

Сначала перепишем код так, чтобы видеть номер запущенной функции для IO-операции:

import asyncio
# создаём корутину, которая симулирует IO операцию
async def fetch_data(id, wait_time):
   print(f'\nКорутина с IO-операцией {id} начала работу')
   # asyncio.sleep имитирует асинхронное ожидание на внешней системе,
   # мы можем ждать сразу несколько таких операций параллельно
   await asyncio.sleep(wait_time)
   # отчитываемся о завершении работы и возвращаем данные в виде строки
   print(f'Данные из корутины {id} загружены')
   return f'Данные, которые возвращает корутина {id}'

Объявим в основной функции два объекта этой корутины так, как обычно делают в синхронном коде, — по очереди. Укажем время выполнения в две секунды для каждой обработки IO-запроса:

# создаём корутину с основной логикой
async def main():
   # создаём два объекта одной и той корутины
   task1 = fetch_data(1, 2)
   task2 = fetch_data(2, 2)

   # создаём переменную, которая запустит корутину через слово await
   result1 = await task1
   # смотрим, что получили из первой корутины
   print('Полученный результат result1: ', result1)
   # создаём переменную, которая запустит следующую корутину через слово await
   result2 = await task2
   # смотрим, что получили из второй корутины
   print('Полученный результат result2: ', result2)
# запускаем код
asyncio.run(main()

Мы использовали синтаксис async-await, но пока не получили никакого прироста в скорости. Код получился таким же синхронным, функции выполняются по очереди.

Как превратить асинхронный код в синхронный

Создаём отдельные асинхронные задачи через create_task

Чтобы начать получать пользу от асинхронности, функции нужно запускать через специальный синтаксис. Код IO-корутины мы никак не меняем, но в основной функции будем создавать для неё задачи через команду asyncio.create_task:

# создаём корутину с основной логикой
async def main():
   # создаём 3 задачи, используя create_task и корутину с IO-операцией
   task1 = asyncio.create_task(fetch_data(1,3))
   task2 = asyncio.create_task(fetch_data(2,3))
   task3 = asyncio.create_task(fetch_data(3,3))
   # запускаем все три задачи
   result1 = await task1
   result2 = await task2
   result3 = await task3
   # смотрим на результат, который возвращает каждая из задач
   print(result1)
   print(result2)
   print(result3)

Обратите внимание, что все три корутины вызываются также по очереди: это происходит на строках со словами await. Для каждой из корутин мы указали время выполнения имитированного запроса в 3 секунды.

Результат — все три асинхронные функции выполнились за 3 секунды. Получилось то, чего мы добивались: все запросы обрабатывались на своих подсистемах параллельно, а мы за эти 3 секунды могли сделать ещё какую-нибудь операцию на нашей машине.

Создаём отдельные асинхронные задачи через create_task

Запускаем несколько корутин из одного места: gather

Выше мы создавали для каждой корутины свой объект-задачу, но можно сделать проще: объединить всё в одной строке. Для того используют функцию gather, которая выглядит так:

# создаём корутину с основной логикой
async def main():
   # запускаем все три корутины одновременно и ждём их выполнения параллельно
   results = await asyncio.gather(fetch_data(1, 3),
                                  fetch_data(2, 3),
                                  fetch_data(3, 3))
   # смотрим на результат и скорость его появления
   for result in results:
       print(result)

Функции fetch_data() запускаются почти одновременно: Python запускает каждую, видит, что включилась асинхронная операция с ожиданием, и запускает следующую функцию. Получается очень быстро, и кажется, что это происходит в один момент.

Результатом выполнения будет всё то же самое, что при asyncio.create_task, но писать это проще и удобнее.

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

Что сделаем в следующий раз

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

Обложка:

Алексей Сухов

Корректор:

Ирина Михеева

Вёрстка:

Маша Климентьева

Соцсети:

Юлия Зубарева

Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию
Еще по теме
hard