ГЛАВА 7. УПРАВЛЕНИЕ ПРОЦЕССАМИ В предыдущей главе был рассмотрен контекст процесса и описаны алгоритмы для работы с ним; в данной главе речь пойдет об использовании и реализации системных функций, управляющих контекстом процесса. Системная функция fork создает новый процесс, функция exit завершает выполнение процесса, а wait дает возможность родительскому процессу синхронизировать свое продолжение с завершением порожденного процесса. Об асинхронных событиях процессы информи- руются при помощи сигналов. Поскольку ядро синхронизирует выполнение функций exit и wait при помощи сигналов, описание механизма сигналов предваряет со- бой рассмотрение функций exit и wait. Системная функция exec дает процессу возможность запускать "новую" программу, накладывая ее адресное пространство на исполняемый образ файла. Системная функция brk позволяет динамически вы- делять дополнительную память; теми же самыми средствами ядро динамически на- ращивает стек задачи, выделяя в случае необходимости дополнительное прост- ранство. В заключительной части главы дается краткое описание основных групп операций командного процессора shell и начального процесса init. На Рисунке 7.1 показана взаимосвязь между системными функциями, рассмат- риваемыми в данной главе, с одной стороны, и алгоритмами, описанными в пре- дыдущей главе, с другой. Почти во всех функциях используются алгоритмы sleep и wakeup, отсутствующие на рисунке. Функция exec, кроме того, взаимодейству- ет с алгоритмами работы с файловой системой, речь о которых шла в главах 4 и 5. +-----------------------------+---------------------+------------+ | Системные функции, имеющие | Системные функции, | Функции | | ющие дело с управлением па- | связанные с синхро- | смешанного | | мятью | низацией | типа | +-------+-------+-------+-----+--+----+------+----+-+-----+------+ | fork | exec | brk | exit |wait|signal|kill|setrgrр|setuid| +-------+-------+-------+--------+----+------+----+-------+------+ |dupreg |detach-|growreg| detach-| | |attach-| reg | | reg | | | reg |alloc- | | | | | | reg | | | | | |attach-| | | | | | reg | | | | | |growreg| | | | | |loadreg| | | | | |mapreg | | | | +-------+-------+-------+--------+-------------------------------+ Рисунок 7.1. Системные функции управления процессом и их связь с другими алгоритмами 7.1 СОЗДАНИЕ ПРОЦЕССА Единственным способом создания пользователем нового процесса в операци- онной системе UNIX является выполнение системной функции fork. Процесс, вы- зывающий функцию fork, называется родительским (процесс-родитель), вновь создаваемый процесс называется порожденным (процесс-потомок). Синтаксис вы- зова функции fork: 179 pid = fork(); В результате выполнения функции fork пользовательский контекст и того, и другого процессов совпадает во всем, кроме возвращаемого значения переменной pid. Для родительского процесса в pid возвращается идентификатор порожденно- го процесса, для порожденного - pid имеет нулевое значение. Нулевой процесс, возникающий внутри ядра при загрузке системы, является единственным процес- сом, не создаваемым с помощью функции fork. В ходе выполнения функции ядро производит следующую последовательность действий: 1. Отводит место в таблице процессов под новый процесс. 2. Присваивает порождаемому процессу уникальный код идентификации. 3. Делает логическую копию контекста родительского процесса. Поскольку те или иные составляющие процесса, такие как область команд, могут разде- ляться другими процессами, ядро может иногда вместо копирования области в новый физический участок памяти просто увеличить значение счетчика ссылок на область. 4. Увеличивает значения счетчика числа файлов, связанных с процессом, как в таблице файлов, так и в таблице индексов. 5. Возвращает родительскому процессу код идентификации порожденного процес- са, а порожденному процессу - нулевое значение. Реализацию системной функции fork, пожалуй, нельзя назвать тривиальной, так как порожденный процесс начинает свое выполнение, возникая как бы из воздуха. Алгоритм реализации функции для систем с замещением страниц по зап- росу и для систем с подкачкой процессов имеет лишь незначительные различия; все изложенное ниже в отношении этого алгоритма касается в первую очередь традиционных систем с подкачкой процессов, но с непременным акцентированием внимания на тех моментах, которые в системах с замещением страниц по запросу реализуются иначе. Кроме того, конечно, предполагается, что в системе имеет- ся свободная оперативная память, достаточная для размещения порожденного процесса. В главе 9 будет отдельно рассмотрен случай, когда для порожденного процесса не хватает памяти, и там же будут даны разъяснения относительно ре- ализации алгоритма fork в системах с замещением страниц. На Рисунке 7.2 приведен алгоритм создания процесса. Сначала ядро должно удостовериться в том, что для успешного выполнения алгоритма fork есть все необходимые ресурсы. В системе с подкачкой процессов для размещения порожда- емого процесса требуется место либо в памяти, либо на диске; в системе с за- мещением страниц следует выделить память для вспомогательных таблиц (в част- ности, таблиц страниц). Если свободных ресурсов нет, алгоритм fork заверша- ется неудачно. Ядро ищет место в таблице процессов для конструирования кон- текста порождаемого процесса и проверяет, не превысил ли пользователь, вы- полняющий fork, ограничение на максимально-допустимое количество параллельно запущенных процессов. Ядро также подбирает для нового процесса уникальный идентификатор, значение которого превышает на единицу максимальный из сущес- твующих идентификаторов. Если предлагаемый идентификатор уже присвоен друго- му процессу, ядро берет идентификатор, следующий по порядку. Как только бу- дет достигнуто максимально-допустимое значение, отсчет идентификаторов опять начнется с 0. Поскольку большинство процессов имеет короткое время жизни, при переходе к началу отсчета значительная часть идентификаторов оказывается свободной. На количество одновременно выполняющихся процессов накладывается ограни- чение (конфигурируемое), отсюда ни один из пользователей не может занимать в таблице процессов слишком много места, мешая тем самым другим пользователям создавать новые процессы. Кроме того, простым пользователям не разрешается создавать процесс, занимающий последнее свободное место в таблице процессов, в противном случае система зашла бы в тупик. Другими словами, поскольку в таблице процессов нет свободного места, то ядро не может гарантировать, что все существующие процессы завершатся естественным образом, поэтому новые 180 +------------------------------------------------------------+ | алгоритм fork | | входная информация: отсутствует | | выходная информация: для родительского процесса - идентифи-| | катор (PID) порожденного процесса | | для порожденного процесса - 0 | | { | | проверить доступность ресурсов ядра; | | получить свободное место в таблице процессов и уникаль- | | ный код идентификации (PID); | | проверить, не запустил ли пользователь слишком много | | процессов; | | сделать пометку о том, что порождаемый процесс находится| | в состоянии "создания"; | | скопировать информацию в таблице процессов из записи, | | соответствующей родительскому процессу, в запись, соот-| | ветствующую порожденному процессу; | | увеличить значения счетчиков ссылок на текущий каталог и| | на корневой каталог (если он был изменен); | | увеличить значение счетчика открытий файла в таблице | | файлов; | | сделать копию контекста родительского процесса (адресное| | пространство, команды, данные, стек) в памяти; | | поместить в стек фиктивный уровень системного контекста | | над уровнем системного контекста, соответствующим по- | | рожденному процессу; | | фиктивный контекстный уровень содержит информацию, | | необходимую порожденному процессу для того, чтобы | | знать все о себе и будучи выбранным для исполнения | | запускаться с этого места; | | если (в данный момент выполняется родительский процесс) | | { | | перевести порожденный процесс в состояние "готовности| | к выполнению"; | | возвратить (идентификатор порожденного процесса); | | /* из системы пользователю */ | | } | | в противном случае /* выполняется порожденный | | процесс */ | | { | | записать начальные значения в поля синхронизации ад- | | ресного пространства процесса; | | возвратить (0); /* пользователю */ | | } | | } | +------------------------------------------------------------+ Рисунок 7.2. Алгоритм fork процессы создаваться не будут. С другой стороны, суперпользователю нужно дать возможность исполнять столько процессов, сколько ему потребуется, ко- нечно, учитывая размер таблицы процессов, при этом процесс, исполняемый су- перпользователем, может занять в таблице и последнее свободное место. Пред- полагается, что суперпользователь может прибегать к решительным мерам и за- пускать процесс, побуждающий остальные процессы к завершению, если это вызы- вается необходимостью (см. раздел 7.2.3, где говорится о системной функции kill). Затем ядро присваивает начальные значения различным полям записи таблицы 181 процессов, соответствующей порожденному процессу, копируя в них значения по- лей из записи родительского процесса. Например, порожденный процесс "насле- дует" у родительского процесса коды идентификации пользователя (реальный и тот, под которым исполняется процесс), группу процессов, управляемую роди- тельским процессом, а также значение, заданное родительским процессом в фун- кции nice и используемое при вычислении приоритета планирования. В следующих разделах мы поговорим о назначении этих полей. Ядро передает значение поля идентификатора родительского процесса в запись порожденного, включая послед- ний в древовидную структуру процессов, и присваивает начальные значения раз- личным параметрам планирования, таким как приоритет планирования, использо- вание ресурсов центрального процессора и другие значения полей синхрониза- ции. Начальным состоянием процесса является состояние "создания" (см. Рису- нок 6.1). После того ядро устанавливает значения счетчиков ссылок на файлы, с ко- торыми автоматически связывается порождаемый процесс. Во-первых, порожденный процесс размещается в текущем каталоге родительского процесса. Число процес- сов, обращающихся в данный момент к каталогу, увеличивается на 1 и, соответ- ственно, увеличивается значение счетчика ссылок на его индекс. Во-вторых, если родительский процесс или один из его предков уже выполнял смену корне- вого каталога с помощью функции chroot, порожденный процесс наследует и но- вый корень с соответствующим увеличением значения счетчика ссылок на индекс корня. Наконец, ядро просматривает таблицу пользовательских дескрипторов для родительского процесса в поисках открытых файлов, известных процессу, и уве- личивает значение счетчика ссылок, ассоциированного с каждым из открытых файлов, в глобальной таблице файлов. Порожденный процесс не просто наследует права доступа к открытым файлам, но и разделяет доступ к файлам с родитель- ским процессом, так как оба процесса обращаются в таблице файлов к одним и тем же записям. Действие fork в отношении открытых файлов подобно действию алгоритма dup: новая запись в таблице пользовательских дескрипторов файла указывает на запись в глобальной таблице файлов, соответствующую открытому файлу. Для dup, однако, записи в таблице пользовательских дескрипторов файла относятся к одному процессу; для fork - к разным процессам. После завершения всех этих действий ядро готово к созданию для порожден- ного процесса пользовательского контекста. Ядро выделяет память для адресно- го пространства процесса, его областей и таблиц страниц, создает с помощью алгоритма dupreg копии всех областей родительского процесса и присоединяет с помощью алгоритма attachreg каждую область к порожденному процессу. В систе- ме с подкачкой процессов ядро копирует содержимое областей, не являющихся областями разделяемой памяти, в новую зону оперативной памяти. Вспомним из раздела 6.2.4 о том, что в пространстве процесса хранится указатель на соот- ветствующую запись в таблице процессов. За исключением этого поля, во всем остальном содержимое адресного пространства порожденного процесса в начале совпадает с содержимым пространства родительского процесса, но может расхо- диться после завершения алгоритма fork. Родительский процесс, например, пос- ле выполнения fork может открыть новый файл, к которому порожденный процесс уже не получит доступ автоматически. Итак, ядро завершило создание статической части контекста порожденного процесса; теперь оно приступает к созданию динамической части. Ядро копирует в нее первый контекстный уровень родительского процесса, включающий в себя сохраненный регистровый контекст задачи и стек ядра в момент вызова функции fork. Если в данной реализации стек ядра является частью пространства про- цесса, ядро в момент создания пространства порожденного процесса автомати- чески создает и системный стек для него. В противном случае родительскому процессу придется скопировать в пространство памяти, ассоциированное с по- рожденным процессом, свой системный стек. В любом случае стек ядра для по- рожденного процесса совпадает с системным стеком его родителя. Далее ядро создает для порожденного процесса фиктивный контекстный уровень (2), в кото- ром содержится сохраненный регистровый контекст из первого контекстного уровня. Значения счетчика команд (регистр PC) и других регистров, сохраняе- 182 мые в регистровом контексте, устанавливаются таким образом, чтобы с их по- мощью можно было "восстанавливать" контекст порожденного процесса, пусть да- же последний еще ни разу не исполнялся, и чтобы этот процесс при запуске всегда помнил о том, что он порожденный. Например, если программа ядра про- веряет значение, хранящееся в регистре 0, для того, чтобы выяснить, является ли данный процесс родительским или же порожденным, то это значение переписы- вается в регистровый контекст порожденного процесса, сохраненный в составе первого уровня. Механизм сохранения используется тот же, что и при переклю- чении контекста (см. предыдущую главу). Родительский процесс +---------------------------------------------+ Таблица | +---------+ Частная Адресное простран- | файлов | | Область | таблица ство процесса | +---------+ | | данных | областей +------------------+| | - | | +---------+ процесса | Открытые файлы --||-- + | - | | | +------+ | || - | - | | -- - - + | + +| Текущий каталог -||+ | +---------+ | - +------+ -| ||- -- -| | | +---------+ + + | || Измененный корень||| | | | | | Стек | +------+ -+------------------+|- - +---------+ | | задачи + - + | |+------------------+|| | | - | | +---------+ +------+ -| - ||- - | - | | || - ||| | | - | | -| - ||- - +---------+ | -- - - - - - - - -+| Стек ядра ||| + - + | | | +------------------+|- - | | +---------------------------------------------+| | +---------+ - - - | - | +----+----+ | | | - | |Разделяе-| - - | - | | мая | | | +---------+ | область | - -- -| | | команд | | | | | +----+----+ - - +---------+ - | | +---------+ + - - - - - - - - + - - +---------------------------------------------++ -|+ Таблица | +---------+ Частная | Адресное простран- | -- файлов | | Область | таблица - ство процесса | || +---------+ | | данных | областей |+------------------+| -- | - | | +---------+ процесса -| Открытые файлы --||-- +| | - | | | +------+ || || - | - | | -- - - + | +--| Текущий каталог -||+ | +---------+ | - +------+ | ||- -- + | | +---------+ + + | | Измененный корень||+ - - -| | | | Стек | +------+ +------------------+| +---------+ | | задачи + - + | +------------------+| | - | | +---------+ +------+ | - || | - | | | - || +---------+ | | Стек ядра || | | | +------------------+| | | +---------------------------------------------+ +---------+ Порожденный процесс | - | | - | +---------+ Рисунок 7.3. Создание контекста нового процесса при выполне- нии функции fork 183 Если контекст порожденного процесса готов, родительский процесс заверша- ет свою роль в выполнении алгоритма fork, переводя порожденный процесс в состояние "готовности к запуску, находясь в памяти" и возвращая пользователю его идентификатор. Затем, используя обычный алгоритм планирования, ядро вы- бирает порожденный процесс для исполнения и тот "доигрывает" свою роль в ал- горитме fork. Контекст порожденного процесса был задан родительским процес- сом; с точки зрения ядра кажется, что порожденный процесс возобновляется после приостанова в ожидании ресурса. Порожденный процесс при выполнении функции fork реализует ту часть программы, на которую указывает счетчик ко- манд, восстанавливаемый ядром из сохраненного на уровне 2 регистрового кон- текста, и по выходе из функции возвращает нулевое значение. На Рисунке 7.3 представлена логическая схема взаимодействия родительско- го и порожденного процессов с другими структурами данных ядра сразу после завершения системной функции fork. Итак, оба процесса совместно пользуются файлами, которые были открыты родительским процессом к моменту исполнения функции fork, при этом значение счетчика ссылок на каждый из этих файлов в таблице файлов на единицу больше, чем до вызова функции. Порожденный процесс имеет те же, что и родительский процесс, текущий и корневой каталоги, значе- ние же счетчика ссылок на индекс каждого из этих каталогов так же становится на единицу больше, чем до вызова функции. Содержимое областей команд, данных и стека (задачи) у обоих процессов совпадает; по типу области и версии сис- темной реализации можно установить, могут ли процессы разделять саму область команд в физических адресах. Рассмотрим приведенную на Рисунке 7.4 программу, которая представляет собой пример разделения доступа к файлу при исполнении функции fork. Пользо- вателю следует передавать этой программе два параметра - имя существующего файла и имя создаваемого файла. Процесс открывает существующий файл, создает новый файл и - при условии отсутствия ошибок - порождает новый процесс. Внутри программы ядро делает копию контек- ста родительского процесса для порожденного, при этом родительский процесс исполняется в одном адресном пространстве, а порожденный - в другом. Каждый из процессов может работать со своими собственными копиями глобальных пере- менных fdrd, fdwt и c, а также со своими собственными копиями стековых пере- менных argc и argv, но ни один из них не может обращаться к переменным дру- гого процесса. Тем не менее, при выполнении функции fork ядро делает копию адресного пространства первого процесса для второго, и порожденный процесс, таким образом, наследует доступ к файлам родительского (то есть к файлам, им ранее открытым и созданным) с правом использования тех же самых деск- рипторов. Родительский и порожденный процессы независимо друг от друга, конечно, вызывают функцию rdwrt и в цикле считывают по одному байту информацию из ис- ходного файла и переписывают ее в файл вывода. Функция rdwrt возвращает уп- равление, когда при считывании обнаруживается конец файла. Ядро перед тем уже увеличило значения счетчиков ссылок на исходный и результирующий файлы в таблице файлов, и дескрипторы, используемые в обоих процессах, адресуют к одним и тем же строкам в таблице. Таким образом, дескрипторы fdrd в том и в другом процессах указывают на запись в таблице файлов, соответствующую ис- ходному файлу, а дескрипторы, подставляемые в качестве fdwt, - на запись, соответствующую результирующему файлу (файлу вывода). Поэтому оба процесса никогда не обратятся вместе на чтение или запись к одному и тому же адресу, вычисляемому с помощью смещения внутри файла, поскольку ядро смещает внутри- файловые указатели после каждой операции чтения или записи. Несмотря на то, что, казалось бы, из-за того, что процессы распределяют между собой рабочую нагрузку, они копируют исходный файл в два раза быстрее, содержимое резуль- тирующего файла зависит от очередности, в которой ядро запускает процессы. Если ядро запускает процессы так, что они исполняют системные функции попе- ременно (чередуя и спаренные вызовы функций read-write), содержимое резуль- 184 +------------------------------------------------------------+ | #include | | int fdrd, fdwt; | | char c; | | | | main(argc, argv) | | int argc; | | char *argv[]; | | { | | if (argc != 3) | | exit(1); | | if ((fdrd = open(argv[1],O_RDONLY)) == -1) | | exit(1); | | if ((fdwt = creat(argv[2],0666)) == -1) | | exit(1); | | | | fork(); | | /* оба процесса исполняют одну и ту же программу */ | | rdwrt(); | | exit(0); | | } | | | | rdwrt(); | | { | | for(;;) | | { | | if (read(fdrd,&c,1) != 1) | | return; | | write(fdwt,&c,1); | | } | | } | +------------------------------------------------------------+ Рисунок 7.4. Программа, в которой родительский и порожденный процессы разделяют доступ к файлу тирующего файла будет совпадать с содержимым исходного файла. Рассмотрим, однако, случай, когда процессы собираются считать из исходного файла после- довательность из двух символов "ab". Предположим, что родительский процесс считал символ "a", но не успел записать его, так как ядро переключилось на контекст порожденного процесса. Если порожденный процесс считывает символ "b" и записывает его в результирующий файл до возобновления родительского процесса, строка "ab" в результирующем файле будет иметь вид "ba". Ядро не гарантирует согласование темпов выполнения процессов. Теперь перейдем к программе, представленной на Рисунке 7.5, в которой процесс-потомок наследует от своего родителя файловые дескрипторы 0 и 1 (со- ответствующие стандартному вводу и стандартному выводу). При каждом выполне- нии системной функции pipe производится назначение двух файловых дескрипто- ров в массивах to_par и to_chil. Процесс вызывает функцию fork и делает ко- пию своего контекста: каждый из процессов имеет доступ только к своим собст- венным данным, так же как и в предыдущем примере. Родительский процесс зак- рывает файл стандартного вывода (дескриптор 1) и дублирует дескриптор запи- си, возвращаемый в канал to_chil. Поскольку первое свободное место в таблице дескрипторов родительского процесса образовалось в результате только что вы- полненной операции закрытия (close) файла вывода, ядро переписывает туда дескриптор записи в канал и этот дескриптор становится дескриптором файла стандартного вывода для to_chil. Те же самые действия родительский процесс выполняет в отношении дескриптора файла стандартного ввода, заменяя его дес- 185 криптором чтения из канала to_par. И порожденный процесс закрывает файл стандартного ввода (дескриптор 0) и так же дублирует дескриптор чтения из канала to_chil. Поскольку первое свободное место в таблице дескрипторов фай- лов прежде было занято файлом стандартного ввода, его дескриптором становит- ся дескриптор чтения из канала to_chil. Аналогичные действия выполняются и в отношении дескриптора файла стандартного вывода, заменяя его дескриптором записи в канал to_par. И тот, и другой процессы закрывают файлы, дескрипторы +------------------------------------------------------------+ | #include | | char string[] = "hello world"; | | main() | | { | | int count,i; | | int to_par[2],to_chil[2]; /* для каналов родителя и | | потомка */ | | char buf[256]; | | pipe(to_par); | | pipe(to_chil); | | if (fork() == 0) | | { | | /* выполнение порожденного процесса */ | | close(0); /* закрытие прежнего стандартного ввода */ | | dup(to_chil[0]); /* дублирование дескриптора чтения | | из канала в позицию стандартного | | ввода */ | | close(1); /* закрытие прежнего стандартного вывода */| | dup(to_par[0]); /* дублирование дескриптора записи | | в канал в позицию стандартного | | вывода */ | | close(to_par[1]); /* закрытие ненужных дескрипторов | | close(to_chil[0]); канала */ | | close(to_par[0]); | | close(to_chil[1]); | | for (;;) | | { | | if ((count = read(0,buf,sizeof(buf))) == 0) | | exit(); | | write(1,buf,count); | | } | | } | | /* выполнение родительского процесса */ | | close(1); /* перенастройка стандартного ввода-вывода */| | dup(to_chil[1]); | | close(0); | | dup(to_par[0]); | | close(to_chil[1]); | | close(to_par[0]); | | close(to_chil[0]); | | close(to_par[1]); | | for (i = 0; i < 15; i++) | | { | | write(1,string,strlen(string)); | | read(0,buf,sizeof(buf)); | | } | | } | +------------------------------------------------------------+ Рисунок 7.5. Использование функций pipe, dup и fork 186 которых возвратила функция pipe - хорошая традиция, в чем нам еще предстоит убедиться. В результате, когда родительский процесс переписывает данные в стандартный вывод, запись ведется в канал to_chil и данные поступают к по- рожденному процессу, который считывает их через свой стандартный ввод. Когда же порожденный процесс пишет данные в стандартный вывод, запись ведется в канал to_par и данные поступают к родительскому процессу, считывающему их через свой стандартный ввод. Так через два канала оба процесса обмениваются сообщениями. Результаты этой программы не зависят от того, в какой очередности про- цессы выполняют свои действия. Таким образом, нет никакой разницы, возвраща- ется ли управление родительскому процессу из функции fork раньше или позже, чем порожденному процессу. И так же безразличен порядок, в котором процессы вызывают системные функции перед тем, как войти в свой собственный цикл, ибо они используют идентичные структуры ядра. Если процесс-потомок исполняет функцию read раньше, чем его родитель выполнит write, он будет приостановлен до тех пор, пока родительский процесс не произведет запись в канал и тем са- мым не возобновит выполнение потомка. Если родительский процесс записывает в канал до того, как его потомок приступит к чтению из канала, первый процесс не сможет в свою очередь считать данные из стандартного ввода, пока второй процесс не прочитает все из своего стандартного ввода и не произведет запись данных в стандартный вывод. С этого места порядок работы жестко фиксирован: каждый процесс завершает выполнение функций read и write и не может выпол- нить следующую операцию read до тех пор, пока другой процесс не выполнит па- ру read-write. Родитель- ский процесс после 15 итераций завершает работу; порожденный процесс натал- кивается на конец файла ("end-of-file"), поскольку канал не связан больше ни с одним из записывающих процессов, и тоже завершает работу. Если порожденный процесс попытается произвести запись в канал после завершения родительского процесса, он получит сигнал о том, что канал не связан ни с одним из процес- сов чтения. Мы упомянули о том, что хорошей традицией в программировании является закрытие ненужных файловых дескрипторов. В пользу этого говорят три довода. Во-первых, дескрипторы файлов постоянно находятся под контролем системы, ко- торая накладывает ограничение на их количество. Во-вторых, во время исполне- ния порожденного процесса присвоение дескрипторов в новом контексте сохраня- ется (в чем мы еще убедимся). Закрытие ненужных файлов до запуска процесса открывает перед программами возможность исполнения в "стерильных" условиях, свободных от любых неожиданностей, имея открытыми только файлы стандартного ввода-вывода и ошибок. Наконец, функция read для канала возвращает признак конца файла только в том случае, если канал не был открыт для записи ни од- ним из процессов. Если считывающий процесс будет держать дескриптор записи в канал открытым, он никогда не узнает, закрыл ли записывающий процесс работу на своем конце канала или нет. Вышеприведенная программа не работала бы над- лежащим образом, если бы перед входом в цикл выполнения процессом-потомком не были закрыты дескрипторы записи в канал. 7.2 СИГНАЛЫ Сигналы сообщают процессам о возникновении асинхронных событий. Посылка сигналов производится процессами - друг другу, с помощью функции kill, - или ядром. В версии V (вторая редакция) системы UNIX существуют 19 различных сигналов, которые можно классифицировать следующим образом: * Сигналы, посылаемые в случае завершения выполнения процесса, то есть тогда, когда процесс выполняет функцию exit или функцию signal с пара- метром death of child (гибель потомка); * Сигналы, посылаемые в случае возникновения вызываемых процессом особых ситуаций, таких как обращение к адресу, находящемуся за пределами вирту- 187 ального адресного пространства процесса, или попытка записи в область памяти, открытую только для чтения (например, текст программы), или по- пытка исполнения привилегированной команды, а также различные аппаратные ошибки; * Сигналы, посылаемые во время выполнения системной функции при возникно- вении неисправимых ошибок, таких как исчерпание системных ресурсов во время выполнения функции exec после освобождения исходного адресного пространства (см. раздел 7.5); * Сигналы, причиной которых служит возникновение во время выполнения сис- темной функции совершенно неожиданных ошибок, таких как обращение к не- существующей системной функции (процесс передал номер системной функции, который не соответствует ни одной из имеющихся функций), запись в канал, не связанный ни с одним из процессов чтения, а также использование недо- пустимого значения в параметре "reference" системной функции lseek. Ка- залось бы, более логично в таких случаях вместо посылки сигнала возвра- щать код ошибки, однако с практической точки зрения для аварийного за- вершения процессов, в которых возникают подобные ошибки, более предпоч- тительным является именно использование сигналов (*); * Сигналы, посылаемые процессу, который выполняется в режиме задачи, нап- ример, сигнал тревоги (alarm), посылаемый по истечении определенного пе- риода времени, или произвольные сигналы, которыми обмениваются процессы, использующие функцию kill; * Сигналы, связанные с терминальным взаимодействием, например, с "зависа- нием" терминала (когда сигнал-носитель на терминальной линии прекращает- ся по любой причине) или с нажатием клавиш "break" и "delete" на клавиа- туре терминала; * Сигналы, с помощью которых производится трассировка выполнения процесса. Условия применения сигналов каждой группы будут рассмотрены в этой и последующих главах. Концепция сигналов имеет несколько аспектов, связанных с тем, каким об- разом ядро посылает сигнал процессу, каким образом процесс обрабатывает сиг- нал и управляет реакцией на него. Посылая сигнал процессу, ядро устанавлива- ет в единицу разряд в поле сигнала записи таблицы процессов, соответствующий типу сигнала. Если процесс находится в состоянии приостанова с приоритетом, допускающим прерывания, ядро возобновит его выполнение. На этом роль отпра- вителя сигнала (процесса или ядра) исчерпывается. Процесс может запоминать сигналы различных типов, но не имеет возможности запоминать количество полу- чаемых сигналов каждого типа. Например, если процесс получает сигнал о "за- висании" или об удалении процесса из системы, он устанавливает в единицу со- ответствующие разряды в поле сигналов таблицы процессов, но не может ска- зать, сколько экземпляров сигнала каждого типа он получил. Ядро проверяет получение сигнала, когда процесс собирается перейти из режима ядра в режим задачи, а также когда он переходит в состояние приоста- нова или выходит из этого состояния с достаточно низким приоритетом планиро- вания (см. Рисунок 7.6). Ядро обрабатывает сигналы только тогда, когда про- цесс возвращается из режима ядра в режим задачи. Таким образом, сигнал не оказывает немедленного воздействия на поведение процесса, исполняемого в ре- жиме ядра. Если процесс исполняется в режиме задачи, а ядро тем временем об- рабатывает прерывание, послужившее поводом для посылки процессу сигнала, яд- ро распознает и обработает сигнал по выходе из прерывания. Таким образом, процесс не будет исполняться в режиме задачи, пока какие-то сигналы остаются необработанными. На Рисунке 7.7 представлен алгоритм, с помощью которого ядро определяет, --------------------------------------- (*) Использование сигналов в некоторых обстоятельствах позволяет обнаружить ошибки при выполнении программ, не проверяющих код завершения вызываемых системных функций (сообщил Д.Ричи). 188 Выполняется в режиме задачи +-------+ | | Проверка | 1 | и Вызов функ- | | + - обработка ции, преры- ++------+ -+ - сигналов вание | ^ ^- -+- Преры- +-----+ +-------+ |- -|- - + вание, | | | +-------+ +---+ Возврат в возврат| | | | Возврат | режим задачи из пре-| | | | | рыва-| v v | Выполняет- | +-------+ ния | +------++ся в режи- ++------+ | | +-->| |ме ядра | | | 9 |<-----------+ 2 +------------>| 7 | | | Выход | | Резервирует-| | +-------+ ++------+ ся +-------+ Прекращение | ^ - Зарезер- существования |- - -|- - - - - - - - + - вирован | |- - - - - - - + + -- -+ +---------------+ +------+ -------- Проверка | Приостанов Запуск | - + - - - сигналов v | - При-+-------+ +-+-----+ Готов к ос- | | Возобновление | | запуску та- | 4 +----------------------->| 3 | в памяти нов-| | | | лен +---+---+ ++------+ в па- | | ^ ^ мяти | | | | Достаточно | | | | памяти | | | +---+ | Вы- Вы- | | | | грузка грузка | | | Создан | | |За- ++------+ | | |груз-| | fork | | |ка | 8 |<----- | | | | | | | | ++------+ | | | | | | | | Недоста- | | | +---+ точно | | | | памяти | | | | (только система | | | | подкачки) v v | v +-------+ +---+---+ | | Возобновление | | | 6 +----------------------->| 5 | | | | | +-------+ +-------+ Приостановлен, Готов к запуску, выгружен выгружен Рисунок 7.6. Диаграмма переходов процесса из состояние в состояние с указанием моментов проверки и обработки сигналов 189 получил ли процесс сигнал или нет. Условия, в которых формируются сигналы типа "гибель потомка", будут рассмотрены позже. Мы также увидим, что процесс может игнорировать отдельные сигналы, если воспользуется функцией signal. В алгоритме issig ядро просто гасит индикацию тех сигналов, на которые процесс не желает обращать внимание, и привлекает внимание процесса ко всем осталь- ным сигналам. +------------------------------------------------------------+ | алгоритм issig /* проверка получения сигналов */ | | входная информация: отсутствует | | выходная информация: "истина", если процесс получил сигна- | | лы, которые его интересуют | | "ложь" - в противном случае | | { | | выполнить пока (поле в записи таблицы процессов, содер- | | жащее индикацию о получении сигнала, хранит ненулевое | | значение) | | { | | найти номер сигнала, посланного процессу; | | если (сигнал типа "гибель потомка") | | { | | если (сигналы данного типа игнорируются) | | освободить записи таблицы процессов, которые | | соответствуют потомкам, прекратившим существо-| | вание; | | в противном случае если (сигналы данного типа при-| | нимаются) | | возвратить (истину); | | } | | в противном случае если (сигнал не игнорируется) | | возвратить (истину); | | сбросить (погасить) сигнальный разряд, установленный | | в соответствующем поле таблицы процессов, хранящем | | индикацию получения сигнала; | | } | | возвратить (ложь); | | } | +------------------------------------------------------------+ Рисунок 7.7. Алгоритм опознания сигналов 7.2.1 Обработка сигналов Ядро обрабатывает сигналы в контексте того процесса, который получает их, поэтому чтобы обработать сигналы, нужно запустить процесс. Существует три способа обработки сигналов: процесс завершается по получении сигнала, не обращает внимание на сигнал или выполняет особую (пользовательскую) функцию по его получении. Реакцией по умолчанию со стороны процесса, исполняемого в режиме ядра, является вызов функции exit, однако с помощью функции signal процесс может указать другие специальные действия, принимаемые по получении тех или иных сигналов. Синтаксис вызова системной функции signal: oldfunction = signal(signum,function); где signum - номер сигнала, при получении которого будет выполнено действие, связанное с запуском пользовательской функции, function - адрес функции, oldfunction - возвращаемое функцией значение. Вместо адреса функции процесс может передавать вызываемой процедуре signal числа 1 и 0: если function = 1, процесс будет игнорировать все последующие поступления сигнала с номером 190 signum (особый случай, связанный с игнорированием сигнала "гибель потомка", рассматривается в разделе 7.4), если = 0 (значение по умолчанию), процесс по получении сигнала в режиме ядра завершается. В пространстве процесса поддер- живается массив полей для обработки сигналов, по одному полю на каждый опре- деленный в системе сигнал. В поле, соответствующем сигналу с указанным номе- ром, ядро сохраняет адрес пользовательской функции, вызываемой по получении сигнала процессом. Способ обработки сигналов одного типа не влияет на обра- ботку сигналов других типов. +------------------------------------------------------------+ | алгоритм psig /* обработка сигналов после проверки их | | существования */ | | входная информация: отсутствует | | выходная информация: отсутствует | | { | | выбрать номер сигнала из записи таблицы процессов; | | очистить поле с номером сигнала; | | если (пользователь ранее вызывал функцию signal, с по- | | мощью которой сделал указание игнорировать сигнал дан- | | ного типа) | | возвратить управление; | | если (пользователь указал функцию, которую нужно выпол- | | нить по получении сигнала) | | { | | из пространства процесса выбрать пользовательский | | виртуальный адрес функции обработки сигнала; | | /* следующий оператор имеет нежелательные побочные | | эффекты */ | | очистить поле в пространстве процесса, содержащее | | адрес функции обработки сигнала; | | внести изменения в пользовательский контекст: | | искусственно создать в стеке задачи запись, ими- | | тирующую обращение к функции обработки сигнала; | | внести изменения в системный контекст: | | записать адрес функции обработки сигнала в поле | | счетчика команд, принадлежащее сохраненному ре- | | гистровому контексту задачи; | | возвратить управление; | | } | | если (сигнал требует дампирования образа процесса в па- | | мяти) | | { | | создать в текущем каталоге файл с именем "core"; | | переписать в файл "core" содержимое пользовательско-| | го контекста; | | } | | немедленно запустить алгоритм exit; | | } | +------------------------------------------------------------+ Рисунок 7.8. Алгоритм обработки сигналов Обрабатывая сигнал (Рисунок 7.8), ядро определяет тип сигнала и очищает (гасит) разряд в записи таблицы процессов, соответствующий данному типу сиг- нала и установленный в момент получения сигнала процессом. Если функции об- работки сигнала присвоено значение по умолчанию, ядро в отдельных случаях перед завершением процесса сбрасывает на внешний носитель (дампирует) образ процесса в памяти (см. упражнение 7.7). Дампирование удобно для программис- 191 тов тем, что позволяет установить причину завершения процесса и посредством этого вести отладку программ. Ядро дампирует состояние памяти при поступле- нии сигналов, которые сообщают о каких-нибудь ошибках в выполнении процес- сов, как например, попытка исполнения запрещенной команды или обращение к адресу, находящемуся за пределами виртуального адресного пространства про- цесса. Ядро не дампирует состояние памяти, если сигнал не связан с программ- ной ошибкой. Например, прерывание, вызванное нажатием клавиш "delete" или "break" на терминале, имеет своим результатом посылку сигнала, который сооб- щает о том, что пользователь хочет раньше времени завершить процесс, в то время как сигнал о "зависании" является свидетельством нарушения связи с ре- гистрационным терминалом. Эти сигналы не связаны с ошибками в протекании процесса. Сигнал о выходе (quit), однако, вызывает сброс состояния памяти, несмотря на то, что он возникает за пределами выполняемого процесса. Этот сигнал, обычно вызываемый одновременным нажатием клавиш , дает прог- раммисту возможность получать дамп состояния памяти в любой момент после за- пуска процесса, что бывает необходимо, если процесс попадает в бесконечный цикл выполнения одних и тех же команд (зацикливается). Если процесс получает сигнал, на который было решено не обращать внима- ние, выполнение процесса продолжается так, словно сигнала и не было. Пос- кольку ядро не сбрасывает значение соответствующего поля, свидетельствующего о необходимости игнорирования сигнала данного типа, то когда сигнал поступит вновь, процесс опять не обратит на него внимание. Если процесс получает сиг- нал, реагирование на который было признано необходимым, сразу по возвращении процесса в режим задачи выполняется заранее условленное действие, однако прежде чем перевести процесс в режим задачи, ядро еще должно предпринять следующие шаги: 1. Ядро обращается к сохраненному регистровому контексту задачи и выбирает значения счетчика команд и указателя вершины стека, которые будут возв- ращены пользовательскому процессу. 2. Сбрасывает в пространстве процесса прежнее значение поля функции обра- ботки сигнала и присваивает ему значение по умолчанию. 3. Создает новую запись в стеке задачи, в которую, при необходимости выде- ляя дополнительную память, переписывает значения счетчика команд и ука- зателя вершины стека, выбранные ранее из сохраненного регистрового кон- текста задачи. Стек задачи будет выглядеть так, как будто процесс произ- вел обращение к пользовательской функции (обработки сигнала) в той точ- ке, где он вызывал системную функцию или где ядро прервало его выполне- ние (перед опознанием сигнала). 4. Вносит изменения в сохраненный регистровый контекст задачи: устанавлива- ет значение счетчика команд равным адресу функции обработки сигнала, а значение указателя вершины стека равным глубине стека задачи. Таким образом, по возвращении из режима ядра в режим задачи процесс приступит к выполнению функции обработки сигнала; после ее завершения управ- ление будет передано на то место в программе пользователя, где было произве- дено обращение к системной функции или произошло прерывание, тем самым как бы имитируется выход из системной функции или прерывания. В качестве примера можно привести программу (Рисунок 7.9), которая при- нимает сигналы о прерывании (SIGINT) и сама посылает их (в результате выпол- нения функции kill). На Рисунке 7.10 представлены фрагменты программного ко- да, полученные в результате дисассемблирования загрузочного модуля в опера- ционной среде VAX 11/780. При выполнении процесса обращение к библиотечной процедуре kill имеет адрес (шестнадцатиричный) ee; эта процедура в свою оче- редь, прежде чем вызвать системную функцию kill, исполняет команду chmk (пе- ревести процесс в режим ядра) по адресу 10a. Адрес возврата из системной функции - 10c. Во время исполнения системной функции ядро посылает процессу сигнал о прерывании. Ядро обращает внимание на этот сигнал тогда, когда про- цесс собирается вернуться в режим задачи, выбирая из сохраненного регистро- вого контекста адрес возврата 10c и помещая его в стек задачи. При этом ад- 192 рес функции обработки сигнала, 104, ядро помещает в сохраненный регистровый контекст задачи. На Рисунке 7.11 показаны различные состояния стека задачи и сохраненного регистрового контекста. В рассмотренном алгоритме обработки сигналов имеются некоторые несоот- ветствия. Первое из них и наиболее важное связано с очисткой перед возвраще- нием процесса в режим задачи того поля в пространстве процесса, которое со- держит адрес пользовательской функции обработки сигнала. Если процессу снова понадобится обработать сигнал, ему опять придется прибегнуть к помощи сис- темной функции signal. При этом могут возникнуть нежелательные последс- +-------------------------------------------+ | #include | | main() | | { | | extern catcher(); | | signal(SIGINT,catcher); | | kill(0,SIGINT); | | } | | | | catcher() | | { | | } | +-------------------------------------------+ Рисунок 7.9. Исходный текст программы приема сигналов +--------------------------------------------------------+ | **** VAX DISASSEMBLER **** | | | | _main() | | e4: | | e6: pushab Ox18(pc) | | ec: pushl $Ox2 | | # в следующей строке вызывается функция signal | | ee: calls $Ox2,Ox23(pc) | | f5: pushl $Ox2 | | f7: clrl -(sp) | | # в следующей строке вызывается библиотечная процеду-| | ра kill | | f9: calls $Ox2,Ox8(pc) | | 100: ret | | 101: halt | | 102: halt | | 103: halt | | _catcher() | | 104: | | 106: ret | | 107: halt | | _kill() | | 108: | | # в следующей строке вызывается внутреннее прерывание| | операционной системы | | 10a: chmk $Ox25 | | 10c: bgequ Ox6 | | 10e: jmp Ox14(pc) | | 114: clrl r0 | | 116: ret | +--------------------------------------------------------+ Рисунок 7.10. Результат дисассемблирования программы приема сигналов 193 До После | | | | | | +-->+--------------------+ | | Вершина | | Новая запись с вы- | | | +-- стека --+ | зовом функции | | | | задачи | | | | | ---->|Адрес возврата (10c)| +--------------------+<--+ - +--------------------+ | Стек задачи | - | Стек задачи | | до | - | до | | получения сигнала | - | получения сигнала | +--------------------+ - +--------------------+ Стек задачи - Стек задачи - +--------------------+ - +--------------------+ | Адрес возврата | - | Адрес возврата | | в процессе (10c) -|---------------- | в процессе (104) | +--------------------+ +--------------------+ | Сохраненный регист-| | Сохраненный регист-| | ровый контекст за- | | ровый контекст за- | | дачи | | дачи | +--------------------+ +--------------------+ Системный контекстный Системный контекстный уровень 1 уровень 1 Область сохранения Область сохранения регистров регистров Рисунок 7.11. Стек задачи и область сохранения структур ядра до и после получения сигнала твия: например, могут создасться условия для конкуренции, если второй раз сигнал поступит до того, как процесс получит возможность запустить системную функцию. Поскольку процесс выполняется в режиме задачи, ядру следовало бы произвести переключение контекста, чтобы увеличить тем самым шансы процесса на получение сигнала до момента сброса значения поля функции обработки сиг- нала. Эту ситуацию можно разобрать на примере программы, представленной на Ри- сунке 7.12. Процесс обращается к системной функции signal для того, чтобы дать указание принимать сигналы о прерываниях и исполнять по их получении функцию sigcatcher. Затем он порождает новый процесс, запускает системную функцию nice, позволяющую сделать приоритет запуска процесса-родителя ниже приоритета его потомка (см. главу 8), и входит в бесконечный цикл. Порожден- ный процесс задерживает свое выполнение на 5 секунд, чтобы дать родительско- му процессу время исполнить системную функцию nice и снизить свой приоритет. После этого порожденный процесс входит в цикл, в каждой итерации которого он посылает родительскому процессу сигнал о прерывании (посредством обращения к функции kill). Если в результате ошибки, например, из-за того, что родитель- ский процесс больше не существует, kill завершается, то завершается и порож- денный процесс. Вся идея состоит в том, что родительскому процессу следует запускать функцию обработки сигнала при каждом получении сигнала о прерыва- нии. Функция обработки сигнала выводит сообщение и снова обращается к функ- ции signal при очередном появлении сигнала о прерывании, родительский же процесс продолжает 194 +------------------------------------------------------------+ | #include | | sigcatcher() | | { | | printf("PID %d принял сигнал\n",getpid()); /* печать | | PID */ | | signal(SIGINT,sigcatcher); | | } | | | | main() | | { | | int ppid; | | | | signal(SIGINT,sigcatcher); | | | | if (fork() == 0) | | { | | /* дать процессам время для выполнения установок */ | | sleep(5); /* библиотечная функция приостанова на| | 5 секунд */ | | ppid = getppid(); /* получить идентификатор родите- | | ля */ | | for (;;) | | if (kill(ppid,SIGINT) == -1) | | exit(); | | } | | | | /* чем ниже приоритет, тем выше шансы возникновения кон-| | куренции */ | | nice(10); | | for (;;) | | ; | | } | +------------------------------------------------------------+ Рисунок 7.12. Программа, демонстрирующая возникновение соперничества между процессами в ходе обработки сигналов исполнять циклический набор команд. Однако, возможна и следующая очередность наступления событий: 1. Порожденный процесс посылает родительскому процессу сигнал о прерывании. 2. Родительский процесс принимает сигнал и вызывает функцию обработки сиг- нала, но резервируется ядром, которое производит переключение контекста до того, как функция signal будет вызвана повторно. 3. Снова запускается порожденный процесс, который посылает родительскому процессу еще один сигнал о прерывании. 4. Родительский процесс получает второй сигнал о прерывании, но перед тем он не успел сделать никаких распоряжений относительно способа обработки сигнала. Когда выполнение родительского процесса будет возобновлено, он завершится. В программе описывается именно такое поведение процессов, поскольку вы- зов родительским процессом функции nice приводит к тому, что ядро будет чаще запускать на выполнение порожденный процесс. По словам Ричи (эти сведения были получены в частной беседе), сигналы были задуманы как события, которые могут быть как фатальными, так и проходя- щими незаметно, которые не всегда обрабатываются, поэтому в ранних версиях системы конкуренция процессов, связанная с посылкой сигналов, не фиксирова- лась. Тем не менее, она представляет серьезную проблему в тех программах, где осуществляется прием сигналов. Эта проблема была бы устранена, если бы 195 поле описания сигнала не очищалось по его получении. Однако, такое решение породило бы новую проблему: если поступающий сигнал принимается, а поле очи- щено, вложенные обращения к функции обработки сигнала могут переполнить стек. С другой стороны, ядро могло бы сбросить значение функции обработки сигнала, тем самым делая распоряжение игнорировать сигналы данного типа до тех пор, пока пользователь вновь не укажет, что нужно делать по получении подобных сигналов. Такое решение предполагает потерю информации, так как процесс не в состоянии узнать, сколько сигналов им было получено. Однако, информации при этом теряется не больше, чем в том случае, когда процесс по- лучает большое количество сигналов одного типа до того, как получает возмож- ность их обработать. В системе BSD, наконец, процесс имеет возможность бло- кировать получение сигналов и снимать блокировку при новом обращении к сис- темной функции; когда процесс снимает блокировку сигналов, ядро посылает процессу все сигналы, отложенные (повисшие) с момента установки блокировки. Когда процесс получает сигнал, ядро автоматически блокирует получение следу- ющего сигнала до тех пор, пока функция обработки сигнала не закончит работу. В этих действиях ядра наблюдается аналогия с тем, как ядро реагирует на ап- паратные прерывания: оно блокирует появление новых прерываний на время обра- ботки предыдущих. Второе несоответствие в обработке сигналов связано с приемом сигналов, поступающих во время исполнения системной функции, когда процесс приостанов- лен с допускающим прерывания приоритетом. Сигнал побуждает процесс выйти из приостанова (с помощью longjump), вернуться в режим задачи и вызвать функцию обработки сигнала. Когда функция обработки сигнала завершает работу, проис- ходит то, что процесс выходит из системной функции с ошибкой, сообщающей о прерывании ее выполнения. Узнав об ошибке, пользователь запускает системную функцию повторно, однако более удобно было бы, если бы это действие автома- тически выполнялось ядром, как в системе BSD. Третье несоответствие проявляется в том случае, когда процесс игнорирует поступивший сигнал. Если сигнал поступает в то время, когда процесс находит- ся в состоянии приостанова с допускающим прерывания приоритетом, процесс во- зобновляется, но не выполняет longjump. Другими словами, ядро узнает о том, что процесс проигнорировал поступивший сигнал только после возобновления его выполнения. Логичнее было бы оставить процесс в состоянии приостанова. Одна- ко, в момент посылки сигнала к пространству процесса, в котором ядро хранит адрес функции обработки сигнала, может отсутствовать доступ. Эта проблема может быть решена путем запоминания адреса функции обработки сигнала в запи- си таблицы процессов, обращаясь к которой, ядро получало бы возможность ре- шать вопрос о необходимости возобновления процесса по получении сигнала. С другой стороны, процесс может немедленно вернуться в состояние приостанова (по алгоритму sleep), если обнаружит, что в его возобновлении не было необ- ходимости. Однако, пользовательские процессы не имеют возможности осознавать собственное возобновление, поскольку ядро располагает точку входа в алгоритм sleep внутри цикла с условием продолжения (см. главу 2), переводя процесс вновь в состояние приостанова, если ожидаемое процессом событие в действи- тельности не имело места. Ко всему сказанному выше следует добавить, что ядро обрабатывает сигналы типа "гибель потомка" не так, как другие сигналы. В частности, когда процесс узнает о получении сигнала "гибель потомка", он выключает индикацию сигнала в соответствующем поле записи таблицы процессов и по умолчанию действует так, словно никакого сигнала и не поступало. Назначение сигнала "гибель по- томка" состоит в возобновлении выполнения процесса, приостановленного с до- пускающим прерывания приоритетом. Если процесс принимает такой сигнал, он, как и во всех остальных случаях, запускает функцию обработки сигнала. Дейст- вия, предпринимаемые ядром в том случае, когда процесс игнорирует поступив- ший сигнал этого типа, будут описаны в разделе 7.4. Наконец, когда процесс вызвал функцию signal с параметром "гибель потомка" (death of child), ядро посылает ему соответствующий сигнал, если он имеет потомков, прекративших существование. В разделе 7.4 на этом моменте мы остановимся более подробно. 196 7.2.2 Группы процессов Несмотря на то, что в системе UNIX процессы идентифицируются уникальным кодом (PID), системе иногда приходится использовать для идентификации про- цессов номер "группы", в которую они входят. Например, процессы, имеющие об- щего предка в лице регистрационного shell'а, взаимосвязаны, и поэтому когда пользователь нажимает клавиши "delete" или "break", или когда терминальная линия "зависает", все эти процессы получают соответствующие сигналы. Ядро использует код группы процессов для идентификации группы взаимосвязанных процессов, которые при наступлении определенных событий должны получать об- щий сигнал. Код группы запоминается в таблице процессов; процессы из одной группы имеют один и тот же код группы. Для того, чтобы присвоить коду группы процессов начальное значение, при- равняв его коду идентификации процесса, следует воспользоваться системной функцией setpgrp. Синтаксис вызова функции: grp = setpgrp(); где grp - новый код группы процессов. При выполнении функции fork про- цесс-потомок наследует код группы своего родителя. Использование функции setpgrp при назначении для процесса операторского терминала имеет важные особенности, на которые стоит обратить внимание (см. раздел 10.3.5). 7.2.3 Посылка сигналов процессами Для посылки сигналов процессы используют системную функцию kill. Синтак- сис вызова функции: kill(pid,signum) где в pid указывается адресат посылаемого сигнала (область действия сигна- ла), а в signum - номер посылаемого сигнала. Связь между значением pid и со- вокупностью выполняющихся процессов следующая: * Если pid - положительное целое число, ядро посылает сигнал процессу с идентификатором pid. * Если значение pid равно 0, сигнал посылается всем процессам, входящим в одну группу с процессом, вызвавшим функцию kill. * Если значение pid равно -1, сигнал посылается всем процессам, у которых реальный код идентификации пользователя совпадает с тем, под которым ис- полняется процесс, вызвавший функцию kill (об этих кодах более подробно см. в разделе 7.6). Если процесс, пославший сигнал, исполняется под ко- дом идентификации суперпользователя, сигнал рассылается всем процессам, кроме процессов с идентификаторами 0 и 1. * Если pid - отрицательное целое число, но не -1, сигнал посылается всем процессам, входящим в группу с номером, равным абсолютному значению pid. Во всех случаях, если процесс, пославший сигнал, исполняется под кодом идентификации пользователя, не являющегося суперпользователем, или если коды идентификации пользователя (реальный и исполнительный) у этого процесса не совпадают с соответствующими кодами процесса, принимающего сигнал, kill за- вершается неудачно. В программе, приведенной на Рисунке 7.13, главный процесс сбрасывает ус- тановленное ранее значение номера группы и порождает 10 новых процессов. При рождении каждый процесс-потомок наследует номер группы процессов своего ро- дителя, однако, процессы, созданные в нечетных итерациях цикла, сбрасывают это значение. Системные функции getpid и getpgrp возвращают значения кода идентификации выполняемого процесса и номера группы, в которую он входит, а функция pause приостанавливает выполнение процесса до момента получения сиг- нала. В конечном итоге родительский процесс запускает функцию kill и посыла- ет сигнал о прерывании всем процессам, входящим в одну с ним группу. Ядро 197 +------------------------------------------------------------+ | #include | | main() | | { | | register int i; | | | | setpgrp(); | | for (i = 0; i < 10; i++) | | { | | if (fork() == 0) | | { | | /* порожденный процесс */ | | if (i & 1) | | setpgrp(); | | printf("pid = %d pgrp = %d\n",getpid(),getpgrp());| | pause(); /* системная функция приостанова вы- | | полнения */ | | } | | } | | kill(0,SIGINT); | | } | +------------------------------------------------------------+ Рисунок 7.13. Пример использования функции setpgrp посылает сигнал пяти "четным" процессам, не сбросившим унаследованное значе- ние номера группы, при этом пять "нечетных" процессов продолжают свое выпол- нение. 7.3 ЗАВЕРШЕНИЕ ВЫПОЛНЕНИЯ ПРОЦЕССА В системе UNIX процесс завершает свое выполнение, запуская системную функцию exit. После этого процесс переходит в состояние "прекращения сущест- вования" (см. Рисунок 6.1), освобождает ресурсы и ликвидирует свой контекст. Синтаксис вызова функции: exit(status); где status - значение, возвращаемое функцией родительскому процессу. Процес- сы могут вызывать функцию exit как в явном, так и в неявном виде (по оконча- нии выполнения программы: начальная процедура (startup), компонуемая со все- ми программами на языке Си, вызывает функцию exit на выходе программы из функции main, являющейся общей точкой входа для всех программ). С другой стороны, ядро может вызывать функцию exit по своей инициативе, если процесс не принял посланный ему сигнал (об этом мы уже говорили выше). В этом случае значение параметра status равно номеру сигнала. Система не накладывает никакого ограничения на продолжительность выпол- нения процесса, и зачастую процессы существуют в течение довольно длительно- го времени. Нулевой процесс (программа подкачки) и процесс 1 (init), к при- меру, существуют на протяжении всего времени жизни системы. Продолжительными процессами являются также getty-процессы, контролирующие работу терминальной линии, ожидая регистрации пользователей, и процессы общего назначения, вы- полняемые под руководством администратора. На Рисунке 7.14 приведен алгоритм функции exit. Сначала ядро отменяет обработку всех сигналов, посылаемых процессу, поскольку ее продолжение ста- новится бессмысленным. Если процесс, вызывающий функцию exit, возглавляет 198 +------------------------------------------------------------+ | алгоритм exit | | входная информация: код, возвращаемый родительскому про- | | цессу | | выходная информация: отсутствует | | { | | игнорировать все сигналы; | | если (процесс возглавляет группу процессов, ассоцииро- | | ванную с операторским терминалом) | | { | | послать всем процессам, входящим в группу, сигнал о | | "зависании"; | | сбросить в ноль код группы процессов; | | } | | закрыть все открытые файлы (внутренняя модификация алго-| | ритма close); | | освободить текущий каталог (алгоритм iput); | | освободить области и память, ассоциированную с процессом| | (алгоритм freereg); | | создать запись с учетной информацией; | | прекратить существование процесса (перевести его в соот-| | ветствующее состояние); | | назначить всем процессам-потомкам в качестве родителя | | процесс init (1); | | если кто-либо из потомков прекратил существование, | | послать процессу init сигнал "гибель потомка"; | | послать сигнал "гибель потомка" родителю данного процес-| | са; | | переключить контекст; | | } | +------------------------------------------------------------+ Рисунок 7.14. Алгоритм функции exit группу процессов, ассоциированную с операторским терминалом (см. раздел 10.3.5), ядро делает предположение о том, что пользователь прекращает рабо- ту, и посылает всем процессам в группе сигнал о "зависании". Таким образом, если пользователь в регистрационном shell'е нажмет последовательность кла- виш, означающую "конец файла" (Ctrl-d), при этом с терминалом остались свя- занными некоторые из существующих процессов, процесс, выполняющий функцию exit, пошлет им всем сигнал о "зависании". Кроме того, ядро сбрасывает в ноль значение кода группы процессов для всех процессов, входящих в данную группу, поскольку не исключена возможность того, что позднее текущий код идентификации процесса (процесса, который вызвал функцию exit) будет присво- ен другому процессу и тогда последний возглавит группу с указанным кодом. Процессы, входившие в старую группу, в новую группу входить не будут. После этого ядро просматривает дескрипторы открытых файлов, закрывает каждый из этих файлов по алгоритму close и освобождает по алгоритму iput индексы теку- щего каталога и корня (если он изменялся). Наконец, ядро освобождает всю выделенную задаче память вместе с соответ- ствующими областями (по алгоритму detachreg) и переводит процесс в состояние прекращения существования. Ядро сохраняет в таблице процессов код возврата функции exit (status), а также суммарное время исполнения процесса и его по- томков в режиме ядра и режиме задачи. В разделе 7.4 при рассмотрении функции wait будет показано, каким образом процесс получает информацию о времени вы- полнения своих потомков. Ядро также создает в глобальном учетном файле за- пись, которая содержит различную статистическую информацию о выполнении про- цесса, такую как код идентификации пользователя, использование ресурсов цен- трального процессора и памяти, объем потоков ввода-вывода, связанных с про- 199 цессом. Пользовательские программы могут в любой момент обратиться к учетно- му файлу за статистическими данными, представляющими интерес с точки зрения слежения за функционированием системы и организации расчетов с пользователя- ми. Ядро удаляет процесс из дерева процессов, а его потомков передает про- цессу 1 (init). Таким образом, процесс 1 становится законным родителем всех продолжающих существование потомков завершающегося процесса. Если кто-либо из потомков прекращает существование, завершающийся процесс посылает процес- су init сигнал "гибель потомка" для того, чтобы процесс начальной загрузки мог удалить запись о потомке из таблицы процессов (см. раздел 7.9); кроме того, завершающийся процесс посылает этот сигнал своему родителю. В типичной ситуации родительский процесс синхронизирует свое выполнение с завершающимся потомком с помощью системной функции wait. Прекращая существование, процесс переключает контекст и ядро может теперь выбирать для исполнения следующий процесс; ядро с этих пор уже не будет исполнять процесс, прекративший сущес- твование. В программе, приведенной на Рисунке 7.15, процесс создает новый процесс, который печатает свой код идентификации и вызывает системную функцию pause, приостанавливаясь до получения сигнала. Процесс-родитель печатает PID своего потомка и завершается, возвращая только что выведенное значение через пара- метр status. Если бы вызов функции exit отсутствовал, начальная процедура сделала бы его по выходе процесса из функции main. Порожденный процесс про- должает ожидать получения сигнала, даже если его родитель уже завершился. 7.4 ОЖИДАНИЕ ЗАВЕРШЕНИЯ ВЫПОЛНЕНИЯ ПРОЦЕССА Процесс может синхронизировать продолжение своего выполнения с моментом завершения потомка, если воспользуется системной функцией wait. Синтаксис вызова функции: +------------------------------------------------------------+ | main() | | { | | int child; | | | | if ((child = fork()) == 0) | | { | | printf("PID потомка %d\n",getpid()); | | pause(); /* приостанов выполнения до получения | | сигнала */ | | } | | /* родитель */ | | printf("PID потомка %d\n",child); | | exit(child); | | } | +------------------------------------------------------------+ Рисунок 7.15. Пример использования функции exit pid = wait(stat_addr); где pid - значение кода идентификации (PID) прекратившего свое существование потомка, stat_addr - адрес переменной целого типа, в которую будет помещено возвращаемое функцией exit значение, в пространстве задачи. Алгоритм функции wait приведен на Рисунке 7.16. Ядро ведет поиск потом- ков процесса, прекративших существование, и в случае их отсутствия возвраща- ет ошибку. Если потомок, прекративший существование, обнаружен, ядро переда- ет его код идентификации и значение, возвращаемое через параметр функции exit, процессу, вызвавшему функцию wait. Таким образом, через параметр функ- 200 ции exit (status) завершающийся процесс может передавать различные значения, в закодированном виде содержащие информацию о причине завершения процесса, однако на практике этот параметр используется по назначению довольно редко. Ядро передает в соответствующие поля, принадлежащие пространству родитель- ского процесса, накопленные значения продолжительности исполнения процес- са-потомка в режиме ядра и в режиме задачи и, наконец, освобождает в таблице процессов место, которое в ней занимал прежде прекративший существование процесс. Это место будет предоставлено новому процессу. Если процесс, выполняющий функцию wait, имеет потомков, продолжающих су- ществование, он приостанавливается до получения ожидаемого сигнала. Ядро не возобновляет по своей инициативе процесс, приостановившийся с помощью функ- ции wait: такой процесс может возобновиться только в случае получения сигна- ла. На все сигналы, кроме сигнала "гибель потомка", процесс реагирует ранее рассмотренным образом. Реакция процесса на сигнал "гибель потомка" проявля- ется по-разному в зависимости от обстоятельств: * По умолчанию (то есть если специально не оговорены никакие другие дейст- вия) процесс выходит из состояния останова, в которое он вошел с помощью функции wait, и запускает алгоритм issig для опознания типа поступившего сигнала. Алгоритм issig (Рисунок 7.7) рассматривает особый случай пос- тупления сигнала типа "гибель потомка" и возвращает "ложь". Поэтому ядро не выполняет longjump из функции sleep, а возвращает управление функции wait. Оно перезапускает функцию wait, находит потомков, прекративших су- ществование (по крайней мере, одного), освобождает место в таблице про- цессов, занимаемое этими потомками, и выходит из функции wait, возвращая +------------------------------------------------------------+ | алгоритм wait | | входная информация: адрес переменной для хранения значения| | status, возвращаемого завершающимся | | процессом | | выходная информация: идентификатор потомка и код возврата | | функции exit | | { | | если (процесс, вызвавший функцию wait, не имеет потом- | | ков) | | возвратить (ошибку); | | | | для (;;) /* цикл с внутренним циклом */ | | { | | если (процесс, вызвавший функцию wait, имеет потом-| | ков, прекративших существование) | | { | | выбрать произвольного потомка; | | передать его родителю информацию об использова-| | нии потомком ресурсов центрального процессора;| | освободить в таблице процессов место, занимае- | | мое потомком; | | возвратить (идентификатор потомка, код возврата| | функции exit, вызванной потомком); | | } | | если (у процесса нет потомков) | | возвратить ошибку; | | приостановиться с приоритетом, допускающим прерыва-| | ния (до завершения потомка); | | } | | } | +------------------------------------------------------------+ Рисунок 7.16. Алгоритм функции wait 201 управление процессу, вызвавшему ее. * Если процессы принимает сигналы данного типа, ядро делает все необходи- мые установки для запуска пользовательской функции обработки сигнала, как и в случае поступления сигнала любого другого типа. * Если процесс игнорирует сигналы данного типа, ядро перезапускает функцию wait, освобождает в таблице процессов место, занимаемое потомками, прек- ратившими существование, и исследует оставшихся потомков. Например, если пользователь запускает программу, приведенную на Рисунке 7.17, с параметром и без параметра, он получит разные результаты. Сначала рассмотрим случай, когда пользователь запускает программу без параметра (единственный параметр - имя программы, то есть argc равно 1). Родительский процесс порождает 15 потомков, которые в конечном итоге завершают свое вы- полнение с кодом возврата i, номером процесса в порядке очередности созда- ния. Ядро, исполняя функцию wait для родителя, находит потомка, прекративше- го существование, и передает родителю его идентификатор и код возврата функ- ции exit. При этом заранее не известно, какой из потомков будет обнаружен. Из текста программы, реализующей системную функцию exit, написанной на языке Си и включенной в библиотеку стандартных подпрограмм, видно, что программа запоминает код возврата функции exit в битах 8-15 поля ret_code и возвращает функции wait идентификатор процесса-потомка. Таким образом, в ret_code хра- нится значение, равное 256*i, где i - номер потомка, а в ret_val заносится значение идентификатора потомка. Если пользователь запускает программу с параметром (то есть argc > 1), родительский процесс с помощью функции signal делает распоряжение игнориро- вать сигналы типа "гибель потомка". Предположим, что родительский процесс, выполняя функцию wait, приостановился еще до того, как его потомок произвел обращение к функции exit: когда процесс-потомок переходит к выполнению функ- ции exit, он посылает своему родителю сигнал "гибель потомка"; родительский процесс возобновляется, поскольку он был приостановлен с приоритетом, допус- кающим прерывания. Когда так или иначе родительский процесс продолжит свое +------------------------------------------------------------+ | #include | | main(argc,argv) | | int argc; | | char *argv[]; | | { | | int i,ret_val,ret_code; | | | | if (argc >= 1) | | signal(SIGCLD,SIG_IGN); /* игнорировать гибель | | потомков */ | | for (i = 0; i < 15; i++) | | if (fork() == 0) | | { | | /* процесс-потомок */ | | printf("процесс-потомок %x\n",getpid()); | | exit(i); | | } | | ret_val = wait(&ret_code); | | printf("wait ret_val %x ret_code %x\n",ret_val,ret_code);| | } | +------------------------------------------------------------+ Рисунок 7.17. Пример использования функции wait и игнорирова- ния сигнала "гибель потомка" 202 выполнение, он обнаружит, что сигнал сообщал о "гибели" потомка; однако, поскольку он игнорирует сигналы этого типа и не обрабатывает их, ядро удаляет из таблицы процессов запись, соответствующую прекратившему существование потомку, и продолжает выполнение функции wait так, словно сигнала и не было. Ядро выполняет эти действия вся- кий раз, когда родительский процесс получает сигнал типа "гибель потомка", до тех пор, пока цикл выполнения функции wait не будет завершен и пока не будет установлено, что у процесса больше потомков нет. Тогда функция wait возвращает значение, равное -1. Разница между двумя способами запуска прог- раммы состоит в том, что в первом случае процесс-родитель ждет завершения любого из потомков, в то время как во втором случае он ждет, пока завершатся все его потомки. В ранних версиях системы UNIX функции exit и wait не использовали и не рассматривали сигнал типа "гибель потомка". Вместо посылки сигнала функция exit возобновляла выполнение родительского процесса. Если родительский про- цесс при выполнении функции wait приостановился, он возобновляется, находит потомка, прекратившего существование, и возвращает управление. В противном случае возобновления не происходит; процесс-родитель обнаружит "погибшего" потомка при следующем обращении к функции wait. Точно так же и процесс на- чальной загрузки (init) может приостановиться, используя функцию wait, и за- вершающиеся по exit процессы будут возобновлять его, если он имеет усынов- ленных потомков, прекращающих существование. В такой реализации функций exit и wait имеется одна нерешенная проблема, связанная с тем, что процессы, прекратившие существование, нельзя убирать из системы до тех пор, пока их родитель не исполнит функцию wait. Если процесс создал множество потомков, но так и не исполнил функцию wait, может произой- ти переполнение таблицы процессов из-за наличия потомков, прекративших су- ществование с помощью функции exit. В качестве примера рассмотрим текст программы планировщика процессов, приведенный на Рисунке 7.18. Процесс про- изводит считывание данных из файла стандартного ввода до тех пор, пока не будет обнаружен конец файла, создавая при каждом исполнении функции read но- вого потомка. Однако, процесс-родитель не дожидается завершения каждого по- томка, поскольку он стремится запускать процессы на выполнение как можно быстрее, тем более, что может пройти довольно много времени, прежде чем про- цесс-потомок завершит свое выполнение. Если, обратившись к +------------------------------------------------------------+ | #include | | main(argc,argv) | | { | | char buf[256]; | | | | if (argc != 1) | | signal(SIGCLD,SIG_IGN); /* игнорировать гибель | | потомков */ | | while (read(0,buf,256)) | | if (fork() == 0) | | { | | /* здесь процесс-потомок обычно выполняет | | какие-то операции над буфером (buf) */ | | exit(0); | | } | | } | +------------------------------------------------------------+ Рисунок 7.18. Пример указания причины появления сигнала "ги- бель потомков" 203 функции signal, процесс распорядился игнорировать сигналы типа "гибель по- томка", ядро будет очищать записи, соответствующие прекратившим существова- ние процессам, автоматически. Иначе в конечном итоге из-за таких процессов может произойти переполнение таблицы. 7.5 ВЫЗОВ ДРУГИХ ПРОГРАММ Системная функция exec дает возможность процессу запускать другую прог- рамму, при этом соответствующий этой программе исполняемый файл будет распо- лагаться в пространстве памяти процесса. Содержимое пользовательского кон- текста после вызова функции становится недоступным, за исключением передава- емых функции параметров, которые переписываются ядром из старого адресного пространства в новое. Синтаксис вызова функции: execve(filename,argv,envp) где filename - имя исполняемого файла, argv - указатель на массив парамет- ров, которые передаются вызываемой программе, а envp - указатель на массив параметров, составляющих среду выполнения вызываемой программы. Вызов сис- темной функции exec осуществляют несколько библиотечных функций, таких как execl, execv, execle и т.д. В том случае, когда программа использует пара- метры командной строки main(argc,argv) , +------------------------------------------------------------+ | алгоритм exec | | входная информация: (1) имя файла | | (2) список параметров | | (3) список переменных среды | | выходная информация: отсутствует | | { | | получить индекс файла (алгоритм namei); | | проверить, является ли файл исполнимым и имеет ли поль- | | зователь право на его исполнение; | | прочитать информацию из заголовков файла и проверить, | | является ли он загрузочным модулем; | | скопировать параметры, переданные функции, из старого | | адресного пространства в системное пространство; | | для (каждой области, присоединенной к процессу) | | отсоединить все старые области (алгоритм detachreg);| | для (каждой области, определенной в загрузочном модуле) | | { | | выделить новые области (алгоритм allocreg); | | присоединить области (алгоритм attachreg); | | загрузить область в память по готовности (алгоритм | | loadreg); | | } | | скопировать параметры, переданные функции, в новую об- | | ласть стека задачи; | | специальная обработка для setuid-программ, трассировка; | | проинициализировать область сохранения регистров задачи | | (в рамках подготовки к возвращению в режим задачи); | | освободить индекс файла (алгоритм iput); | | } | +------------------------------------------------------------+ Рисунок 7.19. Алгоритм функции exec 204 массив argv является копией одноименного параметра, передаваемого функции exec. Символьные строки, описывающие среду выполнения вызываемой программы, имеют вид "имя=значение" и содержат полезную для программ информацию, такую как начальный каталог пользователя и путь поиска исполняемых программ. Про- цессы могут обращаться к параметрам описания среды выполнения, используя глобальную пере- менную environ, которую заводит начальная процедура Си-интерпретатора. На Рисунке 7.19 представлен алгоритм выполнения системной функции exec. Сначала функция обращается к файлу по алгоритму namei, проверяя, является ли файл исполнимым и отличным от каталога, а также проверяя наличие у пользова- теля права исполнять программу. Затем ядро, считывая заголовок файла, опре- деляет размещение информации в файле (формат файла). На Рисунке 7.20 изображен логический формат исполняемого файла в файло- вой системе, обычно генерируемый транслятором или загрузчиком. Он разбивает- ся на четыре части: 1. Главный заголовок, содержащий информацию о том, на сколько разделов де- лится файл, а также содержащий начальный адрес исполнения процесса и не- которое "магическое число", описывающее тип исполняемого файла. 2. Заголовки разделов, содержащие информацию, описывающую каждый раздел в файле: его размер, виртуальные адреса, в которых он располагается, и др. 3. Разделы, содержащие собственно "данные" файла (например, текстовые), ко- торые загружаются в адресное пространство процесса. 4. Разделы, содержащие смешанную информацию, такую как таблицы идентифика- торов и другие данные, используемые в процессе отладки. +---------------------------+ | Тип файла | Главный заголовок | Количество разделов | | Начальное состояние регис-| | тров | +---------------------------+ | Тип раздела | Заголовок 1-го раздела | Размер раздела | | Виртуальный адрес | +---------------------------+ | Тип раздела | Заголовок 2-го раздела | Размер раздела | - | Виртуальный адрес | - +---------------------------+ - | - | - | - | - +---------------------------+ - | Тип раздела | Заголовок n-го раздела