> Как параллелить выполнение функции в Python (Python)

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

Компании: Сбер

Стек: Python

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

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

Параллельное выполнение функций в Python зависит от типа задачи: для I/O-bound используйте asyncio или потоки (concurrent.futures.ThreadPoolExecutor), для CPU-bound - процессы (ProcessPoolExecutor) или multiprocessing. Из-за GIL потоки не дают прироста для CPU-нагрузки. Выбор инструмента определяется характером работы: асинхронность - для сетевых операций, потоки - для блокирующего I/O, процессы - для вычислений.

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

В Python параллелизм реализуется тремя основными механизмами:

  1. Потоки (threads) - легковесны, но ограничены GIL (Global Interpreter Lock). GIL позволяет выполнять только один байткод за раз, поэтому для CPU-bound задач потоки бесполезны. Для I/O-bound - эффективны, так как GIL освобождается во время системных вызовов.

  2. Процессы (processes) - каждый процесс имеет собственную память и свой GIL. Полный параллелизм на многоядерных системах. Минусы: overhead на создание, межпроцессное взаимодействие (pickle, shared memory).

  3. Асинхронность (asyncio) - кооперативная многозадачность в одном потоке. Идеально для сетевых операций и большого числа конкурентных задач. Требует асинхронных библиотек (aiohttp, asyncpg).

Ключевой trade-off: потоки - простота и привычность, процессы - максимальная производительность для CPU, asyncio - масштабируемость для I/O.

На практике

Для типичного backend-сценария (обработка запросов, обращение к внешним API, работа с БД) чаще всего используется asyncio или ThreadPoolExecutor. Если функция блокирующая (например, синхронный HTTP-клиент), её оборачивают в run_in_executor или используют asyncio.to_thread.

Для CPU-интенсивных задач (парсинг, обработка изображений, ML-инференс) - ProcessPoolExecutor с concurrent.futures. Важно помнить про передачу данных между процессами: она сериализуется через pickle, что может быть дорого.

Пример кода

PYTHON
import asyncio
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
# I/O-bound: асинхронно
async def fetch_url(url: str) -> str:
await asyncio.sleep(0.1) # имитация сетевого вызова
return f"data from {url}"
async def run_async():
urls = [f"http://example.com/{i}" for i in range(10)]
results = await asyncio.gather(*(fetch_url(u) for u in urls))
return results
# I/O-bound: потоки
def blocking_io(item: int) -> int:
time.sleep(0.1)
return item * 2
def run_threads():
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(blocking_io, range(10)))
return results
# CPU-bound: процессы
def cpu_intensive(n: int) -> int:
return sum(i * i for i in range(n))
def run_processes():
with ProcessPoolExecutor(max_workers=4) as executor:
results = list(executor.map(cpu_intensive, [10_000_000] * 4))
return results
# Комбинированный подход: асинхронность + потоки для блокирующих вызовов
async def hybrid():
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, blocking_io, 42)
return result

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

Начните с классификации задач: I/O-bound vs CPU-bound. Затем объясните GIL и его влияние. Покажите понимание trade-off: потоки - просто, но ограничены; процессы - мощно, но дорого; asyncio - масштабируемо, но требует асинхронного кода. Упомяните concurrent.futures как высокоуровневый API. Приведите пример выбора для конкретного сценария. Если спросят про multiprocessing - сравните с ProcessPoolExecutor (первый - низкоуровневый, второй - удобнее).

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

  • Понимание GIL и его последствий.
  • Умение классифицировать задачи по типу нагрузки.
  • Знание конкретных инструментов и их ограничений.
  • Понимание overhead'ов (создание процессов, сериализация данных).
  • Способность выбрать правильный механизм под задачу, а не использовать один инструмент для всего.

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

  • Использование потоков для CPU-bound задач - ожидание ускорения, которого не будет из-за GIL.
  • Игнорирование asyncio для I/O, выбор потоков без необходимости.
  • Создание процесса на каждую задачу без пула - огромный overhead.
  • Передача больших данных между процессами через pickle - узкое место.
  • Забывают про asyncio.to_thread для блокирующих вызовов внутри асинхронного кода.
  • Не учитывают, что ProcessPoolExecutor требует, чтобы функция была pickle-able (например, не может быть lambda или методом экземпляра).

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

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