сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · ETL и оркестрация

Асинхронность и параллелизм в Python

Конкурентность: async, потоки, процессы

В Python три модели параллелизма под разные задачи, и путать их дорого. Собес проверяет, понимаешь ли ты роль GIL и почему один блокирующий вызов убивает всю асинхронность.

Стержень: сетевой I/O - asyncio или потоки, CPU-нагрузка - процессы; GIL пускает в один момент только один поток байткода.

// Формулировки: «asyncio, потоки или процессы?», «зачем GIL и на что влияет?», «что морозит event loop?».

Выбор модели и GIL

GIL (global interpreter lock) пускает исполнять байткод в один момент только один поток, поэтому выбор зависит от природы нагрузки. Много сетевых вызовов (I/O-bound) - asyncio или потоки: пока один ждёт ответа, другие работают. CPU-жующая обработка - процессы: только они обходят GIL и грузят все ядра.

asyncio масштабируется на тысячи одновременных I/O: корутины дёшевы, переключение происходит на await. Потоки дороже по памяти, но не требуют переписывать код в async.

// Поэтому потоки для CPU-bound бесполезны - GIL не пустит их считать параллельно, ускорения нет. Тут нужны процессы или векторизация numpy.

GIL
в момент времени байткод исполняет один поток
I/O-bound / CPU-bound
ждём сеть/диск / жжём процессор

Ловушки asyncio

Любой блокирующий вызов внутри корутины (requests, time.sleep, тяжёлый CPU) морозит весь event loop - все остальные корутины встают. Нужны асинхронные аналоги (aiohttp, asyncio.sleep) или вынос в пул через run_in_executor.

Неограниченный fan-out тоже опасен: gather на десять тысяч запросов положит и чужой API (429, бан), и своё число соединений. Параллелизм ограничивают Semaphore или очередью задач.

// И тихая классика - забытый await: корутина создаётся, но не запускается, в логе повисает RuntimeWarning «coroutine was never awaited», а код молча не делает работу.

sem = asyncio.Semaphore(10)   # лимит fan-out
async def fetch(u):
    async with sem:
        return await client.get(u)
await asyncio.gather(*map(fetch, urls))
run_in_executor
блокирующий вызов в пуле потоков из async-кода
Semaphore
ограничитель числа одновременных задач

Процессы и обработка ошибок

concurrent.futures даёт простой API без полного asyncio: ThreadPoolExecutor.map для I/O, ProcessPoolExecutor для CPU. Часто этого достаточно.

Процессы платят сериализацией: аргументы и результаты гоняются через pickle, поэтому большие объекты дороже переслать, чем пересчитать - отдавай пути и ключи, а не сами данные. И таймаут обязателен: задача без него - вечный висяк в проде (asyncio.timeout/wait_for).

// В gather по умолчанию первое исключение рушит весь сбор; return_exceptions=True собирает и успехи, и ошибки в список, но тогда их надо явно разобрать, а не считать всё успехом.

ThreadPool / ProcessPool
пул потоков для I/O / процессов для CPU
return_exceptions
gather собирает исключения вместо падения на первом

Как отвечать: «asyncio, потоки или процессы - как выбираешь?»

От природы нагрузки и от GIL. Если задача I/O-bound - куча сетевых запросов, чтение из БД, беру asyncio: корутины дёшевы, пока одна ждёт ответа, работают другие, и так масштабируюсь на тысячи одновременных вызовов. Если переписывать в async дорого или библиотека синхронная, беру потоки - они тоже перекрывают ожидание I/O, потому что на времени ожидания GIL отпускается. А вот для CPU-bound задачи потоки бесполезны: GIL не пустит их считать параллельно, ускорения ноль - тут нужны процессы через ProcessPoolExecutor или векторизация numpy. При этом слежу за ловушками asyncio: ни одного блокирующего вызова внутри корутины, иначе замёрзнет весь loop; fan-out ограничиваю семафором, чтобы не убить API; и не забываю await, иначе корутина просто не запустится. Процессы использую с оглядкой на сериализацию - гоняю ключи и пути, а не большие объекты.

Почему это сильный ответ: выбор строго от I/O-bound vs CPU-bound через GIL, названы обе ловушки asyncio (блокирующий вызов, неограниченный fan-out) и цена процессов (pickle) - понимание механики, а не заученное «asyncio быстрее».

