Индекс
НазадОглавлениеВперед


Глава 4
Порождение программ и процессов

В первой главе мы дали строгое определение понятия процесса. Прикладной программист, однако, разрабатывает не "процесс", а "программу", не задумываясь обычно над тем, как и какие механизмы ОС обеспечат ее представление в виде процесса. Ряд авторов (например, [8, 9]) нестрого определяют процесс как "программу в стадии выполнения". Такое определение, "адаптированное" для уровня прикладного программиста, в ряде случаев может считаться справедливым и весьма удобным, так как соответствует интуитивному пониманию этого термина.

Программа превращается в процесс в тот момент, когда ОС создает для нее блок контекста. Блок контекста отвечает за состояние процесса и представляет процесс в состязаниях за обладание ресурсами. Блок контекста, однако, не определяет содержание процесса. Содержательная часть процесса представляется для ОС другой структурой - адресным пространством процесса. Ядро ОС не обрабатывает содержимое адресного пространства, а только отвечает за размещение его в памяти.

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

4.1. Компиляция

Этот этап реализуется не ОС, а системами программирования, которые представляют собой "системы, образуемые языком программирования, компиляторами или интерпретаторами программ, представленных на этом языке, соответствующей документацией, а также вспомогательными средствами для подготовки программ в форме, пригодной для выполнения" [4]. Системы программирования представляют собой предмет изучения отдельного курса, им посвящена обширная литература, здесь мы остановимся только на тех их аспектах, которые имеют отношение к ОС и аппаратным средствам вычислительной системы.

Основным функциональным назначением системы программирования является генерация объектного кода программы (машинных команд). Компиляторы предварительно формируют структуру виртуального адресного пространства: определяют состав сегментов и формируют содержимое (образы) кодовых сегментов и сегментов инициализированных статических данных.

Система программирования создает также дополнительный интерфейс между программистом и ОС. Состав и спецификации этого интерфейса могут быть либо стандартными для языка, либо определяться конкретной системой программирования. Везде в этом пособии мы описываем системные вызовы ОС, как некоторые процедуры или функции высокоуровневого языка программирования. В конкретных системах программирования набор таких процедур составляет библиотеки системных вызовов, эти процедуры обеспечивают передачу вызовов ОС в той форме, в какой они специфицированы для данной ОС (например, в виде программного прерывания с передачей параметров через регистры общего назначения). Многие процедуры систем программирования включают в себя интегрированный системный сервис - выполнение в составе одной процедуры нескольких системных вызовов с некоторой обработкой их результатов. Можно говорить о том, что системы программирования продолжают тенденцию виртуализации ресурсов: они формируют на базе примитивов, обеспечиваемых системными вызовами ОС, ресурсы более высокого уровня, доступные через средства системы программирования. Так, работая на языке высокого уровня, мы имеем в своем распоряжении виртуальную ЭВМ, в которой "система команд" представлена операциями, операторами и стандартными процедурами языка, а адресация выполняется в пространстве символьных имен. Некоторые языки или их конкретные системы программирования могут включать в себя и более сложные средства управления ресурсами, такие как: буферизацию ввода/вывода (см. главу 6), работу с файлами сложной логической структуры (см. главу 7), средства синхронизации и взаимодействия процессов (см. главу 8) и т.д.

С целью получения наиболее эффективного объектного кода компиляторы могут выполнять оптимизацию обрабатываемой программы. Можно выделить три стадии такой оптимизации:

Ни одна из указанных стадий не является строго обязательной. На первой стадии выполняется оптимизация логической структуры программы и ее виртуальной памяти. К оптимизационным процедурам этого этапа можно отнести: оптимизацию циклов, устранение избыточных вычислений, преобразование индексных выражений, сокращение числа переменных и т.п. Эти методы требуют не пооператорного просмотра, а анализа всей программы или ее участка. Такая оптимизация может осуществляться либо на уровне исходного языка, либо на уровне некоторого промежуточного языка. Подавляющее большинство современных языков высокого уровня воплощает принципы структурного программирования [6], а это означает, что с одной стороны, эффективность алгоритма зачастую приносится в жертву его ясности, но с другой, - что логическая структура программы получается строгой и, следовательно, легко анализируемой и оптимизируемой. Общность принципов структурного программирования для разных языков позволяют также формировать единое промежуточное представление разноязыковых программ и применять оптимизационные процедуры, не зависящие от языка.

