Как запустить задачу на несколько ядер в Python
Что проверяют
Понимаете ли вы, что в стандартном CPython потоки не исполняют Python-код параллельно из-за GIL, и знаете ли, чем это обходят. Следом спрашивают про накладные расходы процессов и про то, когда потоков всё-таки достаточно.
Как отвечать
Сначала определите тип нагрузки. Для I/O-bound задач (сеть, диск, запросы к базе) хватает потоков или asyncio: во время ожидания GIL отпускается. Для CPU-bound кода на чистом Python нужны процессы, у каждого свой интерпретатор и свой GIL.
concurrent.futures.ProcessPoolExecutor: самый удобный вариант.executor.map(func, items, chunksize=100)раздаёт работу пулу, число воркеров по умолчанию равно числу доступных ядер;multiprocessing.Pool: то же на уровне ниже, плюсimap_unorderedдля выдачи результатов по мере готовности;- C-расширения: NumPy в тяжёлых операциях,
hashlibиzlibна больших данных отпускают GIL, поэтому их можно параллелить потоками; - free-threaded сборка: CPython без GIL, экспериментальная в 3.13 и официально поддерживаемая с 3.14 (
python3.14t). Часть C-расширений под неё ещё не готова, так что в проде это осознанный выбор, а не вариант по умолчанию; - больше одной машины: Dask или Ray.
Обязательные детали про процессы. Аргументы и результаты передаются через pickle, поэтому лямбды и локальные функции передать нельзя, а большие объекты копируются дорого. Код запуска прячут под if __name__ == "__main__":: при методе spawn (по умолчанию в Windows и macOS) дочерний процесс заново импортирует модуль. В Linux с 3.14 по умолчанию forkserver, а не fork.
Где спотыкаются
Запускают CPU-bound расчёт в ThreadPoolExecutor и удивляются, что загружено одно ядро.
Отдают процессам слишком мелкие задачи. Если сама работа занимает микросекунды, сериализация и пересылка съедают весь выигрыш. Спасает chunksize или укрупнение задач.
Меняют глобальные переменные в воркере и ждут, что изменения увидит родитель. У процессов раздельная память, результат возвращают явно или через multiprocessing.Queue и Manager.