На чём валят

  • requests внутри async def - конкурентности ноль, loop заморожен.
  • gather на 10 000 запросов без семафора - 429 или бан от API.
  • Потоки для CPU-bound - GIL, ускорения нет; нужны процессы или numpy.
  • Гонки на общих счётчиках/словарях из потоков - Lock или очередь.
  • Забытый await - корутина создана, но не запущена; RuntimeWarning в логе.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 8, остальные разбираются в тренажёре.

  1. #async_concurrency1 / 5
    Когда для параллелизма берут multiprocessing, а когда threading?
    A)Брать процессы: они быстрее потоков благодаря обходу GIL на всех задачах
    B)Процессы — для CPU-bound (обходят GIL), потоки — для I/O-bound (ждут сеть/диск, GIL отпущен)
    C)Брать потоки: из-за GIL процессы в Python не дают выигрыша
    D)Выбор между ними определяется лишь объёмом данных в байтах, а не характером нагрузки
    показать ответ и разбор
    +B)Процессы — для CPU-bound (обходят GIL), потоки — для I/O-bound (ждут сеть/диск, GIL отпущен)

    // разбор: Из-за GIL потоки не ускоряют CPU-bound код (парсинг, вычисления) — тут нужны процессы, у каждого свой интерпретатор и ядро. Зато на I/O-bound (много сетевых/дисковых ожиданий) потоки эффективны: пока один ждёт, GIL отпущен и работает другой, а процессы были бы лишними накладными. Правило: считаем — процессы, ждём — потоки/async.

  2. #async_concurrency2 / 5
    Для чего в пайплайне применяют async/await?
    A)Ускорять тяжёлые вычисления, распараллеливая их по всем ядрам процессора автоматически
    B)Исполнять код строго последовательно, исключая конкурентность между корутинами
    C)Кооперативная конкурентность для I/O-bound без множества потоков
    D)Заменять собой базы данных, храня состояние пайплайна прямо внутри цикла событий asyncio
    показать ответ и разбор
    +C)Кооперативная конкурентность для I/O-bound без множества потоков

    // разбор: async/await даёт кооперативную конкурентность: одна корутина на время ожидания I/O (запрос к API, БД) добровольно уступает управление, и event loop запускает другую готовую. Тысячи одновременных сетевых операций ведутся одним потоком без накладных расходов на тысячи потоков. Выигрыш только на I/O-bound; на CPU-bound async не помогает (один поток всё равно считает по очереди).

  3. #async_concurrency3 / 5
    Зачем брать concurrent.futures.ThreadPoolExecutor для батча HTTP-запросов?
    A)Ускорять вычислительные CPU-bound задачи, обходя ограничение GIL интерпретатора
    B)Обеспечить строго последовательное выполнение запросов ровно в том порядке, в каком они были отправлены в пул
    C)Создавать отдельный поток ОС под каждый входящий запрос без ограничений
    D)Выполнять множество I/O-запросов конкурентно через пул потоков с простым map/submit-интерфейсом
    показать ответ и разбор
    +D)Выполнять множество I/O-запросов конкурентно через пул потоков с простым map/submit-интерфейсом

    // разбор: ThreadPoolExecutor держит фиксированный пул потоков и раздаёт им задачи (submit/map). Для I/O-bound батча (сотни HTTP-запросов) это резко ускоряет: пока потоки ждут ответов, GIL отпущен и они ждут параллельно, а размер пула ограничивает одновременную нагрузку на downstream. Проще ручного управления потоками и без переписывания на async.

  4. #async_concurrency4 / 5
    Почему async/await не ускоряет тяжёлую CPU-bound трансформацию?
    A)Event loop однопоточный: пока корутина считает, она не уступает управление — конкуренции нет
    B)Ускоряет, потому что asyncio незаметно распределяет вычисления по нескольким ядрам процессора в фоне
    C)Не ускоряет, потому что async медленнее обычного синхронного кода
    D)Не ускоряет, если забыть поставить ключевое слово await перед вызовом трансформации
    показать ответ и разбор
    +A)Event loop однопоточный: пока корутина считает, она не уступает управление — конкуренции нет

    // разбор: async даёт конкурентность только когда корутины уступают управление на await (обычно на I/O). CPU-bound код не ждёт, он считает — и, не встречая await, монопольно держит единственный поток event loop, блокируя все прочие корутины. Параллелизма по ядрам у asyncio нет. Тяжёлые вычисления выносят в пул процессов (run_in_executor с ProcessPool), а не оборачивают в async.

  5. #async_concurrency5 / 5
    Пул из 200 потоков шлёт запросы в сервис, и тот начинает падать. Что улучшить?
    A)Увеличить пул до 500 потоков — больше параллелизма даёт выше пропускную способность
    B)Ограничить конкурентность (семафор/меньший пул), чтобы не превышать пропускную способность downstream
    C)Убрать таймауты, чтобы запросы дожидались ответа перегруженного сервиса
    D)Ретраить упавшие запросы немедленно и без ограничений, пока сервис наконец не ответит успешно
    показать ответ и разбор
    +B)Ограничить конкурентность (семафор/меньший пул), чтобы не превышать пропускную способность downstream

    // разбор: Неограниченная конкурентность легко превышает то, что downstream держит, и валит его (а заодно себя ретраями). Вводят backpressure: семафор или пул фиксированного размера, ограничивающий число одновременных запросов до безопасного, плюс таймауты и ретраи с backoff. Цель — не «жать на газ», а держать нагрузку в пределах ёмкости приёмника.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.