> Что происходит при смешивании синхронного кода с CPU-bound задачами в Python (Python)

Уровень: senior · Роль: backend · Язык: Python · Категория: Технические вопросы

Компании: Sunlight

Стек: Python

> Пример ответа

Короткий ответ

Смешивание синхронного кода с CPU-bound задачами в Python приводит к блокировке event loop в asyncio или к неэффективному использованию GIL в многопоточности. CPU-bound задачи занимают GIL, не отпуская его, поэтому другие потоки или корутины голодают. Для таких задач нужны отдельные процессы (multiprocessing) или вынос в executor с ProcessPoolExecutor, иначе производительность деградирует до последовательного выполнения.

Подробное объяснение

В Python есть три уровня конкурентности: потоки (threading), процессы (multiprocessing) и асинхронность (asyncio). Каждый из них по-разному взаимодействует с CPU-bound нагрузкой.

GIL и потоки. GIL (Global Interpreter Lock) - это мьютекс, который позволяет только одному потоку исполнять байткод Python в любой момент времени. Для I/O-bound задач GIL переключается между потоками во время блокирующих операций, поэтому threading даёт выигрыш. Для CPU-bound задач поток не блокируется, а непрерывно выполняет вычисления, удерживая GIL. Переключение происходит только через определённое количество инструкций (sys.setswitchinterval, по умолчанию 5 мс), но это лишь создаёт иллюзию параллелизма - суммарное время выполнения не уменьшается, а часто растёт из-за накладных расходов на переключение.

Asyncio и event loop. В asyncio весь код выполняется в одном потоке. Event loop переключается между корутинами только в точках await. Если внутри корутины есть синхронный CPU-bound код без await, event loop блокируется целиком - все остальные корутины, включая обработку сетевых запросов, замирают. Это критично для backend-серверов: один тяжёлый запрос может остановить обслуживание всех остальных клиентов.

Процессы. multiprocessing обходит GIL, создавая отдельные интерпретаторы. Каждый процесс имеет свой GIL и свою память. Это единственный способ получить настоящий параллелизм для CPU-bound задач в Python. Однако межпроцессное взаимодействие дороже (pickle, pipes, shared memory), и старт процесса медленнее, чем потока.

Комбинирование. На практике часто используют гибридные подходы: asyncio для I/O и ProcessPoolExecutor для CPU-bound задач. Это позволяет сохранить отзывчивость event loop и задействовать все ядра CPU.

На практике

В backend-разработке типичный сценарий - веб-сервер (FastAPI, aiohttp) с эндпоинтом, который выполняет тяжёлые вычисления: обработка изображений, парсинг больших документов, ML-инференс, агрегация данных. Если такой код написать синхронно внутри async-функции, сервер перестанет отвечать на другие запросы.

Правильный подход:

  • для CPU-bound задач использовать loop.run_in_executor с ProcessPoolExecutor;
  • для лёгких задач, которые всё же блокируют (например, time.sleep), использовать asyncio.to_thread с ThreadPoolExecutor - но это не решает проблему CPU-bound;
  • для тяжёлых и длительных вычислений - выделенный worker-процесс с очередью (Celery, RQ) или отдельный сервис.

Важно помнить: run_in_executor с ProcessPoolExecutor требует, чтобы функция была pickle-able, а передача больших данных между процессами может свести на нет выигрыш от параллелизма.

Пример кода

PYTHON
import asyncio
import concurrent.futures
def cpu_bound_task(n: int) -> int:
"""Тяжёлая CPU-bound операция."""
result = 0
for i in range(n):
result += i * i
return result
async def async_handler(n: int) -> int:
"""Async-обработчик, который не блокирует event loop."""
loop = asyncio.get_running_loop()
# Выполняем CPU-bound задачу в отдельном процессе
with concurrent.futures.ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, cpu_bound_task, n)
return result
# Пример неправильного подхода - блокирует event loop
async def bad_handler(n: int) -> int:
return cpu_bound_task(n) # event loop заморожен на время выполнения

Как отвечать на собеседовании

Начни с сути: CPU-bound задачи в Python упираются в GIL, поэтому потоки не дают ускорения, а asyncio - блокируется. Затем покажи понимание различий между threading, multiprocessing и asyncio. Обязательно упомяни, что для backend это критично, потому что блокировка event loop останавливает весь сервер. Приведи практическое решение: ProcessPoolExecutor или вынос в отдельный сервис. Если спросят про альтернативы - упомяни C-расширения, NumPy (освобождает GIL), или альтернативные реализации Python (Jython, IronPython) без GIL, но с оговоркой о их неприменимости в продакшене.

Что проверяет интервьюер

Интервьюер проверяет:

  • понимание GIL и его влияния на потоки;
  • знание модели выполнения asyncio (кооперативная многозадачность);
  • умение выбирать правильный инструмент под тип нагрузки;
  • осознание последствий блокировки event loop в реальном backend;
  • знание executor'ов и их ограничений.

Типичные ошибки

  • Утверждение, что потоки ускоряют CPU-bound задачи - это неверно из-за GIL.
  • Использование asyncio.to_thread для CPU-bound задач - это создаёт только иллюзию параллелизма, GIL остаётся узким местом.
  • Запуск ProcessPoolExecutor на каждый запрос - создание пула процессов дорого, пул нужно переиспользовать.
  • Передача больших объёмов данных между процессами без учёта стоимости сериализации.
  • Игнорирование того, что run_in_executor с ProcessPoolExecutor не работает с lambda и замыканиями - функция должна быть импортируемой и pickle-able.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью