Текст книги "Операционная система UNIX"
Автор книги: Андрей Робачевский
Жанр:
ОС и Сети
сообщить о нарушении
Текущая страница: 19 (всего у книги 39 страниц)
Создание процесса
Как уже обсуждалось, в UNIX проведена четкая грань между программой и процессом. Каждый процесс в конкретный момент времени выполняет инструкции некоторой программы, которая может быть одной и той же для нескольких процессов.[39]39
Естественно, речь здесь идет о выполнении в режиме задачи, в режиме ядра процесс выполняет инструкции ядра операционной системы.
[Закрыть] Примером может служить командный интерпретатор, с которым одновременно работают несколько пользователей, таким образом инструкции программы shell выполняют несколько различных процессов. Такие процессы могут совместно использовать один сегмент кода в памяти, но в остальном они являются изолированными друг от друга и имеют собственные сегменты данных и стека.
В любой момент процесс может запустить другую программу и начать выполнять ее инструкции; такую операцию он может сделать несколько раз.
В операционной системе UNIX имеются отдельные системные вызовы для создания (порождения) процесса, и для запуска новой программы. Системный вызов fork(2) создает новый процесс, который является точной копией родителя. После возвращения из системного вызова оба процесса выполняют инструкции одной и той же программы и имеют одинаковые сегменты данных и стека.
Тем не менее между родительским и дочерним процессом имеется ряд различий:
□ Дочернему процессу присваивается уникальный идентификатор PID, отличный от родительского.
□ Соответственно и идентификатор родительского процесса PPID для родителя и потомка различны.
□ Дочерний процесс получает собственную копию u-area и, в частности, собственные файловые дескрипторы, хотя он разделяет те же записи файловой таблицы.
□ Для дочернего процесса очищаются все ожидающие доставки сигналы.
□ Временная статистика выполнения процесса в режиме ядра и задачи для дочернего процесса обнуляется.
□ Блокировки памяти и записей, установленные родительским процессом, потомком не наследуются.
Более подробно наследуемые характеристики представлены в табл. 3.4.
Таблица 3.4. Наследование установок при создании процесса и запуске программы
fork(2) | exec(2) | |
---|---|---|
Сегмент кода (text) | Да, разделяемый | Нет |
Сегмент данных (data) | Да, копируется при записи (copy-on-write) | Нет |
Окружение | Да | Возможно |
Аргументы | Да | Возможно |
Идентификатор пользователя UID | Да | Да |
Идентификатор группы GID | Да | Да |
Эффективный идентификатор пользователя EUID | Да | Да (Нет, при вызове setuid(2)) |
Эффективный идентификатор группы EGID | Да | Да (Нет, при вызове setgid(2)) |
ID процесса (PID) | Нет | Да |
ID группы процессов | Да | Да |
ID родительского процесса (PPID) | Нет | Да |
Приоритет nice number | Да | Да |
Права доступа к создаваемому файлу | Да | Да |
Ограничение на размер файла | Да | Да |
Сигналы, обрабатываемые по умолчанию | Да | Да |
Игнорируемые сигналы | Да | Да |
Перехватываемые сигналы | Да | Нет |
Файловые дескрипторы | Да | Да, если для файлового дескриптора не установлен флаг FD_CLOEXEC (например, с помощью fcntl(2)) |
Файловые указатели | Да, разделяемые | Да, если для файлового дескриптора не установлен флаг FD_CLOEXEC (например, с помощью fcntl(2)) |
В общем случае вызов fork(2) выполняет следующие действия:
□ Резервирует место в области свопинга для сегмента данных и стека процесса.
□ Размещает новую запись proc
в таблице процессов и присваивает процессу уникальный идентификатор PID.
□ Инициализирует структуру proc
(поля структуры proc подробно рассматривались в разделе «Структуры данных процесса»).
□ Размещает карты отображения, необходимые для трансляции адреса.
□ Размещает u-area процесса и копирует ее содержимое с родительского.
□ Создает соответствующие области процесса, часть из которых совпадает с родительскими.
□ Инициализирует аппаратный контекст процесса, копируя его с родительского.
□ Устанавливает в ноль возвращаемое дочернему процессу вызовом fork(2) значение.
□ Устанавливает возвращаемое родительскому процессу вызовом fork(2) значение равным PID потомка.
□ Помечает процесс готовым к запуску и помещает его в очередь на выполнение.
Системный вызов fork(2) в итоге создает для дочернего процесса отдельную копию адресного пространства родителя. Во многих случаях, вскоре после этого, дочерний процесс делает системный вызов exec(2) для запуска новой программы, при этом существующее адресное пространство уничтожается и создается новое. Таким образом создание фактической копии адресного пространства процесса, т.е. выделение оперативной памяти и создание соответствующих карт отображения, является неоправданным.
Для решения данной проблемы используются два подхода. Первый из них, предложенный в UNIX System V, называется "копирование при записи" (copy-on-write или COW). Суть этого подхода заключается в том, что сегменты данных и стека родительского процесса помечаются доступными только для чтения, а дочерний процесс, хотя и получает собственные карты отображения, разделяет эти сегменты с родительским. Другими словами, сразу после создания процесса и родитель и потомок адресуют одни и те же страницы физической памяти. Если какой-либо из двух процессов попытается модифицировать данные или стек, возникнет страничная ошибка, поскольку страница открыта только для чтения, а не для записи. При этом будет запущен обработчик ошибки ядра, который создаст для процесса копию этой страницы, доступную для записи. Таким образом, фактическому копированию подлежат только модифицируемые страницы, а не все адресное пространство процесса. Если дочерний процесс делает системный вызов exec(2) или вообще завершает свое выполнение, права доступа к страницам родителя, имеющим флаг COW, возвращаются к их прежним значениям (т.е. до создания дочернего процесса), а флаг COW очищается.
Другой подход используется в BSD UNIX. В этой версии системы был предложен новый системный вызов – vfork(2). Использование этого вызова имеет смысл, когда дочерний процесс сразу же выполняет вызов exec(2) и запускает новую программу. При вызове vfork(2) родительский процесс предоставляет свое адресное пространство дочернему и переходит в состояние сна, пока последний не вернет его обратно. Далее дочерний процесс выполняется в адресном пространстве родителя, пока не делает вызов exec(2) или exit(2), после чего ядро возвращает адресное пространство родителю и пробуждает его. С помощью vfork(2) можно добиться максимального быстродействия, т.к. в этом случае мы полностью избегаем копирования, даже для карт отображения. Вместо этого адресное пространство родительского процесса предоставляется потомку передачей нескольких аппаратных регистров, отвечающих за трансляцию адресов. Однако vfork(2) таит в себе потенциальную опасность, поскольку позволяет одному процессу использовать и даже модифицировать адресное пространство другого.
Для управления памятью процесса ядру необходимо соответствующим образом задать области. При этом структуры pregion
дочернего процесса, соответствующие разделяемым областям, указывают на те же структуры region, что и для родителя. Для областей, совместное использование которых недопустимо, ядро размещает отдельные структуры region
для дочернего процесса (изначально копируя их содержимое с родительского) и устанавливает соответствующие указатели. На рис. 3.15 представлена схема этих операций. Заметим, что совместная работа и дублирование областей являются отдельным механизмом, не связанным с рассмотренными выше подходами, для совместного использования адресного пространства, например COW. Так, после создания отдельной копии неразделяемой области она по-прежнему будет адресовать те же страницы памяти, что и соответствующая область родителя.
Рис. 3.15. Создание областей нового процесса
Запуск новой программы
Запуск новой программы осуществляется с помощью системного вызова exec(2). Напомним, что при этом создается не новый процесс, а новое адресное пространство процесса, которое загружается содержимым новой программы. Если процесс был создан вызовом vfork(2), старое адресное пространство возвращается родителю, в противном случае оно просто уничтожается. После возврата из вызова exec(2) процесс продолжает выполнение кода новой программы.
Операционная система UNIX обычно поддерживает несколько форматов исполняемых файлов. Старейший из них – a.out, в разделе "Форматы исполняемых файлов" главы 2 также были рассмотрены форматы COFF и ELF. В любом случае исполняемый файл содержит заголовок, позволяющий ядру правильно разместить адресное пространство процесса и загрузить в него соответствующие фрагменты исполняемого файла.
Перечислим ряд действий, которые выполняет exec(2) для запуска новой программы:
□ Производит трансляцию имени файла. В результате возвращается индексный дескриптор, с помощью которого осуществляется доступ к файлу. При этом проверяются права доступа.
□ Считывает заголовок файла и проверяет, является ли файл исполняемым. Вызов exec(2) также распознает скрипты, о которых говорилось в главе 1. При этом он анализирует первую строку скрипта, которая обычно имеет вид #!shellname
. В этом случае exec(2) запускает программу, указанную shellname, передавая ей в качестве аргумента имя скрипта. Если исполняемый файл (т.е. файл с установленным атрибутом x) не является бинарным и не содержит в первой строке названия интерпретатора, exec(2) запускает интерпретатор по умолчанию (/bin/sh, /usr/bin/sh, или /usr/bin/ksh, как предписывает стандарт XPG4), передавая ему содержимое файла в качестве ввода.
□ Если исполняемый файл имеет атрибуты SUID или SGID, exec(2) соответствующим образом изменяет эффективные идентификаторы UID и GID для этого процесса.[40]40
Напомним, что в этом случае EUID и EGID не наследуются от родительского процесса, а присваиваются равными идентификаторам UID и GID исполняемого файла.
[Закрыть]
□ Сохраняет аргументы вызова exec(2) и переменные окружения в адресном пространстве ядра, поскольку адресное пространство процесса будет уничтожено.
□ Резервирует место в области свопинга для сегмента данных и стека.
□ Освобождает старые области процесса и соответствующие области свопинга. Если процесс был создан вызовом vfork(2), старое адресное пространство возвращается родителю.
□ Размещает и инициализирует карты отображения для новых сегментов кода, данных и стека. Если сегмент кода является активным, например, какой-либо процесс уже выполняет эту программу, данная область используется совместно. В противном случае область заполняется содержимым соответствующего раздела исполняемого файла или инициализируется нулями для неинициализированных данных. Поскольку управление памятью процесса построено на механизме страничного замещения по требованию, копирование происходит постранично и только тогда, когда процесс обращается к страницам, отсутствующим в памяти.
□ Копирует сохраненные аргументы и переменные окружения в новый стек процесса.
□ Устанавливает обработку всех сигналов на умалчиваемые значения, поскольку процесс теперь не имеет требуемых обработчиков. Установки для игнорируемых и заблокированных сигналов не изменяются.
□ Инициализирует аппаратный контекст процесса. В частности, после этого указатель инструкций адресует точку входа новой программы.
В случае, когда программа использует динамические библиотеки, соответствующий раздел исполняемого файла (для файла формата ELF данный раздел имеет тип INTERP
) содержит имя редактора связей динамической библиотеки. В этом случае редактор связей должен быть запущен до начала выполнения основной программы для связывания с программами требуемых динамических библиотек. Таким образом точка входа в программу устанавливается на точку входа в редактор связей. После завершения своей работы редактор связей, в свою очередь, запускает программу самостоятельно, анализируя заголовок исполняемого файла. Стадии запуска новой программы проиллюстрированы на рис. 3.16.
Рис. 3.16. Запуск новой программы: а) Адресное пространство процесса до вызова exec(2); б) Уничтожение старого адресного пространства; в) Новое адресное пространство процесса; г) Новое адресное пространство процесса при использовании динамических библиотек
Выполнение в режиме ядра
Существуют всего три события, при которых выполнение процесса переходит в режим ядра – аппаратные прерывания, особые ситуации и системные вызовы. Во всех случаях ядро UNIX получает управление и вызывает соответствующую системную процедуру для обработки события. Перед вызовом ядро сохраняет состояние прерванного процесса в системном стеке. После завершения обработки, состояние процесса восстанавливается и процесс возвращается в исходный режим выполнения. Чаще всего это режим задачи, но если, например, прерывание возникло, когда процесс уже находился в режиме ядра, после обработки события он останется в этом режиме.
Отметим существенную разницу между прерываниями и особыми ситуациями. Аппаратные прерывания генерируются периферийными устройствами при наступлении определенных событий (например, завершение дисковой операции ввода/вывода или поступление данных на последовательный порт) и имеют асинхронный характер, поскольку невозможно точно сказать, в какой момент наступит то или иное прерывание. Более того, эти прерывания, как правило, не связаны с текущим процессом, а вызваны внешними событиями. Именно поэтому, обработка прерываний происходит в системном контексте, при этом недопустим доступ к адресному пространству процесса, например, к его u-area. По этой же причине, обработка прерываний не должна блокироваться, поскольку это вызовет блокирование выполнения независимого процесса.
Напротив, особые ситуации вызваны самим процессом, и связаны с выполнением тех или иных инструкций, например, деление на ноль или обращение к несуществующей странице памяти. Таким образом, обработка особых ситуаций производится в контексте процесса, при этом может использоваться его адресное пространство, а сам процесс – при необходимости блокироваться (перемещаться в состояние сна).
Системные вызовы позволяют процессам воспользоваться базовыми услугами ядра. Интерфейс системных вызовов определяет ограниченный набор точек входа в ядро системы, обращение к которым изменяет режим выполнения процесса и позволяет выполнять привилегированные инструкции ядра. Стандартная библиотека С, позволяющая использовать системные функции как обычные процедуры, на самом деле содержит заглушки, обеспечивающие фактическую реализацию вызова соответствующей точки входа ядра. Эта реализация существенным образом зависит от аппаратной архитектуры системы. Например, для систем на базе процессоров Intel используются шлюзы (gate). Имеются два типа шлюзов: шлюзы ловушек (trap gate) и шлюзы вызовов (call gate). Для осуществления вызова через шлюз ловушки процесс выполняет команду прерывания, а при работе шлюз вызова – команду межсегментного вызова.
Выполнение системного вызова происходит в режиме ядра, но в контексте процесса, сделавшего системный вызов. Таким образом, открыт доступ к адресному пространству процесса и используется стек ядра процесса.
Сон и пробуждение
Процесс обычно переводится в состояние сна при обработке системной функции. Если для завершения обработки запроса требуется недоступный ресурс, процесс снимается с процессора и переводится в состояние сна. Недоступность ресурса может быть связана с запуском операции ввода/вывода с диска, ожиданием выделения (освобождения) буфера, ожиданием ввода или вывода на терминал или ожиданием завершения дочернего процесса. К недоступным ресурсам можно также отнести отсутствующую в памяти страницу, к виртуальному адресу которой обратился процесс. В любом случае процесс переходит в состояние сна до наступления события, делающего ресурс доступным. Во время сна процесс не потребляет вычислительные ресурсы системы. При этом выполняется переключение контекста на другой, высокоприоритетный процесс для выполнения. Таким образом, процесс, ожидающий ввода с клавиатуры, не занимает процессор, циклически опрашивая терминальную линию, а процесс, считывающий данные с диска, не блокирует выполнение других задач.
Состояние сна – это логическое состояние процесса, при этом он не перемещается физически в памяти. Переход в состояние сна в первую очередь определяется занесением в системную таблицу процессов соответствующего флага состояния и события, пробуждающего процесс.
События возвещают о доступности того или иного ресурса. Как правило события связаны с работой периферийных устройств, таких как диск, терминал и принтер, поэтому об их наступлении сигнализируют соответствующие аппаратные прерывания. Наступления одного и того же события может ожидать несколько процессов. Поскольку переход из состояния в состояние акт скорее логический, то и пробуждаются все эти процессы одновременно. Однако это не означает, что какой-либо один из них сразу начнет выполняться. Это лишь приводит к тому, что их состояние меняется от "сна" к "готов к выполнению", и они помещаются в очередь на запуск. Задачу выбора процесса для запуска затем решает планировщик процессов.
События, в ожидании которых "засыпают" процессы, не являются равноценными.
Во-первых, они различаются по вероятности наступления. Например событие, связанное с завершением операции ввода с диска или освобождением буфера, имеет высокую вероятность. Как правило, подобные операции имеют конечное время выполнения, в противном случае система оказалась бы заблокированной. С другой стороны, вероятность наступления события, связанного с вводом с терминала, может быть весьма низкой. Пользователь может надолго оставить терминал, не завершив сеанса работы с системой. В длительном ожидании события нет ничего опасного – процесс не занимает ресурсы процессора, однако без специальных мер выключение терминала приведет к блокировке этого устройства. Для того чтобы избежать подобной ситуации, должна существовать возможность вывести процесс из состояния сна, несмотря на отсутствие ожидаемого события. В этом случае используется стандартное решение – отправление процессу сигнала. В противоположность этому, отправление сигнала процессу, ожидающему операции ввода с диска, может привести к ухудшению производительности системы. Поэтому все события и связанные с ними ресурсы разделяются на две категории по вероятности их наступления: на допускающие прерывание сигналом и на не допускающие таковых.
Во-вторых, процессы, разбуженные событием, должны иметь различную вероятность запуска. Это, в первую очередь, связано с тем, что несколько ресурсов могут отображаться на одно событие. Например, процесс А, ожидающий завершения операции ввода с диска, и процесс В, ожидающий освобождения буфера ввода, будут связаны с одним и тем же событием. Они оба окажутся "разбуженными" и затем "готовыми к запуску" после завершения этой операции. Если процесс В будет запущен первым, он все равно не сможет выполняться, так как буфер не освобожден процессом А. Даже в случае, когда спящие процессы связаны с различными событиями, необходимо отдавать предпочтение процессу с более ценным ресурсом. Например, освобождение буфера ввода безусловно предпочтительнее завершения ввода с терминала.
Поскольку планировщик принимает решение о запуске процесса, основываясь на приоритетах, единственным способом установить "справедливый" порядок запуска процессов является присвоение определенного приоритета каждому событию. Приоритет процесса и его влияние на планирование достаточно подробно обсуждались в разделе "Контекст процесса".
Завершение выполнения процесса
Процесс завершает свое выполнение с помощью функции exit()
. Эта функция может быть вызвана системным вызовом exit(2), а если завершение процесса вызвано получением сигнала, функцию exit()
вызывает само ядро. Функция exit()
выполняет следующие действия:
□ Отключает все сигналы.
□ Закрывает все открытые файлы.
□ Сохраняет статистику использования вычислительных ресурсов и код возврата в записи proc
таблицы процессов.
□ Изменяет состояние процесса на "зомби".
□ Делает процесс init(1M) родительским для всех потомков данного процесса.
□ Освобождает адресное пространство процесса, u-area, карты отображения и области свопинга, связанные с процессом.
□ Отправляет сигнал SIGCHLD
родительскому процессу, уведомляя его о «смерти» потомка.
□ Пробуждает родительский процесс, если тот ожидает завершения потомка.
□ Запускает функцию переключения контекста, в результате чего высокоприоритетный процесс получает доступ к вычислительным ресурсам.
После завершения выполнения функции exit()
процесс находится в состоянии «зомби». При этом от процесса остается запись proc
в таблице процессов, содержащая статистику использования вычислительных ресурсов и код возврата. Эта информация может потребоваться родительскому процессу, поэтому освобождение структуры proc производит родитель с помощью системного вызова wait(2) возвращающего статистику и код возврата потомка. Если родительский процесс заканчивает свое выполнение раньше потомка, «родительские права» переходят к процессу init(1M). В этом случае после смерти потомка init(1M) делает системный вызов wait(2) и освобождает структуру proc
.
Другая ситуация возникает, если потомок заканчивает свое выполнение раньше родителя, а родительский процесс не производит вызова wait(2). В этом случае структура proc
потомка не освобождается и процесс продолжает находиться в состоянии «зомби» до перезапуска операционной системы. Хотя такой процесс (которого, вообще говоря, не существует) не потребляет ресурсов системы, он занимает место в таблице процессов, тем самым уменьшая максимальное число активных задач.