Чтобы сделать 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
. Поэтому основная функция будет работать в такой последовательности:
- Сначала мы получим сообщение о запуске.
- Затем создадим объект второй корутины.
- После этого мы получим сообщение об окончании работы основной корутины, которое переставили наверх.
- Следующей строкой запустим созданную корутину
fetch_data()
. Она выполнит написанный в ней код и вернёт результат. - Последним выводом на экран будут результаты выполненной функции
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
.
Как превратить асинхронный код в синхронный
Сначала перепишем код так, чтобы видеть номер запущенной функции для 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 секунды могли сделать ещё какую-нибудь операцию на нашей машине.
Запускаем несколько корутин из одного места: 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
.
Что сделаем в следующий раз
В следующей статье шагнём дальше — поработаем с контекстным менеджером и научимся синхронизировать асинхронные функции между собой. Подпишитесь, чтобы не пропустить продолжение.