Салимóненко Дмитрий Александрович
Задание 5: Многопроцессность в Linux с помощью функции fork()
(C++, Linux)
СОДЕРЖАНИЕ
- Введение
- Программный код, иллюстрирующий многопроцессность
- Теоретические аспекты
- Как тестировать программу
- 1. Время задержки
- 2. Самостоятельное завершение
- 3. Идентификаторы процессов
- 4. Как не допустить появление процесса-зомби в случае, если потомок завершится раньше, чем родитель?
- 5. Обработка ошибок родителем и потомком
- Самостоятельное задание:
Введение
В этом задании на простом демонстрационном примере Вы можете (если есть желание) разобраться с многопроцессностью в Linux. Схема работы программы является примерно следующей:
- Запускается сама программа, т.е. родительский процесс.
- Из родительского процесса запускается его потомок, который, в общем случае, ведет себя НЕЗАВИСИМО от родительского (ну, если не вмешиваться в его работу специальными способами, конечно).
Процесс-потомок появляется в результате выполнения функции fork()
. Понятно, что можно запустить множество процессов-потомков (до нескольких сотен, по крайней мере). Но, мы с Вами здесь рассмотрим только простейший пример, когда потомок будет один.
fork()
в своих разработках. Ибо по мере выполнения Вы столкнетесь с некоторыми неочевидными нюансами, теоретически объяснить которые так вот сразу и сходу является, по-видимому, проблематичным.
Дальнейшее поведение программы зависит от того, что делается в потомке и/или родителе. В частности, большое значение играет и хронология окончания их работы. Возможны две ситуации:
- Родитель заканчивает работу раньше, чем потомок (некорректная ситуация, ведущая к появлению так называемого процесса-зомби, коим будет являться некорректно завершенный потомок),
- Родитель заканчивает работу позже, чем потомок (нормальная ситуация).
Также несколько по-разному происходит обработка ошибок (для демонстрации этого в программе намеренно введена грубая ошибка).
Программный код, иллюстрирующий многопроцессность
Давайте сразу посмотрим исходный код (назовем его fork.c):
- #include <stdio.h>
- #include <errno.h>
- #include <sys/types.h>
- #include <unistd.h>
- #include <string.h>
- #include <stdlib.h>
- #include <time.h>
- #include <sys/time.h>
- #include <sys/wait.h>
- int main(int argc, char **argv)
- {
- pid_t pid, old_ppid, new_ppid;
- pid_t child, parent, parent_init;
- time_t timer;
- char* time_str;
- int err_val = 555;
- int sl, status;
- parent = getpid(); /* определяем pid самой программы-родителя перед вызовом fork() */
- parent_init = getppid(); // Определяем pid родительского процесса для данной программы-родителя
- FILE* fd = fopen("file.txt", "w" ); // Это файл с "логами" программы
- timer = time(NULL); // Запускаем таймер
- time_str = ctime(&timer);
- fprintf(fd, "До вызова fork(): %s\n", time_str);
- fflush(fd);
- if ((child = fork()) < 0) { // Если ошибка при выполнении функции fork()
- fprintf(stderr, " fork of child failed: %s\n", strerror(errno));
- exit(1);
- } else if (child == 0) { // Начинает выполняться процесс-ПОТОМОК
- printf("Child process begining... \n");
- sleep(3); // Пока потомок ожидает 2 секунды, может выполняться процесс-родитель
- timer = time(NULL);
- time_str = ctime(&timer);
- fprintf(fd, "Потомок (Child): %s \n", time_str);
- fflush(fd);
- /* это выполняет только порожденный процесс-потомок */
- printf("pid родительского процесса для программы-родителя: %d\n", parent_init);
- printf("pid родительского процесса: %d\n", parent);
- printf("pid для потомка: %d\n", getpid());
- err_val = 777;
- //err_val = 1/0; // Здесь программа не выводит ошибку, потомок просто прекращает работу
- printf("err_val value-CHILD: %d %p\n", err_val, &err_val); // Будет ли выполняться эта команда? Почему?
- timer = time(NULL);
- time_str = (char*)ctime(&timer);
- fprintf(fd, "Потомок после 1/0 (Child): %s\n", time_str);
- fflush(fd); // Принудительно делаем запись в файл, очищаем буфер, в котором хранится переменная time_str
- //exit(0);
- // Конец исходного кода процесса-потомка
- } else { // Если child>0, то начинает выполняться процесс-РОДИТЕЛЬ
- printf("Parent process begining... \n");
- sl = atoi(argv[1]); // Задаем время задержки родительского процесса из 2-го аргумента командной строки
- printf("sleep: %d (для родительского процесса)\n", sl);
- sleep(sl);
- timer = time(NULL);
- time_str = ctime(&timer);
- fprintf(fd, "Родитель (Parent): %s \n", time_str);
- fflush(fd);
- //err_val = 1/0; // Раскомментируйте эту строчку, проанализируйте результат; получается, что родительский процесс реагирует иначе, чем потомок
- printf("err_val value-PARENT: %d %p\n", err_val, &err_val); // Какое значение err_val выведет эта команда, почему?
- // Сравните значение и, главное, адрес переменной err_val, выводимый потомком и родителем. Почему наблюдается совпадение адресов? Ведь значения-то этой переменной в потомке отличается от ее значения в родителе... а виртуальные адреса, что, совпадают?...
- // printf("Родительский процесс ждет завершения потомка... Время: %s сек. \n", time_str);
- // wait(&status);
- timer = time(NULL);
- time_str = ctime(&timer);
- printf("Родительский процесс отработал. Время: %s сек. \n", time_str);
- //exit(0); /* родитель завершается после fork() */
- }
- printf("err_val value (родитель или потомок?): %d %p\n", err_val, &err_val); // Какое значение err_val выведет эта команда, почему?
- //exit(0);
- }
Здесь потребуется детально разобраться с особенностями работы процессов: родителя и потомка, запускаемых из одного и того же программного кода. Потребуется запускать программу с различными параметрами.
Кстати, чтобы ускорить тестирование программы, целесообразно запускать ее компиляцию и, собственно, выполнение в одну строчку примерно таким образом:
g++ -o fork fork.c && ./fork 1
Здесь параметр «1» означает, как обычно, argv[1]. В данном случае, это – время задержки родительского процесса (в секундах).
Теоретические аспекты
Процесс-потомок наследует следующие признаки родителя:
- сегменты кода, данных и стека программы;
- таблицу файлов, в которой находятся состояния флагов дескрипторов файла, указывающие, читается ли файл или пишется. Кроме того, в таблице файлов содержится текущая позиция указателя записи-чтения;
- рабочий и корневой каталоги;
- реальный и эффективный номер пользователя и номер группы;
- приоритеты процесса (администратор может изменить их через nice);
- контрольный терминал;
- маску сигналов;
- ограничения по ресурсам;
- сведения о среде выполнения;
- разделяемые сегменты памяти.
Потомок не наследует от родителя следующих признаков:
- идентификатора процесса (PID, PPID);
- израсходованного времени ЦП (оно обнуляется);
- сигналов процесса-родителя, требующих ответа;
- блокированных файлов (record locking).
Поэтому, в частности, нет необходимости объявлять переменную, используемую и потомком, и родителем, дважды. Потомок, при успешном создании, получит ее копию. При этом он сможет изменять ее по своему усмотрению, у родителя же останется принадлежащая ему копия этой переменной, он также может изменять ее независимо от потомка (см. строчки 16, 39).
Весь код после fork()
выполняется ДВАЖДЫ, как в процессе-потомке, так и в процессе-родителе.
Зомби — это процесс, который завершился, но не был удален. Удаление зомби возлагается на родительский процесс.
Как тестировать программу
Следует проанализировать ее работу при следующих наборах (вариантах) условий:
- Разные времена задержки родительского процесса: 1 и 3. В первом случае родительский процесс завершается быстрее, чем потомок; во втором случае – наоборот (итого – 2 варианта).
Примечание: время задержки процесса-потомка всегда фиксировано и равно 2 секунды (см. строчку 30).
- По очереди раскомментировать заведомо ошибочные строчки 40 и 57 (итого - 4 варианта).
- По очереди раскомментировать строчки 46, 65, осуществляющие завершение процессов потомка и родительского, соответственно (итого – 4 варианта).
Всего получается, по максимуму, 2*4*4 = 32 варианта. По мере тестирования окажется, что (большую) часть вариантов можно будет пропустить в силу очевидности. В таблице их проводить также не следует.
Конечно, можно также поэкспериментировать и с другими аспектами, но эти, на мой взгляд, являются основными.
Результаты тестирования целесообразно оформить, например, в виде таблицы следующего вида:
№ варианта | Быстрее завершается: | Попытка выполнения заведомо неверной команды err_val = 1/0; | Завершение через exit(0); | Комментарий (выявленные Вами особенности) |
1 | Потомок | - | - | |
2 | Родитель | - | - | |
3 | Потомок | Потомок | - | |
4 | Потомок | Потомок | Потомок | |
5 | Потомок | Потомок | Родитель | |
6 | Потомок | Потомок | Потомок и Родитель | |
… | ||||
Обсудим наиболее важные особенности работы родителя и потомка
1. Время задержки
Время задержки (задаваемое функцией sleep()
) используется для того, чтобы можно было протестировать работу программы в зависимости от того, кто раньше завершится: потомок или родитель. Как правило, потомок должен завершаться РАНЬШЕ родителя, тогда программа будет завершена корректно. В противоположном случае родитель, будучи завершенным, уже не в состоянии проконтролировать работу созданного им потомка. Именно поэтому, несмотря на его, вроде бы, завершение, последний, тем не менее, остается в памяти (в виде зомби). В некоторых частных случаях такое поведение бывает как раз желательным, но в типичных ситуациях такого быть не должно.
В последнем случае мы можем видеть, что, несмотря на завершение родителя и появление, как обычно, командной строки, вдруг (через секунду), как из небытия, возникает процесс потомок и продолжает свою работу…
Как видим, программу можно условно разделить на 3 части:
- Родитель (строчки 1…27, 46…66),
- Потомок (строчки 28…47),
- Оставшаяся часть (строчки 67…69).
Кто же (потомок или родитель) будет выполнять строчки 67…69?
Оказывается, и потомок, и родитель. Но, порядок выполнения будет разным, в зависимости от того, кто будет первым или последним. Это можно увидеть по выводу значения переменной 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
. В программе используются три вида идентификаторов (см. предыдущую таблицу, правый столбец):
- Исходного (системного) процесса операционной системы, который является родительским для нашей программы (
pid=9557
), - Непосредственно родительский процесс (
pid=21396
), - Процесс-потомок (
pid=21397
).
Можно сделать выводы:
- pid исходного системного процесса, который является прародителем всех остальных процессов, не меняется и в моей операционной системе составляет 9577.
- pidпотомка = pidродителя + 1. Однако, это не касается исходного (прародительского) процесса и родительского, так как 9557+1 ≠ 21396.
Таким образом, каждый потомок получает pid
на 1 больший. Вы можете поэкспериментировать, создав еще одного потомка. Это следует делать, дополнив код еще одной конструкцией вида
- if ((child2 = fork()) < 0) {
- …..
- } else if (child2 == 0) { // Начинает выполняться ВТОРОЙ процесс-ПОТОМОК
- ……
- } else { // Если child2 > 0, то начинает выполняться процесс-РОДИТЕЛЬ
- …..
- }
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()
.
err_val
.
Самостоятельное задание:
- Изучите исходный код программы, скомпилируйте, запустите его, протестируйте и составьте таблицу (см. выше).
- Проанализируйте выявленные особенности и сделайте устный или письменный их обзор.
- Реализуйте запуск родителем 2-го потомка. Поэкспериментируйте с разными временами его работы путем задания соответствующих значений в функции
sleep()
.
С уважением, Салимоненко Д.А.