В дочернем процессе, созданном через fork, почему изменение одного элемента большой структуры может резко увеличить потребление памяти?
Из-за механизма copy-on-write: после fork родитель и дочерний процесс сначала используют общие физические страницы памяти. При записи в страницу операционная система создаёт для записывающего процесса её отдельную копию, поэтому изменение даже одного элемента может скопировать не только сам объект, а всю затронутую страницу памяти.
fork появился как эффективный способ создать новый процесс без немедленного полного копирования адресного пространства. Полное копирование памяти на момент запуска было бы дорогим, особенно если дочерний процесс сразу заменяет свой код через exec.
Механизм copy-on-write позволяет отложить копирование до первой записи. Это ускоряет запуск процессов, но делает стоимость изменения памяти зависимой от того, сколько страниц будет модифицировано.
В Python после fork дочерний процесс получает логически независимую копию состояния родителя, но физически страницы могут оставаться общими. Если в родителе уже загружены большие списки, словари, кэши или модели, запись в них из дочернего процесса может постепенно увеличить его RSS.
Особенно опасна ситуация, когда разработчик рассчитывает на дешёвое совместное использование памяти, но рабочий код массово изменяет унаследованные объекты. Кроме того, внутренние изменения объектов CPython, например обновление счётчиков ссылок, тоже могут приводить к записи в страницы и разрушать совместное использование.
При fork виртуальные адресные пространства процессов указывают на одни физические страницы, помеченные как доступные только для чтения с точки зрения механизма copy-on-write. Когда процесс пытается записать в такую страницу, возникает исключение защиты памяти, после чего ядро создаёт копию страницы и разрешает запись только в неё.
Копируется обычно страница памяти, а не отдельный объект Python. Поэтому изменение небольшого объекта может затронуть страницу, содержащую другие объекты. При массовых изменениях дочерний процесс получает всё больше собственных страниц, и фактическое потребление памяти приближается к независимой копии.
Это не означает, что после fork память всегда удваивается. Неизменяемые страницы могут оставаться общими, а объём копирования зависит от размещения объектов, размера страниц, поведения аллокатора и характера записей. Точные показатели также следует оценивать по метрикам процесса, а не только по размеру Python-объектов.
Механизм применим только там, где доступен fork; на системах или в конфигурациях с spawn состояние создаётся иначе и обычно передаётся через сериализацию. Для надёжного контроля памяти используют spawn, явную передачу минимальных данных, внешнее разделяемое хранилище или специализированные механизмы общей памяти.
Сервис загружает большую модель до создания рабочих процессов и рассчитывает экономить память благодаря fork. Вариант с активным изменением общего Python-кэша в каждом процессе оказался неудачным: записи затронули множество страниц, и суммарное потребление памяти выросло почти до размера отдельных копий модели.
Можно было создавать процессы через spawn, но тогда модель пришлось бы загружать отдельно в каждый процесс, что увеличило время запуска и базовое потребление памяти. Можно было использовать общую память, однако это потребовало изменить формат данных и явно синхронизировать доступ.
Выбранное решение — оставить неизменяемую модель общей после fork, а изменяемые кэши сделать локальными и ограниченными по размеру. В результате страницы модели в основном оставались общими, а рост памяти стал предсказуемым; цена решения — отсутствие общего изменяемого кэша между процессами.
1. Копируется ли при fork вся память процесса немедленно?
Нет. Сначала копируются таблицы виртуальной памяти и создаётся логически независимое адресное пространство, а физические страницы по возможности используются совместно. Реальное копирование происходит при записи в конкретную страницу.
2. Почему даже чтение объекта Python иногда связано с ростом памяти?
Само чтение значения обычно не требует изменения страницы, но операции управления временем жизни объектов могут менять счётчики ссылок. В CPython такие изменения являются записями в память, поэтому страницы с объектами могут стать приватными даже без очевидной логической модификации структуры.
Это одна из причин, по которой совместное использование сложных Python-объектов после fork не гарантирует идеальную экономию памяти. Чем активнее дочерний процесс создаёт, удаляет и обрабатывает объекты, тем больше страниц он может затронуть.
3. Чем поведение fork отличается от spawn с точки зрения состояния Python-процесса?
При fork дочерний процесс начинает с копии состояния родителя на момент создания, используя copy-on-write. При spawn запускается новый интерпретатор, который импортирует необходимый модуль, поэтому состояние не наследуется автоматически и должно быть создано или передано явно.
fork может быстрее стартовать и экономить память для неизменяемых данных, но чувствителен к унаследованному состоянию и потокам. spawn обычно даёт более чистую изоляцию и предсказуемое состояние, но требует сериализации аргументов и повторной инициализации ресурсов.