Разделяемая память в Python под нагрузкой: что ломается первым
Пул из восьми процессов на 32-ядерной машине упирается не в CPU, а в сериализацию и копирование при fork. Разбираем, почему shared_memory течёт, где multiprocessing.Manager становится узким горлышком и что мерить.

Считаете, что multiprocessing раздаёт работу по ядрам линейно: восемь процессов — восемь ускорений. На практике на 32-ядерной машине пул воркеров, каждый из которых читает большой numpy-массив и возвращает агрегат, разгоняется до 4x и упирается в стену. CPU загружен на 60%, но новые воркеры ничего не дают. Проблема не в вычислениях, а в том, как Python передаёт данные между процессами. Разберём, что ломается раньше остального.
Материал про CPython 3.12 на Linux. Windows и macOS ведут себя иначе из-за другого метода запуска процессов — про это в конце.
Ломается первым: сериализация аргументов и результатов
Когда вы пишете pool.map(func, big_list), каждый элемент списка проходит через pickle в родителе, летит в трубу (pipe) к воркеру и распаковывается там. Результат идёт обратно тем же путём. Если элемент — это numpy-массив на 200 МБ, вы платите за pickle, копирование в буфер трубы, чтение в дочернем процессе и обратную упаковку результата.
Профилировщик показывает CPU в воркерах, но реальное узкое место — родительский процесс, который в одиночку сериализует и раздаёт данные всем восьми. Один поток pickle против восьми воркеров — это и есть потолок 4x.
Симптом узнаваем: родительский процесс висит на 100% одного ядра, воркеры периодически простаивают, ожидая новую порцию. Добавление воркеров усугубляет — очередь на сериализацию растёт.
Как это померить
Не гадайте — снимите профиль родителя отдельно от воркеров:
# CPython 3.12
# Замер времени только на раздачу данных, без полезной работы в воркере
import time
import multiprocessing as mp
import numpy as np
def noop(chunk):
# ничего не считаем: измеряем только стоимость передачи
return chunk.shape
if __name__ == "__main__":
data = [np.random.rand(1_000_000) for _ in range(64)]
ctx = mp.get_context("fork")
start = time.perf_counter()
with ctx.Pool(processes=8) as pool:
pool.map(noop, data)
print(f"transfer-only: {time.perf_counter() - start:.3f} s")Если это время близко ко времени полного прогона с реальной работой — вы уперлись в передачу, а не в вычисления. Тогда данные надо не копировать, а разделять.
Ломается вторым: fork копирует память лениво, но Python её пишет
На Linux при методе fork дочерний процесс не копирует память родителя сразу — работает copy-on-write. Кажется, что большой массив, созданный до пула, воркеры получат бесплатно. Так и есть — ровно до первой записи в страницу.
Проблема в том, что CPython трогает почти каждый объект: счётчик ссылок лежит в заголовке объекта. Как только воркер обращается к unpickle-нутому питоновскому объекту, интерпретатор инкрементирует refcount — страница помечается изменённой и физически копируется. В итоге CoW-экономия работает для сырых буферов (numpy держит данные вне питоновских объектов) и почти не работает для больших структур из питоновских объектов вроде списка на миллион элементов.
- Работает CoW: numpy-массив, bytes, memoryview — данные в C-буфере, refcount трогается один раз на объект.
- Не работает:
list,dictс миллионами питоновских объектов — каждое обращение бьёт по страницам.
Мерить так: cat /proc/PID/smaps_rollup по воркеру, поле Private_Dirty. Оно показывает, сколько страниц уже скопировано приватно. Рост этого числа под нагрузкой — прямой признак, что CoW не спасает.
Явная разделяемая память: shared_memory и её грабли
Правильный ответ на копирование — multiprocessing.shared_memory. Родитель создаёт сегмент, кладёт туда numpy-массив поверх буфера, передаёт воркерам только имя сегмента (короткую строку), а не сами данные.
# CPython 3.12
import numpy as np
from multiprocessing import shared_memory
# --- родитель ---
arr = np.random.rand(10_000_000)
shm = shared_memory.SharedMemory(create=True, size=arr.nbytes)
# кладём данные в разделяемый буфер
shared = np.ndarray(arr.shape, dtype=arr.dtype, buffer=shm.buf)
shared[:] = arr[:]
# воркеру передаём только имя: shm.name
# --- воркер ---
def worker(name, shape, dtype):
existing = shared_memory.SharedMemory(name=name)
view = np.ndarray(shape, dtype=dtype, buffer=existing.buf)
result = float(view.sum())
existing.close() # закрыть в воркере, но НЕ unlink
return resultЗдесь три места, где всё ломается на проде.
Кто вызывает unlink
Сегмент физически удаляется только при unlink(). Вызывать его должен ровно один процесс — обычно родитель после того, как все воркеры закончили. Если unlink вызовет воркер, остальные получат обращение к удалённому сегменту. Если не вызовет никто — сегмент останется в /dev/shm после завершения программы. Проверяйте: ls -la /dev/shm после прогона, там не должно быть ваших psm_*.
resource_tracker и ложные варнинги
В нескольких версиях Python 3.8–3.9 resource_tracker выдавал предупреждение resource_tracker: There appear to be N leaked shared_memory objects даже при корректном закрытии, потому что трекер пытался убрать сегмент, который вы уже удалили штатно. Поведение уточнялось в более поздних версиях; на 3.12 оно спокойнее. Если вы на старом Python и видите этот варнинг при заведомо чистом коде — сверьтесь с трекером issues для вашей минорной версии, прежде чем чинить несуществующую утечку.
Отвал воркера оставляет сегмент
Если воркер падает по необработанному исключению или его убивает OOM-killer, close() не вызовется, а сегмент останется. Оборачивайте работу воркера в try/finally и держите список созданных сегментов в родителе, чтобы гарантированно вызвать unlink в finally самого пула.
Manager: удобно, но это отдельный процесс с сериализацией
multiprocessing.Manager раздаёт разделяемые list, dict, Namespace. Выглядит как общая память, но устроен иначе: менеджер — это отдельный серверный процесс, а объекты у воркеров — прокси. Каждое чтение и запись по прокси — это RPC-вызов с сериализацией через сокет.
Manager платит сериализацией за каждую операцию доступа. Общий словарь на десятки тысяч записей, к которому воркеры лезут в цикле, превращает менеджер в единую точку контеншена — все процессы стоят в очереди к одному серверу.
| Механизм | Что передаёт между процессами | Где ломается под нагрузкой |
|---|---|---|
| Pool.map с большими аргументами | Полный pickle каждого элемента | Родитель — узкое горло сериализации |
| fork + CoW | Ничего сразу, копирует при записи | refcount пишет в страницы, копирование растёт |
| shared_memory | Только имя сегмента | Ручное управление жизненным циклом, утечки в /dev/shm |
| Manager (прокси) | RPC на каждую операцию | Единый серверный процесс, контеншен |
Когда что выбирать
- Данные большие, читаются воркерами и не меняются —
shared_memoryс numpy поверх буфера. Расплата — ручной unlink. - Данные большие, но задача — обработать поток мелких элементов — оставьте
Pool, но передавайте не сами данные, а смещения/индексы в уже разделённый массив. - Нужен общий изменяемый счётчик или небольшой словарь состояния —
Managerтерпим, пока обращений немного. На высокой частоте доступа он станет горлом. - Простой счётчик —
multiprocessing.ValueилиArrayс явной блокировкой дешевле менеджера: без отдельного процесса и RPC.
Граничные случаи и где всё ломается неожиданно
spawn вместо fork. На Windows и macOS (с 3.8) по умолчанию spawn: дочерний процесс не наследует память вообще, а заново импортирует ваш модуль и получает аргументы через pickle. Глобальные объекты, созданные до пула, в воркерах не появятся. Код верхнего уровня модуля выполнится в каждом воркере повторно — если он не под if __name__ == "__main__", получите либо ошибку, либо рекурсивный запуск процессов.
Несериализуемые объекты. Открытый сокет, блокировка, лямбда, локальная функция — не пиклятся. При spawn это выстрелит сразу; при fork может проработать, потому что объект наследуется, но код станет непереносимым.
Размер трубы. Большой результат из воркера может заблокировать процесс на записи в pipe, если родитель не успевает читать. Это выглядит как дедлок при штатной на вид логике.
Что делать дальше
Первый шаг — разделить замер на два: время только на передачу данных (воркер-заглушка, как в примере выше) и время полной работы. Если они близки, оптимизировать вычисления бессмысленно, работайте с передачей. Смотрите в проде на загрузку родительского процесса отдельно от воркеров и на Private_Dirty в /proc/PID/smaps_rollup. Держите мониторинг /dev/shm, чтобы ловить утёкшие сегменты. Инвалидация и синхронизация записей в разделяемый буфер несколькими процессами — отдельная тема со своими блокировками, здесь мы разбирали только чтение и передачу.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: multiprocessing — Process-based parallelism (Python 3.12 docs), multiprocessing.shared_memory (Python 3.12 docs)
Частые вопросы
У меня Python 3.8, там нет multiprocessing.shared_memory?
Почему на Windows мой код с fork не работает?
Если у меня GIL мешает, поможет ли free-threaded Python 3.13?
Замеры в статье — это про мою нагрузку?
Течёт память между запусками — где искать?
Можно ли передать открытый файловый дескриптор или сокет в воркер?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.