Системно-зависимая стадия оптимизации связана прежде всего с дисциплинами управления памятью в ОС. Если компилятор "знает", какие алгоритмы управления памятью (например, страничного обмена) применяет ОС, он может "помочь" ОС. Поскольку компилятор, в отличие от ОС, обладает возможностью глобального анализа программы, он может предсказывать будущую потребность в тех или иных страницах памяти, влиять на размер и состав рабочего набора процесса. Компилятор может также перестроить программу таким образом, чтобы повысить степень локализации обращений к памяти и тем самым снизить интенсивность страничного обмена.

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

Недостатком современных суперскалярных процессоров является необходимость для процессора в каждом цикле анализировать поток команд, чтобы определить, какие конвейерные линии могут быть использованы. Это является препятствием как для увеличения числа линий, так и для сокращения времени цикла. Перспективной для RISС-процессоров, по-видимому, является идея упаковки нескольких простых команд в одну большую команду фиксированной длины. Такая команда называется VLIW (very long instruction word - очень длинное командное слово). Составляющие VLIW-команды должны выполняться строго последовательно, сами VLIW-команды могут выполняться параллельно. Процессор, таким образом, просто загружает очередную VLIW-команду в очередную конвейерную линию, не занимаясь анализом командного потока. Задача формирования VLIW-команд с оптимизацией их под данную платформу ложится на компилятор. На сегодняшний день подобные подходы применяются, например, в процессорах фирмы Hewlett-Packard и процессоре Itanium фирмы Intel.

4.2. Компоновка и загрузка

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

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

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

При статическом связывании может происходить также и формирование оверлейной структуры адресного пространства программы, рассмотренное в предыдущей главе.

Вспомним, что на этапе загрузки может происходить также трансляция виртуальных адресов программы в физические адреса. В этом случае загрузочный модуль должен содержать таблицу перемещений - список тех адресов в программе, которые должны быть модифицированы при загрузке базовым адресом программы. Эти операции выполняются перемещающим загрузчиком, пример такого загрузчика - загрузчик EXE-файлов в MS DOS.

Статическое связывание и загрузка выполняются системными утилитами, не входящими в ядро ОС, они не составляют основной предмет нашего рассмотрения. Алгоритмы функционирования редакторов связей и перемещающих загрузчиков подробно рассматриваются, например, в [3, 9], здесь же мы остановимся на проблемах динамического связывания, выполняемого ОС.

Современные ОС позволяют сочетать статическую компоновку с динамической. Хотя в литературе, посвященной этим ОС, возможность динамического связывания описывается как принципиально новая, на самом деле, она была впервые реализована еще в 1965 году в ОС MULTICS [21], и с тех пор ее механизмы не претерпели значительных изменений. В современных ОС модули, подключаемые к программам динамически, носят название библиотек динамической компоновки (dynamic link library), соответственно, файлы, содержащие образы таких модулей, обычно имеют расширения DLL.

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



а) модули до связывания



б) модули после связывания
Рис.4.1. Установка межмодульных связей при динамической компоновке

При совместном использовании процедур, входящих в состав модулей DLL, несколькими процессами все процессы используют один и тот же экземпляр кода DLL, но для каждого использования создается отдельный стек, таким образом, для каждого использования имеется свой набор экземпляров локальных переменных процедуры.

Динамическая компоновка при загрузке совершенно прозрачна для программы. При подготовке программных и DLL-файлов требуются специальные директивы для компоновщика, но в самом тексте программы обращения к динамически подключаемым процедурам ничем не отличаются от обращений к процедурам, подключенным статически. В современных ОС динамически подключаемые библиотеки широко используются для системного программного обеспечения. API ОС, средства работы с терминалом, графические средства и т.п. представляют собой библиотеки динамической компоновки, совместно используемые всеми программами. При загрузке любой программы эти модули, как правило, уже находятся в памяти.

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

Динамическая компоновка на этапе выполнения дает возможность программе самой управлять порядком загрузки модулей и установки связей. Программа может, например, менять загруженный в память набор модулей в разных фазах своего выполнения или использовать разные модули в зависимости от конфигурации вычислительной системы или условий выполнения и т.п. Для обеспечения таких возможностей ОС должна предоставлять в распоряжение программиста соответствующий набор системных вызовов. Этот набор может быть следующим.

Системный вызов:

    mod_handle = loadModule (mod_name); 
