Асинхронность в Python для senior interview: от asyncio до выбора правильной реализации под задачу. asyncio.. asyncio. backend.. asyncio. backend. cooperative multitasking.. asyncio. backend. cooperative multitasking. event loop.. asyncio. backend. cooperative multitasking. event loop. non-blocking io.. asyncio. backend. cooperative multitasking. event loop. non-blocking io. python.. asyncio. backend. cooperative multitasking. event loop. non-blocking io. python. асинхронность.. asyncio. backend. cooperative multitasking. event loop. non-blocking io. python. асинхронность. Программирование.

Каждый Python-разработчик знает базовую формулу: asyncio нужен для I/O, потоки ну тоже иногда, процессы — для CPU-bound. На собеседовании такого ответа хватает ровно до первого уточняющего вопроса.

А потом начинаются уже интересные вещи.

Почему await не делает код параллельным?
Почему асинхронный код всё равно может полностью положить event loop?
Чем Task отличается от Future не на уровне “одно ждёт другое”, а на уровне устройства рантайма?
Что именно делает цикл событий, когда вы пишете await asyncio.sleep(1)?
Почему в одном месте нужен create_task, в другом — TaskGroup, а в третьем лучше вообще уйти в thread pool?
Как выбрать правильную модель исполнения под конкретную backend-задачу, а не просто бездумно писать async def везде подряд?

В этой статье разберём асинхронность в Python с той глубиной, которая нужна для senior Python backend interview и для нормальной инженерной работы в проде:

— чем блокирующий I/O отличается от неблокирующего на уровне ОС и рантайма
— как устроены coroutine, Future, Task и event loop в CPython
— как работает кооперативная многозадачность
— где именно asyncio выигрывает, а где становится вреден
— как связаны async, threads, processes и GIL
— как проектировать cancellation, backpressure, timeouts и bounded concurrency
— как выбирать правильную модель под задачу

Эта статья о том, как асинхронность работает под капотом.

Почему тема асинхронности почти всегда раскрывается слишком поверхностно

С async в Python есть одна старая проблема: его обычно объясняют слишком рано и слишком упрощённо.

Говорят что-то вроде “асинхронность позволяет не блокировать поток исполнения”, показывают async def fetch() и await asyncio.sleep(1), а дальше у человека в голове остаётся модель:

async/await = быстрая штука для i/o-bound задач
event loop = что-то крутится, когда-то про него читал, но уже забыл
если много запросов — просто делай всё асинхронным

А потом этот человек приходит в прод, пишет асинхронный endpoint, внутри которого делает CPU-тяжёлый JSON encode, синхронный вызов SDK, пару блокирующих функций из старой библиотеки — и искренне не понимает, почему асинхронный сервис внезапно стал тормозить сильнее обычного.

Проблема в том, что асинхронность — модель управления ожиданием и конкуренцией. И если не понимать эту модель до конца, очень легко получить код, который выглядит современно, но ведёт себя хуже синхронного.

С чего вообще начинается асинхронность: медленный I/O и простаивающий CPU

Весь смысл async начинается с одной простой мысли: I/O медленный, а CPU в это время простаивает.

Когда сервис делает запрос в базу / HTTP-вызов во внешний сервис / чтение из сокета, он большую часть времени не считает, а ждёт, пока внешняя система ответит.

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

Если поток один — всё приложение в этот момент стоит.
Если потоков много — ОС начинает переключать их между собой, у тебя растут расходы на scheduling, стеки, синхронизацию, contention.

Возникает вопрос: а что, если во время ожидания I/O не блокировать исполнение целиком, а отдать управление обратно циклу, чтобы он пока занялся другой работой?
Это и есть фундаментальная идея асинхронности.

Блокирующий и неблокирующий I/O: где проходит настоящая граница

При блокирующем I/O системный вызов не возвращает управление, пока операция не завершится или не сможет продвинуться.

Например:

  • read() ждёт данные

  • accept() ждёт новое соединение

  • recv() ждёт байты из сокета

Поток, который вызвал такой syscall, спит в ядре и не делает полезной работы.

Неблокирующий I/O

