Асинхронность и параллелизм в Python
В 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, остальные разбираются в тренажёре.
- Когда для параллелизма берут 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.
- Для чего в пайплайне применяют 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 не помогает (один поток всё равно считает по очереди).
- Зачем брать 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.
- Почему 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.
- Пул из 200 потоков шлёт запросы в сервис, и тот начинает падать. Что улучшить?A)Увеличить пул до 500 потоков — больше параллелизма даёт выше пропускную способностьB)Ограничить конкурентность (семафор/меньший пул), чтобы не превышать пропускную способность downstreamC)Убрать таймауты, чтобы запросы дожидались ответа перегруженного сервисаD)Ретраить упавшие запросы немедленно и без ограничений, пока сервис наконец не ответит успешно
показать ответ и разбор
+B)Ограничить конкурентность (семафор/меньший пул), чтобы не превышать пропускную способность downstream// разбор: Неограниченная конкурентность легко превышает то, что downstream держит, и валит его (а заодно себя ретраями). Вводят backpressure: семафор или пул фиксированного размера, ограничивающий число одновременных запросов до безопасного, плюс таймауты и ретраи с backoff. Цель — не «жать на газ», а держать нагрузку в пределах ёмкости приёмника.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.