загружает модуль из файла с указанным именем. Если указанного модуля нет в памяти, ОС производит загрузку его из файла, если же модуль в памяти уже есть, ОС просто увеличивает на 1 счетчик использований этого модуля. Вызов должен возвращать манипулятор модуля, используемый для его идентификации во всех последующих операциях с ним.

Возможна модификация этого вызова:

    mod_handle = getModuleHandle (mod_name); 
получить манипулятор модуля: если модуль уже есть в памяти, вызов возвращает его манипулятор (и увеличивает счетчик использований), если нет - возвращает признак ошибки. В последнем случае программа может либо загрузить модуль вызовом loadModule, либо перейти на такую свою ветвь, которая не предусматривает обращений к данному модулю.

Системный вызов:

    freeModule (mod_handle); 
выгружает модуль. ОС уменьшает на 1 счетчик использования модуля, если этот счетчик обратился в 0, освобождает память.

Системный вызов:

    vaddr = getProcAddress (mod_handle, proc_name); 
возвращает виртуальный адрес процедуры с заданным именем в уже загруженном модуле. Все дальнейшие обращения к данной процедуре программа производит по этому адресу.

4.3. Цикл жизни процесса

Программа, готовая к выполнению, превратится в процесс только тогда, когда ОС создаст для нее блок контекста и запись в системной таблице процессов. ОС существенно различаются по тому признаку, насколько часто они создают новые процессы и сколько процессов могут одновременно существовать в системе.

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

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

Различные подходы могут применяться в интерактивных системах. Во-первых, в интерактивной системе может копироваться стратегия многозадачной системы с пакетной обработкой: с каждым терминалом связывается единственный процесс-сеанс (session). Таким образом, предельное число процессов в системе ограничивается числом терминалов. Пользователь каждого терминала работает как бы в однозадачной среде (VM). Во-вторых, для преодоления стесненности пользователя в сеансе система может позволять в ходе сеанса порождать дополнительные, фоновые (background) процессы. Такие процессы выполняют программы, не требующие в ходе выполнения взаимодействия с оператором. Фоновые процессы работают параллельно с процессом, поддерживающим интерактивную работу в сеансе. Наконец, в-третьих, система может позволять порождать любые процессы и в любом количестве. Для каждой новой выполняемой программы создается новый процесс, который уничтожается с завершением программы. Процессы могут выполняться как последовательно, так и параллельно, ограничением на количество параллельно выполняемых процессов является объем ресурсов вычислительной системы и ОС, в частности, возможные ограничения на предельный размер таблицы процессов. Подход без ограничений на число процессов иногда называют философией "дешевых" процессов. В таких системах "накладные расходы" на создание процессов минимальны и наблюдается тенденция к предельному упрощению отдельных процессов: каждый отдельный процесс реализует некоторую весьма элементарную функцию, а сложные действия реализуются как комбинации тех или иных элементарных действий.

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

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

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

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

В конкретных системах могут вводиться некоторые разумные ограничения на число порождаемых процессов, связанные с предельными размерами таблицы процессов и очереди к планировщику. Может ограничиваться, например, число потомков у процесса или число процессов, принадлежащих одному пользователю.

Процесс-потомок при запуске не знает идентификатора своего родителя, но, как правило, может его получить при помощи системного вызова.

Ниже мы приводим набор системных вызовов, обеспечивающих порождение процессов и "родственные отношения" между ними.

Порождение нового процесса и выполнение в нем программы:

    pid = load(filename); 
для нового процесса создается новая запись в системной таблице процессов и блок контекста. В блоке контекста формируется описание адресного пространства процесса - например, таблица сегментов. Выполняется формирование адресного пространства - образы некоторых частей адресного пространства (сегментов) процесса (коды и инициализированные статические данные) загружаются из файла, имя которого является параметром вызова, выделяется память для неинициализированных данных. Формирование всех сегментов не обязательно происходит сразу же при создании процесса: во-первых, если ОС придерживается "ленивой" тактики, то формирование памяти может быть отложено до обращения к соответствующему сегменту; во-вторых, в загрузочный модуль могут быть включены характеристики сегментов: предзагружаемый (preload) или загружаемый по вызову (load-on-call). Новому процессу должна быть выделена также и вторичная память - для сохранения образов сегментов/страниц при свопинге. Часть вторичной памяти для процесса уже существует: это образы неизменяемых сегментов процесса в загрузочном модуле. Для более эффективного поиска таких сегментов ОС может включать в блок контекста специальную таблицу, содержащую адреса сегментов в загрузочном модуле. При выделении вторичной памяти для изменяемых сегментов все ОС обычно следуют "ленивой" тактике. Ресурсы процесса-родителя копируются в блок контекста потомка. В вектор состояния нового процесса заносятся значения, выбор которых в регистры процессора приведет к передаче управления на стартовую точку программы. Новый процесс ставится в очередь готовых к выполнению. Вызов load возвращает идентификатор порожденного процесса.

