Салимóненко Дмитрий Александрович

Операционные системы

Задание 5: Многопроцессность в Linux с помощью функции fork()

(C++, Linux)

Введение


В этом задании на простом демонстрационном примере Вы можете (если есть желание) разобраться с многопроцессностью в Linux. Схема работы программы является примерно следующей:

  1. Запускается сама программа, т.е. родительский процесс.
  2. Из родительского процесса запускается его потомок, который, в общем случае, ведет себя НЕЗАВИСИМО от родительского (ну, если не вмешиваться в его работу специальными способами, конечно).

Процесс-потомок появляется в результате выполнения функции fork(). Понятно, что можно запустить множество процессов-потомков (до нескольких сотен, по крайней мере). Но, мы с Вами здесь рассмотрим только простейший пример, когда потомок будет один.

Примечание. Вообще, я бы крайне рекомендовал выполнить это или аналогичное задание (тестирование) ПЕРЕД тем, как использовать многопроцессность посредством функции fork() в своих разработках. Ибо по мере выполнения Вы столкнетесь с некоторыми неочевидными нюансами, теоретически объяснить которые так вот сразу и сходу является, по-видимому, проблематичным.
Впрочем, увы, так обстоят дела, наверное, практически для каждой функции/команды в любом, мало-мальски серьезном, языке программирования. Перед тем, как использовать ту или иную функцию, не следует ограничиваться только чтением мануалов/учебников ибо, там, увы, бывают описаны, как правило, далеко не все особенности. Так что - практика и только практика…

Дальнейшее поведение программы зависит от того, что делается в потомке и/или родителе. В частности, большое значение играет и хронология окончания их работы. Возможны две ситуации:

  1. Родитель заканчивает работу раньше, чем потомок (некорректная ситуация, ведущая к появлению так называемого процесса-зомби, коим будет являться некорректно завершенный потомок),
  2. Родитель заканчивает работу позже, чем потомок (нормальная ситуация).

Также несколько по-разному происходит обработка ошибок (для демонстрации этого в программе намеренно введена грубая ошибка).

Программный код, иллюстрирующий многопроцессность

