Как механизм copy-on-write позволяет процессу, созданному через fork, читать память родителя без немедленного полного копирования?
При fork дочерний процесс первоначально получает виртуальное адресное пространство, отображающее те же физические страницы, что и родитель. Копирование происходит только при попытке записи: операционная система создаёт отдельную копию изменяемой страницы для записывающего процесса.
Это экономит память и ускоряет запуск дочернего процесса, но выигрыш уменьшается при активных изменениях данных. Кроме того, в CPython даже служебные изменения счётчиков ссылок могут приводить к записи в страницы и нарушать совместное использование.
Системный вызов fork появился как эффективный способ создать новый Unix-процесс, сохранив его состояние на момент запуска. Полное копирование всей памяти при каждом создании процесса было бы дорогим по времени и объёму памяти, особенно когда дочерний процесс использует только небольшую часть данных.
Механизм copy-on-write решил эту проблему на уровне операционной системы: страницы сначала разделяются между процессами, а физическое копирование откладывается до первой записи. Поэтому создание процесса может быть быстрым даже при большом адресном пространстве родителя.
Представим родительский процесс, загрузивший большой справочник, модель или кэш, после чего он запускает несколько рабочих процессов. Если каждый рабочий процесс сразу получит независимую копию всех объектов, потребление памяти может вырасти почти пропорционально числу процессов.
При fork чтение общих данных обычно сохраняет страницы разделяемыми, но изменение объекта требует приватной копии затронутых страниц. Ошибочно считать, что дочерний процесс всегда использует память родителя совместно или что передача процесса через fork делает Python-объекты безопасно общими для записи.
После fork родитель и потомок имеют отдельные виртуальные адресные пространства. Записи таблиц страниц initially указывают на одни и те же физические страницы, помеченные операционной системой как доступные только для чтения в контексте copy-on-write.
Когда один из процессов записывает в такую страницу, возникает исключение защиты памяти. Ядро выделяет новую физическую страницу, копирует в неё содержимое старой и направляет запись в новую страницу. Другой процесс продолжает видеть исходную версию, поэтому изменения не становятся общими.
Гранулярность копирования обычно соответствует странице памяти, а не отдельному Python-объекту. Изменение одного небольшого объекта может скопировать всю страницу, содержащую его данные; расположение объектов и характер доступа поэтому влияют на фактический расход памяти.
Для CPython есть дополнительная тонкость: изменение счётчика ссылок — это тоже запись в память. Простое чтение структуры Python иногда сопровождается увеличением или уменьшением reference count, поэтому активное использование объектов может приводить к копированию страниц даже без логического изменения их содержимого.
Copy-on-write применим прежде всего к процессам, созданным через fork. При варианте запуска spawn новый интерпретатор запускается отдельно, а необходимые объекты обычно передаются через сериализацию; первоначального общего адресного пространства родителя там нет. Поведение доступных вариантов запуска также зависит от платформы и настроек приложения.
Практический компромисс таков: предварительная загрузка неизменяемых данных до fork может заметно сократить суммарные расходы памяти, но запись в эти данные, сборка мусора, изменение ссылок и локальные рабочие структуры постепенно уменьшают выгоду. Для действительно общего изменяемого состояния нужны явные средства межпроцессного взаимодействия, например разделяемая память или очереди, а не обычные Python-ссылки.
Минимальная иллюстрация механизма на Unix:
Массив не копируется целиком в момент fork; запись делает приватной только страницу, в которую попадает изменяемый байт. В реальном CPython оценка эффекта может быть сложнее из-за служебных записей интерпретатора и работы распределителя памяти.
Сервис загружает большую модель, а затем создаёт несколько рабочих процессов через fork. Вариант с загрузкой модели внутри каждого рабочего процесса прост и предсказуем, но почти полностью дублирует память и увеличивает время запуска. Вариант с загрузкой до fork позволяет процессам совместно читать модель и обычно экономит память.
Команда выбрала предварительную загрузку модели до создания рабочих процессов, запретила её изменение после запуска и вынесла изменяемые буферы в локальное состояние работников. Это сохранило преимущество copy-on-write; попытка обновлять модель на месте была отклонена, поскольку она постепенно превращала общие страницы в приватные и ухудшала предсказуемость потребления памяти.
Нет. Копируются таблицы отображения памяти и необходимые служебные структуры, а физические страницы данных первоначально могут быть общими. Полное или частичное копирование начинается только для страниц, в которые выполняется запись.
Нет. После fork процессы имеют независимые адресные пространства. Дочерний процесс может начать с тех же значений, но запись в объект изменяет его приватную копию страницы; для обмена результатами нужны IPC-механизмы, сериализация, разделяемая память или другой явный канал взаимодействия.
Потому что логическое чтение не всегда является физически чистым чтением. Операции интерпретатора могут изменять счётчики ссылок и другие служебные поля объектов, а такие записи затрагивают страницы памяти и вызывают их приватное копирование. Поэтому неизменяемая с точки зрения бизнес-логики структура не гарантирует полного сохранения общих страниц; особенно чувствительны объекты, которые активно создаются, уничтожаются или обходятся в дочерних процессах.