Смена программы процесса:

    exec (filename); 

Завершается программа, выдавшая этот системный вызов, вместо нее запускается другая программа. Вызов exec может быть реализован как комбинация вызовов exit (завершить текущий процесс) и load (создать новый процесс), но может и не порождать смену процессов, а только обновлять адресное пространство (включая и блок контекста) текущего процесса. В последнем случае сохраняются также и ресурсы процесса. Идентификатор процесса не изменяется.

Расщепление процесса:

    pid = fork(); 
порождается новый процесс - копия процесса-родителя. При копировании таблицы сегментов родителя в блок контекста потомка принимаются во внимание характеристики сегментов:

Для копируемых сегментов часто применяется "ленивая" тактика копирования при записи (copy-on-write). В этом случае выделение сегмента и копирование данных откладывается, родитель и потомок используют один и тот же сегмент в реальной памяти, но в их таблицах этот сегмент помечается недоступным для записи. Совместное использование сегмента для чтения продолжается, пока один из процессов не попытается писать в него. Попытка записи вызовет прерывание-ловушку по нарушению доступа, обработчик этого прерывания обеспечит создание копии сегмента, изменит его базовый адрес в таблице потомка и снимет с сегмента защиту записи.

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

    if ( ( childpid = fork() ) == 0 ) 
        < процесс-потомок >;
    else < процесс-родитель >; 

То есть, вызов возвращает 0 процессу-потомку и идентификатор потомка - процессу-родителю.

В ОС Unix вызов fork является единственным средством создания нового процесса. Поэтому, как правило, ветвь, соответствующая процессу-потомку содержит вызов exec, меняющий программу, выполняемую потомком. Эффект в этом случае буде тот же, что и при выполнении вызова load. Применение именно такой схемы, видимо, объясняется легкостью передачи ресурсов. Ниже мы покажем структуру отношений системных и пользовательских процессов в Unix.

Ожидание завершения потомка:

    exitcode = waitchild(pid); 
процесс-родитель блокируется до завершения процесса-потомка, идентификатор которого является параметром вызова (или всего семейства, возглавляемого этом потомком, или любого из членов этого семейства). Вызов может возвращать код завершения процесса-потомка (задается вызовом exit) и идентификатор завершившегося потомка. Разумеется, применение этого вызова имеет смысл только при асинхронном запуске потомка.

Выход из процесса:

    exit(exitcode); 
приводит к освобождению занятых процессом ресурсов, в том числе, и ресурса памяти. Ресурсы, запрошенные процессом динамически, требуют явного освобождения процессом (например, процесс должен закрыть все открытые им файлы), но если процесс "забыл" это сделать, это сделает за него ОС при выполнении данного вызова. При выполнении exit также могут выполняться процедуры, заданные вызовами exitlist (см. ниже). Вызов exit не обязательно должен приводить к немедленному полному уничтожению процесса. Может сохраняться соответствующая ему запись в таблице процессов и часть блока контекста, но процесс помечается завершенным. Неполное удаление процесса объясняется тем, что после процесса остается еще некоторая информация, которая может быть востребована, статистические данные о его выполнении, код завершения (параметр вызова exit), который будет прочитан вызовом waitchild в родителе и т.п. Полное удаление процесса произойдет после того, как вся остаточная информация будет обработана.

Формирование списка выхода:

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

Принудительное завершение:

    kill(pid); 
завершает процесс-потомок или все семейство процессов, им возглавляемое. Выполнение этого вызова заключается в выдаче сигнала kill (механизм сигналов описывается в главе 9), по умолчанию обработка этого сигнала вызывает выполнение exit с установкой специального кода завершения.

Изменить приоритет:

    setPriority ( pid, priority ); 
изменяет приоритет потомка или всего его семейства. Приоритет может задаваться как абсолютный, так и в виде приращения (положительного или отрицательного) к текущему приоритету. Как правило, пользовательские процессы не могут изменять свой приоритет в сторону увеличения.

Получение идентификаторов:

    pid = getpid(mode); 
вызов возвращает процессу его собственный идентификатор и/или идентификатор процесса-родителя.

