Пул процессов в Python: типичные ошибки multiprocessing
Скрипт с multiprocessing.Pool на Windows уходит в бесконечный форк, на Linux течёт память, а в Jupyter молча зависает. Разбираем ошибки, из-за которых пул процессов ведёт себя не так, как задумано.

Вы переписали цикл с for на Pool.map, чтобы загрузить все ядра, но получили либо зависание, либо ошибку «An attempt has been made to start a new process before the current process has finished its bootstrapping phase», либо ускорение в полтора раза вместо восьмикратного. Причина почти всегда не в самом пуле, а в том, как процессы стартуют и что между ними передаётся. Ниже — конкретные грабли multiprocessing в CPython (проверялось на 3.11 и 3.12), с кодом, который можно запустить, и объяснением, почему очевидный вариант ломается.
Что вообще происходит при старте процесса
В отличие от потоков, процесс — это отдельная копия интерпретатора с собственной памятью. Способ создания этой копии называется start method, и от него зависит половина ваших проблем.
- fork — дочерний процесс наследует всю память родителя копированием. По умолчанию на Linux (в 3.12 всё ещё fork, но обсуждается смена).
- spawn — запускается чистый интерпретатор, который заново импортирует ваш модуль. По умолчанию на Windows и macOS.
- forkserver — компромисс: один раз форкается «чистый» сервер-процесс, от него отпочковываются рабочие.
Метод по умолчанию можно узнать через multiprocessing.get_start_method() и задать через set_start_method. Большинство ошибок ниже возникают именно из-за того, что код писался с расчётом на fork, а запускается под spawn — или наоборот.
Термин: пикл
Пиклинг (pickling) — сериализация объекта Python в байты для передачи в другой процесс. Всё, что вы отдаёте в пул — функция и её аргументы — должно быть picklable. Лямбды, локальные функции, открытые файлы и сетевые соединения не пиклятся, и именно на этом падает половина новичковых пулов под spawn.
Ошибка 1. Нет защиты if __name__ == "__main__"
Под spawn дочерний процесс заново импортирует ваш скрипт целиком. Если создание пула написано на верхнем уровне модуля, каждый новый процесс при импорте снова попытается создать пул — и вы получите либо ошибку про bootstrapping, либо форк-бомбу.
# НЕПРАВИЛЬНО: под Windows/macOS это уйдёт в бесконечное создание процессов
from multiprocessing import Pool
def square(number):
return number * number
pool = Pool(4)
results = pool.map(square, range(10))
print(results)
Правильно — вся точка входа под защитой:
# ПРАВИЛЬНО
from multiprocessing import Pool
def square(number):
return number * number
if __name__ == "__main__":
with Pool(4) as pool:
results = pool.map(square, range(10))
print(results)
На Linux с fork первый вариант «работает», и это ловушка: код едет в прод, кто-то запускает его на macOS — и всё встаёт. Пишите защиту сразу, независимо от вашей ОС.
Ошибка 2. Передаёте в пул то, что не пиклится
Лямбда и вложенная функция не сериализуются стандартным pickle. Под fork такой код случайно проходит, потому что функция наследуется через память; под spawn падает с PicklingError или AttributeError: Can't pickle local object.
# Упадёт под spawn:
with Pool(4) as pool:
pool.map(lambda x: x * x, range(10)) # лямбда не пиклится
Решение — функция уровня модуля. Если нужно «зашить» дополнительный аргумент, используйте functools.partial с picklable-функцией, а не замыкание:
from functools import partial
from multiprocessing import Pool
def multiply(factor, number):
return factor * number
if __name__ == "__main__":
with Pool(4) as pool:
results = pool.map(partial(multiply, 3), range(10))
print(results)
Ошибка 3. Ждёте ускорения на задачах, где GIL ни при чём
Пул процессов даёт выигрыш на CPU-bound задачах: разбор чисел, обработка изображений, вычисления. На IO-bound (сеть, диск) накладные расходы на пиклинг и межпроцессный обмен часто съедают весь выигрыш, а иногда делают хуже.
Процесс — дорогой ресурс. Передать в него список из миллиона мелких чисел, чтобы каждый умножить на два, — значит потратить на сериализацию больше, чем на сами вычисления. Пул окупается, когда одна задача считается заметно дольше, чем передаётся.
Проверьте это профилировщиком до того, как усложнять код. Для IO-bound берите concurrent.futures.ThreadPoolExecutor или asyncio — там процессы не нужны.
Ошибка 4. Мелкая гранулярность и дефолтный chunksize
По умолчанию Pool.map сам вычисляет размер чанка, и на большом итерируемом это обычно нормально. Но при потоковой обработке через imap/imap_unordered размер чанка по умолчанию равен 1 — каждый элемент отправляется в процесс отдельно, и на мелких задачах это убивает производительность.
from multiprocessing import Pool
def heavy(number):
total = 0
for i in range(10000):
total += (number * i) % 7
return total
if __name__ == "__main__":
with Pool() as pool:
# chunksize подбирается опытным путём под размер задачи
for result in pool.imap_unordered(heavy, range(100000), chunksize=200):
pass
Универсального значения нет: слишком маленький chunksize — много обмена, слишком большой — простой ядер в конце. Начинайте с сотен и меряйте.
Ошибка 5. Пул не закрывается, процессы висят
Если создать пул без with и забыть про close()/join(), воркеры остаются жить и в неудачном случае держат память и файловые дескрипторы. Контекстный менеджер решает это сам, но помните: выход из with вызывает terminate(), то есть жёстко убивает воркеров. Если нужно дождаться завершения всех задач корректно — вызывайте close() и join() явно внутри блока.
Ошибка 6. Утечка памяти в долгоживущем пуле
Воркер живёт долго и обрабатывает тысячи задач. Если внутри задачи копится глобальное состояние или библиотека фрагментирует память (частый случай с обработкой изображений и NumPy), процесс раздувается. Лечится параметром maxtasksperchild — после N задач воркер перезапускается, освобождая память.
from multiprocessing import Pool
if __name__ == "__main__":
# воркер перезапускается каждые 100 задач
with Pool(processes=4, maxtasksperchild=100) as pool:
results = pool.map(heavy, range(100000))
Цена: перезапуск процесса не бесплатен, поэтому не ставьте maxtasksperchild=1 без реальной причины.
Сравнение инструментов
| Инструмент | Для чего | Обходит GIL | Цена |
|---|---|---|---|
| multiprocessing.Pool | CPU-bound, отдельные задачи | Да | Пиклинг аргументов, старт процессов |
| concurrent.futures.ProcessPoolExecutor | То же, но API удобнее | Да | Те же ограничения по picklable |
| ThreadPoolExecutor | IO-bound: сеть, диск | Нет | Не ускорит вычисления |
| asyncio | Много одновременного IO | Нет | Нужен async-совместимый код |
ProcessPoolExecutor из concurrent.futures — обёртка над тем же механизмом с более чистым API (submit, map, futures). Все ошибки выше касаются и его: та же защита __main__, те же требования к picklable.
Отдельный случай: Jupyter и интерактивная сессия
В Jupyter и IPython функции, объявленные в ячейке, живут в модуле __main__, который дочерний процесс под spawn не может корректно импортировать — пул молча зависает или падает на пиклинге. Обходной путь: вынесите функции в отдельный .py-файл и импортируйте, либо запускайте пул из обычного скрипта, а не из ноутбука.
Что делать дальше
Перед тем как заворачивать код в пул, задайте себе три вопроса: задача CPU-bound или IO-bound; пиклятся ли аргументы и функция; сколько времени займёт одна задача относительно её передачи. Если ответы за пул — начните с ProcessPoolExecutor, добавьте защиту __main__, замерьте с разным chunksize и maxtasksperchild. В проде смотрите на суммарное потребление памяти всеми воркерами и на равномерность загрузки ядер к концу задачи — неравномерный «хвост» обычно значит, что chunksize подобран плохо. Передача больших массивов между процессами — отдельная тема, там смотрите в сторону shared memory (multiprocessing.shared_memory) вместо копирования.
Детский психолог: Стратегии воспитания без травм, скрипты при истериках.
Спросить за 12 ₽Источники: multiprocessing — Process-based parallelism (Python docs), concurrent.futures — Launching parallel tasks (Python docs)
Частые вопросы
У меня Python 3.12 на Linux, всё работает без if __name__ == "__main__". Зачем он мне?
Почему пул падает с ошибкой Can't pickle local object?
Пул не ускорил программу, а замедлил. Что не так?
Как выбрать число процессов и chunksize?
Пул зависает в Jupyter, хотя в обычном скрипте всё работает. Почему?
Процессы съедают всё больше памяти при долгой работе. Как ограничить?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.