В неблокирующем режиме операция сразу возвращает управление:

  • если данные готовы — отлично, получаем их

  • если не готовы — syscall возвращает что-то вроде “пока нельзя читать”

После этого рантайм или приложение должны как-то узнать, когда дескриптор станет готов. Для этого и существуют механизмы readiness notification:

  • select

  • poll

  • epoll

  • kqueue

  • IOCP на Windows

Именно event loop в asyncio сидит вокруг этого слоя.

То есть принципиальная разница такая:
blocking I/O: поток застревает в ожидании
non-blocking I/O: поток получает управление обратно и может заняться чем-то ещё, пока readiness отслеживает loop.

Async — это не threading

Потоки — это несколько независимых execution contexts внутри процесса. Их планирует ОС. Поток может быть вытеснен в любой момент. Это preemptive concurrency.

asyncio — это, по сути, кооперативная многозадачность в одном потоке. Пока корутина сама не уступит управление через await, никакой другой Python-код в этом loop не пойдёт.

То есть asyncio не делает много всего одновременно в том же смысле, что threads. Он позволяет множеству задач эффективно делить один поток, если они часто ждут I/O и умеют явно уступать управление. Это очень важно.

Если ты внутри корутины делаешь вот так:

async def handler():
  heavy_cpu_work()
  return 42

то пока heavy_cpu_work() не закончится, весь event loop фактически заморожен.

Где здесь место GIL

Без GIL про асинхронность на senior-интервью лучше вообще не говорить. Советую в этой теме тоже покопаться.

Что важно понимать

В обычном CPython есть Global Interpreter Lock. Это значит, что в один конкретный момент времени Python bytecode в одном процессе в классической сборке CPython исполняет только один поток.

Отсюда следуют две вещи:

  1. Для I/O-bound задач потоки всё ещё полезны, потому что во время блокирующих I/O-операций GIL может освобождаться, и другой поток может работать.

  2. Для CPU-bound Python-кода потоки обычно не дают реального ускорения по CPU, потому что упираются в GIL.

Поэтомуasyncio вообще не про обход GIL.
asyncio решает другую проблему: эффективное управление большим количеством ожиданий I/O.

Если коротко и чисто под ответ для интервьюера:

asyncio не заменяет потоки и не обходит GIL. Он снижает накладные расходы на конкуренцию там, где задача в основном ждёт I/O и может кооперативно уступать управление.

Что такое coroutine в Python на самом деле

На уровне синтаксиса все знают:

async def f():    
  await g()

Но под капотом важно понимать, что coroutine — это объект состояния исполнения, а не функция, которая выполняется в фоне.

Когда ты вызываешь async def, тело не выполняется сразу. Создаётся coroutine object.

async def foo():    
  return 1

coro = foo()
print(coro)

То есть foo() здесь не вернул 1, а создал объект, который может быть позже продвинут event loop’ом.

Если очень грубо

Корутина — это state machine над frame выполнения:

  • где мы сейчас находимся

  • на каком await остановились

  • что ждём

  • какое значение надо вернуть в вызвавший код

  • какое исключение надо пробросить дальше

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

От генераторов к async/await

Исторически Python пришёл к async не на пустом месте. До async def и await были: генераторы, yield, generator-based coroutines,yield from.

И это важно понимать концептуально, потому что await — это развитие идеи “приостановить выполнение и позже продолжить”.

Если сильно упростить:

  • yield — отдать значение наружу и приостановиться

  • yield from — делегировать подгенератору

  • await — дождаться awaitable и приостановить корутину до готовности результата

То есть await — это специальная форма кооперативной передачи управления через protocol awaitable objects.

Что именно можно await-ить

На интервью почти всегда спрашивают, что можно передать в await.

Можно ответить “корутину” и закончить собеседование, или можно углубиться:

await работает с awaitable объектами. В эту категорию входят:

  • coroutine objects

  • Task

  • Future

  • любой объект с await

Пример кастомного awaitable:

class MyAwaitable:
  def __await__(self):
    yield
    return 42
    
async def main():
  result = await MyAwaitable()
  print(result)

В реальном backend-коде так пишут редко, но на собесе это хороший маркер того, что ты понимаешь модель, а не только API.

Event loop: что это такое