Давайте сразу посмотрим исходный код (назовем его fork.c):

  1. #include <stdio.h>
  2. #include <errno.h>
  3. #include <sys/types.h>
  4. #include <unistd.h>
  5. #include <string.h>
  6. #include <stdlib.h>
  7. #include <time.h>
  8. #include <sys/time.h>
  9. #include <sys/wait.h>

  1. int main(int argc, char **argv)
  2. {
  3. pid_t pid, old_ppid, new_ppid;
  4. pid_t child, parent, parent_init;
  5. time_t timer;
  6. char* time_str;
  7. int err_val = 555;
  8. int sl, status;

  1. parent = getpid(); /* определяем pid самой программы-родителя перед вызовом fork() */
  2. parent_init = getppid(); // Определяем pid родительского процесса для данной программы-родителя

  1. FILE* fd = fopen("file.txt", "w" );  // Это файл с "логами" программы
  2. timer = time(NULL);  // Запускаем таймер
  3. time_str = ctime(&timer);
  4. fprintf(fd, "До вызова fork(): %s\n", time_str);
  5. fflush(fd);

  1. if ((child = fork()) < 0) { // Если ошибка при выполнении функции fork()
  2. fprintf(stderr, " fork of child failed: %s\n", strerror(errno));
  3. exit(1);

  1. } else if (child == 0) { // Начинает выполняться процесс-ПОТОМОК
  2. printf("Child process begining... \n");
  3. sleep(3); // Пока потомок ожидает 2 секунды, может выполняться процесс-родитель

  1. timer = time(NULL);
  2. time_str = ctime(&timer);
  3. fprintf(fd, "Потомок (Child): %s \n", time_str);
  4. fflush(fd);

  1. /* это выполняет только порожденный процесс-потомок */
  2. printf("pid родительского процесса для программы-родителя: %d\n", parent_init);
  3. printf("pid родительского процесса: %d\n", parent);
  4. printf("pid для потомка: %d\n", getpid());
  5. err_val = 777;  
  6. //err_val = 1/0; // Здесь программа не выводит ошибку, потомок просто прекращает работу

  1. printf("err_val value-CHILD:  %d  %p\n", err_val, &err_val);  // Будет ли выполняться эта команда? Почему?
  2. timer = time(NULL);  
  3. time_str = (char*)ctime(&timer);
  4. fprintf(fd, "Потомок после 1/0 (Child): %s\n", time_str);
  5. fflush(fd); // Принудительно делаем запись в файл, очищаем буфер, в котором хранится переменная time_str

  1. //exit(0);
  2. // Конец исходного кода процесса-потомка  
  3. } else { // Если child>0, то начинает выполняться процесс-РОДИТЕЛЬ

  1. printf("Parent process begining... \n");
  2. sl = atoi(argv[1]); // Задаем время задержки родительского процесса из 2-го аргумента командной строки
  3. printf("sleep: %d (для родительского процесса)\n", sl);  
  4. sleep(sl);

  1. timer = time(NULL);  
  2. time_str = ctime(&timer);
  3. fprintf(fd, "Родитель (Parent): %s \n", time_str);
  4. fflush(fd);
  5. //err_val = 1/0; // Раскомментируйте эту строчку, проанализируйте результат; получается, что родительский процесс реагирует иначе, чем потомок
  6. printf("err_val value-PARENT: %d  %p\n", err_val, &err_val); // Какое значение err_val выведет эта команда, почему?
  7. // Сравните значение и, главное, адрес переменной err_val, выводимый потомком и родителем. Почему наблюдается совпадение адресов? Ведь значения-то этой переменной в потомке отличается от ее значения в родителе... а виртуальные адреса, что, совпадают?...
  8. // printf("Родительский процесс ждет завершения потомка... Время: %s сек. \n", time_str);  
  9. // wait(&status);
  10. timer = time(NULL);  
  11. time_str = ctime(&timer);
  12. printf("Родительский процесс отработал. Время: %s сек. \n", time_str);    
  13. //exit(0); /* родитель завершается после fork() */
  14. }

  1. printf("err_val value (родитель или потомок?): %d  %p\n", err_val, &err_val); // Какое значение err_val выведет эта команда, почему?  
  2. //exit(0);
  3. }

Здесь потребуется детально разобраться с особенностями работы процессов: родителя и потомка, запускаемых из одного и того же программного кода. Потребуется запускать программу с различными параметрами.

Кстати, чтобы ускорить тестирование программы, целесообразно запускать ее компиляцию и, собственно, выполнение в одну строчку примерно таким образом:

g++ -o fork fork.c && ./fork 1

Здесь параметр «1» означает, как обычно, argv[1]. В данном случае, это – время задержки родительского процесса (в секундах).

Теоретические аспекты

Процесс-потомок наследует следующие признаки родителя:

  • сегменты кода, данных и стека программы;
  • таблицу файлов, в которой находятся состояния флагов дескрипторов файла, указывающие, читается ли файл или пишется. Кроме того, в таблице файлов содержится текущая позиция указателя записи-чтения;
  • рабочий и корневой каталоги;
  • реальный и эффективный номер пользователя и номер группы;
  • приоритеты процесса (администратор может изменить их через nice);
  • контрольный терминал;
  • маску сигналов;
  • ограничения по ресурсам;
  • сведения о среде выполнения;
  • разделяемые сегменты памяти.

Потомок не наследует от родителя следующих признаков:

  • идентификатора процесса (PID, PPID);
  • израсходованного времени ЦП (оно обнуляется);
  • сигналов процесса-родителя, требующих ответа;
  • блокированных файлов (record locking).

Поэтому, в частности, нет необходимости объявлять переменную, используемую и потомком, и родителем, дважды. Потомок, при успешном создании, получит ее копию. При этом он сможет изменять ее по своему усмотрению, у родителя же останется принадлежащая ему копия этой переменной, он также может изменять ее независимо от потомка (см. строчки 16, 39).

