Семинары 3-4. Процессы в операционной системе UNIX.

(Основываются на лекции 2)

Предыдущий семинар | Программа курса | Следующий семинар

Программа семинаров

  1. Понятие процесса в UNIX, его контекст.
  2. Идентификация процесса.
  3. Состояния процесса. Краткая диаграмма состояний.
  4. Иерархия процессов.
  5. Системные вызовы getpid(), getppid().
  6. Создание процесса в UNIX. Системный вызов fork(). 
  7. Завершение процесса. Функция exit().
  8. Параметры функции main() в языке С. Переменные среды и аргументы командной строки.
  9. Изменение пользовательского контекста процесса. Семейство функций для системного вызова exec().
  10. Демоны.

Цели занятия

  1. Дать понятие об иерархии процессов в UNIX.
  2. Дать понятие о содержании контекста процесса в UNIX.
  3. Научить создавать новый процесс.
  4. Научить запускать новую программу.
  5. Дать понятие об использовании переменных среды и аргументов командной строки.
  6. Студент должен осознать разницу между системными вызовами fork() и exec().
  7. Студент должен понять, как завершается программа, написанная на C, даже если она не вызывает явно функцию exit()

Практические работы

  1. Написание, компиляция и запуск программы с системными вызовами getpid() и getppid().
  2. Прогон программы с использованием вызова fork(), где порожденный процесс делает то же самое, что и родитель.
  3. Написание, компиляция и запуск программы с использованием вызова fork() с разным поведением процессов ребенка и родителя.
  4. Написание, компиляция и запуск программы с распечаткой значений переменных среды и аргументов командной строки.
  5. Прогон программы с использованием системного вызова exec().
  6. Написание, компиляция и запуск программы для изменения пользовательского контекста в порожденном процессе.