Если просто, то event loop — это просто цикл, который делает примерно три вещи:

  1. смотрит, какие callback/таски готовы к выполнению

  2. смотрит, какие I/O-события пришли от ОС

  3. переводит задачи между состояниями ожидания и готовности

Грубо:

while not stopped:
  process_ready_callbacks()
  process_timers()
  poll_io_events()
  wake_tasks_waiting_for_events()

Этот цикл и есть центр всей asyncio модели.

Что делает await asyncio.sleep(1)

Это любимый вопрос.

Когда корутина делает:

await asyncio.sleep(1)

не происходит засыпание потока на секунду. Происходит примерно следующее:

  1. создаётся awaitable / future, связанный с таймером

  2. текущая Task приостанавливается

  3. event loop регистрирует, что через 1 секунду надо пометить future как completed

  4. loop переключается на другие ready-задачи

  5. по таймеру эта задача снова попадает в ready queue

  6. выполнение корутины продолжается после await

Ключевая мысль: sleep в asyncio — это не блокировка, а добровольная сдача управления loop’у до определённого момента.

Future и Task: в чем разница

Future — это объект-обещание результата, который будет готов позже.

У него есть состояние: pending / done / cancelled

В Future можно потом положить: результат, исключение, отмену.

Это низкоуровневая сущность синхронизации между producer и consumer.

Task — это Future, который оборачивает корутину и продвигает её исполнение в event loop.

То есть:

  • Future сам по себе ничего не исполняет

  • Task исполняет coroutine step by step

Очень короткая формулировка:

Future — контейнер для будущего результата.
Task — активный исполнитель coroutine, который тоже является future-like объектом.

Именно поэтому await task работает: task — awaitable.

Что происходит при create_task

Такая запись:

task = asyncio.create_task(worker())
  1. создаёт coroutine object worker()

  2. заворачивает его в Task

  3. регистрирует task в текущем event loop

  4. loop получает право продвигать её выполнение, когда наступит её очередь

С этого момента задача может начать выполняться конкурентно с точки зрения event loop, то есть interleaving execution: кусок одной, кусок другой, пока они кооперативно уступают управление.

Кооперативная многозадачность: почему один плохой кусок кода может убить весь loop

В asyncio нет принудительного вытеснения корутины по таймеру квантов, как у ОС с потоками. Если корутина не доходит до await, она не уступает управление.

Плохой пример:

async def bad():
  while True:
    do_cpu_work()

или так:

async def bad():
  time.sleep(5)

В обоих случаях event loop встанет колом.

  • В первом — потому что чистый CPU без yield points.

  • Во втором — потому что time.sleep блокирует поток.

Именно поэтому async-код требует дисциплины:

  • не использовать блокирующие вызовы внутри loop

  • не выполнять тяжёлый CPU прямо в корутинах

  • уметь выносить такое в thread/process pool или отдельный сервис

Почему async/await != быстрее

Асинхронность не ускоряет выполнение одной конкретной операции. Она улучшает утилизацию ожиданий.

Если есть одна операция, которая делает один SQL-запрос и всё, то async не обязан быть быстрее. Может быть даже медленнее из-за накладных расходов на event loop, task scheduling и abstraction layers.

asyncio начинает выигрывать, когда:

  • одновременно много I/O-bound операций

  • нужно держать много соединений

  • нужны высокие concurrency levels

  • важна эффективность на ожидании

Пример: 10 000 открытых сокетов с ожиданием событий.
Сделать это на тредах можно, но будет больно.
Сделать это через event loop — естественно.

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

Где потоки всё ещё хороши

Многие разработчики, и я в том числе, после знакомства с asyncio начинают думать, что threads — это легаси. Это суждение ошибочно.

Потоки в Python до сих пор отлично подходят, когда:

  1. Нужно интегрировать блокирующую библиотеку, которую нельзя заменить.

  2. Работа I/O-bound, но библиотека синхронная.

  3. Нужно offload-нуть небольшой блокирующий кусок так, чтобы не стопорить event loop.

  4. Есть код, где переписывать всё под async бессмысленно.

Типичный пример:

result = await asyncio.to_thread(blocking_sdk_call, arg1, arg2)