Весь код после fork() выполняется ДВАЖДЫ, как в процессе-потомке, так и в процессе-родителе.

Зомби — это процесс, который завершился, но не был удален. Удаление зомби возлагается на родительский процесс.

Как тестировать программу

Следует проанализировать ее работу при следующих наборах (вариантах) условий:

  1. Разные времена задержки родительского процесса: 1 и 3. В первом случае родительский процесс завершается быстрее, чем потомок; во втором случае – наоборот (итого – 2 варианта).

Примечание: время задержки процесса-потомка всегда фиксировано и равно 2 секунды (см. строчку 30).

  1. По очереди раскомментировать заведомо ошибочные строчки 40 и 57 (итого - 4 варианта).
  2. По очереди раскомментировать строчки 46, 65, осуществляющие завершение процессов потомка и родительского, соответственно (итого – 4 варианта).

Всего получается, по максимуму, 2*4*4 = 32 варианта. По мере тестирования окажется, что (большую) часть вариантов можно будет пропустить в силу очевидности. В таблице их проводить также не следует.

Конечно, можно также поэкспериментировать и с другими аспектами, но эти, на мой взгляд, являются основными.

Результаты тестирования целесообразно оформить, например, в виде таблицы следующего вида:

№ варианта Быстрее завершается: Попытка выполнения заведомо неверной команды err_val = 1/0; Завершение через exit(0); Комментарий (выявленные Вами особенности)
1 Потомок - -
2 Родитель - -
3 Потомок Потомок -
4 Потомок Потомок Потомок
5 Потомок Потомок Родитель
6 Потомок Потомок Потомок и Родитель















Обсудим наиболее важные особенности работы родителя и потомка

1. Время задержки

Время задержки (задаваемое функцией sleep()) используется для того, чтобы можно было протестировать работу программы в зависимости от того, кто раньше завершится: потомок или родитель. Как правило, потомок должен завершаться РАНЬШЕ родителя, тогда программа будет завершена корректно. В противоположном случае родитель, будучи завершенным, уже не в состоянии проконтролировать работу созданного им потомка. Именно поэтому, несмотря на его, вроде бы, завершение, последний, тем не менее, остается в памяти (в виде зомби). В некоторых частных случаях такое поведение бывает как раз желательным, но в типичных ситуациях такого быть не должно.

Скрыть пояснение
а не в «противном». Я категорически не желаю пользоваться последним термином и Вам не советую. Ибо он вызывает нежелательные смысловые ассоциации. С другой стороны, есть в русском языке четкие и ясные термины: «противоположный», «иной». Вот их я и использую здесь, как, впрочем, и везде.

В последнем случае мы можем видеть, что, несмотря на завершение родителя и появление, как обычно, командной строки, вдруг (через секунду), как из небытия, возникает процесс потомок и продолжает свою работу…

Как видим, программу можно условно разделить на 3 части:

  1. Родитель (строчки 1…27, 46…66),
  2. Потомок (строчки 28…47),
  3. Оставшаяся часть (строчки 67…69).

Кто же (потомок или родитель) будет выполнять строчки 67…69?

Если закомментированы строчки 46, 65. При этом ни потомок, ни родитель сами по себе не завершаются.

Оказывается, и потомок, и родитель. Но, порядок выполнения будет разным, в зависимости от того, кто будет первым или последним. Это можно увидеть по выводу значения переменной err_val: для родителя выведется значение 555, тогда как для потомка – 777. Вот типичный пример:

