Сериализация между процессами в Python: что ломается первым под нагрузкой
Пул из восьми воркеров начинает захлёбываться не на вычислениях, а на передаче данных. Разбираем, во что превращается pickle под нагрузкой и чем его заменить.

Симптом выглядит так: у вас ProcessPoolExecutor на восемь воркеров, каждый обрабатывает батч из pandas DataFrame, а профилировщик показывает, что процессорное время внутри воркера — 40 мс, но задача возвращается через 300 мс. Остальные 260 мс уходят не на счёт. Они уходят на то, чтобы разложить объект в байты, протолкнуть через пайп и собрать обратно. Это цена межпроцессной сериализации, и под нагрузкой она ломается раньше, чем сама вычислительная логика.
Разбираем механизм: почему multiprocessing в CPython вынужден сериализовать всё, что пересекает границу процесса, сколько это стоит в миллисекундах и мегабайтах, и какие альтернативы снимают проблему, а какие только маскируют. Речь про CPython 3.11–3.12, стандартную библиотеку multiprocessing и concurrent.futures.
Почему процессы вообще сериализуют
Потоки в CPython делят одно адресное пространство и упираются в GIL*. Процессы GIL обходят, но у каждого своя память. Чтобы передать объект из родителя в воркер или обратно, его нужно превратить в поток байтов на одной стороне и восстановить на другой. В стандартной библиотеке это делает модуль pickle.
Каждый вызов pool.map(func, items) сериализует и func, и каждый элемент items, отправляет их через ОС-пайп в воркер, там разбирает, считает, сериализует результат и гонит обратно. На маленьких объектах накладные расходы теряются в шуме. На DataFrame в сотню мегабайт или на списке из миллиона словарей pickle становится узким местом — и по времени, и по памяти, потому что во время сериализации в памяти живут одновременно и объект, и его байтовое представление.
Правило, которое экономит часы отладки: если объект переживает границу процесса, вы платите за него сериализацией дважды — на выходе и на входе. Проектируйте так, чтобы границу пересекало как можно меньше и как можно более простых данных.
* GIL (Global Interpreter Lock) — глобальная блокировка интерпретатора CPython, из-за которой байткод Python в один момент исполняет только один поток. Именно поэтому CPU-bound задачи разносят по процессам, а не по потокам.
Что ломается первым
Проблемы приходят в предсказуемом порядке.
- Время на сериализацию крупных объектов. pickle протокола 5 (по умолчанию с Python 3.8) неплох, но большой вложенный объект с миллионами мелких элементов разбирается медленно из-за перебора ссылок и проверок на циклы.
- Пик по памяти. Сериализованное представление и исходный объект существуют одновременно. Передаёте DataFrame на 300 МБ — на пике держите близко к 600 МБ только под этот объект, плюс копию в воркере.
- Несериализуемые объекты. Открытый сокет, файловый дескриптор, лямбда, локальная функция, объект с
threading.Lockвнутри — pickle упадёт сTypeError: cannot pickle ...илиPicklingError. - Тихая деградация под параллелизмом. Восемь воркеров одновременно тянут данные через родительский процесс, который сериализует их последовательно. Родитель становится бутылочным горлышком, а воркеры простаивают.
Сколько это стоит: как померить у себя
Не берите чужие цифры — они зависят от структуры ваших объектов. Замерьте отдельно сериализацию, отдельно передачу. Минимальный замер стоимости pickle:
import pickle
import time
import pandas as pd
import numpy as np
# DataFrame на ~100 МБ: 5 млн строк, три колонки
rows = 5_000_000
df = pd.DataFrame({
"id": np.arange(rows),
"value": np.random.rand(rows),
"label": np.random.choice(["a", "b", "c"], rows),
})
start = time.perf_counter()
blob = pickle.dumps(df, protocol=5) # протокол 5 доступен с Python 3.8
dump_ms = (time.perf_counter() - start) * 1000
start = time.perf_counter()
restored = pickle.loads(blob)
load_ms = (time.perf_counter() - start) * 1000
print(f"размер blob: {len(blob) / 1024 / 1024:.1f} МБ")
print(f"dumps: {dump_ms:.0f} мс, loads: {load_ms:.0f} мс")Запустите на своём железе и своих данных. Если dump_ms + load_ms сопоставимо с временем самой обработки одного батча — сериализация и есть ваша проблема, и дальше есть смысл читать про альтернативы. Если она в разы меньше — узкое место не здесь, копайте в другом месте.
Альтернативы и их цена
1. Не передавать вообще: shared memory
С Python 3.8 в стандартной библиотеке есть multiprocessing.shared_memory. Идея: положить numpy-массив в общий блок памяти один раз, а воркерам передать только имя блока — короткую строку. Воркер подключается к блоку по имени и читает без копирования и без pickle.
from multiprocessing import shared_memory
import numpy as np
data = np.random.rand(5_000_000)
# Создаём общий блок и копируем в него данные один раз
shm = shared_memory.SharedMemory(create=True, size=data.nbytes)
shared_arr = np.ndarray(data.shape, dtype=data.dtype, buffer=shm.buf)
shared_arr[:] = data[:]
# Воркеру передаём только имя, форму и dtype — несколько десятков байт
task = (shm.name, data.shape, str(data.dtype))
# ... воркер: подключается по имени и читает без копии
# existing = shared_memory.SharedMemory(name=task[0])
# view = np.ndarray(task[1], dtype=task[2], buffer=existing.buf)
# После работы обязательно освободить, иначе блок останется в системе
shm.close()
shm.unlink()Цена: работает чисто для однородных бинарных данных (numpy-массивы, буферы), а не для произвольных Python-объектов со ссылками. И вы обязаны сами закрывать и удалять блоки — забытый unlink() оставляет мусор в /dev/shm на Linux до перезагрузки.
2. Сменить формат сериализации
Для табличных данных Apache Arrow и формат Parquet сериализуют быстрее и компактнее pickle, потому что хранят данные по колонкам в бинарном виде без Python-накладных. Библиотека pyarrow позволяет обмениваться таблицами через shared memory почти без копирования. Это внешняя зависимость — проверьте актуальный API в документации pyarrow, он менялся между мажорными версиями.
3. Уменьшить то, что пересекает границу
Часто дешевле переписать задачу так, чтобы воркер получал не данные, а ссылку на данные: путь к файлу, диапазон строк, ключ в БД. Воркер сам читает свой кусок. Через пайп летит строка вместо мегабайтов.
4. Не плодить процессы на каждую задачу
Если задач много и они короткие, стоимость сериализации func и запуска перевешивает выигрыш. Держите пул живым и шлите в него батчи покрупнее — накладные расходы амортизируются на большем объёме полезной работы.
| Подход | Выигрыш | Чем платите |
|---|---|---|
| pickle по умолчанию | Работает из коробки, любой объект | Время и память на крупных данных |
| shared_memory | Ноль копий для numpy/буферов | Только бинарные данные, ручное освобождение |
| Arrow / Parquet | Быстрая колоночная сериализация | Внешняя зависимость, только табличные данные |
| Передавать ссылку, а не данные | Через пайп летят байты, а не мегабайты | Воркер должен иметь доступ к источнику |
Где подход ломается
- fork против spawn. На Linux по умолчанию
fork: дочерний процесс наследует память родителя копированием при записи, и большие объекты, созданные до форка, доступны без сериализации. На Windows и macOS (с Python 3.8 по умолчанию) методspawn— родительская память не наследуется, всё уезжает через pickle. Код, который летал на Linux, на macOS упирается в сериализацию. Метод задаётся черезmultiprocessing.set_start_method(). - Глобальные объекты при spawn. При
spawnмодуль импортируется заново в каждом воркере. Тяжёлая инициализация на уровне модуля выполнится в каждом процессе. - Утечка shared memory. Незакрытые блоки переживают процесс. На проде это выглядит как медленно заполняющийся
/dev/shmи внезапныйOSError: No space left on device. - Несериализуемые замыкания. Лямбда или локальная функция не пиклится. Выносите функцию воркера на уровень модуля.
Что делать дальше
Начните с замера: приведённый выше сниппет показывает, съедает ли сериализация значимую долю времени. Если да — посмотрите, что именно пересекает границу процесса, и попробуйте по порядку: сначала уменьшить объём (передавать ссылку вместо данных), потом сменить формат для табличных данных, и только для горячего пути с numpy — shared_memory. В проде следите за размером /dev/shm и за пиком RSS воркеров: скачок памяти в момент старта задачи — прямой признак дорогой сериализации. Точный набор параметров set_start_method и поведение shared_memory сверяйте с документацией вашей версии CPython, поведение по умолчанию менялось между 3.8 и 3.14.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: Python docs — multiprocessing.shared_memory, Python docs — pickle protocol and object serialization
Частые вопросы
У меня Python 3.9, а не 3.12 — что-то отличается?
Я сделал всё как в статье, а воркеры всё равно простаивают. Что не так?
Стоит ли вообще использовать процессы, а не потоки?
Почему pickle падает с TypeError: cannot pickle?
Безопасно ли распаковывать pickle из внешнего источника?
Мой блок shared_memory иногда остаётся в системе после падения процесса. Как чистить?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.