Это абсолютно нормальный production-паттерн.

Где нужны процессы

Процессы нужны там, где упираешься в CPU и хочешь реальный parallelism.
Например: тяжёлый Python compute / CPU-heavy parsing / batch processing / большие ETL-задачи

Потоки здесь часто не дадут ускорения из-за GIL, а процессы дадут, потому что у каждого процесса свой интерпретатор и свой GIL.

Минусы процессов: IPC дороже, сериализация дороже, память дороже, lifecycle сложнее

Но если задача CPU-bound, это правильный инструмент.

Structured concurrency: почему TaskGroup лучше россыпи create_task

Код на asyncio можно написать так:

task1 = asyncio.create_task(a())
task2 = asyncio.create_task(b())

res1 = await task1
res2 = await task2

Проблемы тут сразу знакомые:

  • забыли дождаться task

  • забыли отменить sibling task при ошибке

  • получили dangling tasks

  • исключения потерялись или приехали позже

  • cancellation стала хаотичной

TaskGroup решает именно это: даёт область жизни для группы дочерних задач.

Пример:

async with asyncio.TaskGroup() as tg:
  tg.create_task(a())
  tg.create_task(b())

Смысл:

  • если одна задача падает, группа корректно управляет остальными

  • жизненный цикл дочерних задач привязан к lexical scope

  • меньше фоновых “потеряшек”

Cancellation: самая недооценённая часть async-кода

Почти любой junior/middle знает await. Гораздо меньше разработчиков по-настоящему понимают cancellation semantics.
А в проде именно отмена часто ломает асинхронные системы.

Что происходит при отмене задачи

Когда task отменяют, внутрь корутины пробрасывается CancelledError.

То есть отмена — это не мгновенное выключение выполнения, а кооперативное исключение, которое код должен корректно пережить.

Пример:

async def worker():
  try:
    await asyncio.sleep(10)
  finally:
    await cleanup()

Если задачу отменили, finally всё равно выполнится. И это критично для:

  • закрытия соединений

  • rollback

  • release semaphore

  • ack/nack сообщений

  • снятия lock

Антипаттерн

except Exception:
  ...

Исторически и практически очень важно не проглатывать отмену там, где её не надо проглатывать. Cancellation — это часть control flow, а не обычная бизнес-ошибка.

Timeout — это не просто “подождать N секунд”

timeout надо воспринимать как политику ограниченного ожидания, а не как декоративный параметр.

Если ты делаешь запрос во внешний сервис без timeout, то ты создаёшь:

  • висящие таски

  • давление на пул соединений

  • рост latency

  • накопление очереди запросов

  • cascade failure

Поэтому в хорошем async-коде timeout — обязательный слой дизайна.

Backpressure и bounded concurrency

Допустим, у тебя есть 50 000 записей, и ты пишешь:

tasks = [asyncio.create_task(process(item)) for item in items]
await asyncio.gather(*tasks)

Выглядит норм. На деле это может обернуться сбоем.

Ты можешь:

  • создать слишком много task objects

  • убить пул соединений

  • устроить memory spike

  • засыпать downstream сервис лавиной запросов

  • получить timeout storm

Хороший код ограничивает конкуренцию.

Пример через semaphore:

sem = asyncio.Semaphore(100)

async def bounded_process(item):
  async with sem:
    return await process(item)

Это и есть bounded concurrency (ограниченная конкурентность).

А backpressure — это идея не принимать или не производить работу быстрее, чем система умеет её переваривать.

В backend-инженерии это одна из ключевых тем: очереди, лимиты, семафоры, connection pools, rate limiting, batching

Если говорить очень коротко:

Без bounded concurrency асинхронный код легко превращается в очень быстрый способ убить собственный сервис.

gather, wait, as_completed: когда что использовать

asyncio.gather

Когда надо дождаться набора awaitable и собрать результаты в одном месте.

results = await asyncio.gather(a(), b(), c())

Важно отметить:

  • по умолчанию ошибка одного awaitable валит весь gather

  • есть return_exceptions=True, но использовать его надо осознанно, иначе можно замаскировать ошибки

asyncio.wait

Низкоуровневый инструмент, когда нужен контроль:

  • дождаться first completed

  • first exception

  • all completed