Раньше завершился (строчки 46, 65 – закомментированы):
Родитель Потомок
VirtualBox:~/Рабочий стол/programs_fork$ g++ -o fork fork.c && ./fork 1
Parent process begining...
sleep: 1 (для родительского процесса)
Child process begining...
Error value-PARENT: 555
Родительский процесс работал 1 сек. и сейчас будет завершен.
Error value (родитель или потомок?): 555
VirtualBox:~/Рабочий стол/programs_fork$ pid родительского процесса для программы-родителя: 9577
pid родительского процесса: 20598
pid для потомка: 20599
Error value-CHILD: 777
Error value (родитель или потомок?): 777
^C
VirtualBox:~/Рабочий стол/programs_fork$ g++ -o fork fork.c && ./fork 3
Parent process begining...
sleep: 3 (для родительского процесса)
Child process begining...
pid родительского процесса для программы-родителя: 9577
pid родительского процесса: 20803
pid для потомка: 20804
Error value-CHILD: 777
Error value (родитель или потомок?): 777
Error value-PARENT: 555
Родительский процесс работал 3 сек. и сейчас будет завершен.
Error value (родитель или потомок?): 555
VirtualBox:~/Рабочий стол/programs_fork$
Пришлось прервать потомка-зомби при помощи сигнала Ctrl + C Программа полностью завершилась сама, поэтому появилась командная строка

Как видим, значение Error value является разным в обоих случаях. Причем, оставшийся код программы (строчки 67…69) выполняется ДВАЖДЫ: и потомком, и родителем. Причина тому – отсутствие самостоятельного завершения и того, и другого в силу отсутствия команды типа exit(). А вот порядок выполнения зависит, естественно, от того, кто успеет первым.

2. Самостоятельное завершение

Для того, чтобы и родитель, и потомок самостоятельно завершались, необходимо раскомментировать строчки 46, 65. Вот что получится:

Раньше завершился (строчки 46, 65 – раскомментированы):
Родитель Потомок
VirtualBox:~/Рабочий стол/programs_fork$ g++ -o fork fork.c && ./fork 1
Parent process begining...
sleep: 1 (для родительского процесса)
Child process begining...
Error value-PARENT: 555
Родительский процесс работал 1 сек. и сейчас будет завершен.
VirtualBox:~/Рабочий стол/programs_fork$ pid родительского процесса для программы-родителя: 9577
pid родительского процесса: 21388
pid для потомка: 21389
Error value-CHILD: 777
^C
VirtualBox:~/Рабочий стол/programs_fork$ g++ -o fork fork.c && ./fork 3
Parent process begining...
sleep: 3 (для родительского процесса)
Child process begining...
pid родительского процесса для программы-родителя: 9577
pid родительского процесса: 21396
pid для потомка: 21397
Error value-CHILD: 777
Error value-PARENT: 555
Родительский процесс работал 3 сек. и сейчас будет завершен.
VirtualBox:~/Рабочий стол/programs_fork$
Пришлось прервать потомка-зомби при помощи сигнала Ctrl + C Программа полностью завершилась сама, поэтому появилась командная строка

Как видим, теперь строчки 66…69 не выполняются. Остальное, по сути, осталось без изменений.

3. Идентификаторы процессов

Это – так называемый pid. В программе используются три вида идентификаторов (см. предыдущую таблицу, правый столбец):

  1. Исходного (системного) процесса операционной системы, который является родительским для нашей программы (pid=9557),
  2. Непосредственно родительский процесс (pid=21396),
  3. Процесс-потомок (pid=21397).

Можно сделать выводы:

  1. pid исходного системного процесса, который является прародителем всех остальных процессов, не меняется и в моей операционной системе составляет 9577.
  2. pidпотомка = pidродителя + 1. Однако, это не касается исходного (прародительского) процесса и родительского, так как 9557+1 ≠ 21396.

Таким образом, каждый потомок получает pid на 1 больший. Вы можете поэкспериментировать, создав еще одного потомка. Это следует делать, дополнив код еще одной конструкцией вида

  1. if ((child2 = fork()) < 0) {
  2. …..

  1. } else if (child2 == 0) { // Начинает выполняться ВТОРОЙ процесс-ПОТОМОК
  2. ……
  3. } else { // Если child2 > 0, то начинает выполняться процесс-РОДИТЕЛЬ

  1. …..
  2. }

4. Как не допустить появление процесса-зомби в случае, если потомок завершится раньше, чем родитель?

Для этого используется системный вызов wait(). Он заставит ждать родителя до тех пор, пока не завершится первый из всех возможных потомков. Используется этот вызов примерно так:

#include <sys/wait.h>

int status;

wait(&status);

Системный вызов wait() следует вставить там, где должен остановиться процесс-родитель в ожидании, пока потомок не закончит свою работу. Т.е. следует раскомментировать строчки 60, 61. Вот что получилось в результате:

VirtualBox:~/Рабочий стол/programs_fork$ g++ -o fork fork.c && ./fork 1

Parent process begining...

sleep: 1 (для родительского процесса)

Child process begining...

Error value-PARENT: 555

Родительский процесс ждет завершения потомка... Время: Mon Dec 24 01:12:59 2018 сек.

pid родительского процесса для программы-родителя: 9577

pid родительского процесса: 23002

pid для потомка: 23003

Error value-CHILD: 777

Родительский процесс отработал. Время: Mon Dec 24 01:13:00 2018 сек.

VirtualBox:~/Рабочий стол/programs_fork$

Как видим, несмотря на то, что родительский процесс мог бы закончить свою работу в 01:12:59, тем не менее, он ждал, пока завершится процесс-потомок. Определим время, которое должен был ожидать родитель и сравним его с фактическим.

Вначале, после запуска программы, родитель работал 1 сек. В тоже самое время потомок работал в течение 2 сек. Поэтому родителю, после того, как он дошел до команды wait(), осталось ждать 2-1=1 сек. И, действительно, родительский процесс отработал в 01:13:00. Вы можете установить другое время, например, не 2 сек., а 5 сек., тогда разница будет видна яснее.

Примечание. Практически все время работы и потомка, и родителя затрачено на ожидание в результате выполнения функции sleep(). Она использовалась для того, чтобы нагляднее продемонстрировать работу программы.
5. Обработка ошибок родителем и потомком

Для демонстрации обработки критических ошибок в программу введены строчки 40, 57, пока закомментированные. Следует их раскомментировать и изучить, что изменится в работе программы. При этом наличие критических ошибок в родителе приводит к аварийному останову программы и дампу памяти, тогда как критическая ошибка в потомке вызывает лишь его останов.

И, наконец, о виртуальных адресах

Как Вы заметили, виртуальные адреса переменной err_val и в потомке, и в родителе... совпадают. Создается (ложное) впечатление, что у них виртуальные адресные пространства - общие. И здесь надо бы сделать пояснения. См. также задание 3, где показан пример получения виртуальных адресов для функций, переменных, используемых процессом.

При создании родителем процесса-потомка при помощи функции fork(), для последнего операционная система создает копию адресного пространства его родителя. Копия создается, естественно, именно для того адресного пространства, которое присутствовало у родителя на момент создания потомка (т.е. на момент выполнения функции fork()). Именно по этой причине виртуальный адрес переменной err_val является одним и тем же как у родителя, так и у потомка. Вместе с тем, НЕЛЬЗЯ говорить, что оба этих адресных пространства являются общими. Общими они будут, к примеру, для разных потоков одного и того же процесса. Тогда как для разных процессов они будут независимыми, различными, хотя и полностью совпадающими на момент создания потомка. В "штатном режиме" выполнения программы доступ, к примеру, родителя к адресному пространству потомка (или, наоборот) невозможен. Если такой доступ все же необходим, следует применять специальные средства, например, запуск программы при помощи функции exec().


Если более наглядно, представьте себе два города (аналоги процессов), например, Уфа и Москва. В каждом из этих городов есть дом по адресу: ул. Ленина, дом 1. Т.е. адреса, на первый взгляд, одинаковые, только города - разные. Так вот, точно также обстоит дело и с "одинаковыми" виртуальными адресами переменной err_val.



Самостоятельное задание:
  1. Изучите исходный код программы, скомпилируйте, запустите его, протестируйте и составьте таблицу (см. выше).
  2. Проанализируйте выявленные особенности и сделайте устный или письменный их обзор.
  3. Реализуйте запуск родителем 2-го потомка. Поэкспериментируйте с разными временами его работы путем задания соответствующих значений в функции sleep().

С уважением, Салимоненко Д.А.