multiprocessing и shared_memory в Python: что внутри
Форк, pickle через pipe и участок разделяемой памяти — три разных механизма под одним модулем multiprocessing. Разбираем, где копируются данные, а где нет, и почему это влияет на скорость.

Запускаете пул из восьми процессов, передаёте каждому массив на 500 МБ — и через секунду видите, как процесс-родитель съедает 4 ГБ, хотя массив один. Дочерние процессы не делят его, а получают копию: multiprocessing по умолчанию сериализует аргументы через pickle и толкает их по pipe. Разберём, что происходит внутри при разных способах старта процесса, где именно копируются данные и как модуль shared_memory (Python 3.8+) убирает копию для сырых буферов. Речь про CPython 3.11–3.12.
Три способа стартовать процесс — три разных механизма
У multiprocessing есть контекст запуска: fork, spawn и forkserver. Выбор влияет не на API, а на то, как дочерний процесс получает состояние родителя.
- fork — вызывается системный
fork(). Дочерний процесс получает копию адресного пространства родителя. Физически память не копируется сразу: работает copy-on-write, страницы дублируются ядром только при записи. По умолчанию на Linux до Python 3.14; в 3.14 дефолт на Linux меняется наforkserver. - spawn — запускается новый интерпретатор с нуля, родительское состояние не наследуется. Всё, что нужно дочернему процессу, передаётся через pickle. Дефолт на macOS и Windows.
- forkserver — при старте поднимается отдельный процесс-сервер, и каждый новый воркер форкается уже от него. Меньше наследуется мусора, чем при прямом форке от большого родителя.
Проверить и задать контекст явно:
import multiprocessing as mp
# явный контекст вместо глобального set_start_method
ctx = mp.get_context("spawn")
print(ctx.get_start_method()) # spawn
with ctx.Pool(processes=4) as pool:
results = pool.map(str.upper, ["a", "b", "c"])
print(results)
Почему fork выглядит бесплатным, но им не является
При fork кажется, что дочерний процесс видит родительский большой массив без затрат — copy-on-write же. Но в CPython есть подвох: сборщик мусора и подсчёт ссылок трогают заголовки объектов. Каждый раз, когда дочерний процесс читает объект (даже не меняя данные), меняется его ob_refcnt — а это запись в память, и COW-страница дублируется. Так «общий» массив постепенно копируется по факту доступа.
Copy-on-write в CPython экономит память ровно до первого чтения объекта: инкремент счётчика ссылок — это запись, и ядро честно копирует страницу. Для больших неизменяемых данных COW работает хуже, чем ожидают.
Что происходит при передаче аргументов в pool.map
Когда вы вызываете pool.map(func, data), каждый элемент data сериализуется через pickle.dumps в родителе, байты идут по pipe в воркер, там pickle.loads восстанавливает объект. Результат функции проделывает обратный путь. Отсюда два следствия:
- Аргумент и результат должны быть picklable. Лямбда, открытый файловый дескриптор, соединение с БД — не пройдут (
PicklingError). - На больших объектах вы платите двойной сериализацией и копированием байтов. Для NumPy-массива на 500 МБ это заметная задержка на старте каждой задачи.
Именно эту копию убирает shared_memory.
shared_memory: буфер без копии
multiprocessing.shared_memory.SharedMemory (Python 3.8+) выделяет именованный участок памяти на уровне ОС (на POSIX — через shm_open, файл в /dev/shm). Несколько процессов открывают его по имени и видят один и тот же байтовый буфер. Копирования при доступе нет — это сырая память, а не Python-объект со счётчиком ссылок.
Родитель создаёт буфер и кладёт туда массив:
import numpy as np
from multiprocessing import shared_memory
data = np.arange(10_000_000, dtype=np.float64) # ~80 МБ
# создаём разделяемый буфер под размер массива
shm = shared_memory.SharedMemory(create=True, size=data.nbytes)
# view поверх буфера, копируем данные один раз
shared_arr = np.ndarray(data.shape, dtype=data.dtype, buffer=shm.buf)
shared_arr[:] = data[:]
print("имя буфера:", shm.name) # это имя передаём воркерам
Воркер подключается к тому же буферу по имени — без копирования содержимого:
from multiprocessing import shared_memory
import numpy as np
def worker(shm_name, shape, dtype):
# подключаемся к существующему буферу
existing = shared_memory.SharedMemory(name=shm_name)
arr = np.ndarray(shape, dtype=dtype, buffer=existing.buf)
total = arr.sum() # читаем без копии
existing.close() # отпускаем ссылку, буфер не удаляем
return total
Передаёте воркеру не сам массив, а строку shm.name плюс форму и dtype — они мелкие и сериализуются мгновенно.
Кто отвечает за удаление буфера
Тут главная ловушка. У SharedMemory два метода:
close()— процесс отсоединяется от буфера, но буфер продолжает существовать в ОС.unlink()— помечает буфер на удаление. Вызывать его должен ровно один процесс, обычно создатель, и только после того, как все остальные сделалиclose().
Если забыть unlink(), участок в /dev/shm останется висеть после завершения программы — утечка на уровне ОС, которую видно как файл в /dev/shm и которая переживёт ваш процесс. В Python 3.8 за этим следил отдельный resource_tracker, и он иногда удалял буфер раньше времени, выдавая предупреждение о «leaked shared_memory». Поведение трекера правили в последующих версиях, поэтому явный unlink() из одного контролируемого места надёжнее, чем надежда на автоматику.
# в родителе, когда все воркеры закончили
shm.close()
shm.unlink() # физически удаляем буфер
Когда что применять
| Способ | Копия данных | Что передаётся | Когда брать |
|---|---|---|---|
| pool.map с аргументом | двойная (pickle туда-обратно) | сам объект | мелкие задачи, объекты до сотен КБ |
| fork + глобальная переменная | по факту чтения (COW ломается) | ничего, наследуется | только Linux, только чтение, недолго |
| SharedMemory | одна при заполнении | имя буфера, форма, dtype | большие сырые буферы, NumPy |
| Manager (list, dict) | на каждый доступ (прокси через сокет) | прокси-объект | структуры с редким доступом, синхронизация |
Manager удобен, но каждый доступ к его прокси-объекту — это сериализация и обмен по сокету с процессом-менеджером. На горячем пути он медленнее прямой передачи. Для больших числовых данных выбор один — shared_memory.
Где подход ломается
- Гонки при записи.
shared_memoryне даёт никакой синхронизации. Если два процесса пишут в один буфер, поведение не определено. Нужна блокировка — беритеmultiprocessing.Lockотдельно. - Только сырые байты. В буфер ложатся числовые массивы и байтовые структуры. Произвольный Python-объект туда не положить без сериализации — а это снова копия.
- spawn против fork. Под
spawnглобальные переменные не наследуются, поэтому трюк «положил массив в глобал и форкнулся» не работает вовсе. Пишите код так, будто воркер стартует с чистого листа. - Размер фиксирован. Буфер создаётся под конкретный
sizeи не растёт. Нужен больше — создавайте новый. - Windows. Буфер держится живым, пока хотя бы один процесс держит его открытым; после закрытия всеми он исчезает, даже без
unlink(). Логика жизненного цикла отличается от POSIX — не переносите допущения между ОС.
Как измерить, что копия действительно ушла
Не верьте на слово — замерьте. Два показателя:
- Время старта задачи. Оберните
pool.mapвtime.perf_counter()на большом массиве с обычной передачей и с передачей имени буфера. Разница на массиве в сотни МБ будет исчисляться сотнями миллисекунд на задачу. - RSS процессов. Смотрите
psutil.Process().memory_info().rssу родителя и воркеров. Приshared_memoryсумма RSS не должна расти на размер массива, умноженный на число воркеров.
Условия замера фиксируйте: версия CPython, ОС, размер и dtype массива, число процессов. На разном железе абсолютные цифры разойдутся, важна относительная разница между способами.
Что делать дальше
Если данные читаются многими воркерами и не меняются — переведите передачу на shared_memory и вынесите unlink() в один контролируемый блок (например, в finally родителя). Если процессы ещё и пишут — добавьте Lock и продумайте, кто владеет какими диапазонами буфера. Проверьте, на каком контексте вы работаете: перед миграцией на Python 3.14 явно задайте get_context(), чтобы смена дефолта на Linux не поменяла поведение молча. Дальше стоит посмотреть в сторону concurrent.futures.ProcessPoolExecutor с параметром initializer — он позволяет подключить буфер один раз на воркер, а не на каждую задачу.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: multiprocessing — Process-based parallelism, Python docs, multiprocessing.shared_memory — Shared memory for direct access
Частые вопросы
А если у меня Python 3.8, shared_memory будет работать?
Почему после завершения программы в /dev/shm остаются файлы?
Не сработало: воркер видит нули вместо данных. В чём дело?
Почему fork с большим массивом в глобале всё равно ест память?
shared_memory защищает от гонок при записи?
Стоит ли использовать fork в проде на Linux?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.