asyncio.as_completed

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

Это полезно, например, когда:

  • запросы разной длительности

  • нужно начинать обработку раньше

  • важна streaming-like semantics

Основная мысль: ты выбираешь какая модель завершения нужна задаче.

Почему asyncio.gather(*huge_list) часто плохая идея

Потому что у разработчиков в голове gather часто означает параллелизм, а на практике — создание очень много активной работы разом.

Если у тебя 100k объектов и ты на каждый создаёшь корутину, это: память, планирование, давление на loop, downstream overload.

Гораздо умнее — batching или bounded worker pool.

Примерно так:

queue = asyncio.Queue()
workers = [asyncio.create_task(worker(queue)) for _ in range(100)]

Или через semaphore с windowed execution.

Async context managers и async iterators

async with

Нужен, когда вход/выход из контекста сам требует awaitable-действий:

  • открыть/закрыть соединение

  • начать/закончить транзакцию

  • захватить/освободить async lock

  • подписаться/отписаться от стрима

async for

Нужен, когда источник данных асинхронный:

  • стрим байтов по сети

  • батчи сообщений

  • cursor over async DB results

  • websocket stream

То есть это способ вшить ожидание в протокол итерации и управления ресурсами.

Событийный цикл и fairness: почему некоторые задачи могут “голодать”

В asyncio нет жёсткой гарантии идеальной fairness в бытовом смысле: все получают одинаковое время. Всё сильно зависит от того, как часто задачи доходят до await, какие callback’и попадают в ready queue, сколько работы делает каждый step задачи между yield points.

Если одна задача делает очень долгие куски CPU между двумя await, она будет ухудшать responsiveness всего loop.

Поэтому good async code — это не только везде await, а ещё и разумная гранулярность работы.

Что происходит при работе с сокетами на уровне loop

Если смотреть концептуально, event loop работает примерно так:

  1. есть набор file descriptors / handles

  2. loop регистрирует интерес к событиям read/write

  3. ОС через selector сообщает, какие дескрипторы готовы

  4. loop будит связанные callbacks/tasks

  5. те продвигают корутины дальше

То есть loop оркестрирует готовность I/O и исполнение continuation’ов.

Что такое uvloop, где помогает, а где не спасёт

uvloop — это альтернативная реализация event loop для asyncio, построенная поверх libuv. Проще говоря, это более быстрый “движок” для асинхронного Python-кода. Саму модель asyncio он не меняет: корутины, await, Task и Future работают так же, но loop часто обрабатывает события и I/O эффективнее.

При этом uvloop не лечит архитектурные проблемы. Если внутри корутины есть блокирующий код, CPU-heavy вычисления или нет bounded concurrency, переход на uvloop сам по себе сервис не спасёт.

uvloop обычно помогает, потому что даёт более быстрый event loop implementation поверх libuv.

Он может улучшить: loop overhead, scheduling, networking throughput, event processing efficiency.

Но он не спасёт, если: блокирующие вызовы внутри корутин, CPU-heavy Python logic, плохой connection pool design, отсутствие backpressure, миллионы лишних task objects, timeout/cancellation chaos.

Хорошая формулировка:

uvloop оптимизирует event loop layer, но не исправляет неправильную concurrency model в приложении.

Асинхронность и базы данных: где узкие места

Узкие места в async DB access обычно такие:

  • pool size

  • connection starvation

  • long transactions

  • N+1 queries

  • downstream DB latency

  • backpressure при burst traffic

Async-драйвер помогает не блокировать loop во время ожидания БД, но если пул 10 соединений, а запросов 2000, у тебя bottleneck никуда не исчез.
То же самое с HTTP clients, Redis, Kafka.

Async делает ожидание более эффективным.
Он не убирает ограничения внешней системы.

Deadlocks и race conditions в async-коде никуда не делись

Есть миф, что async упрощает concurrency и убирает race conditions.
Он их меняет, но не убирает.

Проблемы всё ещё есть:

  • разделяемое изменяемое состояние

  • потерянное обновление

  • ошибки из-за порядка выполнения

  • гонки при отмене задач

  • повторное закрытие ресурса / повторный запрос

  • lock, удерживаемый через await

  • неочевидное чередование выполнения корутин

