В предзагруженном сервере на Unix после fork дочерние процессы быстро получают высокий Private Dirty: как gc.freeze перед fork может уменьшить это влияние?
gc.freeze() переносит уже существующие отслеживаемые объекты CPython в постоянное поколение сборщика циклических ссылок. После fork эти объекты не участвуют в обычных циклах GC, поэтому сборщик реже изменяет служебные данные в общих страницах памяти и реже провоцирует copy-on-write. Это может снизить объём приватно изменённых страниц дочерних процессов, но не предотвращает копирование страниц, которые приложение действительно изменяет.
Модель copy-on-write позволяет дочернему процессу после fork совместно использовать страницы памяти родителя, пока ни один из процессов их не изменяет. Проблема предзагруженных Python-сервисов в том, что даже фоновая работа сборщика мусора может записывать служебные поля объектов и тем самым превращать общие страницы в приватные.
Для этого сценария в CPython появился механизм постоянного поколения: долгоживущие объекты можно исключить из обычных циклов обнаружения циклического мусора. Он предназначен прежде всего для уменьшения лишних записей после fork, а не для общего ускорения или уменьшения размера объектов.
Сервер сначала загружает конфигурацию, маршруты и другие долгоживущие данные, затем создаёт рабочие процессы через fork. Хотя дочерние процессы почти не меняют эти данные, их Private Dirty может расти из-за работы интерпретатора и сборщика мусора.
Простое отключение сборщика решает только часть проблемы и может быть опасным: новые циклические ссылки перестанут своевременно обнаруживаться. Неправильное применение freeze тоже рискованно: объект, ставший недостижимым после заморозки, не будет обычным образом собран, пока его не разморозят.
Вызов gc.freeze() перемещает все текущие отслеживаемые сборщиком объекты в специальное постоянное поколение. Последующие циклы сборки не сканируют это поколение, поэтому GC не обновляет для этих объектов обычные поколения и связанные с ними служебные структуры.
Типичная последовательность для предварительно загруженного процесса выглядит так: перед fork отключают автоматический GC, после полной инициализации долгоживших объектов вызывают freeze, выполняют fork, а в дочернем процессе снова включают GC для обработки новых объектов.
В примере отключение GC и freeze решают разные задачи. gc.disable() предотвращает автоматические циклы сборки в критический момент, а gc.freeze() оставляет уже подготовленные объекты вне последующих циклов GC. Отступ перед переменной в примере является опечаткой недопустимым в Python; корректная строка должна начинаться без пробела: gc_disabled = not gc.isenabled().
Freeze не делает объекты неизменяемыми и не защищает их от изменения полями, словарями или списками. Любая запись в страницу памяти всё равно может вызвать copy-on-write, поэтому эффект максимален, когда дочерние процессы в основном читают предзагруженное состояние.
Механизм относится к CPython и особенно полезен вместе с fork-подобной моделью запуска. На системах или в режимах multiprocessing, где используется spawn, память родителя таким способом не разделяется, поэтому ожидаемый эффект отсутствует. Проверять результат следует по PSS и Private Dirty, а не только по RSS: RSS учитывает общие страницы целиком и может вводить в заблуждение.
Веб-сервис загружал большой набор правил и затем создавал несколько рабочих процессов через fork. Вариант без специальных действий был прост, но GC дочерних процессов постепенно увеличивал Private Dirty. Вариант только с постоянным отключением GC уменьшал записи, однако создавал риск накопления циклического мусора.
Выбранным решением стали отключение автоматического GC перед fork, freeze долгоживущего состояния и включение GC в дочерних процессах после их запуска. Такой подход сохранил сборку новых циклических ссылок и одновременно исключил предзагруженные объекты из обычных циклов GC. Эффект оценивали по PSS и Private Dirty при одинаковой нагрузке, поскольку одно лишь снижение RSS не доказывает уменьшение фактически занятой физической памяти.
1. Уменьшает ли gc.freeze размер самих объектов?
Нет. Freeze не уплотняет объекты, не удаляет их поля и не заменяет словари более компактными структурами. Он меняет участие уже существующих объектов в циклическом сборщике и тем самым может уменьшить служебные записи после fork.
2. Почему freeze не гарантирует полное сохранение общей памяти?
Потому что copy-on-write срабатывает при любой записи в общую страницу. Изменение списка, словаря, счётчика ссылок или другой структуры, расположенной на странице, может сделать страницу приватной. Freeze снижает один источник записей, связанный с GC, но не отменяет обычную работу Python-кода и интерпретатора.
3. В чём риск заморозить временные объекты?
Замороженные объекты исключаются из обычных циклов обнаружения циклического мусора. Если после freeze они образуют недостижимый цикл, он может оставаться в памяти дольше ожидаемого. Поэтому freeze применяют после формирования устойчивого набора долгоживущих объектов, а не как безусловное действие в произвольной точке программы; при необходимости набор можно вернуть из постоянного поколения через gc.unfreeze() и снова разрешить его обычную обработку.