Введение: Зачем вообще нужна асинхронность?
Ваш Python-скрипт работает медленно. Вы запускаете его и смотрите, как он "висит", ожидая загрузки файла, ответа от API или завершения запроса к базе данных. Проблема почти всегда одна — ожидание. В программировании такие задачи называются I/O-bound (ограниченные вводом-выводом), и именно они "съедают" драгоценное время.
В стандартном, синхронном коде, программа останавливается на каждой такой операции и просто ждет. Процессор в это время простаивает, хотя мог бы делать что-то полезное. Это все равно что бариста в кофейне будет готовить только один заказ за раз, заставляя всю очередь ждать, пока он наливает молоко для одного-единственного латте.
Асинхронный подход меняет правила игры. Вместо того чтобы "зависать" в ожидании, программа говорит: "Окей, эта задача пока ждет ответа от сети, а я пока займусь другой". Она эффективно использует время простоя, переключаясь между задачами и выполняя ту, которая готова к работе. В итоге общая производительность приложения, интенсивно работающего с сетью или файлами, может вырасти в разы.
В Python за эту магию отвечает библиотека asyncio и синтаксический сахар async/await. Их задача — дать вам возможность писать конкурентный код, который не блокируется на операциях ожидания.
В этой статье мы простыми словами разберем ключевые концепции asyncio, сравним скорость синхронного и асинхронного кода и напишем вашу первую настоящую асинхронную программу.
2. Синхронный vs. Асинхронный код: Наглядное сравнение
Теория — это хорошо, но ничто не убеждает лучше, чем наглядный пример. Давайте напишем две небольшие программы, которые делают одно и то же: скачивают содержимое нескольких веб-страниц. Первая будет работать традиционным, синхронным способом, а вторая — асинхронным. Задача проста, но разница в скорости вас удивит.
Для чистоты эксперимента мы будем обращаться к сайту httpbin.org, который специально создан для тестирования HTTP-запросов. Мы запросим эндпоинт /delay/2, который имитирует долгий сетевой ответ, заставляя нашу программу ждать 2 секунды.
Синхронный пример: терпеливое ожидание
В этом примере мы используем популярную библиотеку requests. Код получается простым и понятным: он выполняет задачи строго друг за другом.
import requests
import time
# Список URL-адресов, которые мы будем запрашивать
urls = [
'https://httpbin.org/delay/2',
'https://httpbin.org/delay/2',
'https://httpbin.org/delay/2',
]
def fetch_sync(url):
"""Синхронно выполняет запрос к URL."""
print(f"Запрашиваю {url}...")
requests.get(url)
print(f"Запрос к {url} завершен.")
start_time = time.time()
for url in urls:
fetch_sync(url)
end_time = time.time()
print(f"\nСинхронное выполнение заняло: {end_time - start_time:.2f} секунд.")
Что здесь происходит?
Программа последовательно проходит по списку urls. Для каждого URL она вызывает функцию fetch_sync, которая отправляет запрос и ждет ответа 2 секунды. Только после получения ответа от первого URL, она приступает к запросу второго, и так далее.
Результат выполнения:
Запрашиваю https://httpbin.org/delay/2...
Запрос к https://httpbin.org/delay/2 завершен.
Запрашиваю https://httpbin.org/delay/2...
Запрос к https://httpbin.org/delay/2 завершен.
Запрашиваю https://httpbin.org/delay/2...
Запрос к https://httpbin.org/delay/2 завершен.
Синхронное выполнение заняло: 6.12 секунд.
Как и ожидалось, общее время выполнения составило чуть больше 6 секунд (2 секунды на каждый из трех запросов). Все логично, но неэффективно.
Асинхронный "тизер": магия asyncio
Теперь сделаем то же самое, но с использованием asyncio и библиотеки aiohttp — асинхронного аналога requests. Код выглядит немного иначе, но основная идея — запустить все задачи одновременно и дождаться их завершения.
import aiohttp
import asyncio
import time
# Те же URL-адреса
urls = [
'https://httpbin.org/delay/2',
'https://httpbin.org/delay/2',
'https://httpbin.org/delay/2',
]
async def fetch_async(session, url):
"""Асинхронно выполняет запрос к URL."""
print(f"Запрашиваю {url}...")
async with session.get(url) as response:
await response.text()
print(f"Запрос к {url} завершен.")
async def main():
"""Основная асинхронная функция."""
async with aiohttp.ClientSession() as session:
# Создаем список задач для одновременного выполнения
tasks = [fetch_async(session, url) for url in urls]
# Запускаем задачи и ждем их завершения
await asyncio.gather(*tasks)
start_time = time.time()
# Запускаем выполнение асинхронной программы
asyncio.run(main())
end_time = time.time()
print(f"\nАсинхронное выполнение заняло: {end_time - start_time:.2f} секунд.")
Что здесь происходит?
Мы определяем асинхронные функции с помощью
async def.Внутри
mainмы не вызываемfetch_asyncв цикле последовательно. Вместо этого мы создаем список "задач" (tasks).asyncio.gather(*tasks)говорит Python: "Запусти все эти задачи одновременно и сообщи мне, когда все они будут выполнены".Программа запускает первый запрос, и пока она ждет ответа от сервера (те самые 2 секунды), она не блокируется, а немедленно запускает второй, а затем и третий запрос. Все три ожидания происходят параллельно.
Результат выполнения:
Запрашиваю https://httpbin.org/delay/2...
Запрашиваю https://httpbin.org/delay/2...
Запрашиваю https://httpbin.org/delay/2...
Запрос к https://httpbin.org/delay/2 завершен.
Запрос к https://httpbin.org/delay/2 завершен.
Запрос к https://httpbin.org/delay/2 завершен.
Асинхронное выполнение заняло: 2.05 секунд.
Результат говорит сам за себя. Вместо 6 секунд мы потратили чуть больше 2. Программа выполнилась так же быстро, как и самый долгий из ее запросов. Мы не избавились от ожидания, но мы заставили его работать на нас, выполняя другие задачи в это время.
Это простое сравнение наглядно показывает мощь асинхронного подхода для задач, связанных с вводом-выводом. В следующем разделе мы подробно разберем, какие именно конструкции языка (async, await, asyncio.gather) позволили нам добиться такого ускорения.
3. Погружение в asyncio: Основные концепции
Чтобы понять, как произошла магия в предыдущем примере, нам нужно познакомиться с четырьмя китами, на которых держится asyncio. На первый взгляд они могут показаться сложными, но если разобраться в их ролях, все встанет на свои места.
А. Корутины (Coroutines)
Что это такое? Корутина, или сопрограмма — это основа а. Проще говоря, это функция, выполнение которой можно приостановить в определённый момент, а затем возобновить с того же самого места. В отличие от обычных функций, которые работают от начала и до конца без остановок, корутины умеют "сотрудничать" (co-operate), передавая управление друг другу.
-
Синтаксис: Создать корутину очень просто — достаточно объявить функцию с ключевым словом
async def.import asyncio async def my_coroutine(): print("Hello") await asyncio.sleep(1) # Приостанавливаем выполнение на 1 секунду print("World") -
Важный нюанс: Если просто вызвать такую функцию, ее код не выполнится. Вместо этого вызов вернет специальный объект —
coroutine object. Чтобы запустить корутину, ее нужно передать вasyncio.>>> my_coroutine() <coroutine object my_coroutine at 0x10f3b7c80> # Код не выполнился!
Б. Ключевое слово await
Что это такое?
await— это оператор, который буквально говорит: "приостанови выполнение этой корутины, пока не будет получен результат от того, что я ожидаю". Пока одна корутина "спит" наawait,asyncioможет выполнять другие задачи.-
Синтаксис:
awaitможно использовать только внутриasync defфункции. Он применяется к другим корутинам или специальным "ожидаемым" объектам (awaitable objects).async def main(): # await "говорит" корутине main подождать, # пока my_coroutine не завершится. await my_coroutine()В нашем примере
await asyncio.sleep(1)приостанавливает работуmy_coroutineи передает управление обратно циклу событий, который в это время может заняться другими делами.
В. Цикл событий (Event Loop)
Что это такое? Цикл событий — это ядро любой
asyncioпрограммы. Его можно представить как диспетчера или менеджера, который управляет выполнением всех асинхронных задач.-
Как он работает: Цикл событий работает по простому принципу:
Берет задачу из очереди.
Запускает ее выполнение.
Если задача доходит до
await, она приостанавливается и возвращается в очередь "ожидающих".Диспетчер берет следующую "готовую" задачу и так по кругу.
Этот непрерывный цикл опроса и выполнения задач и есть Event Loop. К счастью, в современных версиях Python нам редко нужно управлять циклом событий вручную. Функция
asyncio.run(main())делает всю грязную работу за нас: создает цикл, запускает в нем нашу главную корутинуmain, ждет ее завершения и корректно закрывает цикл.
Г. Задачи (Tasks)
Что это такое? Если корутина — это описание работы, то
Task— это уже запланированное выполнение этой работы. Задача — это обертка вокруг корутины, которая позволяет ей выполняться конкурентно, "в фоне".-
Создание задач: Задачи создаются с помощью функции
asyncio.create_task(). Как только вы создаете задачу, цикл событий получает ее на исполнение, и она начинает выполняться при первой же возможности (как только текущая корутина сделаетawait).async def main(): print("Запускаем задачу...") # Планируем выполнение my_coroutine в фоне. # main не будет ждать ее завершения здесь. task = asyncio.create_task(my_coroutine()) # Можно продолжить делать что-то еще print("Задача запущена, main продолжает работу.") # А вот здесь мы дожидаемся завершения фоновой задачи await task print("Задача завершена.") asyncio.run(main())
Итог по концепциям:
async defсоздает корутину (инструкцию).awaitприостанавливает корутину и передает управление циклу событий.Цикл событий (Event Loop) решает, какую задачу выполнять следующей.
asyncio.create_task()превращает корутину в Задачу и отдает ее на исполнение циклу событий, позволяя коду выполняться конкурентно.
Понимание этих четырех элементов — ключ к освоению асинхронного программирования на Python. В следующем разделе мы соберем все это воедино и напишем нашу первую полноценную асинхронную программу.
4. Практика: Пишем первую асинхронную программу
Теория усвоена, пора переходить к практике. Мы напишем несколько небольших программ, каждая из которых будет иллюстрировать одну из ключевых концепций asyncio.
"Hello, async world!": Самый простой старт
Как и в любом языке, начнем с вывода простого сообщения. Эта программа показывает базовый шаблон для запуска любого asyncio-приложения.
import asyncio
# 1. Объявляем главную корутину
async def main():
print("Hello...")
# Здесь могла бы быть долгая операция, но пока мы просто подождем 1 секунду
await asyncio.sleep(1)
print("...async world!")
# 2. Запускаем программу с помощью asyncio.run()
# Эта функция создает event loop, выполняет нашу корутину main
# и закрывает loop после завершения.
print("Запускаем программу")
asyncio.run(main())
print("Программа завершена")
Что здесь происходит?
async def main(): Мы определили точку входа в нашу асинхронную логику.await asyncio.sleep(1): Мы использовалиawait, чтобы приостановить выполнениеmainна 1 секунду.asyncio.sleep()— это асинхронный аналогtime.sleep(). В отличие отtime.sleep(), он не блокирует весь поток, а просто передает управление циклу событий.asyncio.run(main()): Это стандартный способ запустить главную корутину в Python 3.7+.
Результат выполнения:
Запускаем программу
Hello...
(пауза в 1 секунду)
...async world!
Программа завершена
Асинхронные "сны": последовательное ожидание
Давайте посмотрим, как await работает при последовательных вызовах. Мы создадим корутину, которая имитирует какую-то работу, засыпая на заданное время.
import asyncio
import time
async def make_coffee():
print("Начинаю делать кофе")
await asyncio.sleep(3) # Имитация долгой работы (3 сек)
print("Кофе готов!")
async def toast_bread():
print("Начинаю делать тост")
await asyncio.sleep(2) # Имитация долгой работы (2 сек)
print("Тост готов!")
async def main():
start_time = time.time()
# Последовательно ждем выполнения каждой корутины
await make_coffee()
await toast_bread()
end_time = time.time()
print(f"Завтрак приготовлен за {end_time - start_time:.2f} секунд")
asyncio.run(main())
Что здесь происходит?
Программа сначала вызывает make_coffee() и await ждет ее полного завершения (3 секунды). Только после этого она вызывает toast_bread() и ждет еще 2 секунды.
Результат выполнения:
Начинаю делать кофе
Кофе готов!
Начинаю делать тост
Тост готов!
Завтрак приготовлен за 5.03 секунд
Общее время — около 5 секунд. Мы просто выполняли задачи одна за другой. Пока что это ничем не лучше обычного синхронного кода. Но этот пример хорошо показывает, что await — это точка, где выполнение текущей корутины (main) приостанавливается до завершения ожидаемой (make_coffee).
Запуск нескольких задач одновременно: asyncio.gather()
А теперь — самое интересное. Давайте приготовим наш завтрак по-настоящему асинхронно. Мы хотим, чтобы тост готовился в то же время, пока варится кофе. Для этого нам нужно запланировать обе корутины как независимые Задачи (Tasks) и запустить их одновременно. Самый удобный способ сделать это — использовать asyncio.gather().
import asyncio
import time
async def make_coffee():
print("Начинаю делать кофе")
await asyncio.sleep(3)
print("Кофе готов!")
async def toast_bread():
print("Начинаю делать тост")
await asyncio.sleep(2)
print("Тост готов!")
async def main():
start_time = time.time()
# Создаем "корзину" задач, которые должны выполниться одновременно
# asyncio.gather запускает их все и ждет, пока последняя не завершится
await asyncio.gather(make_coffee(), toast_bread())
end_time = time.time()
print(f"Завтрак приготовлен за {end_time - start_time:.2f} секунд")
asyncio.run(main())
Что здесь происходит?
asyncio.gather(make_coffee(), toast_bread()): Эта функция принимает одну или несколько корутин, "заворачивает" их вTasksи планирует их одновременное выполнение.awaitпередgatherтеперь означает "приостановитьmainдо тех пор, пока все задачи, переданные вgather, не будут выполнены".
Результат выполнения:
Начинаю делать кофе
Начинаю делать тост
(проходит 2 секунды)
Тост готов!
(проходит еще 1 секунда)
Кофе готов!
Завтрак приготовлен за 3.02 секунд
Вот она, сила асинхронности! Обе задачи стартовали одновременно. Через 2 секунды завершилась самая быстрая из них (toast_bread), а через 3 секунды — самая долгая (make_coffee). Общее время выполнения равно времени самой долгой задачи.
Мы только что на простом примере увидели, как превратить последовательный код в конкурентный, значительно сократив общее время ожидания.
5. Частые ошибки новичков
Переход на асинхронное программирование часто сопровождается несколькими типичными ошибками. Понимание этих "граблей" сэкономит вам много времени и нервов при отладке.
Ошибка 1: Вызов блокирующего кода внутри корутины
Это самая серьезная и самая коварная ошибка. Если вы вызываете обычную, синхронную функцию, которая надолго занимает процессор или ждет ответа от сети (например, time.sleep() или requests.get()), вы полностью блокируете цикл событий. Вся магия асинхронности исчезает, и ваша программа снова становится медленной и неотзывчивой, так как другие задачи не могут выполняться, пока блокирующая операция не завершится.
Неправильно:
import time
import asyncio
async def main():
print("Что-то делаем...")
# НЕПРАВИЛЬНО! Это заморозит всю программу на 5 секунд
time.sleep(5)
print("...завершили.")
asyncio.run(main())
Правильно:
Всегда используйте асинхронные аналоги для операций ввода-вывода.
Вместо
time.sleep()->await asyncio.sleep()Вместо
requests.get()->await aiohttp_session.get()Если асинхронной альтернативы нет, запускайте блокирующий код в отдельном потоке с помощью
asyncio.to_thread()(в Python 3.9+) илиloop.run_in_executor().
Ошибка 2: Забыли await при вызове корутины
Это, пожалуй, самая распространенная ошибка среди начинающих. Если вы вызываете корутину (async def функцию) как обычную функцию, ее код не исполняется. Вместо этого вы просто создаете объект-корутину.
В результате вы получите предупреждение RuntimeWarning: coroutine '...' was never awaited, а ваша программа не будет работать так, как вы ожидаете.
Неправильно:
import asyncio
async def my_coro():
print("Эта строка никогда не будет напечатана")
async def main():
# НЕПРАВИЛЬНО! Просто создали объект, но не запустили его
my_coro()
asyncio.run(main())
# Вывод: (ничего, кроме возможного RuntimeWarning)
Правильно:
Всегда используйте await при вызове корутины, чтобы дождаться ее выполнения, или asyncio.create_task() для фонового запуска.
async def main():
# ПРАВИЛЬНО
await my_coro()
Ошибка 3: Непонимание, когда асинхронность НЕ нужна (CPU-bound vs. I/O-bound)
Асинхронность — не серебряная пуля. Она показывает невероятную эффективность для I/O-bound задач, где программа большую часть времени ждет ответа от сети, диска или базы данных.
Однако для CPU-bound задач, которые требуют интенсивных вычислений (например, обработка изображений, математические расчеты, архивация данных), asyncio не даст никакого прироста производительности, а может даже замедлить программу. Поскольку asyncio работает в одном потоке, "тяжелая" математическая задача просто займет процессор и не позволит переключиться на другие задачи.
Неправильно:
import asyncio
# Эта функция на 100% загрузит одно ядро процессора
def heavy_calculation():
return sum(i * i for i in range(10**7))
async def main():
# Это будет выполняться последовательно, без выигрыша в скорости
await asyncio.gather(
asyncio.to_thread(heavy_calculation),
asyncio.to_thread(heavy_calculation)
)
Хотя to_thread формально делает код неблокирующим для event loop, реального параллелизма для CPU-задач вы не получите из-за Global Interpreter Lock (GIL) в Python.
Правильно:
Для CPU-bound задач используйте модуль multiprocessing, который позволяет запускать вычисления в отдельных процессах, используя все ядра вашего процессора.
7. Домашнее задание
Теория и примеры — это только половина пути. Лучший способ закрепить знания — применить их на практике. Ниже вы найдете 5 задач разного уровня сложности. Попробуйте решить их самостоятельно, прежде чем заглядывать в чужие решения.
Задача 1: Асинхронный таймер (простой уровень)
Условие:
Напишите асинхронную функцию countdown(name, seconds).
Она должна принимать два аргумента:
name(имя таймера, строка) иseconds(количество секунд, целое число).Функция должна печатать числа от
secondsдо 1, делая паузу в 1 секунду между выводами.После вывода "1" функция должна напечатать сообщение
"{name}: Пуск!".
Пример вызова в main:
async def main():
await countdown("Таймер 1", 3)
asyncio.run(main())
Ожидаемый результат:
3
2
1
Таймер 1: Пуск!
Задача 2: Параллельные гонки (простой уровень)
Условие:
Представьте, что у вас есть два гонщика. Напишите асинхронную программу, которая имитирует их забег.
Создайте корутину
racer(name, delay), которая "бежит"delayсекунд (используяasyncio.sleep) и по завершении выводит сообщение"{name} финишировал!".-
В главной функции
mainсоздайте двух гонщиков:"Молния" с задержкой 2 секунды.
"Стрела" с задержкой 2.5 секунды.
Запустите их "забег" одновременно с помощью
asyncio.gather.Программа должна вывести, кто финишировал, по мере их прибытия.
Ожидаемый результат:
(проходит 2 секунды)
Молния финишировал!
(проходит еще 0.5 секунды)
Стрела финишировал!
Задача 3: Проверка доступности сайтов (средний уровень)
Условие:
Напишите асинхронную программу для проверки статуса нескольких веб-сайтов.
Возьмите список URL-адресов:
['https://www.python.org', 'https://www.google.com', 'https://non-existent-domain-12345.org'].Напишите корутину
check_status(session, url), которая делает GET-запрос по URL с помощьюaiohttp.Корутина должна возвращать строку:
f"{url}: OK"если код ответа 200, илиf"{url}: Ошибка {response.status}"в противном случае.Не забудьте обернуть сетевой запрос в
try...except, чтобы поймать ошибки соединения (например, для несуществующего домена) и вернуть сообщение видаf"{url}: Не удалось подключиться".Используйте
asyncio.gatherдля одновременной проверки всех сайтов и выведите результаты.
Ожидаемый результат (порядок может отличаться):
https://www.python.org: OK
https://www.google.com: OK
https://non-existent-domain-12345.org: Не удалось подключиться
Задача 4: Фоновая задача (средний уровень)
Условие:
Напишите программу, которая запускает долгую фоновую задачу и продолжает выполнять другую работу, не дожидаясь ее завершения.
Создайте корутину
long_running_task(), которая имитирует долгую операцию: печатает "Фоновая задача начала работу...", ждет 5 секунд, а затем печатает "Фоновая задача завершена.".В главной функции
mainзапуститеlong_running_task()в фоне с помощьюasyncio.create_task().Сразу после запуска фоновой задачи
mainдолжна напечатать "Основная программа продолжает работу..." и затем выполнить цикл, который 3 раза с интервалом в 1 секунду печатает "Основная программа работает...".Убедитесь, что сообщения из основной программы и фоновой задачи выводятся параллельно.
Ожидаемый результат:
Фоновая задача начала работу...
Основная программа продолжает работу...
Основная программа работает...
(пауза 1 сек)
Основная программа работает...
(пауза 1 сек)
Основная программа работает...
(пауза 2 сек)
Фоновая задача завершена.
Задача 5: Асинхронный повар (сложный уровень)
Условие:
Смоделируйте процесс приготовления блюда, где одни действия зависят от других, а некоторые могут выполняться параллельно.
-
Напишите три корутины-действия с имитацией времени через
asyncio.sleep:prepare_ingredients()— "Нарезка овощей..." (2 секунды).fry_meat()— "Жарка мяса..." (4 секунды).boil_rice()— "Варка риса..." (3 секунды).
Логика приготовления: Нарезка овощей — это первый шаг, и без него нельзя начать ни жарить мясо, ни варить рис. Однако, после того как овощи нарезаны, жарка мяса и варка риса могут происходить одновременно.
В
mainорганизуйте вызовы этих корутин в правильной последовательности. Сначала дождаться выполненияprepare_ingredients(), а затем одновременно запуститьfry_meat()иboil_rice()с помощьюasyncio.gather.Замерьте и выведите общее время приготовления.
Вопрос: Какое общее время выполнения вы ожидаете получить и почему? (Ответ: ~6 секунд, потому что 2 секунды на нарезку + 4 секунды на жарку мяса, так как это самая долгая из параллельных задач).
Анонс новых статей, полезные материалы, а так же если в процессе решения возникнут сложности, обсудить их или задать вопрос по статье можно в моём Telegram-сообществе.
Уверен, у вас все получится. Вперед, к практике!
Wallor
Пытался запустить пример с
aiohttp у себя. Python 3.14, ругается на не русском:ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1077)
Понимаю, что требует сертификат, но почему тогда в последовательном варианте молчал и работал?