Очень неприятный паттерн:

async with lock:
  await some_network_call()

Технически иногда так можно. Но это очень опасно:

  • lock удерживается долго

  • внутри await может случиться timeout/cancellation

  • растёт contention

Важно думать:

  • что именно ты защищаешь lock’ом

  • можно ли сократить критическую секцию

  • нельзя ли отделить вычисление состояния от внешнего I/O

Async locks, semaphores, queues: это не просто примитивы, а инструменты архитектуры

Lock

Используется для защиты общего изменяемого состояния внутри event loop.

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

Semaphore

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

Типичные примеры:

  • пул соединений к базе данных

  • внешние HTTP-сервисы

  • файловые дескрипторы

  • вынесенные в thread pool CPU-задачи

То есть это инструмент, который дает указания: одновременно можно делать не больше N таких операций.

Queue

Используется для построения схемы производитель -> потребитель и управления потоком задач.

С её помощью можно:

  • накапливать задачи

  • обрабатывать их воркерами

  • не перегружать систему

  • реализовывать backpressure (когда мы не принимаем больше работы, чем можем обработать)

Что по итогу отвечать на самые популярные вопросы

  1. Что такое асинхронность в Python?
    В Python асинхронность — это модель кооперативной многозадачности, в которой множество I/O-bound операций делят один поток исполнения через event loop. Корутины явно уступают управление в точках await, а loop в это время продвигает другие готовые задачи и будит ожидающие по сигналам готовности I/O или таймерам. Это не замена параллельным вычислениям и не обход GIL, а способ эффективно управлять большим количеством ожиданий с меньшими накладными расходами, чем у thread-per-request модели.

  2. Когда выбрать asyncio, а когда потоки или процессы?
    asyncio я выбираю, когда задача явно I/O-bound и в системе много одновременных ожиданий: HTTP, БД, очереди, сокеты. Если работа блокирующая, но библиотека синхронная и переписывать её невыгодно, я вынесу её в thread pool. Если задача CPU-bound и нужен реальный parallel execution, я пойду в процессы или в отдельный compute layer. То есть я выбираю по профилю нагрузки: ожидание, блокирующий I/O или CPU.

Каверзные вопросы

Почему await не делает код параллельным?

Потому что await лишь кооперативно уступает управление loop’у. Параллельного выполнения Python bytecode от этого не появляется.

Почему async-код может тормозить весь сервис?

Потому что event loop один, и если внутри него запустить блокирующий или CPU-heavy код без yield points, он стопорит весь loop.

Чем Task отличается от Future?

Future — контейнер результата, который появится позже. Task — future, который продвигает coroutine execution в loop.

Можно ли писать CPU-bound код в async def?

Синтаксически да, архитектурно обычно нет. Он блокирует loop.

Почему asyncio не заменяет потоки полностью?

Потому что он не решает задачу реального параллелизма и не помогает со старым блокирующим кодом без offloading.

Что будет, если внутри async-функции вызвать requests.get()?

Заблокируешь event loop на время вызова.

Когда использовать TaskGroup, а не create_task?

Когда нужен структурированный жизненный цикл группы дочерних задач и корректная обработка ошибок/отмены.

Что такое backpressure?

Это механизм не производить и не принимать больше работы, чем система может обработать без деградации.

Почему asyncio.gather с тысячами задач может быть плох?

Из-за memory pressure, scheduler overhead и перегрузки downstream’ов.

Что сложнее всего в async на практике?

Обычно не await, а cancellation, timeouts, shutdown semantics и bounded concurrency.

Итог

Если коротко, то вот тот уровень понимания асинхронности, который, по моему мнению, нужен на senior Python интервью.

И самое важное — понимать не только API, а поведение системы: как ограничивать конкуренцию (semaphore, очереди), как работать с таймаутами, как устроена отмена задач, как не перегружать downstream-сервисы, как система ведёт себя под нагрузкой.

Если ты можешь это объяснить своими словами и понимаешь, что именно происходит под капотом, а не просто расставляешь await — то ты уже лучше многих.

Автор: ester_mrt

Источник

Rambler's Top100