Сериализация между процессами в Python: цена и альтернативы
Обмен объектами между процессами в multiprocessing проходит через pickle — и на больших массивах это стоит секунд и гигабайтов. Разбираем, где именно теряется время и чем pickle можно обойти.

Классическая картина: вы распараллелили обработку через multiprocessing.Pool, замерили — и оказалось, что четыре воркера работают медленнее одного потока. CPU-профиль показывает, что воркеры простаивают, а основное время съедает не расчёт, а передача данных. В CPython обмен объектами между процессами по умолчанию идёт через модуль pickle: объект сериализуется в байты, передаётся через канал, десериализуется на другой стороне. На массиве в сотни мегабайт это превращается в главную статью расходов.
Разберём, во что обходится эта сериализация, как её измерить и какими способами её можно сократить или убрать вовсе. Всё проверялось на CPython 3.12; поведение pickle и multiprocessing в целом одинаково начиная с 3.8, где протокол 5 стал доступен по умолчанию.
Откуда берётся цена
Процессы в отличие от потоков не делят память. Чтобы передать объект из одного процесса в другой, его нужно превратить в последовательность байтов, перекинуть через IPC-канал (на Unix — pipe или сокет) и восстановить. За сериализацию отвечает pickle, и она стоит трёх ресурсов сразу:
- CPU на упаковку и распаковку. Для вложенных структур из миллионов мелких объектов
pickleобходит граф целиком, и это чистая нагрузка на процессор. - Память. В момент передачи объект существует минимум дважды: оригинал плюс его байтовое представление. Для большого
numpy-массива это удвоение пиковой памяти. - Задержка канала. Байты идут через pipe с ограниченным буфером, крупный объект передаётся кусками.
Отдельная ловушка — аргументы функции, которую вы передаёте в Pool.map. Если функция замкнута на большой объект (например, DataFrame в замыкании), этот объект сериализуется и отправляется в каждый вызов. Тысяча задач — тысяча копий одних и тех же данных.
Правило, которое экономит часы отладки: если задача считается доли секунды, а данные к ней весят десятки мегабайт, вы платите за пересылку больше, чем за расчёт. Параллелизм здесь только вредит.
Как это измерить, а не угадать
Прежде чем оптимизировать, замерьте отдельно время сериализации и время расчёта. Для этого не нужен профайлер процессов — достаточно прогнать pickle вручную на том же объекте:
import pickle
import time
import numpy as np
# массив ~200 МБ float64
data = np.random.rand(25_000_000)
start = time.perf_counter()
blob = pickle.dumps(data, protocol=pickle.HIGHEST_PROTOCOL)
dump_time = time.perf_counter() - start
start = time.perf_counter()
restored = pickle.loads(blob)
load_time = time.perf_counter() - start
print(f"размер blob: {len(blob) / 1e6:.1f} МБ")
print(f"dumps: {dump_time * 1000:.1f} мс")
print(f"loads: {load_time * 1000:.1f} мс")Если сумма dumps и loads сопоставима с временем самой задачи или превышает его — сериализация и есть ваше узкое место, и дальше имеет смысл смотреть на альтернативы. Точные цифры зависят от железа и типа данных, поэтому не переносите чужие замеры на свой случай — прогоните на своих объектах.
Четыре способа снизить цену
1. Протокол 5 и out-of-band буферы
Начиная с Python 3.8 доступен протокол pickle 5, который умеет передавать крупные бинарные буферы (например, содержимое numpy-массива) «вне потока» — без лишнего копирования в промежуточный bytes. Это уменьшает удвоение памяти. numpy и pandas поддерживают этот механизм через протокол буферов. По умолчанию multiprocessing в свежих версиях уже использует высокий протокол, но при ручной сериализации указывайте его явно: pickle.HIGHEST_PROTOCOL. Детали out-of-band API смотрите в документации модуля pickle, раздел про PickleBuffer.
2. Общая память вместо пересылки
Если данные — большой числовой массив, который воркеры только читают, его вообще не нужно сериализовать. Положите его в разделяемую память один раз, а воркерам передайте только имя сегмента. Модуль multiprocessing.shared_memory появился в Python 3.8:
import numpy as np
from multiprocessing import shared_memory
# в главном процессе: создаём сегмент под массив
source = np.random.rand(25_000_000)
shm = shared_memory.SharedMemory(create=True, size=source.nbytes)
shared_arr = np.ndarray(source.shape, dtype=source.dtype, buffer=shm.buf)
shared_arr[:] = source[:] # копируем данные в общую память один раз
# воркеру передаём только имя и параметры, не сам массив
name, shape, dtype = shm.name, source.shape, source.dtype
def worker(name, shape, dtype):
# подключаемся к уже существующему сегменту
existing = shared_memory.SharedMemory(name=name)
arr = np.ndarray(shape, dtype=dtype, buffer=existing.buf)
result = float(arr.mean()) # читаем без копирования
existing.close()
return resultЧерез каналы уходят три коротких значения вместо 200 МБ. Плата — ручное управление жизненным циклом сегмента: после работы обязательно вызовите shm.close() в каждом процессе и shm.unlink() в том, кто создал сегмент, иначе память не освободится до перезагрузки. На запись из нескольких воркеров без блокировок этот подход не рассчитан — гонки данных на вашей совести.
3. Инициализация воркера вместо аргумента
Если большой объект read-only и не помещается в разделяемую память естественно (например, обученная модель или словарь), загрузите его один раз при старте воркера через initializer, а не передавайте в каждую задачу:
from multiprocessing import Pool
_model = None
def init_worker(model_path):
global _model
# загрузка происходит один раз на процесс, а не на задачу
_model = load_model(model_path)
def predict(sample):
return _model.run(sample)
if __name__ == "__main__":
with Pool(processes=4, initializer=init_worker,
initargs=("model.bin",)) as pool:
results = pool.map(predict, samples)Модель читается с диска четыре раза (по разу на процесс) вместо того, чтобы сериализоваться и пересылаться на каждый из тысяч samples.
4. Сменить формат сериализации
Для узких типов данных сторонние библиотеки бьют pickle по скорости и размеру. numpy-массивы дешевле передавать через arr.tobytes() с известной формой и типом, а структурированные данные — через Arrow. Но это не бесплатно: вы берёте на себя описание схемы и внешнюю зависимость. Для разнородных Python-объектов универсального выигрыша нет.
Сравнение подходов
| Подход | Что выигрываете | Чем платите | Когда брать |
|---|---|---|---|
| pickle по умолчанию | Ничего настраивать не нужно | CPU и удвоение памяти на крупных объектах | Данные мелкие, задачи тяжёлые |
| Протокол 5, out-of-band | Меньше копирований памяти | Нужна поддержка со стороны типа | numpy/pandas, крупные буферы |
| shared_memory | Нулевая пересылка read-only данных | Ручной lifecycle, риск гонок | Один большой массив, много воркеров-читателей |
| initializer | Загрузка один раз на процесс | Объект должен быть неизменяемым в задаче | Модель или справочник, много мелких задач |
Когда сериализации вообще быть не должно
Самый честный способ убрать цену pickle — не запускать процессы там, где они не нужны. Если задача упирается в ввод-вывод (сеть, диск), а не в CPU, потоки или asyncio дадут параллелизм без сериализации: потоки делят память. Многопроцессность оправдана, когда работа действительно грузит процессор и попадает под ограничение GIL, а объём передаваемых данных мал относительно объёма вычислений над ними.
Отдельно: инвалидация и синхронизация состояния между процессами — это уже не про сериализацию, здесь мы её не разбираем.
Что делать дальше
Начните с замера: прогоните pickle.dumps на реальном объекте задачи и сравните с временем расчёта. Если сериализация весит больше — определите тип данных. Числовой read-only массив уходит в shared_memory, тяжёлый неизменяемый объект (модель, индекс) — в initializer, крупные numpy/pandas-структуры выигрывают от протокола 5. И держите в голове главный вопрос перед распараллеливанием: сколько данных на одну задачу вы гоняете через канал и стоит ли расчёт этих пересылок.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: Python docs — pickle: Out-of-band Buffers, Python docs — multiprocessing.shared_memory
Частые вопросы
У меня Python 3.7, будет ли работать shared_memory?
Почему четыре воркера работают медленнее одного процесса?
shared_memory не освобождается после завершения, память утекает — что не так?
Можно ли писать в shared_memory из нескольких воркеров одновременно?
Стоит ли заменять pickle на orjson или msgpack для ускорения?
Как понять, что задача упирается в CPU, а не в ввод-вывод?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.