В принципе, вызов fork() можно рассматривать как создание копий родительских сегментов с текстом, данными, кучей и стеком (в некоторых ранних реализациях системы UNIX и в самом деле происходило такое дублирование: образ нового процесса создавался путем копирования памяти родителя в область подкачки, из которого получался дочерний процесс; родитель при этом сохранял свою собственную память). Однако обычное копирование страниц виртуальной памяти родителя в новый дочерний процесс было бы расточительством по целому ряду причин — например, за вызовом fork() часто следует функция exec(), которая заменяет код процесса новой программой и повторного инициализирует сегменты данных, кучи и стека. Большинство современных реализаций UNIX, в том числе и Linux, пытаются избежать такого избыточного копирования, используя две методики.
• Ядро делает текстовый сегмент каждого процесса доступным только для чтения, чтобы они не могли изменить свой собственный код. Это означает, что родитель и потомок могут иметь общий текстовый сегмент. Системный вызов fork() создает текстовый сегмент потомка путем построения записей в таблице страниц памяти для каждого отдельного процесса; каждая запись ссылается на блок страницы физической памяти, который уже используется родителем.
• Для страниц родительского процесса в сегментах с данными, кучей и стеком ядро использует методику, известную как
Мы можем сочетать использование вызовов fork() и wait(), чтобы контролировать изменение объема занимаемой процессом памяти. Нас интересует диапазон задействованных процессом виртуальных страниц, который изменяется в результате воздействия таких факторов, как подстройка стека при входе и выходе в функции, вызов exec() и, что особенно важно в контексте этого раздела, вызовы malloc() и free(), приводящие к изменению кучи.
Представьте, что мы поместили некую функцию func() между вызовами fork() и wait(), как это сделано в листинге 24.3. После выполнения этого кода мы знаем, что с момента вызова func() память родителя не меняется, поскольку все возможные изменения происходят в дочернем процессе. Это может пригодиться по следующим причинам.
• Если мы знаем, что функция func() приводит к утечкам памяти или чрезмерной фрагментации кучи, данный подход устранит проблему (это может быть единственно возможное решение, если у нас нет доступа к исходному коду func()).
• Допустим, у нас есть алгоритм, который выделяет память во время анализа дерева (это может быть, к примеру, игровая программа, которая рассчитывает диапазон возможных ходов и реакций на них). Мы можем написать код с применением вызова free(), чтобы освободить всю выделенную память. Однако в некоторых случаях проще будет воспользоваться описанной выше методикой; это позволит нам сделать откат к начальной точке, оставляя память вызывающего процесса (родителя) без изменений.
Рис. 24.3.
В реализации, показанной в листинге 24.3, результат выполнения функции func() должен быть выражен в 8 битах, которые посредством вызова exit() передаются после завершения дочернего процесса в родительский, вызвавший wait(). При необходимости можно передать больший объем данных, наладив межпроцессное взаимодействие на основе файла, конвейера или как-то иначе.
Листинг 24.3. Вызов функции без изменения состояния памяти процесса
pid_t childPid;
int status;
childPid = fork();
if (childPid == –1)
errExit("fork");
if (childPid == 0) /* Потомок вызывает func() и использует */
exit(func(arg)); /* возвращенное значение в качестве */
/* кода завершения */
/* Родитель ждет завершения работы потомка.
Он может узнать результат выполнения
функции func(), прочитав ее 'status'. */
if (wait(&status) == –1)
errExit("wait");