План занятия

  1. Понятие процесса в UNIX. Его контекст.

    Понятие процесса в лекции Все построение операционной системы UNIX основано на использовании концепции процессов, которая  обсуждалась на лекции. Контекст процесса складывается из пользовательского контекста и контекста ядра, как изображено на рисунке:

    Под пользовательским контекстом процесса понимают код и данные, расположенные в адресном пространстве процесса. Все данные подразделяются на инициализируемые неизменяемые данные (например, константы), инициализируемые изменяемые данные (все переменные, начальные значения которых присваиваются на этапе компиляции), неинициализируемые изменяемые данные (все статические переменные, которым не присвоены начальные значения на этапе компиляции), стек пользователя и данные, расположенные в динамически выделяемой памяти (например, с помощью стандартных библиотечных C функций malloc, calloc и realloc). Исполняемый код и инициализируемые данные составляют содержимое  файла программы, который исполняется в контексте процесса. Пользовательский стек используется при работе процесса в пользовательском режиме (user-mode).

    Понятие контекста процесса и PCB в лекции Под понятием контекст ядра объединяются системный контекст и регистровый контекст, рассмотренные на лекции. Мы будем выделять в контексте ядра стек ядра, который используется при работе процесса в режиме ядра  (kernel mode), и данные ядра, хранящиеся в структурах, являющихся аналогом блока управления процессом - PCB. Состав данных ядра будет уточняться на последующих семинарах. На этом занятии нам достаточно знать, что в данные ядра входят: идентификатор пользователя - UID, групповой идентификатор пользователя - GID, идентификатор процесса - PID, идентификатор родительского процесса - PPID.
     

  2. Идентификация процесса.

    PID в лекцииКаждый процесс в операционной системе получает свой собственный уникальный идентификационный номер PID (Process IDentificator). При создании нового процесса операционная система пытается присвоить ему  свободный номер больший, чем у процесса, созданного перед ним. Если таких свободных номеров не оказывается (например, мы достигли максимально возможного номера для процесса), то операционная система выбирает минимальный из всех свободных номеров. В операционной системе Linux присвоение идентификационных номеров процессов начинается с номера 0, который получает процесс kernel при старте операционной системы. Максимально возможное значение для номера процесса в Linux на базе процессоров Intel составляет 231-1.
     

  3. Состояния процесса. Краткая диаграмма состояний.

    Состояния процессов в лекции Модель состояний процессов в операционной системе UNIX представляет собой детализацию модели состояний, принятой в лекционном курсе.
    Краткая диаграмма состояний процессов в операционной системе UNIX изображена на рисунке:

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

    Приведенная выше диаграмма состояний процессов в Linux не является полной. Она показывает только состояния, для понимания которых достаточно уже полученных знаний. Полную диаграмму состояний процессов в операционной системе UNIX можно найти в книге Баха "Архитектура операционной системы UNIX" (рисунок 6.1.).
     

  4. Иерархия процессов.

    Иерархия процессов в лекции  В операционной системе UNIX все процессы кроме одного, создающегося при старте операционной системы, могут быть порождены только какими-либо другими процессами. В качестве  процесса прародителя всех остальных процессов в разных UNIX-ообразных системах могут выступать процессы с номерами 1 или 0.  В операционной системе Linux таким родоначальником, существующим только при загрузке,  является процесс kernel с идентификатором 0. 

    Таким образом, все процессы в UNIX связаны отношениями процесс-родитель - процесс-ребенок, образуя генеалогическое дерево процессов. Завершение процессов в лекцииДля сохранения целостности генеалогического дерева в ситуациях, когда процесс-родитель завершает свою работу до завершения выполнения процесса-ребенка, идентификатор родительского процесса в данных ядра процесса-ребенка (PPID - Parent Process IDentificator) изменяет свое значение на значение 1, соответствующее идентификатору процесса init, время жизни которого определяет время функционирования операционной системы. Тем самым процесс init как бы усыновляет осиротевшие процессы. Наверное, логичнее было бы изменять PPID не на значение 1, а на значение идентификатора ближайшего существующего процесса-прародителя умершего процесса-родителя, но в UNIX почему-то такая схема реализована не была.
     

  5. Системные вызовы getppid() и getpid().

    Данные ядра, находящиеся в контексте ядра процесса, не могут быть прочитаны процессом непосредственно. Для получения информации о них процесс должен совершить соответствующий системный вызов. Значение идентификатора текущего процесса может быть получено с помощью системного вызова getpid(), а значение идентификатора родительского процесса для текущего процесса - с помощью системного вызова getppid()
     

  6. Написание программы с использованием getpid() и getppid().

    В качестве примера на использование системных вызовов getpid() и getppid() студентам предлагается самостоятельно написать программу, печатающую значения PID и PPID для текущего процесса. 
     

  7. Создание процесса в UNIX. Системный вызов fork().

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


    Дополнительно к ним может измениться поведение порожденного процесса по отношению к некоторым сигналам, о чем подробнее будет рассказано на семинарах 13-14, когда мы будем говорить о сигналах в операционной системе UNIX.

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

  8. Прогон программы с fork() с одинаковой работой родителя и ребенка.

    Для иллюстрации вышесказанного давайте рассмотрим программу, находящуюся в файле /ftp/pub/sem3-4/stud/03-1.c, откомпилируем ее и запустим на исполнение. 
     
  9. Системный вызов fork() (продолжение).

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

    pid = fork();
    if(pid == -1){
    ...
    /* ошибка */
    ...
    } else if (pid == 0){
    ...
    /* ребенок */
    ...
    } else {
    ...
    /* родитель */
    ...
    }

     
  10. Написание, компиляция и запуск программы с использованием вызова fork() с разным поведением процессов ребенка и родителя.

    Изменить предыдущую программу с fork() так, чтобы родитель и ребенок совершали разные действия (какие не важно).
     
  11. Завершение процесса. Функция exit().

    Существует два способа корректного завершения процесса в программах, написанных на языке C. Первый способ мы использовали до сих пор: процесс корректно завершался по достижении конца функции main() или при выполнении оператора return в функции main(), второй способ применяется при необходимости завершить процесс в каком-либо другом месте программы. Для этого применяется функция exit() из стандартной библиотеки функций для языка C. При выполнении этой функции происходит сброс всех частично заполненных буферов ввода-вывода с закрытием соответствующих потоков, после чего инициируется системный вызов прекращения работы процесса и перевода его в состояние закончил исполнение.

    Возврата из функции в текущий процесс не происходит и функция ничего не возвращает.

    Значение параметра функции exit() – кода завершения процесса – передается ядру операционной системы и может быть затем получено процессом, породившим завершившийся процесс. На самом деле при достижении конца функции main() также неявно вызывается эта функция со значением параметра 0.

    Завершение процессов в лекцииЕсли процесс завершает свою работу раньше, чем его родитель, и родитель явно не указал, что он не хочет получать информацию о статусе завершения порожденного процесса (об этом подробнее на семинарах 13-14 при изучении сигналов), то завершившийся процесс не исчезает из системы окончательно, а остается в состоянии закончил исполнение либо до завершения процесса-родителя, либо до того момента, когда родитель соблаговолит получить эту информацию. Процессы, находящиеся в состоянии закончил исполнение, в операционной системе принято называть процессами-зомби (zombie, defunct).
     

  12. Параметры функции main() в языке C. Переменные среды и аргументы командной строки.

    У функции main() в языке программирования C существует три параметра, которые могут быть переданы ей операционной системой. Полный прототип функции main() выглядит следующим образом:
     

    int main(int argc, char *argv[], char *envp[]);
    Первые два параметра при запуске программы на исполнение командной строкой позволяют узнать полное содержание командной строки. Вся командная строка рассматривается как набор слов, разделенных пробелами. Через параметр argc передается количество слов в командной строке, которой была запущена программа. Параметр argv является массивом указателей на отдельные слова. Так, например, если программа была запущена командой 

    a.out 12 abcd
     
    то значение параметра argc будет равно 3, argv[0] будет указывать на имя программы - первое слово - "a.out", argv[1] - на слово "12",  argv[2] - на слово "abcd". Заметим, что, так как имя программы всегда присутствует на первом месте в командной строке, то argc всегда больше 0, а argv[0] всегда указывает на имя запущенной программы.

    Анализируя в программе содержимое командной строки, мы можем предусмотреть ее различное поведение в зависимости от слов следующих за именем программы. Таким образом, не внося изменений в текст программы, мы можем заставить ее работать по-разному от запуска к запуску. Например компилятор gcc, вызванный командой gcc 1.c будет генерировать исполняемый файл с именем a.out, а при вызове командой gcc 1.c -o 1.exe - файл с именем 1.exe.

    Третий параметр - envp - является массивом указателей на  параметры окружающей среды процесса.  Начальные параметры окружающей среды процесса задаются в специальных конфигурационных файлах для каждого пользователя и устанавливаются при входе пользователя в систему. В последующем они могут быть изменены с помощью специальных команд операционной системы UNIX. Каждый параметр имеет вид: переменная=строка. Такие переменные используются для изменения долгосрочного поведения процессов, в отличие от аргументов командной строки. Например, задание параметра TERM=vt100 может говорить процессам, осуществляющим вывод на экран дисплея, что работать им придется с терминалом vt100. Меняя значение переменной среды TERM, например на TERM=console, мы говорим таким процессам, что они должны изменить свое поведение на вывод для системной консоли.

    Размер массива аргументов командной строки в функции main() мы получали в качестве ее параметра. Так как для массива ссылок на параметры окружающей среды такого параметра нет, то его размер определяется другим способом. Последний элемент этого массива содержит указатель NULL.
     
  13. Написание, компиляция и запуск программы, распечатывающей аргументы командной строки и параметры среды.

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

    Создание процесса в лекцииДля изменения пользовательского контекста процесса используется системный вызов exec(), который пользователь не может вызвать непосредственно. Вызов exec() заменяет  пользовательский контекст текущего процесса на содержимое некоторого исполняемого файла и  устанавливает начальные значения регистров процессора (в том числе устанавливает программный счетчик на начало загружаемой программы). Этот вызов требует для своей работы задания имени исполняемого файла, аргументов командной строки и параметров окружающей среды. Для осуществления вызова программист может воспользоваться одной из 6 функций: execlp(), execvp(), execl(), execv(), execle(), execve(), отличающихся друг от друга представлением параметров, необходимых для работы системного вызова exec(). Взаимосвязь указанных выше функций изображена на рисунке.


    Поскольку системный контекст процесса при вызове exec() остается практически неизменным, то большинство атрибутов процесса, доступных пользователю через системные вызовы (PID, UID, GID, PPID и другие, смысл которых будет становиться понятным по мере углубления наших знаний на дальнейших занятиях), также не изменяется после запуска новой программы

    Nota beneВажно понимать разницу между системными вызовами fork() и exec() Системный вызов fork() создает новый процесс, у которого пользовательский контекст совпадает с пользовательским контекстом процесса-родителя. Системный вызов exec() изменяет  пользовательский контекст текущего процесса, не создавая новый процесс. 

  15. Прогон программы с использованием системного вызова exec().

    Для иллюстрации использования системного вызова exec() давайте рассмотрим программу в файле /ftp/pub/sem3-4/stud/03-2.c, откомпилируем ее и запустим на исполнение. Поскольку в результате должно распечататься содержимое файла с именем 03-2.c, такой файл при запуске должен присутствовать в текущей директории.
     

  16. Написание, компиляция и запуск программы для изменения пользовательского контекста в порожденном процессе.

    Для закрепления полученных знаний модифицируйте программу из пункта 10 так, чтобы порожденный процесс запускал на исполнение новую программу.

  17. Демоны.

    Демоном называется процесс, который запущен в фоновом режиме и не привязан ни к какому управляющему терминалу. Демоны обычно запускаются во время загрузки с правами root или другими специфическими пользовательскими правами (например, apache или postfix) и выполняют задачи системного уровня. Принято называть демоны именами, оканчивающимися на букву d (например, crond или sshd), но это не обязательно.

    Две обязательные особенности демона таковы: он должен запускаться как потомок процесса init и не должен быть связан с терминалом.

    В общем случае программа выполняет следующие действия, прежде чем становится демоном.
       1. Вызов fork(). Это создает новый процесс, который станет демоном.
       2. Родительский процесс выполняет вызов exit(). Это гарантирует, что оригинальный родитель («дедушка» демона) не должен будет обслуживать своего потомка, что родительский процесс демона более не существует, а демон является лидером группы процессов (последнее требование обязательно для следующего шага).
       3. Вызов setsid(), создающий для демона новую группу процессов и сессию; в обеих из них он является лидером. Это также гарантирует, что процесс не имеетсвязанных с ним контролирующих терминалов (так как процесс только что создал новую сессию и не будет назначать терминал).
       4. Изменение рабочего каталога на корневой через chdir(). Это делается потому, что унаследованный рабочий каталог может быть где угодно в файловой системе. Демоны обычно выполняются в течение всего времени работы системы, и какой-то случайный каталог держать постоянно открытым не очень хорошо. Таким образом мы предотвращаем размонтирование администратором файловой системы, содержащей этот каталог.
       5. Закрытие всех файловых дескрипторов. Нам не нужно наследовать открытые файловые дескрипторы и оставлять их открытыми.
       6. Открытие файловых дескрипторов 0, 1 и 2 (стандартный ввод, стандартный вывод и стандартный вывод ошибки) и перенаправление их к /dev/null.

  18. Пример программы, «демонизирующей» себя согласно этим правилам.

    Для иллюстрации вышесказанного давайте рассмотрим шаблон программы-демона . Допишите ее, откомпилируйте и запустите на исполнение.

    Большинство систем UNIX предоставляют функцию daemon() в своих библиотеках С, автоматизируя эти шаги и сводя их к простому:

    #include <unistd.h>
    int daemon (int nochdir, int noclose);

    Если nochdir не равен нулю, демон не изменяет свою рабочую директорию на root. Если noclose не равен нулю, демон не закроет все открытые дескрипторы файлов. Эти параметры полезны, если родительский процесс уже позаботился об этих аспектах процедуры создания демона. Обычно, однако, всем параметрам все же передается 0.В случае успеха вызов возвращает 0. При неудаче возвращается –1 и errno присваивается соответствующий код ошибки из fork() или setsid().

Предыдущий семинар | Программа курса | Следующий семинар