На Рисунке 4.2 приведена в качестве примера схема наследования процессов в ОС Unix. Корнем дерева процессов является процесс init, создаваемый при загрузке ОС. Процесс init порождает для каждой линии связи (терминала) при помощи пары системных вызовов fork-exec свой процесс getty и переходит в ожидание. Каждый процесс getty ожидает ввода со своего терминала. При вводе процесс getty сменяется (системный вызов exec) процессом logon, выполняющим проверку пароля. При правильном вводе пароля процесс logon сменяется (exec) процессом shell. Командный интерпретатор shell (подробнее мы рассмотрим его в главе 12) является корнем поддерева для всех процессов пользователя, выполняющихся в данном сеансе. При завершении сеанса shell завершается (exit), при этом "пробуждается" init и порождает для этого терминала новый процесс getty.


Рис.4.2. Процессы в ОС Unix

Хотя на рис.4.2. между процессами init и shell находятся еще два процесса, shell может считаться прямым потомком init, так как находящиеся между ними процессы завершаются при запуске потомка.

В других ОС может выстраиваться и более сложная иерархия системных процессов. Так, например, в OS/390 между ядром ОС и задачей (task - синоним процесса в терминах IBM) пользователя находятся еще задачи:

Эти задачи существуют параллельно и выполняют управление ресурсами на разных уровнях.

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

4.4. Нити

Философия дешевых процессов подразумевает, что процесс может быть создан легко и быстро. С одной стороны, это позволяет в максимальной степени обеспечивать распараллеливание работ по решению нескольких задач или внутри одной задачи. Но с другой стороны, если ОС выполняет большой объем работ по управлению ресурсами, то создание нового процесса и выделение ему ресурсов не может обойтись без значительных "накладных расходов". Преодоление этого противоречия было найдено в концепции "нитей" (thread, часто переводится также как "поток"), реализованной в большинстве современных ОС и зафиксированной в стандартах POSIX и DCE. Нитью называется отдельная ветвь выполнения процесса. Процесс может состоять из одной или нескольких нитей, которые совместно используют ресурсы процесса, но являются самостоятельными объектами при планировании процессорного времени. Таким образом, нити одного процесса могут выполняться параллельно. Концепция не оговаривает, какие именно ресурсы являются общими, а какие - локальными для нити. В большинстве современных ОС помимо ресурса процессорного времени и вектора состояния процесса, нить имеет собственный:

Прочие же ресурсы: общие переменные, файлы, средства взаимодействия и т. д. - являются общими для нити и породившего ее процесса.

Любой процесс состоит из одной или нескольких нитей. Первая нить - основная - порождается системой при запуске процесса. Основная нить может порождать дочерние нити вызовом типа:

tid = createThread(thr_addr, stack).

Параметрами вызова являются: thr_addr - адрес нити; stack - адрес стека, выделяемого для нити (стек может также назначаться системой по умолчанию). Вызов возвращает идентификатор нити.

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

Для управления выполнением нитей в рамках одного процесса обычно в составе API ОС имеются системные вызовы, позволяющие приостановить выполнение нити и вновь разрешить ее выполнение:

    suspendThread(tid);
    resumeThread(tid);

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

Контрольные вопросы

  1. Каким образом при различных внутренних структурах и даже механизмах обращения к ОС может быть обеспечен одинаковый API для разных ОС?
  2. Какие стадии оптимизации может проходить программа? Какие стадии оптимизации могут быть одинаковыми для программ, написанных на языках C, Pascal, Cobol, Fortran и на языке Ассемблера? Почему для современных процессорных архитектур оптимизация является обязательной?
  3. В чем преимущества динамического связывания по сравнению со статическим?
  4. Почему во многих современных ОС значительная часть системы выполняется в виде библиотек динамической компоновки?
  5. Являются ли "родственные отношения" между процессами обязательными? Являются ли они полезными?
  6. Сравните стратегии систем, в которых порождение процессов выполняется вызовом fork и вызовом load.
  7. Для чего могут быть полезны списки выхода? Приведите примеры задач, для которых было бы целесообразно иметь список выхода, состоящий из более чем одной процедуры.
  8. Дайте определение нити. Какие ресурсы являются собственными для нити?
  9. Приведите примеры задач, решение которых требует применения нитей?
  10. В некоторых клонах ОС Unix нет специального механизма нитей, но нити реализованы как процессы, наследующие адресное пространство родителя. Чем Вы объясните такое решение?

НазадОглавлениеВперед
Индекс