Звездного неба над головой, и чтобы не было войны!

Международный форум городов мира

Информация о пользователе

Привет, Гость! Войдите или зарегистрируйтесь.



UNIX

Сообщений 1 страница 20 из 25

1

https://forumupload.ru/uploads/001b/09/4d/2/t67355.jpg
Морис Дж. Бах
Архитектура операционной системы UNIX

ПРЕДИСЛОВИЕ

Впервые система UNIX была описана в 1974 году в статье Кена Томпсона и Дэнниса Ричи в журнале «Communications of the ACM» [Thompson 74]. С этого времени она получила широкое распространение и завоевала широкую популярность среди производителей ЭВМ, которые все чаще стали оснащать ею свои машины. Особой популярностью она пользуется в университетах, где довольно часто участвует в исследовательском и учебном процессе.
Множество книг и статей посвящено описанию отдельных частей системы; среди них два специальных выпуска «Bell System Technical Journal» за 1978 год [BSTJ 78] и за 1984 год [BSTJ 84]. Во многих книгах описывается пользовательский интерфейс, в частности использование электронной почты, подготовка документации, работа с командным процессором Shell; в некоторых книгах, таких как «The UNIX Programming Environment» [Kernighan 84] и «Advanced UNIX Programming» [Rochkind 85], описывается программный интерфейс. Настоящая книга посвящена описанию внутренних алгоритмов и структур, составляющих основу операционной системы (т. н. «ядро»), и объяснению их взаимосвязи с программным интерфейсом. Таким образом, она будет полезна для работающих в различных операционных средах. Во-первых, она может использоваться в качестве учебного пособия по курсу «Операционные системы» как для студентов последнего курса, так и для аспирантов первого года обучения. При работе с книгой было бы гораздо полезнее обращаться непосредственно к исходному тексту системных программ, но книгу можно читать и независимо от него. Во-вторых, эта книга может служить в качестве справочного руководства для системных программистов, из которого последние могли бы лучше уяснить себе механизм работы ядра операционной системы и сравнить между собой алгоритмы, используемые в UNIX, и алгоритмы, используемые в других операционных системах. Наконец, программисты, работающие в среде UNIX, могут углубить свое понимание механизма взаимодействия программ с операционной системой и посредством этого прийти к написанию более эффективных и совершенных программ.
Содержание и порядок построения материала в книге соответствуют курсу лекций, подготовленному и прочитанному мной для сотрудников фирмы Bell Laboratories, входящей в состав корпорации AT&T, между 1983 и 1984 гг. Несмотря на то, что главное внимание в курсе лекций обращалось на исходный текст системных программ, я обнаружил, что понимание исходного текста облегчается, если пользователь имеет представление о системных алгоритмах. В книге я пытался изложить описание алгоритмов как можно проще, чтобы и в малом отразить простоту и изящество рассматриваемой операционной системы. Таким образом, книга представляет собой не только подробное истолкование особенностей системы на английском языке; это изображение общего механизма работы различных алгоритмов, и что гораздо важнее, это отражение процесса их взаимодействия между собой. Алгоритмы представлены на псевдокоде, похожем на язык Си, поскольку читателю легче воспринимать описание на естественном языке; наименования алгоритмов соответствуют именам процедур, составляющих ядро операционной системы. Рисунки описывают взаимодействие различных информационных структур под управлением операционной системы. В последних главах многие системные понятия иллюстрируются с помощью небольших программ на языке Си. В целях экономии места и обеспечения ясности изложения из этих примеров исключен контроль возникновения ошибок, который обычно предусматривается при написании программ. Эти примеры прогонялись мною под управлением версии V; за исключением программ, иллюстрирующих особенности, присущие версии V, их можно выполнять под управлением других версий операционной системы.
Большое число упражнений, подготовленных первоначально для курса лекций, приведено в конце каждой главы, они составляют ключевую часть книги. Отдельные упражнения, иллюстрирующие основные понятия, размещены непосредственно в тексте книги. Другая часть упражнений отличается большей сложностью, поскольку их предназначение состоит в том, чтобы помочь читателю углубить свое понимание особенностей системы. И, наконец, часть упражнений является по природе исследовательской, предназначенной для изучения отдельных проблем. Упражнения повышенной сложности помечены звездочками.
Системное описание базируется на особенностях операционной системы UNIX версия V редакция 2, распространением которой занимается корпорация AT&T, с учетом отдельных особенностей редакции 3. Это та система, с которой я наиболее знаком, однако я постарался отразить и интересные детали других разновидностей операционных систем, в частности систем, распространяемых через «Berkeley Software Distribution» (BSD). Я не касался вопросов, связанных с характеристиками отдельных аппаратных средств, стараясь только в общих чертах охватить процесс взаимодействия ядра операционной системы с аппаратными средствами и игнорируя характерные особенности физической конфигурации. Тем не менее, там, где вопросы, связанные с машинными особенностями, представились мне важными с точки зрения понимания механизма функционирования ядра, оказалось уместным и углубление в детали. По крайней мере, беглый просмотр затронутых в книге вопросов ясно указывает те составные части операционной системы, которые являются наиболее машинно-зависимыми.
Общение с книгой предполагает наличие у читателя опыта программирования на одном из языков высокого уровня и желательно на языке ассемблера. Читателю рекомендуется приобрести опыт работы с операционной системой UNIX и познакомиться с языком программирования Си [Kernighan 78]. Тем не менее, я старался изложить материал в книге таким образом, чтобы читатель смог овладеть им даже при отсутствии требуемых навыков. В приложении к книге приведено краткое описание обращений к операционной системе, которого будет достаточно для того, чтобы получить представление о содержании книги, но которое не может служить в качестве полного справочного руководства.
Материал в книге построен следующим образом. Глава 1 служит введением, содержащим краткое, общее описание системных особенностей с точки зрения пользователя и объясняющим структуру системы. В главе 2 дается общее представление об архитектуре ядра и поясняются некоторые основные понятия. В остальной части книги освещаются вопросы, связанные с общей архитектурой системы и описанием ее различных компонент как блоков единой конструкции. В ней можно выделить три раздела: файловая система, управление процессами и вопросы, связанные с развитием. Файловая система представлена первой, поскольку ее понимание легче по сравнению с управлением процессами. Так, глава 3 посвящена описанию механизма функционирования системного буфера сверхоперативной памяти (кеша), составляющего основу файловой системы. Глава 4 описывает информационные структуры и алгоритмы, используемые файловой системой. В этих алгоритмах используются методы, объясняемые в главе 3, для ведения внутренней «бухгалтерии», необходимой для управления пользовательскими файлами. Глава 5 посвящена описанию обращений к операционной системе, обслуживающих интерфейс пользователя с файловой системой; для обеспечения доступа к пользовательским файлам используются алгоритмы главы 4.
Основное внимание в главе 6 уделяется управлению процессами. В ней определяется понятие контекста процесса и исследуются внутренние составляющие ядра операционной системы, управляющие контекстом процесса. В частности, рассматривается обращение к операционной системе, обработка прерываний и переключение контекста. В главе 7 анализируются те системные операции, которые управляют контекстом процесса. Глава 8 касается планирования процессов, глава 9 — распределения памяти, включая системы подкачки и замещения страниц.
В главе 10 дается обзор общих особенностей взаимодействия, которое обеспечивают драйверы устройств, особое внимание уделяется дисковым и терминальным драйверам. Несмотря на то, что устройства логически входят в состав файловой системы, их рассмотрение до этого момента откладывалось в связи с возникновением вопросов, связанных с управлением процессами, при обсуждении терминальных драйверов. Эта глава также служит мостиком к вопросам, связанным с развитием системы, которые рассматриваются в конце книги. Глава 11 касается взаимодействия процессов и организации сетей, в том числе сообщений, используемых в версии V, разделения памяти, семафоров и пакетов BSD. Глава 12 содержит компактное изложение особенностей двухпроцессорной системы UNIX, в главе 13 исследуются двухмашинные распределенные вычислительные системы.
Материал, представленный в первых девяти главах, может быть прочитан в процессе изучения курса «Операционные системы» в течение одного семестра, материал остальных глав следует изучать на опережающих семинарах с параллельным выполнением практических заданий.
Теперь мне бы хотелось предупредить читателя о следующем. Я не пытался оценить производительность системы в абсолютном выражении, не касался и параметров конфигурации, необходимых для инсталляции системы. Эти данные меняются в зависимости от типа машины, конфигурации комплекса технических средств, версии и реализации системы, состава задач. Кроме того, я сознательно избегал любых предсказаний по поводу дальнейшего развития операционной системы UNIX. Изложение вопросов, связанных с развитием, не подкреплено обязательством корпорации AT&T обеспечить соответствующие характеристики, даже не гарантируется то, что соответствующие области являются объектом исследования.
Мне приятно выразить благодарность многим друзьям и коллегам за помощь при написании этой книги и за конструктивные критические замечания, высказанные при ознакомлении с рукописью. Я должен выразить глубочайшую признательность Яну Джонстону, который посоветовал мне написать эту книгу, оказал мне поддержку на начальном этапе и просмотрел набросок первых глав. Ян открыл мне многие секреты ремесла и я всегда буду в долгу перед ним. Дорис Райан также поддерживала меня с самого начала, и я всегда буду ценить ее доброту и внимательность. Дэннис Ричи добровольно ответил на многочисленные вопросы, касающиеся исторического и технического аспектов системы. Множество людей пожертвовали своим временем и силами на ознакомление с вариантами рукописи, появление этой книги во многом обязано высказанным ими подробным замечаниям. Среди них Дебби Бэч, Дуг Байер, Лэнни Брэндвейн, Стив Барофф, Том Батлер, Рон Гомес, Месат Гандак, Лаура Изрейел, Дин Джегелс, Кейт Келлеман, Брайан Керниган, Боб Мартин, Боб Митц, Дейв Новиц, Майкл Попперс, Мэрилин Сэфран, Курт Шиммель, Зуи Спитц, Том Вэден, Билл Вебер, Лэрри Вэр и Боб Зэрроу. Мэри Фрустак помогала подготовить рукопись к набору. Я хотел бы также поблагодарить мое руководство за постоянную поддержку, которую я ощущал на всем протяжении работы, и коллег за атмосферу, способствовавшую мне в работе, и за замечательные условия, предоставленные фирмой AT&T Bell Laboratories. Джон Вейт и персонал издательства Prentice-Hall оказали самую разнообразную помощь в придании книге ее окончательного вида. Последней по списку, но не по величине явилась помощь моей жены, Дебби, оказавшей мне эмоциональную поддержку, без которой я бы не достиг успеха.

0

2

https://forumupload.ru/uploads/001b/09/4d/2/67355.jpg
ГЛАВА 1. ОБЩИЙ ОБЗОР ОСОБЕННОСТЕЙ СИСТЕМЫ

За время, прошедшее с момента ее появления в 1969 году, система UNIX стала довольно популярной и получила распространение на машинах с различной мощностью обработки, от микропроцессоров до больших ЭВМ, обеспечивая на них общие условия выполнения программ. Система делится на две части. Одну часть составляют программы и сервисные функции, то, что делает операционную среду UNIX такой популярной; эта часть легко доступна пользователям, она включает такие программы, как командный процессор, обмен сообщениями, пакеты обработки текстов и системы обработки исходных текстов программ. Другая часть включает в себя собственно операционную систему, поддерживающую эти программы и функции. В этой книге дается детальное описание собственно операционной системы. Основное внимание концентрируется на описании системы UNIX версии V, распространением которой занимается корпорация AT&T, при этом рассматриваются интересные особенности и других версий. Приводятся основные информационные структуры и алгоритмы, используемые в операционной системе и в конечном итоге создающие условия для функционирования стандартного пользовательского интерфейса.
Данная глава служит введением в систему UNIX. В ней делается обзор истории ее создания и намечаются контуры общей структуры системы. В следующей главе содержится более детальная вводная информация по операционной системе.

1.1 ИСТОРИЯ

В 1965 году фирма Bell Telephone Laboratories, объединив свои усилия с компанией General Electric и проектом MAC Массачусетского технологического института, приступили к разработке новой операционной системы, получившей название Multics [Organick 72]. Перед системой Multics были поставлены задачи — обеспечить одновременный доступ к ресурсам ЭВМ большого количества пользователей, обеспечить достаточную скорость вычислений и хранение данных и дать возможность пользователям в случае необходимости совместно использовать данные. Многие разработчики, впоследствии принявшие участие в создании ранних редакций системы UNIX, участвовали в работе над системой Multics в фирме Bell Laboratories. Хотя первая версия системы Multics и была запущена в 1969 году на ЭВМ GE 645, она не обеспечивала выполнение главных вычислительных задач, для решения которых она предназначалась, и не было даже ясно, когда цели разработки будут достигнуты. Поэтому фирма Bell Laboratories прекратила свое участие в проекте.
По окончании работы над проектом Multics сотрудники Исследовательского центра по информатике фирмы Bell Laboratories остались без «достаточно интерактивного вычислительного средства» [Ritchie 84a]. Пытаясь усовершенствовать среду программирования, Кен Томпсон, Дэннис Ричи и другие набросали на бумаге проект файловой системы, получивший позднее дальнейшее развитие в ранней версии файловой системы UNIX. Томпсоном были написаны программы, имитирующие поведение предложенной файловой системы в режиме подкачки данных по запросу, им было даже создано простейшее ядро операционной системы для ЭВМ GE 645. В то же время он написал на Фортране игровую программу «Space Travel» («Космическое путешествие») для системы GECOS (Honeywell 635), но программа не смогла удовлетворить пользователей, поскольку управлять «космическим кораблем» оказалось сложно, кроме того, при загрузке программа занимала много места. Позже Томпсон обнаружил малоиспользуемый компьютер PDP-7, оснащенный хорошим графическим дисплеем и имеющий дешевое машинное время. Создавая программу «Космическое путешествие» для PDP-7, Томпсон получил возможность изучить машину, однако условия разработки программ потребовали использования кросс-ассемблера для трансляции программы на машине с системой GECOS и использования перфоленты для ввода в PDP-7. Для того, чтобы улучшить условия разработки, Томпсон и Ричи выполнили на PDP-7 свой проект системы, включивший первую версию файловой системы UNIX, подсистему управления процессами и небольшой набор утилит. В конце концов, новая система больше не нуждалась в поддержке со стороны системы GECOS в качестве операционной среды разработки и могла поддерживать себя сама. Новая система получила название UNIX, по сходству с Multics его придумал еще один сотрудник Исследовательского центра по информатике Брайан Керниган.
Несмотря на то, что эта ранняя версия системы UNIX уже была многообещающей, она не могла реализовать свой потенциал до тех пор, пока не получила применение в реальном проекте. Так, для того, чтобы обеспечить функционирование системы обработки текстов для патентного отдела фирмы Bell Laboratories, в 1971 году система UNIX была перенесена на ЭВМ PDP-11. Система отличалась небольшим объемом: 16 Кбайт для системы, 8 Кбайт для программ пользователей, обслуживала диск объемом 512 Кбайт и отводила под каждый файл не более 64 Кбайт. После своего первого успеха Томпсон собрался было написать для новой системы транслятор с Фортрана, но вместо этого занялся языком Би (B), предшественником которого явился язык BCPL [Richards 69]. Би был интерпретируемым языком со всеми недостатками, присущими подобным языкам, поэтому Ричи переделал его в новую разновидность, получившую название Си (C) и разрешающую генерировать машинный код, объявлять типы данных и определять структуру данных. В 1973 году система была написана заново на Си, это был шаг, неслыханный для того времени, но имевший огромный резонанс среди сторонних пользователей. Количество машин фирмы Bell Laboratories, на которых была инсталлирована система, возросло до 25, в результате чего была создана группа по системному сопровождению UNIX внутри фирмы.
В то время корпорация AT&T не могла заниматься продажей компьютерных продуктов в связи с соответствующим соглашением, подписанным ею с федеральным правительством в 1956 году, и распространяла систему UNIX среди университетов, которым она была нужна в учебных целях. Следуя букве соглашения, корпорация AT&T не рекламировала, не продавала и не сопровождала систему. Несмотря на это, популярность системы устойчиво росла. В 1974 году Томпсон и Ричи опубликовали статью, описывающую систему UNIX, в журнале Communications of the ACM [Thompson 74], что дало еще один импульс к распространению системы. К 1977 году количество машин, на которых функционировала система UNIX, увеличилось до 500, при чем 125 из них работали в университетах. Система UNIX завоевала популярность среди телефонных компаний, поскольку обеспечивала хорошие условия для разработки программ, обслуживала работу в сети в режиме диалога и работу в реальном масштабе времени (с помощью системы MERT [Lycklama 78a]). Помимо университетов, лицензии на систему UNIX были переданы коммерческим организациям. В 1977 году корпорация Interactive Systems стала первой организацией, получившей права на перепродажу системы UNIX с надбавкой к цене за дополнительные услуги, [1] которые заключались в адаптации системы к функционированию в автоматизированных системах управления учрежденческой деятельностью. 1977 год также был отмечен «переносом» системы UNIX на машину, отличную от PDP (благодаря чему стал возможен запуск системы на другой машине без изменений или с небольшими изменениями), а именно на Interdata 8/32.
С ростом популярности микропроцессоров другие компании стали переносить систему UNIX на новые машины, однако ее простота и ясность побудили многих разработчиков к самостоятельному развитию системы, в результате чего было создано несколько вариантов базисной системы. За период между 1977 и 1982 годом фирма Bell Laboratories объединила несколько вариантов, разработанных в корпорации AT&T, в один, получивший коммерческое название UNIX версия III. В дальнейшем фирма Bell Laboratories добавила в версию III несколько новых особенностей, назвав новый продукт UNIX версия V, [2] и эта версия стала официально распространяться корпорацией AT&T с января 1983 года. В то же время сотрудники Калифорнийского университета в Бэркли разработали вариант системы UNIX, получивший название BSD 4.3 для машин серии VAX и отличающийся некоторыми новыми, интересными особенностями. Основное внимание в этой книге концентрируется на описании системы UNIX версии V, однако время от времени мы будем касаться и особенностей системы BSD.
К началу 1984 года система UNIX была уже инсталлирована приблизительно на 100000 машин по всему миру, при чем на машинах с широким диапазоном вычислительных возможностей — от микропроцессоров до больших ЭВМ — и разных изготовителей. Ни о какой другой операционной системе нельзя было бы сказать того же. Популярность и успех системы UNIX объяснялись несколькими причинами:
• Система написана на языке высокого уровня, благодаря чему ее легко читать, понимать, изменять и переносить на другие машины. По оценкам, сделанным Ричи, первый вариант системы на Си имел на 20–40% больший объем и работал медленнее по сравнению с вариантом на ассемблере, однако преимущества использования языка высокого уровня намного перевешивают недостатки (см. [Ritchie 78b], стр. 1965).
• Наличие довольно простого пользовательского интерфейса, в котором имеется возможность предоставлять все необходимые пользователю услуги.
• Наличие элементарных средств, позволяющих создавать сложные программы из более простых.
• Наличие иерархической файловой системы, легкой в сопровождении и эффективной в работе.
• Обеспечение согласования форматов в файлах, работа с последовательным потоком байтов, благодаря чему облегчается чтение прикладных программ.
• Наличие простого, последовательного интерфейса с периферийными устройствами.
• Система является многопользовательской, многозадачной; каждый пользователь может одновременно выполнять несколько процессов.
Архитектура машины скрыта от пользователя, благодаря этому облегчен процесс написания программ, работающих на различных конфигурациях аппаратных средств.
Простота и последовательность вообще отличают систему UNIX и объясняют большинство из вышеприведенных доводов в ее пользу.
Хотя операционная система и большинство команд написаны на Си, система UNIX поддерживает ряд других языков, таких как Фортран, Бейсик, Паскаль, Ада, Кобол, Лисп и Пролог. Система UNIX может поддерживать любой язык программирования, для которого имеется компилятор или интерпретатор, и обеспечивать системный интерфейс, устанавливающий соответствие между пользовательскими запросами к операционной системе и набором запросов, принятых в UNIX.

1.2 СТРУКТУРА СИСТЕМЫ

На Рисунке 1.1 изображена архитектура верхнего уровня системы UNIX. Технические средства, показанные в центре диаграммы, выполняют функции, обеспечивающие функционирование операционной системы и перечисленные в разделе 1.5. Операционная система взаимодействует с аппаратурой непосредственно, [3] обеспечивая обслуживание программ и их независимость от деталей аппаратной конфигурации. Если представить систему состоящей из пластов, в ней можно выделить системное ядро, изолированное от пользовательских программ. Поскольку программы не зависят от аппаратуры, их легко переносить из одной системы UNIX в другую, функционирующую на другом комплексе технических средств, если только в этих программах не подразумевается работа с конкретным оборудованием. Например, программы, рассчитанные на определенный размер машинного слова, гораздо труднее переводить на другие машины по сравнению с программами, не требующими подобных установлений.
Программы, подобные командному процессору shell и редакторам (ed и vi) и показанные на внешнем по отношению к ядру слое, взаимодействуют с ядром при помощи хорошо определенного набора обращений к операционной системе. Обращения к операционной системе понуждают ядро к выполнению различных операций, которых требует вызывающая программа, и обеспечивают обмен данными между ядром и программой. Некоторые из программ, приведенных на рисунке, в стандартных конфигурациях системы известны как команды, однако на одном уровне с ними могут располагаться и доступные пользователю программы, такие как программа a.out, стандартное имя для исполняемого файла, созданного компилятором с языка Си. Другие прикладные программы располагаются выше указанных программ, на верхнем уровне, как это показано на рисунке. Например, стандартный компилятор с языка Си, cc, располагается на самом внешнем слое: он вызывает препроцессор для Си, ассемблер и загрузчик (компоновщик), т. е. отдельные программы предыдущего уровня. Хотя на рисунке приведена двухуровневая иерархия прикладных программ, пользователь может расширить иерархическую структуру на столько уровней, сколько необходимо. В самом деле, стиль программирования, принятый в системе UNIX, допускает разработку комбинации программ, выполняющих одну и ту же, общую задачу.
Многие прикладные подсистемы и программы, составляющие верхний уровень системы, такие как командный процессор shell, редакторы, SCCS (система обработки исходных текстов программ) и пакеты программ подготовки документации, постепенно становятся синонимом понятия «система UNIX». Однако все они пользуются услугами программ нижних уровней и в конечном счете ядра с помощью набора обращений к операционной системе. В версии V принято 64 типа обращений к операционной системе, из которых немногим меньше половины используются часто. Они имеют несложные параметры, что облегчает их использование, предоставляя при этом большие возможности пользователю. Набор обращений к операционной системе вместе с реализующими их внутренними алгоритмами составляют «тело» ядра, в связи с чем рассмотрение операционной системы UNIX в этой книге сводится к подробному изучению и анализу обращений к системе и их взаимодействия между собой. Короче говоря, ядро реализует функции, на которых основывается выполнение всех прикладных программ в системе UNIX, и им же определяются эти функции. В книге часто употребляются термины «система UNIX», «ядро» или «система», однако при этом имеется ввиду ядро операционной системы UNIX, что и должно вытекать из контекста.

1.3 ОБЗОР С ТОЧКИ ЗРЕНИЯ ПОЛЬЗОВАТЕЛЯ

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

1.3.1 Файловая система

Файловая система UNIX характеризуется:
• иерархической структурой,
• согласованной обработкой массивов данных,
• возможностью создания и удаления файлов,
• динамическим расширением файлов,
• защитой информации в файлах,
• трактовкой периферийных устройств (таких как терминалы и ленточные устройства) как файлов.
  Рисунок 1.2. Пример древовидной структуры файловой системы
Файловая система организована в виде дерева с одной исходной вершиной, которая называется корнем (записывается: «/»); каждая вершина в древовидной структуре файловой системы, кроме листьев, является каталогом файлов, а файлы, соответствующие дочерним вершинам, являются либо каталогами, либо обычными файлами, либо файлами устройств. Имени файла предшествует указание пути поиска, который описывает место расположения файла в иерархической структуре файловой системы. Имя пути поиска состоит из компонент, разделенных между собой наклонной чертой (/); каждая компонента представляет собой набор символов, составляющих имя вершины (файла), которое является уникальным для каталога (предыдущей компоненты), в котором оно содержится. Полное имя пути поиска начинается с указания наклонной черты и идентифицирует файл (вершину), поиск которого ведется от корневой вершины дерева файловой системы с обходом тех ветвей дерева файлов, которые соответствуют именам отдельных компонент. Так, пути «/etc/passwd», «/bin/who» и «/usr/src/cmd/who.c» указывают на файлы, являющиеся вершинами дерева, изображенного на Рисунке 1.2, а пути «/bin/passwd» и «/usr/src/date.c» содержат неверный маршрут. Имя пути поиска необязательно должно начинаться с корня, в нем следует указывать маршрут относительно текущего для выполняемого процесса каталога, при этом предыдущие символы «наклонная черта» в имени пути опускаются. Так, например, если мы находимся в каталоге «/dev», то путь «tty01» указывает файл, полное имя пути поиска для которого «/dev/tty01».
Программы, выполняемые под управлением системы UNIX, не содержат никакой информации относительно внутреннего формата, в котором ядро хранит файлы данных, данные в программах представляются как бесформатный поток байтов. Программы могут интерпретировать поток байтов по своему желанию, при этом любая интерпретация никак не будет связана с фактическим способом хранения данных в операционной системе. Так, синтаксические правила, определяющие задание метода доступа к данным в файле, устанавливаются системой и являются едиными для всех программ, однако семантика данных определяется конкретной программой. Например, программа форматирования текста troff ищет в конце каждой строки текста символы перехода на новую строку, а программа учета системных ресурсов acctcom работает с записями фиксированной длины. Обе программы пользуются одними и теми же системными средствами для осуществления доступа к данным в файле как к потоку байтов, и внутри себя преобразуют этот поток по соответствующему формату. Если любая из программ обнаружит, что формат данных неверен, она принимает соответствующие меры.
Каталоги похожи на обычные файлы в одном отношении; система представляет информацию в каталоге набором байтов, но эта информация включает в себя имена файлов в каталоге в объявленном формате для того, чтобы операционная система и программы, такие как ls (выводит список имен и атрибутов файлов), могли их обнаружить.
Права доступа к файлу регулируются установкой специальных битов разрешения доступа, связанных с файлом. Устанавливая биты разрешения доступа, можно независимо управлять выдачей разрешений на чтение, запись и выполнение для трех категорий пользователей: владельца файла, группового пользователя и прочих. Пользователи могут создавать файлы, если разрешен доступ к каталогу. Вновь созданные файлы становятся листьями в древовидной структуре файловой системы.
Для пользователя система UNIX трактует устройства так, как если бы они были файлами. Устройства, для которых назначены специальные файлы устройств, становятся вершинами в структуре файловой системы. Обращение программ к устройствам имеет тот же самый синтаксис, что и обращение к обычным файлам; семантика операций чтения и записи по отношению к устройствам в большой степени совпадает с семантикой операций чтения и записи обычных файлов. Способ защиты устройств совпадает со способом защиты обычных файлов: путем соответствующей установки битов разрешения доступа к ним (файлам). Поскольку имена устройств выглядят так же, как и имена обычных файлов, и поскольку над устройствами и над обычными файлами выполняются одни и те же операции, большинству программ нет необходимости различать внутри себя типы обрабатываемых файлов.
Например, рассмотрим программу на языке Си (Рисунок 1.3), в которой создается новая копия существующего файла. Предположим, что исполняемая версия программы имеет наименование copy. Для запуска программы пользователь вводит с терминала:
copy oldfile newfile
где oldfile — имя существующего файла, а newfile — имя создаваемого файла. Система выполняет процедуру main, присваивая аргументу argc значение количества параметров в списке argv, а каждому элементу массива argv значение параметра, сообщенного пользователем. В приведенном примере argc имеет значение 3, элемент argv[0] содержит строку символов «copy» (имя программы условно является нулевым параметром), argv[1] — строку символов «oldfile», а argv[2] — строку символов «newfile». Затем программа проверяет, правильное ли количество параметров было указано при ее запуске. Если это так, запускается операция open (открыть) для файла oldfile с параметром «read-only» (только для чтения), в случае успешного выполнения которой запускается операция creat (открыть) для файла newfile. Режим доступа к вновь созданному файлу описывается числом 0666 (в восьмеричном коде), что означает разрешение доступа к файлу для чтения и записи для всех пользователей. Все обращения к операционной системе в случае неудачи возвращают код -1; если же неудачно завершаются операции open и creat, программа выдает сообщение и запускает операцию exit (выйти) с возвращением кода состояния, равного 1, завершая свою работу и указывая на возникновение ошибки.
Операции open и creat возвращают целое значение, являющееся дескриптором файла и используемое программой в последующих ссылках на файлы. После этого программа вызывает подпрограмму copy, выполняющую в цикле операцию read (читать), по которой производится чтение в буфер порции символов из существующего файла, и операцию write (писать) для записи информации в новый файл. Операция read каждый раз возвращает количество прочитанных байтов (0 — если достигнут конец файла). Цикл завершается, если достигнут конец файла или если произошла ошибка при выполнении операции read (отсутствует контроль возникновения ошибок при выполнении операции write). Затем управление из подпрограммы copy возвращается в основную программу и запускается операция exit с кодом состояния 0 в качестве параметра, что указывает на успешное завершение выполнения программы.
Программа копирует любые файлы, указанные при ее вызове в качестве аргументов, при условии, что разрешено открытие существующего файла и создание нового файла. Файл может включать в себя как текст, который может быть выведен на печатающее устройство, например, исходный текст программы, так и символы, не выводимые на печать, даже саму программу. Таким образом, оба вызова:
copy copy.c newcopy.c
copy copy newcopy
являются допустимыми. Существующий файл также может быть каталогом. Например, по вызову:
copy . dircontents
копируется содержимое текущего каталога, обозначенного символом ".", в обычный файл «dircontents»; информация в новом файле совпадает, вплоть до каждого байта, с содержимым каталога, только этот файл обычного типа (для создания нового каталога предназначена операция mknod). Наконец, любой из файлов может быть файлом устройства. Например, программа, вызванная следующим образом:
copy /dev/tty terminalread
читает символы, вводимые с терминала (файл /dev/tty соответствует терминалу пользователя), и копирует их в файл terminalread, завершая работу только в том случае, если пользователь нажмет ‹Ctrl/d›. Похожая форма запуска программы:
copy /dev/tty /dev/tty
вызывает чтение символов с терминала и их копирование обратно на терминал.
  #include <fcntl.h>
  char buffer[2048];
  int version = 1; /* будет объяснено в главе 2 */ 
  main(argc, argv) 
  int argc; 
  char *argv[]; 
  {
   int fdold, fdnew; 
   if (argc!= 3)    {
    printf("need 2 arguments for copy program\n"); 
    exit(1);
   } 
   fdold = open(argv[1], O_RDONLY); /* открыть исходный файл только для чтения */ 
   if (fdold == -1) {
    printf("cannot open file %s\n", argv[1]);
    exit(1);
   }
   fdnew = creat(argv[2], 0666); /* создать новый файл с разрешением чтения и записи для всех пользователей */
   if (fdnew == -1)    {
    printf("cannot create file %s\n", argv[2]); 
    exit(1);
   }
   copy(fdold, fdnew);
   exit(0);
  }
  copy(old, new)
  int old, new;
  {
   int count; 
   while ((count = read(old, buffer, sizeof(buffer))) > 0)   write(new, buffer, count);
  }
  Рисунок 1.3. Программа копирования файла

1.3.2 Среда выполнения процессов

Программой называется исполняемый файл, а процессом называется последовательность операций программы или часть программы при ее выполнении. В системе UNIX может одновременно выполняться множество процессов (эту особенность иногда называют мультипрограммированием или многозадачным режимом), при чем их число логически не ограничивается, и множество частей программы (такой как copy) может одновременно находиться в системе. Различные системные операции позволяют процессам порождать новые процессы, завершают процессы, синхронизируют выполнение этапов процесса и управляют реакцией на наступление различных событий. Благодаря различным обращениям к операционной системе, процессы выполняются независимо друг от друга.
Например, процесс, выполняющийся в программе, приведенной на Рисунке 1.4, запускает операцию fork, чтобы породить новый процесс. Новый процесс, именуемый порожденным процессом, получает значение кода завершения операции fork, равное 0, и активизирует операцию execl, которая выполняет программу copy (Рисунок 1.3). Операция execl загружает файл „copy“, который предположительно находится в текущем каталоге, в адресное пространство порожденного процесса и запускает программу с параметрами, полученными от пользователя. В случае успешного выполнения операции execl управление в вызвавший ее процесс не возвращается, поскольку процесс выполняется в новом адресном пространстве (подробнее об этом в главе 7). Тем временем, процесс, запустивший операцию fork (родительский процесс), получает ненулевое значение кода завершения операции, вызывает операцию wait, которая приостанавливает его выполнение до тех пор, пока не закончится выполнение программы copy, и завершается (каждая программа имеет выход в конце главной процедуры, после которой располагаются программы стандартных библиотек Си, подключаемые в процессе компиляции). Например, если исполняемая программа называется run, пользователь запускает ее следующим образом:
run oldfile newfile
Процесс выполняет копирование файла с именем „oldfile“ в файл с именем „newfile“ и выводит сообщение. Хотя данная программа мало что добавила к программе „copy“, в ней появились четыре основных обращения к операционной системе, управляющие выполнением процессов: fork, exec, wait и exit.
  main(argc, argv)
  int argc; 
  char *argv[]; 
  { /* предусмотрено 2 аргумента: исходный файл и новый файл */ 
   if (fork() == 0) execl("copy", "copy", argv[1], argv[2], 0);
   wait((int *)0);
   printf("copy done\n");
  } 
  Рисунок 1.4. Программа порождения нового процесса, выполняющего копирование файлов
Вообще использование обращений к операционной системе дает возможность пользователю создавать программы, выполняющие сложные действия, и как следствие, ядро операционной системы UNIX не включает в себя многие функции, являющиеся частью «ядра» в других системах. Такие функции, и среди них компиляторы и редакторы, в системе UNIX являются программами пользовательского уровня. Наиболее характерным примером подобной программы может служить командный процессор shell, с которым обычно взаимодействуют пользователи после входа в систему. Shell интерпретирует первое слово командной строки как имя команды: во многих командах, в том числе и в командах fork (породить новый процесс) и exec (выполнить порожденный процесс), сама команда ассоциируется с ее именем, все остальные слова в командной строке трактуются как параметры команды.
Shell обрабатывает команды трех типов. Во-первых, в качестве имени команды может быть указано имя исполняемого файла в объектном коде, полученного в результате компиляции исходного текста программы (например, программы на языке Си). Во-вторых, именем команды может быть имя командного файла, содержащего набор командных строк, обрабатываемых shell'ом. Наконец, команда может быть внутренней командой языка shell (в отличие от исполняемого файла). Наличие внутренних команд делает shell языком программирования в дополнение к функциям командного процессора; командный язык shell включает команды организации циклов (for-in-do-done и while-do-done), команды выполнения по условиям (if-then-else-fi), оператор выбора, команду изменения текущего для процесса каталога (cd) и некоторые другие. Синтаксис shell'а допускает сравнение с образцом и обработку параметров. Пользователям, запускающим команды, нет необходимости знать, какого типа эти команды.
Командный процессор shell ищет имена команд в указанном наборе каталогов, который можно изменить по желанию пользователя, вызвав shell. Shell обычно исполняет команду синхронно, с ожиданием завершения выполнения команды прежде, чем считать следующую командную строку. Тем не менее, допускается и асинхронное исполнение, когда очередная командная строка считывается и исполняется, не дожидаясь завершения выполнения предыдущей команды. О командах, выполняемых асинхронно, говорят, что они выполняются на фоне других команд. Например, ввод команды
who
вызывает выполнение системой программы, хранящейся в файле /bin/who [4] и осуществляющей вывод списка пользователей, которые в настоящий момент работают с системой. Пока команда who выполняется, командный процессор shell ожидает завершения ее выполнения и только затем запрашивает у пользователя следующую команду. Если же ввести команду
who&
система выполнит программу who на фоне и shell готов немедленно принять следующую команду.
В среду выполнения каждого процесса в системе UNIX включается текущий каталог. Текущий для процесса каталог является начальным каталогом, имя которого присоединяется ко всем именам путей поиска, которые не начинаются с наклонной черты. Пользователь может запустить внутреннюю команду shell'а cd (изменить каталог) для перемещения по дереву файловой системы и для смены текущего каталога. Командная строка
cd /usr/src/uts
делает текущим каталог «/usr/src/uts». Командная строка
cd ../..
делает текущим каталог, который на две вершины «ближе» к корню (корневому каталогу): параметр «..» относится к каталогу, являющемуся родительским для текущего.
Поскольку shell является пользовательской программой и не входит в состав ядра операционной системы, его легко модифицировать и помещать в конкретные условия эксплуатации. Например, вместо командного процессора Баурна (называемого так по имени его создателя, Стива Баурна), являющегося частью версии V стандартной системы, можно использовать процессор команд Си, обеспечивающий работу механизма ведения истории изменений и позволяющий избегать повторного ввода только что использованных команд. В некоторых случаях при желании можно воспользоваться командным процессором shell с ограниченными возможностями, являющимся предыдущей версией обычного shell'а. Система может работать с несколькими командными процессорами одновременно. Пользователи имеют возможность запускать одновременно множество процессов, процессы же в свою очередь могут динамически порождать новые процессы и синхронизировать их выполнение. Все эти возможности обеспечиваются благодаря наличию мощных программных и аппаратных средств, составляющих среду выполнения процессов. Хотя привлекательность shell'а в наибольшей степени определяется его возможностями как языка программирования и его возможностями в обработке аргументов, в данном разделе основное внимание концентрируется на среде выполнения процессов, управление которой в системе возложено на командный процессор shell. Другие важные особенности shell'а выходят за рамки настоящей книги (подробное описание shell'а см. в [Bourne 78]).

1.3.3 Элементы конструкционных блоков

Как уже говорилось ранее, концепция разработки системы UNIX заключалась в построении операционной системы из элементов, которые позволили бы пользователю создавать небольшие программные модули, выступающие в качестве конструкционных блоков при создании более сложных программ. Одним из таких элементов, с которым часто сталкиваются пользователи при работе с командным процессором shell, является возможность переназначения ввода-вывода. Говоря условно, процессы имеют доступ к трем файлам: они читают из файла стандартного ввода, записывают в файл стандартного вывода и выводят сообщения об ошибках в стандартный файл ошибок. Процессы, запускаемые с терминала, обычно используют терминал вместо всех этих трех файлов, однако каждый файл независимо от других может быть «переназначен». Например, команда
ls
выводит список всех файлов текущего каталога на устройство (в файл) стандартного вывода, а команда
ls › output
переназначает выводной поток со стандартного вывода в файл «output» в текущем каталоге, используя вышеупомянутый системный вызов creat. Подобным же образом, команда
mail mjb ‹ letter
открывает (с помощью системного вызова open) файл «letter» в качестве файла стандартного ввода и пересылает его содержимое пользователю с именем «mjb». Процессы могут переназначать одновременно и ввод, и вывод, как, например, в командной строке:
nroff -mm ‹ doc1 › doc1.out 2› errors
где программа форматирования nroff читает вводной файл doc1, в качестве файла стандартного вывода задает файл doc1.out и выводит сообщения об ошибках в файл errors («2›» означает переназначение вывода, предназначавшегося для файла с дескриптором 2, который соответствует стандартному файлу ошибок). Программы ls, mail и nroff не знают, какие файлы выбраны в качестве файлов стандартного ввода, стандартного вывода и записи сообщений об ошибках; командный процессор shell сам распознает символы «‹», «›» и «2›» и назначает в соответствии с их указанием файлы для стандартного ввода, стандартного вывода и записи сообщений об ошибках непосредственно перед запуском процессов.
Вторым конструкционным элементом является канал, механизм, обеспечивающий информационный обмен между процессами, выполнение которых связано с операциями чтения и записи. Процессы могут переназначать выводной поток со стандартного вывода на канал для чтения с него другими процессами, переназначившими на канал свой стандартный ввод. Данные, посылаемые в канал первыми процессами, являются входными для вторых процессов. Вторые процессы так же могут переназначить свой выводной поток и так далее, в зависимости от пожеланий программиста. И снова, так же как и в вышеуказанном случае, процессам нет необходимости знать, какого типа файл используется в качестве файла стандартного вывода; их выполнение не зависит от того, будет ли файлом стандартного вывода обычный файл, канал или устройство. В процессе построения больших и сложных программ из конструкционных элементов меньшего размера программисты часто используют каналы и переназначение ввода-вывода при сборке и соединении отдельных частей. И действительно, такой стиль программирования находит поддержку в системе, благодаря чему новые программы могут работать вместе с существующими программами.
Например, программа grep производит поиск контекста в наборе файлов (являющихся параметрами программы) по следующему образцу:
grep main a.c b.c c.c
где «main» — подстрока, поиск которой производится в файлах a.c, b.c и c.c с выдачей в файл стандартного вывода тех строк, в которых она содержится. Содержимое выводного файла может быть следующим:
a. c: main(argc,argv)
c. c: /* here is the main loop in the program */
c. c: main()
Программа wc с необязательным параметром -l подсчитывает число строк в файле стандартного ввода. Командная строка
grep main a.c b.c c.c | wc -l
вызовет подсчет числа строк в указанных файлах, где будет обнаружена подстрока «main»; выводной поток команды grep поступит непосредственно на вход команды wc. Для предыдущего примера результат будет такой:
3
Использование каналов зачастую делает ненужным создание временных файлов.

1.4 ФУНКЦИИ ОПЕРАЦИОННОЙ СИСТЕМЫ

На Рисунке 1.1 уровень ядра операционной системы изображен непосредственно под уровнем прикладных программ пользователя. Выполняя различные элементарные операции по запросам пользовательских процессов, ядро обеспечивает функционирование пользовательского интерфейса, описанного выше. Среди функций ядра можно отметить:
• Управление выполнением процессов посредством их создания, завершения или приостановки и организации взаимодействия между ними.
• Планирование очередности предоставления выполняющимся процессам времени центрального процессора (диспетчеризация). Процессы работают с центральным процессором в режиме разделения времени: центральный процессор [5] выполняет процесс, по завершении отсчитываемого ядром кванта времени процесс приостанавливается и ядро активизирует выполнение другого процесса. Позднее ядро запускает приостановленный процесс.
• Выделение выполняемому процессу оперативной памяти. Ядро операционной системы дает процессам возможность совместно использовать участки адресного пространства на определенных условиях, защищая при этом адресное пространство, выделенное процессу, от вмешательства извне. Если системе требуется свободная память, ядро освобождает память, временно выгружая процесс на внешние запоминающие устройства, которые называют устройствами выгрузки. Если ядро выгружает процессы на устройства выгрузки целиком, такая реализация системы UNIX называется системой со свопингом (подкачкой); если же на устройство выгрузки выводятся страницы памяти, такая система называется системой с замещением страниц.
• Выделение внешней памяти с целью обеспечения эффективного хранения информации и выборка данных пользователя. Именно в процессе реализации этой функции создается файловая система. Ядро выделяет внешнюю память под пользовательские файлы, мобилизует неиспользуемую память, структурирует файловую систему в форме, доступной для понимания, и защищает пользовательские файлы от несанкционированного доступа.
Управление доступом процессов к периферийным устройствам, таким как терминалы, ленточные устройства, дисководы и сетевое оборудование.
Выполнение ядром своих функций довольно очевидно. Например, оно узнает, что данный файл является обычным файлом или устройством, но скрывает это различие от пользовательских процессов. Так же оно, форматируя информацию файла для внутреннего хранения, защищает внутренний формат от пользовательских процессов, возвращая им неотформатированный поток байтов. Наконец, ядро реализует ряд необходимых функций по обеспечению выполнения процессов пользовательского уровня, за исключением функций, которые могут быть реализованы на самом пользовательском уровне. Например, ядро выполняет действия, необходимые shell'у как интерпретатору команд: оно позволяет процессору shell читать вводимые с терминала данные, динамически порождать процессы, синхронизировать выполнение процессов, открывать каналы и переадресовывать ввод-вывод. Пользователи могут разрабатывать свои версии командного процессора shell с тем, чтобы привести рабочую среду в соответствие со своими требованиями, не затрагивая других пользователей. Такие программы пользуются теми же услугами ядра, что и стандартный процессор shell.

1.5 ПРЕДПОЛАГАЕМАЯ АППАРАТНАЯ СРЕДА

Выполнение пользовательских процессов в системе UNIX осуществляется на двух уровнях: уровне пользователя и уровне ядра. Когда процесс производит обращение к операционной системе, режим выполнения процесса переключается с режима задачи (пользовательского) на режим ядра: операционная система пытается обслужить запрос пользователя, возвращая код ошибки в случае неудачного завершения операции. Даже если пользователь не нуждается в каких-либо определенных услугах операционной системы и не обращается к ней с запросами, система еще выполняет учетные операции, связанные с пользовательским процессом, обрабатывает прерывания, планирует процессы, управляет распределением памяти и т. д. Большинство вычислительных систем разнообразной архитектуры (и соответствующие им операционные системы) поддерживают большее число уровней, чем указано здесь, однако уже двух режимов, режима задачи и режима ядра, вполне достаточно для системы UNIX.
Основные различия между этими двумя режимами:
В режиме задачи процессы имеют доступ только к своим собственным инструкциям и данным, но не к инструкциям и данным ядра (либо других процессов). Однако в режиме ядра процессам уже доступны адресные пространства ядра и пользователей. Например, виртуальное адресное пространство процесса может быть поделено на адреса, доступные только в режиме ядра, и на адреса, доступные в любом режиме.
Некоторые машинные команды являются привилегированными и вызывают возникновение ошибок при попытке их использования в режиме задачи. Например, в машинном языке может быть команда, управляющая регистром состояния процессора; процессам, выполняющимся в режиме задачи, она недоступна.

Процессы
A B C D
Режим ядра Я . . Я
Режим задачи . З З .

  Рисунок 1.5. Процессы и режимы их выполнения
Проще говоря, любое взаимодействие с аппаратурой описывается в терминах режима ядра и режима задачи и протекает одинаково для всех пользовательских программ, выполняющихся в этих режимах. Операционная система хранит внутренние записи о каждом процессе, выполняющемся в системе. На Рисунке 1.5 показано это разделение: ядро делит процессы A, B, C и D, расположенные вдоль горизонтальной оси, аппаратные средства вводят различия между режимами выполнения, расположенными по вертикали.
Несмотря на то, что система функционирует в одном из двух режимов, ядро действует от имени пользовательского процесса. Ядро не является какой-то особой совокупностью процессов, выполняющихся параллельно с пользовательскими, оно само выступает составной частью любого пользовательского процесса. Сделанный вывод будет скорее относиться к «ядру», распределяющему ресурсы, или к «ядру», производящему различные операции, и это будет означать, что процесс, выполняемый в режиме ядра, распределяет ресурсы и производит соответствующие операции. Например, командный процессор shell считывает вводной поток с терминала с помощью запроса к операционной системе. Ядро операционной системы, выступая от имени процессора shell, управляет функционированием терминала и передает вводимые символы процессору shell. Shell переходит в режим задачи, анализирует поток символов, введенных пользователем и выполняет заданную последовательность действий, которые могут потребовать выполнения и других системных операций.

1.5.1 Прерывания и особые ситуации

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

1.5.2 Уровни прерывания процессора

Ядро иногда обязано предупреждать возникновение прерываний во время критических действий, могущих в случае прерывания запортить информацию. Например, во время обработки списка с указателями возникновение прерывания от диска для ядра нежелательно, т. к. при обработке прерывания можно запортить указатели, что можно увидеть на примере в следующей главе. Обычно имеется ряд привилегированных команд, устанавливающих уровень прерывания процессора в слове состояния процессора. Установка уровня прерывания на определенное значение отсекает прерывания этого и более низких уровней, разрешая обработку только прерываний с более высоким приоритетом. На Рисунке 1.6 показана последовательность уровней прерывания. Если ядро игнорирует прерывания от диска, в этом случае игнорируются и все остальные прерывания, кроме прерываний от часов и машинных сбоев.
  Рисунок 1.6. Стандартные уровни прерываний

1.5.3 Распределение памяти

Ядро постоянно располагается в оперативной памяти, наряду с выполняющимся в данный момент процессом (или частью его, по меньшей мере). В процессе компиляции программа-компилятор генерирует последовательность адресов, являющихся адресами переменных и информационных структур, а также адресами инструкций и функций. Компилятор генерирует адреса для виртуальной машины так, словно на физической машине не будет выполняться параллельно с транслируемой ни одна другая программа.
Когда программа запускается на выполнение, ядро выделяет для нее место в оперативной памяти, при этом совпадение виртуальных адресов, сгенерированных компилятором, с физическими адресами совсем необязательно. Ядро, взаимодействуя с аппаратными средствами, транслирует виртуальные адреса в физические, т. е. отображает адреса, сгенерированные компилятором, в физические, машинные адреса. Такое отображение опирается на возможности аппаратных средств, поэтому компоненты системы UNIX, занимающиеся им, являются машинно-зависимыми. Например, отдельные вычислительные машины имеют специальное оборудование для подкачки выгруженных страниц памяти. Главы 6 и 9 посвящены более подробному рассмотрению вопросов, связанных с распределением памяти, и исследованию их соотношения с аппаратными средствами.

1.6 ВЫВОДЫ

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

0

3

https://forumupload.ru/uploads/001b/09/4d/2/67355.jpg
ГЛАВА 2. ВВЕДЕНИЕ В АРХИТЕКТУРУ ЯДРА ОПЕРАЦИОННОЙ СИСТЕМЫ

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

2.1 АРХИТЕКТУРА ОПЕРАЦИОННОЙ СИСТЕМЫ UNIХ

Как уже ранее было замечено (см. [Christian 83], стр.239), в системе UNIX создается иллюзия того, что файловая система имеет «места» и что у процессов есть «жизнь». Обе сущности, файлы и процессы, являются центральными понятиями модели операционной системы UNIX. На Рисунке 2.1 представлена блок-схема ядра системы, отражающая состав модулей, из которых состоит ядро, и их взаимосвязи друг с другом. В частности, на ней слева изображена файловая подсистема, а справа подсистема управления процессами, две главные компоненты ядра. Эта схема дает логическое представление о ядре, хотя в действительности в структуре ядра имеются отклонения от модели, поскольку отдельные модули испытывают внутреннее воздействие со стороны других модулей.
Схема на Рисунке 2.1 имеет три уровня: уровень пользователя, уровень ядра и уровень аппаратуры. Обращения к операционной системе и библиотеки составляют границу между пользовательскими программами и ядром, проведенную на Рисунке 1.1. Обращения к операционной системе выглядят так же, как обычные вызовы функций в программах на языке Си, и библиотеки устанавливают соответствие между этими вызовами функций и элементарными системными операциями, о чем более подробно см. в главе 6. При этом программы на ассемблере могут обращаться к операционной системе непосредственно, без использования библиотеки системных вызовов. Программы часто обращаются к другим библиотекам, таким как библиотека стандартных подпрограмм ввода-вывода, достигая тем самым более полного использования системных услуг. Для этого во время компиляции библиотеки связываются с программами и частично включаются в программу пользователя. Далее мы проиллюстрируем эти моменты на примере.
На рисунке совокупность обращений к операционной системе разделена на те обращения, которые взаимодействуют с подсистемой управления файлами, и те, которые взаимодействуют с подсистемой управления процессами. Файловая подсистема управляет файлами, размещает записи файлов, управляет свободным пространством, доступом к файлам и поиском данных для пользователей. Процессы взаимодействуют с подсистемой управления файлами, используя при этом совокупность специальных обращений к операционной системе, таких как open (для того, чтобы открыть файл на чтение или запись), close, read, write, stat (запросить атрибуты файла), chown (изменить запись с информацией о владельце файла) и chmod (изменить права доступа к файлу). Эти и другие операции рассматриваются в главе 5.
Подсистема управления файлами обращается к данным, которые хранятся в файле, используя буферный механизм, управляющий потоком данных между ядром и устройствами внешней памяти. Буферный механизм, взаимодействуя с драйверами устройств ввода-вывода блоками, инициирует передачу данных к ядру и обратно. Драйверы устройств являются такими модулями в составе ядра, которые управляют работой периферийных устройств. Устройства ввода-вывода блоками относятся программы пользователя
  Рисунок 2.1. Блок-схема ядра операционной системы
к типу запоминающих устройств с произвольной выборкой; их драйверы построены таким образом, что все остальные компоненты системы воспринимают эти устройства как запоминающие устройства с произвольной выборкой. Например, драйвер запоминающего устройства на магнитной ленте позволяет ядру системы воспринимать это устройство как запоминающее устройство с произвольной выборкой. Подсистема управления файлами также непосредственно взаимодействует с драйверами устройств «неструктурированного» ввода-вывода, без вмешательства буферного механизма. К устройствам неструктурированного ввода-вывода, иногда именуемым устройствами посимвольного ввода-вывода (текстовыми), относятся устройства, отличные от устройств ввода-вывода блоками.
Подсистема управления процессами отвечает за синхронизацию процессов, взаимодействие процессов, распределение памяти и планирование выполнения процессов. Подсистема управления файлами и подсистема управления процессами взаимодействуют между собой, когда файл загружается в память на выполнение (см. главу 7): подсистема управления процессами читает в память исполняемые файлы перед тем, как их выполнить.
Примерами обращений к операционной системе, используемых при управлении процессами, могут служить fork (создание нового процесса), exec (наложение образа программы на выполняемый процесс), exit (завершение выполнения процесса), wait (синхронизация продолжения выполнения основного процесса с моментом выхода из порожденного процесса), brk (управление размером памяти, выделенной процессу) и signal (управление реакцией процесса на возникновение экстраординарных событий). Глава 7 посвящена рассмотрению этих и других системных вызовов.
Модуль распределения памяти контролирует выделение памяти процессам. Если в какой-то момент система испытывает недостаток в физической памяти для запуска всех процессов, ядро пересылает процессы между основной и внешней памятью с тем, чтобы все процессы имели возможность выполняться. В главе 9 описываются два способа управления распределением памяти: выгрузка (подкачка) и замещение страниц. Программу подкачки иногда называют планировщиком, т. к. она «планирует» выделение памяти процессам и оказывает влияние на работу планировщика центрального процессора. Однако в дальнейшем мы будем стараться ссылаться на нее как на «программу подкачки», чтобы избежать путаницы с планировщиком центрального процессора.
Модуль «планировщик» распределяет между процессами время центрального процессора. Он планирует очередность выполнения процессов до тех пор, пока они добровольно не освободят центральный процессор, дождавшись выделения какого-либо ресурса, или до тех пор, пока ядро системы не выгрузит их после того, как их время выполнения превысит заранее определенный квант времени. Планировщик выбирает на выполнение готовый к запуску процесс с наивысшим приоритетом; выполнение предыдущего процесса (приостановленного) будет продолжено тогда, когда его приоритет будет наивысшим среди приоритетов всех готовых к запуску процессов. Существует несколько форм взаимодействия процессов между собой, от асинхронного обмена сигналами о событиях до синхронного обмена сообщениями.
Наконец, аппаратный контроль отвечает за обработку прерываний и за связь с машиной. Такие устройства, как диски и терминалы, могут прерывать работу центрального процессора во время выполнения процесса. При этом ядро системы после обработки прерывания может возобновить выполнение прерванного процесса. Прерывания обрабатываются не самими процессами, а специальными функциями ядра системы, перечисленными в контексте выполняемого процесса.

2.2 ВВЕДЕНИЕ В ОСНОВНЫЕ ПОНЯТИЯ СИСТЕМЫ

В это разделе дается обзор некоторых основных информационных структур, используемых ядром системы, и более подробно описывается функционирование модулей ядра, показанных на Рисунке 2.1.

2.2.1 Обзор особенностей подсистемы управления файлами

Внутреннее представление файла описывается в индексе, который содержит описание размещения информации файла на диске и другую информацию, такую как владелец файла, права доступа к файлу и время доступа. Термин «индекс» (inode) широко используется в литературе по системе UNIX. Каждый файл имеет один индекс, но может быть связан с несколькими именами, которые все отражаются в индексе. Каждое имя является указателем. Когда процесс обращается к файлу по имени, ядро системы анализирует по очереди каждую компоненту имени файла, проверяя права процесса на просмотр входящих в путь поиска каталогов, и в конце концов возвращает индекс файла. Например, если процесс обращается к системе:
open("/fs2/mjb/rje/sourcefile", 1);
ядро системы возвращает индекс для файла «/fs2/mjb/rje/sourcefile». Если процесс создает новый файл, ядро присваивает этому файлу неиспользуемый индекс. Индексы хранятся в файловой системе (и это мы еще увидим), однако при обработке файлов ядро заносит их в таблицу индексов в оперативной памяти.
Ядро поддерживает еще две информационные структуры, таблицу файлов и пользовательскую таблицу дескрипторов файла. Таблица файлов выступает глобальной структурой ядра, а пользовательская таблица дескрипторов файла выделяется под процесс. Если процесс открывает или создает файл, ядро выделяет в каждой таблице элемент, корреспондирующий с индексом файла. Элементы в этих трех структурах — в пользовательской таблице дескрипторов файла, в таблице файлов и в таблице индексов — хранят информацию о состоянии файла и о доступе пользователей к нему. В таблице файлов хранится смещение в байтах от начала файла до того места, откуда начнет выполняться следующая команда пользователя read или write, а также информация о правах доступа к открываемому процессу. Таблица дескрипторов файла идентифицирует все открытые для процесса файлы. На Рисунке 2.2 показаны эти таблицы и связи между ними. В системных операциях open (открыть) и creat (создать) ядро возвращает дескриптор файла, которому соответствует указатель в таблице дескрипторов файла. При выполнении операций read (читать) и write (писать) ядро использует дескриптор файла для входа в таблицу дескрипторов и, следуя указателям на таблицу файлов и на таблицу индексов, находит информацию в файле. Более подробно эти информационные структуры рассматриваются в главах 4 и 5. Сейчас достаточно сказать, что использование этих таблиц обеспечивает различную степень разделения доступа к файлу.
  Рисунок 2.2. Таблицы файлов, дескрипторов файла и индексов
Обычные файлы и каталоги хранятся в системе UNIX на устройствах ввода-вывода блоками, таких как магнитные ленты или диски. Поскольку существует некоторое различие во времени доступа к этим устройствам, при установке системы UNIX на лентах размещают файловые системы. С годами бездисковые автоматизированные рабочие места станут общим случаем, и файлы будут располагаться в удаленной системе, доступ к которой будет осуществляться через сеть (см. главу 13). Для простоты, тем не менее, в последующем тексте подразумевается использование дисков. В системе может быть несколько физических дисков, на каждом из которых может размещаться одна и более файловых систем. Разбивка диска на несколько файловых систем облегчает администратору управление хранимыми данными. На логическом уровне ядро имеет дело с файловыми системами, а не с дисками, при этом каждая система трактуется как логическое устройство, идентифицируемое номером. Преобразование адресов логического устройства (файловой системы) в адреса физического устройства (диска) и обратно выполняется дисковым драйвером. Термин «устройство» в этой книге используется для обозначения логического устройства, кроме специально оговоренных случаев.
Файловая система состоит из последовательности логических блоков длиной 512, 1024, 2048 или другого числа байт, кратного 512, в зависимости от реализации системы. Размер логического блока внутри одной файловой системы постоянен, но может варьироваться в разных файловых системах в данной конфигурации. Использование логических блоков большого размера увеличивает скорость передачи данных между диском и памятью, поскольку ядро сможет передать больше информации за одну дисковую операцию, и сокращает количество продолжительных операций. Например, чтение 1 Кбайта с диска за одну операцию осуществляется быстрее, чем чтение 512 байт за две. Однако, если размер логического блока слишком велик, полезный объем памяти может уменьшиться, это будет показано в главе 5. Для простоты термин «блок» в этой книге будет использоваться для обозначения логического блока, при этом подразумевается логический блок размером 1 Кбайт, кроме специально оговоренных случаев.
  Рисунок 2.3. Формат файловой системы
Файловая система имеет следующую структуру (Рисунок 2.3).
• Блок загрузки располагается в начале пространства, отведенного под файловую систему, обычно в первом секторе, и содержит программу начальной загрузки, которая считывается в машину при загрузке или инициализации операционной системы. Хотя для запуска системы требуется только один блок загрузки, каждая файловая система имеет свой (пусть даже пустой) блок загрузки.
• Суперблок описывает состояние файловой системы — какого она размера, сколько файлов может в ней храниться, где располагается свободное пространство, доступное для файловой системы, и другая информация.
• Список индексов в файловой системе располагается вслед за суперблоком. Администраторы указывают размер списка индексов при генерации файловой системы. Ядро операционной системы обращается к индексам, используя указатели в списке индексов. Один из индексов является корневым индексом файловой системы: это индекс, по которому осуществляется доступ к структуре каталогов файловой системы после выполнения системной операции mount (монтировать) (раздел 5.14).
• Информационные блоки располагаются сразу после списка индексов и содержат данные файлов и управляющие данные. Отдельно взятый информационный блок может принадлежать одному и только одному файлу в файловой системе.

2.2.2 Процессы

В этом разделе мы рассмотрим более подробно подсистему управления процессами. Даются разъяснения по поводу структуры процесса и некоторых информационных структур, используемых при распределении памяти под процессы. Затем дается предварительный обзор диаграммы состояния процессов и затрагиваются различные вопросы, связанные с переходами из одного состояния в другое.
Процессом называется последовательность операций при выполнении программы, которые представляют собой наборы байтов, интерпретируемые центральным процессором как машинные инструкции (т. н. «текст»), данные и стековые структуры. Создается впечатление, что одновременно выполняется множество процессов, поскольку их выполнение планируется ядром, и, кроме того, несколько процессов могут быть экземплярами одной программы. Выполнение процесса заключается в точном следовании набору инструкций, который является замкнутым и не передает управление набору инструкций другого процесса; он считывает и записывает информацию в раздел данных и в стек, но ему недоступны данные и стеки других процессов. Одни процессы взаимодействуют с другими процессами и с остальным миром посредством обращений к операционной системе.
С практической точки зрения процесс в системе UNIX является объектом, создаваемым в результате выполнения системной операции fork. Каждый процесс, за исключением нулевого, порождается в результате запуска другим процессом операции fork. Процесс, запустивший операцию fork, называется родительским, а вновь созданный процесс — порожденным. Каждый процесс имеет одного родителя, но может породить много процессов. Ядро системы идентифицирует каждый процесс по его номеру, который называется идентификатором процесса (PID). Нулевой процесс является особенным процессом, который создается «вручную» в результате загрузки системы; после порождения нового процесса (процесс 1) нулевой процесс становится процессом подкачки. Процесс 1, известный под именем init, является предком любого другого процесса в системе и связан с каждым процессом особым образом, описываемым в главе 7.
Пользователь, транслируя исходный текст программы, создает исполняемый файл, который состоит из нескольких частей:
• набора «заголовков», описывающих атрибуты файла,
• текста программы,
• представления на машинном языке данных, имеющих начальные значения при запуске программы на выполнение, и указания на то, сколько пространства памяти ядро системы выделит под неинициализированные данные, так называемые bss [6]  (ядро устанавливает их в 0 в момент запуска),
• других секций, таких как информация символических таблиц.
Для программы, приведенной на Рисунке 1.3, текст исполняемого файла представляет собой сгенерированный код для функций main и copy, к определенным данным относится переменная version (вставленная в программу для того, чтобы в последней имелись некоторые определенные данные), а к неопределенным — массив buffer. Компилятор с языка Си для системы версии V создает отдельно текстовую секцию по умолчанию, но не исключается возможность включения инструкций программы и в секцию данных, как в предыдущих версиях системы.
Ядро загружает исполняемый файл в память при выполнении системной операции exec, при этом загруженный процесс состоит по меньшей мере из трех частей, так называемых областей: текста, данных и стека. Области текста и данных корреспондируют с секциями текста и bss-данных исполняемого файла, а область стека создается автоматически и ее размер динамически устанавливается ядром системы во время выполнения. Стек состоит из логических записей активации, помещаемых в стек при вызове функции и выталкиваемых из стека при возврате управления в вызвавшую процедуру; специальный регистр, именуемый указателем вершины стека, показывает текущую глубину стека. Запись активации включает параметры передаваемые функции, ее локальные переменные, а также данные, необходимые для восстановления предыдущей записи активации, в том числе значения счетчика команд и указателя вершины стека в момент вызова функции. Текст программы включает последовательности команд, управляющие увеличением стека, а ядро системы выделяет, если нужно, место под стек. В программе на Рисунке 1.3 параметры argc и argv, а также переменные fdold и fdnew, содержащиеся в вызове функции main, помещаются в стек, как только встретилось обращение к функции main (один раз в каждой программе, по условию), так же и параметры old и new и переменная count, содержащиеся в вызове функции copy, помещаются в стек в момент обращения к указанной функции.
  Рисунок 2.4. Стеки задачи и ядра для программы копирования.
Поскольку процесс в системе UNIX может выполняться в двух режимах, режиме ядра или режиме задачи, он пользуется в каждом из этих режимов отдельным стеком. Стек задачи содержит аргументы, локальные переменные и другую информацию относительно функций, выполняемых в режиме задачи. Слева на Рисунке 2.4 показан стек задачи для процесса, связанного с выполнением системной операции write в программе copy. Процедура запуска процесса (включенная в библиотеку) обратилась к функции main с передачей ей двух параметров, поместив в стек задачи запись 1; в записи 1 есть место для двух локальных переменных функции main. Функция main затем вызывает функцию copy с передачей ей двух параметров, old и new, и помещает в стек задачи запись 2; в записи 2 есть место для локальной переменной count. Наконец, процесс активизирует системную операцию write, вызвав библиотечную функцию с тем же именем. Каждой системной операции соответствует точка входа в библиотеке системных операций; библиотека системных операций написана на языке ассемблера и включает специальные команды прерывания, которые, выполняясь, порождают «прерывание», вызывающее переключение аппаратуры в режим ядра. Процесс ищет в библиотеке точку входа, соответствующую отдельной системной операции, подобно тому, как он вызывает любую из функций, создавая при этом для библиотечной функции запись активации. Когда процесс выполняет специальную инструкцию, он переключается в режим ядра, выполняет операции ядра и использует стек ядра.
Стек ядра содержит записи активации для функций, выполняющихся в режиме ядра. Элементы функций и данных в стеке ядра соответствуют функциям и данным, относящимся к ядру, но не к программе пользователя, тем не менее, конструкция стека ядра подобна конструкции стека задачи. Стек ядра для процесса пуст, если процесс выполняется в режиме задачи. Справа на Рисунке 2.4 представлен стек ядра для процесса выполнения системной операции write в программе copy. Подробно алгоритмы выполнения системной операции write будут описаны в последующих разделах.
  Рисунок 2.5. Информационные структуры для процессов
Каждому процессу соответствует точка входа в таблице процессов ядра, кроме того, каждому процессу выделяется часть оперативной памяти, отведенная под задачу пользователя. Таблица процессов включает в себя указатели на промежуточную таблицу областей процессов, точки входа в которую служат в качестве указателей на собственно таблицу областей. Областью называется непрерывная зона адресного пространства, выделяемая процессу для размещения текста, данных и стека. Точки входа в таблицу областей описывают атрибуты области, как например, хранятся ли в области текст программы или данные, закрытая ли эта область или же совместно используемая, и где конкретно в памяти размещается содержимое области. Внешний уровень косвенной адресации (через промежуточную таблицу областей, используемых процессами, к собственно таблице областей) позволяет независимым процессам совместно использовать области. Когда процесс запускает системную операцию exec, ядро системы выделяет области под ее текст, данные и стек, освобождая старые области, которые использовались процессом. Если процесс запускает операцию fork, ядро удваивает размер адресного пространства старого процесса, позволяя процессам совместно использовать области, когда это возможно, и, с другой стороны, производя физическое копирование. Если процесс запускает операцию exit, ядро освобождает области, которые использовались процессом. На Рисунке 2.5 изображены информационные структуры, связанные с запуском процесса. Таблица процессов ссылается на промежуточную таблицу областей, используемых процессом, в которой содержатся указатели на записи в собственно таблице областей, соответствующие областям для текста, данных и стека процесса.
Запись в таблице процессов и часть адресного пространства задачи, выделенная процессу, содержат управляющую информацию и данные о состоянии процесса. Это адресное пространство является расширением соответствующей записи в таблице процессов, различия между данными объектами будут рассмотрены в главе 6. В качестве полей в таблице процессов, которые рассматриваются в последующих разделах, выступают:
• поле состояния,
• идентификаторы, которые характеризуют пользователя, являющегося владельцем процесса (код пользователя или UID),
• значение дескриптора события, когда процесс приостановлен (находится в состоянии «сна»).
Адресное пространство задачи, выделенное процессу, содержит описывающую процесс информацию, доступ к которой должен обеспечиваться только во время выполнения процесса. Важными полями являются:
• указатель на позицию в таблице процессов, соответствующую текущему процессу,
• параметры текущей системной операции, возвращаемые значения и коды ошибок,
• дескрипторы файла для всех открытых файлов,
• внутренние параметры ввода-вывода,
• текущий каталог и текущий корень (см. главу 5),
• границы файлов и процесса.
Ядро системы имеет непосредственный доступ к полям адресного пространства задачи, выделенного выполняемому процессу, но не имеет доступ к соответствующим полям других процессов. С точки зрения внутреннего алгоритма, при обращении к адресному пространству задачи, выделенному выполняемому процессу, ядро ссылается на структурную переменную u, и, когда запускается на выполнение другой процесс, ядро перенастраивает виртуальные адреса таким образом, чтобы структурная переменная u указывала бы на адресное пространство задачи для нового процесса. В системной реализации предусмотрено облегчение идентификации текущего процесса благодаря наличию указателя на соответствующую запись в таблице процессов из адресного пространства задачи.

2.2.2.1 Контекст процесса

Контекстом процесса является его состояние, определяемое текстом, значениями глобальных переменных пользователя и информационными структурами, значениями используемых машинных регистров, значениями, хранимыми в позиции таблицы процессов и в адресном пространстве задачи, а также содержимым стеков задачи и ядра, относящихся к данному процессу. Текст операций системы и ее глобальные информационные структуры совместно используются всеми процессами, но не являются составной частью контекста процесса.
Говорят, что при запуске процесса система исполняется в контексте процесса. Когда ядро системы решает запустить другой процесс, оно выполняет переключение контекста с тем, чтобы система исполнялась в контексте другого процесса. Ядро осуществляет переключение контекста только при определенных условиях, что мы увидим в дальнейшем. Выполняя переключение контекста, ядро сохраняет информацию, достаточную для того, чтобы позднее переключиться вновь на первый процесс и возобновить его выполнение. Аналогичным образом, при переходе из режима задачи в режим ядра, ядро системы сохраняет информацию, достаточную для того, чтобы позднее вернуться в режим задачи и продолжить выполнение с прерванного места. Однако, переход из режима задачи в режим ядра является сменой режима, но не переключением контекста. Если обратиться еще раз к Рисунку 1.5, можно сказать, что ядро выполняет переключение контекста, когда меняет контекст процесса A на контекст процесса B; оно меняет режим выполнения с режима задачи на режим ядра и наоборот, оставаясь в контексте одного процесса, например, процесса A.
Ядро обрабатывает прерывания в контексте прерванного процесса, пусть даже оно и не вызывало никакого прерывания. Прерванный процесс мог при этом выполняться как в режиме задачи, так и в режиме ядра. Ядро сохраняет информацию, достаточную для того, чтобы можно было позже возобновить выполнение прерванного процесса, и обрабатывает прерывание в режиме ядра. Ядро не порождает и не планирует порождение какого-то особого процесса по обработке прерываний.

2.2.2.2 Состояния процесса

Время жизни процесса можно разделить на несколько состояний, каждое из которых имеет определенные характеристики, описывающие процесс. Все состояния процесса рассматриваются в главе 6, однако представляется существенным для понимания перечислить некоторые из состояний уже сейчас:
1. Процесс выполняется в режиме задачи.
2. Процесс выполняется в режиме ядра.
3. Процесс не выполняется, но готов к выполнению и ждет, когда планировщик выберет его. В этом состоянии может находиться много процессов, и алгоритм планирования устанавливает, какой из процессов будет выполняться следующим.
4. Процесс приостановлен («спит»). Процесс «впадает в сон», когда он не может больше продолжать выполнение, например, когда ждет завершения ввода-вывода.
Поскольку процессор в каждый момент времени выполняет только один процесс, в состояниях 1 и 2 может находиться самое большее один процесс. Эти два состояния соответствуют двум режимам выполнения, режиму задачи и режиму ядра.

2.2.2.3 Переходы из состояния в состояние

Состояния процесса, перечисленные выше, дают статическое представление о процессе, однако процессы непрерывно переходят из состояния в состояние в соответствии с определенными правилами. Диаграмма переходов представляет собой ориентированный граф, вершины которого представляют собой состояния, в которые может перейти процесс, а дуги — события, являющиеся причинами перехода процесса из одного состояния в другое. Переход между двумя состояниями разрешен, если существует дуга из первого состояния во второе. Несколько дуг может выходить из одного состояния, однако процесс переходит только по одной из них в зависимости от того, какое событие произошло в системе. На Рисунке 2.6 представлена диаграмма переходов для состояний, перечисленных выше.
Как уже говорилось выше, в режиме разделения времени может выполняться одновременно несколько процессов, и все они могут одновременно работать в режиме ядра. Если им разрешить свободно выполняться в режиме ядра, то они могут испортить глобальные информационные структуры, принадлежащие ядру. Запрещая произвольное переключение контекстов и управляя возникновением событий, ядро защищает свою целостность.
Ядро разрешает переключение контекста только тогда, когда процесс переходит из состояния «запуск в режиме ядра» в состояние «сна в памяти». Процессы, запущенные в режиме ядра, не могут быть выгружены другими процессами; поэтому иногда говорят, что ядро не выгружаемо, при этом процессы, находящиеся в режиме задачи, могут выгружаться системой. Ядро поддерживает целостность своих информационных структур, поскольку оно не выгружаемо, таким образом решая проблему «взаимного исключения» — обеспечения того, что критические секции программы выполняются в каждый момент времени в рамках самое большее одного процесса.
В качестве примера рассмотрим программу (Рисунок 2.7) включения информационной структуры, чей адрес содержится в указателе bp1, в список с использованием указателей после структуры, чей адрес содержится в bp. Если система разрешила переключение контекста при выполнении ядром фрагмента программы, возможно возникновение следующей ситуации. Предположим, ядро выполняет программу до комментариев и затем осуществляет переключение контекста. Список с использованием сдвоенных указателей имеет противоречивый вид: структура bp1 только наполовину входит в этот список. Если процесс следует за передними указателями, он обнаружит bp1 в данном списке, но если он последует за фоновыми указателями, то вообще не найдет структуру bp1 (Рисунок 2.8). Если другие процессы будут обрабатывать указатели в списке до момента повторного запуска первого процесса, структура списка может постоянно разрушаться. Система UNIX предупреждает возникновение подобных ситуаций, запрещая переключение контекстов на время выполнения процесса в режиме ядра. Если процесс переходит в состояние «сна», делая допустимым переключение контекста, алгоритмы ядра обеспечивают защиту целостности информационных структур системы.
  Рисунок 2.6. Состояния процесса и переходы между ними
Проблемой, которая может привести к нарушению целостности информации ядра, является обработка прерываний, могущая вносить изменения в информацию о состоянии ядра. Например, если ядро выполняло программу, приведенную на Рисунке 2.7, и получило прерывание по достижении комментариев, программа обработки прерываний может разрушить ссылки, если будет манипулировать указателями, как было показано ранее. Чтобы решить эту проблему, система могла бы запретить все прерывания на время работы в режиме ядра, но при этом затянулась бы обработка прерывания, что в конечном счете нанесло бы ущерб производительности системы. Вместо этого ядро повышает приоритет прерывания процессора, запрещая прерывания на время выполнения критических секций программы. Секция программы является критической, если в процессе ее выполнения запуск программ обработки произвольного прерывания может привести к возникновению проблем, имеющих отношение к нарушению целостности. Например, если программа обработки прерывания от диска работает с буферными очередями, то часть прерываемой программы, при выполнении которой ядро обрабатывает буферные очереди, является критической секцией по отношению к программе обработки прерывания от диска. Критические секции невелики по размеру и встречаются нечасто, поэтому их существование не оказывает практически никакого воздействия на производительность системы. В других операционных системах данный вопрос решается путем запрещения любых прерываний при работе в системных режимах или путем разработки схем блокировки, обеспечивающих целостность. В главе 12 мы еще вернемся к этому вопросу, когда будем говорить о многопроцессорных системах, где применения указанных мер для решения проблемы недостаточно.
  struct queue {
  } *bp, *bp1;
  bp1-›forp = bp-›forp;
  bp1-›backp = bp;
  bp-›forp = bp1; /* здесь рассмотрите возможность переключения контекста */
  bp1-›forp-›backp = bp1;
  Рисунок 2.7. Пример программы, создающей список с двунаправленными указателями
  Рисунок 2.8. Список с указателями, некорректный из-за переключения контекста
Чтобы подвести черту, еще раз скажем, что ядро защищает свою целостность, разрешая переключение контекста только тогда, когда процесс переходит в состояние «сна», а также препятствуя воздействию одного процесса на другой с целью изменения состояния последнего. Оно также повышает приоритет прерывания процессора на время выполнения критических секций программ, запрещая таким образом прерывания, которые в противном случае могут вызвать нарушение целостности. Планировщик процессов периодически выгружает процессы, выполняющиеся в режиме задачи, для того, чтобы процессы не могли монопольно использовать центральный процессор.

2.2.2.4 «Сон» и пробуждение

Процесс, выполняющийся в режиме ядра, имеет значительную степень автономии в решении того, как ему следует реагировать на возникновение системных событий. Процессы могут общаться между собой и «предлагать» различные альтернативы, но при этом окончательное решение они принимают самостоятельно. Мы еще увидим, что существует набор правил, которым подчиняется поведение процессов в различных обстоятельствах, но каждый процесс в конечном итоге следует этим правилам по своей собственной инициативе. Например, если процесс должен временно приостановить выполнение («перейти ко сну»), он это делает по своей доброй воле. Следовательно, программа обработки прерываний не может приостановить свое выполнение, ибо если это случится, прерванный процесс должен был бы «перейти ко сну» по умолчанию.
Процессы приостанавливают свое выполнение, потому что они ожидают возникновения некоторого события, например, завершения ввода-вывода на периферийном устройстве, выхода, выделения системных ресурсов и т. д. Когда говорят, что процесс приостановился по событию, то имеется ввиду, что процесс находится в состоянии «сна» до наступления события, после чего он пробудится и перейдет в состояние «готовности к выполнению». Одновременно могут приостановиться по событию много процессов; когда событие наступает, все процессы, приостановленные по событию, пробуждаются, поскольку значение условия, связанного с событием, больше не является «истинным». Когда процесс пробуждается, он переходит из состояния «сна» в состояние «готовности к выполнению», находясь в котором он уже может быть выбран планировщиком; следует обратить внимание на то, что он не выполняется немедленно. Приостановленные процессы не занимают центральный процессор. Ядру системы нет надобности постоянно проверять то, что процесс все еще приостановлен, т. к. ожидает наступления события, и затем будить его.
Например, процесс, выполняемый в режиме ядра, должен иногда блокировать структуру данных на случай приостановки в будущем; процессы, пытающиеся обратиться к заблокированной структуре, обязаны проверить наличие блокировки и приостановить свое выполнение, если структура заблокирована другим процессом. Ядро выполняет блокировки такого рода следующим образом:
while (условие «истинно») sleep (событие: условие принимает значение «ложь»);
set condition true;
Ядро снимает блокировку и «будит» все процессы, приостановленные из-за этой блокировки, следующим образом:
set condition false;
wakeup (событие: условие «ложно»);
На Рисунке 2.9 приведен пример, в котором три процесса, A, B и C оспаривают заблокированный буфер. Переход в состояние «сна» вызывается заблокированностью буфера. Процессы, однажды запустившись, обнаруживают, что буфер заблокирован, и приостанавливают свое выполнение до наступления события, по которому буфер будет разблокирован. В конце концов блокировка с буфера снимается и все процессы «пробуждаются», переходя в состояние «готовности к выполнению». Ядро наконец выбирает один из процессов, скажем, B, для выполнения. Процесс B, выполняя цикл «while», обнаруживает, что буфер разблокирован, блокирует его и продолжает свое выполнение. Если процесс B в дальнейшем снова приостановится без снятия блокировки с буфера (например, ожидая завершения операции ввода-вывода), ядро сможет приступить к планированию выполнения других процессов. Если будет при этом выбран процесс A, этот процесс, выполняя цикл «while», обнаружит, что буфер заблокирован, и снова перейдет в состояние «сна»; возможно то же самое произойдет и с процессом C. В конце концов выполнение процесса B возобновится и блокировка с буфера будет снята, в результате чего процессы A и C получат доступ к нему. Таким образом, цикл «while-sleep» обеспечивает такое положение, при котором самое большее один процесс может иметь доступ к ресурсу.
Алгоритмы перехода в состояние «сна» и пробуждения более подробно будут рассмотрены в главе 6. Тем временем они будут считаться «неделимыми». Процесс переходит в состояние «сна» мгновенно и находится в нем до тех пор, пока не будет «разбужен». После того, как он приостанавливается, ядро системы начинает планировать выполнение следующего процесса и переключает контекст на него.

2.3 СТРУКТУРЫ ДАННЫХ ЯДРА

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

2.4 УПРАВЛЕНИЕ СИСТЕМОЙ

К управляющим процессам, грубо говоря, относятся те процессы, которые выполняют различные функции по обеспечению благополучной работы пользователей системы. К таким функциям относятся форматирование дисков, создание новых файловых систем, восстановление разрушенных файловых систем, отладка ядра и др. С концептуальной точки зрения, между управляющими и пользовательскими процессами нет разницы. Они используют один и тот же набор обращений к операционной системе, доступный для всех. Управляющие процессы отличаются от обычных пользовательских процессов только правами и привилегиями, которыми они обладают. Например, режимы разрешения доступа к файлу могут предусматривать предоставление возможности работы с файлами для управляющих процессов и отсутствие такой возможности для обычных пользователей. Внутри системы ядро выделяет особого пользователя, именуемого суперпользователем, и наделяет его особыми привилегиями, о чем мы еще поговорим ниже. Пользователь может стать суперпользователем, если соответствующим образом зарегистрируется в системе или запустит специальную программу. Привилегии суперпользователя будут рассмотрены в следующих главах. Если сказать коротко, ядро системы не выделяет управляющие процессы в отдельный класс.
  Рисунок 2.9. Многократная приостановка выполнения процессов, вызванная блокировкой

2.5 ВЫВОДЫ И ОБЗОР ПОСЛЕДУЮЩИХ ГЛАВ

В этой главе описана архитектура ядра операционной системы; его основными компонентами выступают подсистема управления файлами и подсистема управления процессами. Подсистема управления файлами управляет хранением и выборкой данных в пользовательских файлах. Файлы организованы в виде файловых систем, которые трактуются как логические устройства; физическое устройство, такое как диск, может содержать несколько логических устройств (файловых систем). Каждая файловая система имеет суперблок, в котором описывается структура и содержимое файловой системы, каждый файл в файловой системе описывается индексом, хранящим атрибуты файла. Системные операции работают с файлами, используя индексы.
Процессы находятся в различных состояниях и переходят из состояния в состояние, следуя определенным правилам перехода. В частности, процессы, выполняющиеся в режиме ядра, могут приостановить свое выполнение и перейти в состояние «сна», но ни один процесс не может перевести в это состояние другой процесс. Ядро является невыгружаемым и это означает, что процесс, выполняющийся в режиме ядра, будет продолжать свое выполнение до тех пор, пока не перейдет в состояние «сна» или пока не вернется в режим задачи. Ядро обеспечивает целостность своих информационных структур благодаря своей невыгружаемости, а также путем блокирования прерываний на время выполнения критических секций программы.
В остальных частях главы детально описываются подсистемы, изображенные на Рисунке 2.1, а также взаимодействие между ними, начиная с подсистемы управления файлами и включая подсистему управления процессами. В следующей главе рассматривается буфер сверхоперативной памяти (кеш) и описываются алгоритмы управления буфером, используемые в главах 4, 5 и 7. В главе 4 рассматриваются внутренние алгоритмы файловой системы, включая обработку индексов, структуру файлов, преобразование имени пути в индекс. В главе 5 рассматриваются системные операции, которые, используя приведенные в главе 4 алгоритмы, обращаются к файловой системе, т. е. такие, как open, close, read и write. Глава 6 имеет дело с понятием контекста процесса и его адресным пространством, а глава 7 рассматривает системные операции, связанные с управлением процессами и использующие алгоритмы главы 6. Глава 8 касается планирования выполнения процессов, в главе 9 обсуждаются алгоритмы распределения памяти. Глава 10 посвящена драйверам устройств, рассмотрение которых до того откладывалось, чтобы прежде объяснить связь драйвера терминала с управлением процессами. В главе 11 представлено несколько форм взаимодействия процессов. Наконец, в последних двух главах рассматриваются вопросы, связанные с углубленным изучением особенностей системы, в частности, особенности многопроцессорных систем и распределенных систем.

2.6 УПРАЖНЕНИЯ

1. Рассмотрим следующий набор команд:
grep main a.c b.c c.c › grepout&
wc -1 ‹ grepout&
rm grepout&
Амперсанд (символ "&") в конце каждой командной строки говорит командному процессору shell о том, что команду следует выполнить на фоне, при этом shell может выполнять все командные строки параллельно. Почему это не равноценно следующей командной строке?
grep main a.c b.c c.c | wc -1
2. Рассмотрим пример программы, приведенный на Рисунке 2.7. Предположим, что в тот момент, когда при ее выполнении встретился комментарий, произошло переключение контекста и другой процесс убрал содержимое буфера из списка указателей с помощью следующих команд:
remove(gp)
struct queue *gp;
{
 gp-›forp-›backp = gp-›backp;
 gp-›backp-›forp = gp-›forp;
 gp-›forp = gp-›backp = NULL;
}
Рассмотрим три случая:
Процесс убирает из списка с указателями структуру bp1.
Процесс убирает из списка с указателями структуру, следующую после структуры bp1.
Процесс убирает из списка структуру, которая первоначально следовала за bp1 до того, как структура bp была наполовину включена в указанный список.
В каком состоянии будет список после того, как первый процесс завершит выполнение части программы, расположенной после комментариев?
3. Что произошло бы в том случае, если ядро попыталось бы возобновить выполнение всех процессов, приостановленных по событию, но в системе не было бы к этому моменту ни одного такого процесса?

0

4

https://forumupload.ru/uploads/001b/09/4d/2/67355.jpg
ГЛАВА 3. БУФЕР СВЕРХОПЕРАТИВНОЙ ПАМЯТИ (КЕШ)

Как уже говорилось в предыдущей главе, ядро операционной системы поддерживает файлы на внешних запоминающих устройствах большой емкости, таких как диски, и позволяет процессам сохранять новую информацию или вызывать ранее сохраненную информацию. Если процессу необходимо обратиться к информации файла, ядро выбирает информацию в оперативную память, где процесс сможет просматривать эту информацию, изменять ее и обращаться с просьбой о ее повторном сохранении в файловой системе. Вспомним для примера программу copy, приведенную на Рисунке 1.3: ядро читает данные из первого файла в память и затем записывает эти данные во второй файл. Подобно тому, как ядро должно заносить данные из файла в память, оно так же должно считывать в память и вспомогательные данные для работы с ними. Например, суперблок файловой системы содержит помимо всего прочего информацию о свободном пространстве, доступном файловой системе. Ядро считывает суперблок в память для того, чтобы иметь доступ к его информации, и возвращает его опять файловой системе, когда желает сохранить его содержимое. Похожая вещь происходит с индексом, который описывает размещение файла. Ядро системы считывает индекс в память, когда желает получить доступ к информации файла, и возвращает индекс вновь файловой системе, когда желает скорректировать размещение файла. Ядро обрабатывает такую вспомогательную информацию, не будучи прежде знакома с ней и не требуя для ее обработки запуска каких-либо процессов.
Ядро могло бы производить чтение и запись непосредственно с диска и на диск при всех обращениях к файловой системе, однако время реакции системы и производительность при этом были бы низкими из-за низкой скорости передачи данных с диска. По этой причине ядро старается свести к минимуму частоту обращений к диску, заведя специальную область внутренних информационных буферов, именуемую буферным кешем [7] и хранящую содержимое блоков диска, к которым перед этим производились обращения.
На Рисунке 2.1 показано, что модуль буферного кеша занимает в архитектуре ядра место между подсистемой управления файлами и драйверами устройств (ввода-вывода блоками). Перед чтением информации с диска ядро пытается считать что-нибудь из буфера кеша. Если в этом буфере отсутствует информация, ядро читает данные с диска и заносит их в буфер, используя алгоритм, который имеет целью поместить в буфере как можно больше необходимых данных. Аналогично, информация, записываемая на диск, заносится в буфер для того, чтобы находиться там, если ядро позднее попытается считать ее. Ядро также старается свести к минимуму частоту выполнения операций записи на диск, выясняя, должна ли информация действительно запоминаться на диске или это промежуточные данные, которые будут вскоре затерты. Алгоритмы более высокого уровня позволяют производить предварительное занесение данных в буфер кеша или задерживать запись данных с тем, чтобы усилить эффект использования буфера. В этой главе рассматриваются алгоритмы, используемые ядром при работе с буферами в сверхоперативной памяти.

3.1 ЗАГОЛОВКИ БУФЕРА

Во время инициализации системы ядро выделяет место под совокупность буферов, потребность в которых определяется в зависимости от размера памяти и производительности системы. Каждый буфер состоит из двух частей: области памяти, в которой хранится информация, считываемая с диска, и заголовка буфера, который идентифицирует буфер. Поскольку существует однозначное соответствие между заголовками буферов и массивами данных, в нижеследующем тексте используется термин «буфер» в ссылках как на ту, так и на другую его составляющую, и о какой из частей буфера идет речь будет понятно из контекста.
Информация в буфере соответствует информации в одном логическом блоке диска в файловой системе, и ядро распознает содержимое буфера, просматривая идентифицирующие поля в его заголовке. Буфер представляет собой копию дискового блока в памяти; содержимое дискового блока отображается в буфер, но это отображение временное, поскольку оно имеет место до того момента, когда ядро примет решение отобразить в буфер другой дисковый блок. Один дисковый блок не может быть одновременно отображен в несколько буферов. Если бы два буфера содержали информацию для одного и того же дискового блока, ядро не смогло бы определить, в каком из буферов содержится текущая информация, и, возможно, возвратило бы на диск некорректную информацию. Предположим, например, что дисковый блок отображается в два буфера, A и B. Если ядро запишет данные сначала в буфер A, а затем в буфер B, дисковый блок будет содержать данные из буфера B, если в результате операций записи буфер заполнится до конца. Однако, если ядро изменит порядок, в котором оно копирует содержимое буферов на диск, на противоположный, дисковый блок будет содержать некорректные данные.
Заголовок буфера (Рисунок 3.1) содержит поле «номер устройства» и поле «номер блока», которые определяют файловую систему и номер блока с информацией на диске и однозначно идентифицируют буфер. Номер устройства — это логический номер файловой системы (см. раздел 2.2.1), а не физический номер устройства (диска). Заголовок буфера также содержит указатель на область памяти для буфера, размер которой должен быть не меньше размера дискового блока, и поле состояния, в котором суммируется информация о текущем состоянии буфера. Состояние буфера представляет собой комбинацию из следующих условий:
• буфер заблокирован (термины «заблокирован (недоступен)» и «занят» равнозначны, так же, как и понятия «свободен» и «доступен»),
• буфер содержит правильную информацию,
• ядро должно переписать содержимое буфера на диск перед тем, как переназначить буфер; это условие известно, как «задержка, вызванная записью»,
• ядро читает или записывает содержимое буфера на диск,
• процесс ждет освобождения буфера.
В заголовке буфера также содержатся два набора указателей, используемые алгоритмами выделения буфера, которые поддерживают общую структуру области буферов (буферного пула), о чем подробнее будет говориться в следующем разделе.
  Рисунок 3.1. Заголовок буфера

3.2 СТРУКТУРА ОБЛАСТИ БУФЕРОВ (БУФЕРНОГО ПУЛА)

Ядро помещает информацию в область буферов, используя алгоритм поиска буферов, к которым наиболее долго не было обращений: после выделения буфера дисковому блоку нельзя использовать этот буфер для другого блока до тех пор, пока не будут задействованы все остальные буферы. Ядро управляет списком свободных буферов, который необходим для работы указанного алгоритма. Этот список представляет собой циклический перечень буферов с двунаправленными указателями и с формальными заголовками в начале и в конце перечня (Рисунок 3.2). Все буферы попадают в список при загрузке системы. Если нужен любой свободный буфер, ядро выбирает буфер из «головы» списка, но если в области буферов ищется определенный блок, может быть выбран буфер и из середины списка. И в том, и в другом случае буфер удаляется из списка свободных буферов. Если ядро возвращает буфер буферному пулу, этот буфер добавляется в хвост списка, либо в «голову» списка (в случае ошибки), но никогда не в середину. По мере удаления буферов из списка буфер с нужной информацией продвигается все ближе и ближе к «голове» списка (Рисунок 3.2). Следовательно, те буферы, которые находятся ближе к «голове» списка, в последний раз использовались раньше, чем буферы, находящиеся дальше от «головы» списка.
  Рисунок 3.2. Список свободных буферов
Когда ядро обращается к дисковому блоку, оно сначала ищет буфер с соответствующей комбинацией номеров устройства и блока. Вместо того, чтобы просматривать всю область буферов, ядро организует из буферов особые очереди, хешированные по номеру устройства и номеру блока. В хеш-очереди ядро устанавливает для буферов циклическую связь в виде списка с двунаправленными указателями, структура которого похожа на структуру списка свободных буферов. Количество буферов в хеш-очереди варьируется в течение всего времени функционирования системы, в чем мы еще убедимся дальше. Ядро вынуждено прибегать к функции хеширования, чтобы единообразно распределять буферы между хеш-очередями, однако функция хеширования должна быть несложной, чтобы не пострадала производительность системы. Администраторы системы задают количество хеш-очередей при генерации операционной системы.
  Рисунок 3.3. Буферы в хеш-очередях
На Рисунке 3.3 изображены буферы в хеш-очередях: заголовки хеш-очередей показаны в левой части рисунка, а квадратиками в каждой строке показаны буферы в соответствующей хеш-очереди. Так, квадратики с числами 28, 4 и 64 представляют буферы в хеш-очереди для «блока 0 модуля 4». Пунктирным линиям между буферами соответствуют указатели вперед и назад вдоль хеш-очереди; для простоты на следующих рисунках этой главы данные указатели не показываются, но их присутствие подразумевается. Кроме того, на рисунке блоки идентифицируются только своими номерами и функция хеширования построена на использовании только номеров блоков; однако на практике также используется номер устройства.
Любой буфер всегда находится в хеш-очереди, но его положение в очереди не имеет значения. Как уже говорилось, никакая пара буферов не может одновременно содержать данные одного и того же дискового блока; поэтому каждый дисковый блок в буферном пуле существует в одной и только одной хеш-очереди и представлен в ней только один раз. Тем не менее, буфер может находиться в списке свободных буферов, если его статус «свободен». Поскольку буфер может быть одновременно в хеш-очереди и в списке свободных буферов, у ядра есть два способа его обнаружения. Ядро просматривает хеш-очередь, если ему нужно найти определенный буфер, и выбирает буфер из списка свободных буферов, если ему нужен любой свободный буфер. В следующем разделе будет показано, каким образом ядро осуществляет поиск определенных дисковых блоков в буферном кеше, а также как оно работает с буферами в хеш-очередях и в списке свободных буферов. Еще раз напомним: буфер всегда находится в хеш — очереди, а в списке свободных буферов может быть, но может и отсутствовать.

3.3 МЕХАНИЗМ ПОИСКА БУФЕРА

Как показано на Рисунке 2.1, алгоритмы верхнего уровня, используемые ядром для подсистемы управления файлами, инициируют выполнение алгоритмов управления буферным кешем. При выборке блока алгоритмы верхнего уровня устанавливают логический номер устройства и номер блока, к которым они хотели бы получить доступ. Например, если процесс хочет считать данные из файла, ядро устанавливает, в какой файловой системе находится файл и в каком блоке файловой системы содержатся данные, о чем подробнее мы узнаем из главы 4. Собираясь считать данные из определенного дискового блока, ядро проверяет, находится ли блок в буферном пуле, и если нет, назначает для него свободный буфер. Собираясь записать данные в определенный дисковый блок, ядро проверяет, находится ли блок в буферном пуле, и если нет, назначает для этого блока свободный буфер. Для выделения буферов из пула в алгоритмах чтения и записи дисковых блоков используется операция getblk (Рисунок 3.4).
Рассмотрим в этом разделе пять возможных механизмов использования getblk для выделения буфера под дисковый блок.
1. Ядро обнаруживает блок в хеш-очереди, соответствующий ему буфер свободен.
2. Ядро не может обнаружить блок в хеш-очереди, поэтому оно выделяет буфер из списка свободных буферов.
3. Ядро не может обнаружить блок в хеш-очереди и, пытаясь выделить буфер из списка свободных буферов (как в случае 2), обнаруживает в списке буфер, который помечен как «занят на время записи». Ядро должно переписать этот буфер на диск и выделить другой буфер.
4. Ядро не может обнаружить блок в хеш-очереди, а список свободных буферов пуст.
5. Ядро обнаруживает блок в хеш-очереди, но его буфер в настоящий момент занят.
Обсудим каждый случай более подробно.
Осуществляя поиск блока в буферном пуле по комбинации номеров устройства и блока, ядро ищет хеш-очередь, которая бы содержала этот блок. Просматривая хеш-очередь, ядро придерживается списка с указателями, пока (как в первом случае) не найдет буфер с искомыми номерами устройства и блока. Ядро проверяет занятость блока и в том случае, если он свободен, помечает буфер «занятым» для того, чтобы другие процессы [8] не смогли к нему обратиться. Затем ядро удаляет буфер из списка свободных буферов, поскольку буфер не может одновременно быть занятым и находиться в указанном списке. Если другие процессы попытаются обратиться к блоку в то время, когда его буфер занят, они приостановятся до тех пор, пока буфер не освободится. На Рисунке 3.5 показан первый случай, когда ядро ищет блок 4 в хеш-очереди, помеченной как «блок 0 модуль 4». Обнаружив буфер, ядро удаляет его из списка свободных буферов, делая блоки 5 и 28 соседями в списке.
  алгоритм getblk
  входная информация: номер файловой системы номер блока
  выходная информация: буфер, который можно использовать для блока
  {
   do if (буфер не найден)   {
    if (блок в хеш-очереди)    {
     if (буфер занят) { /* случай 5 */
      sleep (до освобождения буфера);
      continue; /* цикл с условием продолжения */
     }
     пометить буфер занятым; /* случай 1 */
     удалить буфер из списка свободных буферов;
     return буфер;
    }
    else { /* блока нет в хеш-очереди */
     if (в списке нет свободных буферов) { /*случай 4*/
      sleep (до освобождения любого буфера);
      continue; /* цикл с условием продолжения */
     }
     удалить буфер из списка свободных буферов;
     if (буфер помечен для отложенной переписи) { /* случай 3 */ 
      асинхронная перепись содержимого буфера на диск;
      continue; /* цикл с условием продолжения */
     }
     /* случай 2 — поиск свободного буфера */ 
     удалить буфер из старой хеш-очереди;
     включить буфер в новую хеш-очередь;
     return буфер;
    }
   }
  }
  Рисунок 3.4. Алгоритм выделения буфера
  Рисунок 3.5. Поиск буфера — случай 1: буфер в хеш-очереди
  алгоритм brelse
  входная информация: заблокированный буфер
  выходная информация: отсутствует
  {
   возобновить выполнение всех процессов при наступлении события, связанного с освобождением любого буфера;
   возобновить выполнение всех процессов при наступлении события, связанного с освобождением данного буфера;
   поднять приоритет прерывания процессора так, чтобы блокировать любые прерывания;
   if (содержимое буфера верно и буфер не старый)   поставить буфер в конец списка свободных буферов;
   else   поставить буфер в начало списка свободных буферов;
   понизить приоритет прерывания процессора с тем, чтобы вновь разрешить прерывания;
   разблокировать (буфер);
  }
  Рисунок 3.6. Алгоритм высвобождения буфера
Перед тем, как перейти к остальным случаям, рассмотрим, что произойдет с буфером после того, как он будет выделен блоку. Ядро системы сможет читать данные с диска в буфер и обрабатывать их или же переписывать данные в буфер и при желании на диск. Ядро оставляет у буфера пометку «занят»; другие процессы не могут обратиться к нему и изменить его содержимое, пока он занят, таким образом поддерживается целостность информации в буфере. Когда ядро заканчивает работу с буфером, оно освобождает буфер в соответствии с алгоритмом brelse (Рисунок 3.6). Возобновляется выполнение тех процессов, которые были приостановлены из-за того, что буфер был занят, а также те процессы, которые были приостановлены из-за того, что список свободных буферов был пуст. Как в том, так и в другом случае, высвобождение буфера означает, что буфер становится доступным для приостановленных процессов несмотря на то, что первый процесс, получивший буфер, заблокировал его и запретил тем самым получение буфера другими процессами (см. раздел 2.2.2.4). Ядро помещает буфер в конец списка свободных буферов, если только перед этим не произошла ошибка ввода-вывода или если буфер не помечен как «старый» — момент, который будет пояснен далее; в остальных случаях буфер помещается в начало списка. Теперь буфер свободен для использования любым процессом.
Ядро выполняет алгоритм brelse в случае, когда буфер процессу больше не нужен, а также при обработке прерывания от диска для высвобождения буферов, используемых при асинхронном вводе-выводе с диска и на диск (см. раздел 3.4). Ядро повышает приоритет прерывания работы процессора так, чтобы запретить возникновение любых прерываний от диска на время работы со списком свободных буферов, предупреждая искажение указателей буфера в результате вложенного выполнения алгоритма brelse. Похожие последствия могут произойти, если программа обработки прерываний запустит алгоритм brelse во время выполнения процессом алгоритма getblk, поэтому ядро повышает приоритет прерывания работы процессора и в стратегических моментах выполнения алгоритма getblk. Более подробно эти случаи мы разберем с помощью упражнений.
При выполнении алгоритма getblk имеет место случай 2, когда ядро просматривает хеш-очередь, в которой должен был бы находиться блок, но не находит его там. Так как блок не может быть ни в какой другой хеш-очереди, поскольку он не должен «хешироваться» в другом месте, следовательно, его нет в буферном кеше. Поэтому ядро удаляет первый буфер из списка свободных буферов; этот буфер был уже выделен другому дисковому блоку и также находится в хеш-очереди. Если буфер не помечен для отложенной переписи, ядро помечает буфер занятым, удаляет его из хеш-очереди, где он находится, назначает в заголовке буфера номера устройства и блока, соответствующие данному дисковому блоку, и помещает буфер в хеш-очередь. Ядро использует буфер, не переписав информацию, которую буфер прежде хранил для другого дискового блока. Тот процесс, который будет искать прежний дисковый блок, не обнаружит его в пуле и получит для него точно таким же образом новый буфер из списка свободных буферов. Когда ядро заканчивает работу с буфером, оно освобождает буфер вышеописанным способом. На Рисунке 3.7, например, ядро ищет блок 18, но не находит его в хеш-очереди, помеченной как «блок 2 модуль 4». Поэтому ядро удаляет первый буфер из списка свободных буферов (блок 3), назначает его блоку 18 и помещает его в соответствующую хеш-очередь.
  Рисунок 3.7. Второй случай выделения буфера
Если при выполнении алгоритма getblk имеет место случай 3, ядро так же должно выделить буфер из списка свободных буферов. Однако, оно обнаруживает, что удаляемый из списка буфер был помечен для отложенной переписи, поэтому прежде чем использовать буфер ядро должно переписать его содержимое на диск. Ядро приступает к асинхронной записи на диск и пытается выделить другой буфер из списка. Когда асинхронная запись заканчивается, ядро освобождает буфер и помещает его в начало списка свободных буферов. Буфер сам продвинулся от конца списка свободных буферов к началу списка. Если после асинхронной переписи ядру бы понадобилось поместить буфер в конец списка, буфер получил бы «зеленую улицу» по всему списку свободных буферов, результат такого перемещения противоположен действию алгоритма поиска буферов, к которым наиболее долго не было обращений. Например, если обратиться к Рисунку 3.8, ядро не смогло обнаружить блок 18, но когда попыталось выделить первые два буфера (по очереди) в списке свободных буферов, то оказалось, что они оба помечены для отложенной переписи. Ядро удалило их из списка, запустило операции переписи на диск в соответствующие блоки, и выделило третий буфер из списка, блок 4. Далее ядро присвоило новые значения полям буфера «номер устройства» и «номер блока» и включило буфер, получивший имя «блок 18», в новую хеш-очередь.
В четвертом случае (Рисунок 3.9) ядро, работая с процессом A, не смогло найти дисковый блок в соответствующей хеш-очереди и предприняло попытку выделить из списка свободных буферов новый буфер, как в случае 2. Однако, в списке не оказалось ни одного буфера, поэтому процесс A приостановился до тех пор, пока другим процессом не будет выполнен алгоритм brelse, высвобождающий буфер. Планируя выполнение процесса A, ядро вынуждено снова просматривать хеш-очередь в поисках блока. Оно не в состоянии немедленно выделить буфер из списка свободных буферов, так как возможна ситуация, когда свободный буфер ожидают сразу несколько процессов и одному из них будет выделен вновь освободившийся буфер, на который уже нацелился процесс A. Таким образом, алгоритм поиска блока снова гарантирует, что только один буфер включает содержимое дискового блока. На Рисунке 3.10 показана конкуренция между двумя процессами за освободившийся буфер.
Последний случай (Рисунок 3.11) наиболее сложный, поскольку он связан с комплексом взаимоотношений между несколькими процессами. Предположим, что ядро, работая с процессом A, ведет поиск дискового блока и выделяет буфер, но приостанавливает выполнение процесса перед освобождением буфера. Например, если процесс A попытается считать дисковый блок и выделить буфер, как в случае 2, то он приостановится до момента завершения передачи данных с диска. Предположим, что пока процесс A приостановлен, ядро активизирует второй процесс, B, который пытается обратиться к дисковому блоку, чей буфер был только что заблокирован процессом A. Процесс B (случай 5) обнаружит этот захваченный блок в хеш-очереди. Так как использовать захваченный буфер не разрешается и, кроме того, нельзя выделить для одного и того же дискового блока второй буфер, процесс B помечает буфер как «запрошенный» и затем приостанавливается до того момента, когда процесс A освободит данный буфер.
В конце концов процесс A освобождает буфер и замечает, что он запрошен. Тогда процесс A «будит» все процессы, приостановленные по событию «буфер становится свободным», включая и процесс B. Когда же ядро вновь запустит на выполнение процесс B, процесс B должен будет убедиться в том, что буфер свободен. Возможно, что третий процесс, C, ждал освобождения этого же буфера, и ядро запланировало активизацию процесса C раньше B; при этом процесс C мог приостановиться и оставить буфер заблокированным. Следовательно, процесс B должен проверить то, что блок действительно свободен.
  Рисунок 3.8. Третий случай выделения буфера
  Рисунок 3.9. Четвертый случай выделения буфера
Процесс B также должен убедиться в том, что в буфере содержится первоначально затребованный дисковый блок, поскольку процесс C мог выделить данный буфер другому блоку, как в случае 2. При выполнении процесса B может обнаружиться, что он ждал освобождения буфера не с тем содержимым, поэтому процессу B придется вновь заниматься поисками блока. Если же его настроить на автоматическое выделение буфера из списка свободных буферов, он может упустить из виду возможность того, что какой-либо другой процесс уже выделил буфер для данного блока.
  Рисунок 3.10. Состязание за свободный буфер
В конце концов, процесс B найдет этот блок, при необходимости выбрав новый буфер из списка свободных буферов, как в случае 2. Пусть некоторый процесс, осуществляя поиск блока 99 (Рисунок 3.11), обнаружил этот блок в хеш-очереди, однако он оказался занятым. Процесс приостанавливается до момента освобождения блока, после чего он запускает весь алгоритм с самого начала. На Рисунке 3.12 показано содержимое занятого буфера.
Алгоритм выделения буфера должен быть надежным; процессы не должны «засыпать» навсегда и рано или поздно им нужно выделить буфер. Ядро гарантирует такое положение, при котором все процессы, ожидающие выделения буфера, продолжат свое выполнение, благодаря тому, что ядро распределяет буферы во время обработки обращений к операционной системе и освобождает их перед возвратом управления процессам. [9] В режиме задачи процессы непосредственно не контролируют выделение буферов ядром системы, поэтому они не могут намеренно «захватывать» буферы. Ядро теряет контроль над буфером только тогда, когда ждет завершения операции ввода-вывода между буфером и диском. Было задумано так, что если дисковод испорчен, он не может прерывать работу центрального процессора, и тогда ядро никогда не освободит буфер. Дисковод должен следить за работой аппаратных средств в таких случаях и возвращать ядру код ошибки, сообщая о плохой работе диска. Короче говоря, ядро может гарантировать, что процессы, приостановленные в ожидании буфера, в конце концов возобновят свое выполнение.
  Рисунок 3.11. Пятый случай выделения буфера
Можно также представить себе ситуацию, когда процесс «зависает» в ожидании получения доступа к буферу. В четвертом случае, например, если несколько процессов приостанавливаются, ожидая освобождения буфера, ядро не гарантирует, что они получат доступ к буферу в той очередности, в которой они запросили доступ. Процесс может приостановить и возобновить свое выполнение, когда буфер станет свободным, только для того, чтобы приостановиться вновь из — за того, что другой процесс получил управление над буфером первым. Теоретически, так может продолжаться вечно, но практически такой проблемы не возникает в связи с тем, что в системе обычно заложено большое количество буферов.

3.4 ЧТЕНИЕ И ЗАПИСЬ ДИСКОВЫХ БЛОКОВ

Теперь, когда алгоритм выделения буферов нами уже рассмотрен, будет легче понять процедуру чтения и записи дисковых блоков. Чтобы считать дисковый блок (Рисунок 3.13), процесс использует алгоритм getblk для поиска блока в буферном кеше. Если он там, ядро может возвратить его немедленно без физического считывания блока с диска. Если блок в кеше отсутствует, ядро приказывает дисководу «запланировать» запрос на чтение и приостанавливает работу, ожидая завершения ввода-вывода. Дисковод извещает контроллер диска о том, что он собирается считать информацию, и контроллер тогда передает информацию в буфер. Наконец, дисковый контроллер прерывает работу процессора, сообщая о завершении операции ввода-вывода, и программа обработки прерываний от диска возобновляет выполнение приостановленного процесса; теперь содержимое дискового блока находится в буфере. Модули, запросившие информацию данного блока, получают ее; когда буфер им уже не потребуется, они освободят его для того, чтобы другие процессы получили к нему доступ.
  Рисунок 3.12. Состязание за свободный буфер
В главе 5 будет показано, как модули более высокого уровня (такие как подсистема управления файлами) могут предчувствовать потребность во втором дисковом блоке, когда процесс читает информацию из файла последовательно. Эти модули формируют запрос на асинхронное выполнение второй операции ввода-вывода, надеясь на то, что информация уже будет в памяти, когда вдруг возникнет необходимость в ней, и тем самым повышая быстродействие системы. Для этого ядро выполняет алгоритм чтения блока с продвижением breada (Рисунок 3.14). Ядро проверяет, находится ли в кеше первый блок, и если его там нет, приказывает дисководу считать этот блок. Если в буферном кеше отсутствует и второй блок, ядро дает команду дисководу считать асинхронно и его. Затем процесс приостанавливается, ожидая завершения операции ввода-вывода над первым блоком. Когда выполнение процесса возобновляется, он возвращает буфер первому блоку и не обращает внимание на то, когда завершится операция ввода-вывода для второго блока. После завершения этой операции контроллер диска прерывает работу системы; программа обработки прерываний узнает о том, что ввод-вывод выполнялся асинхронно, и освобождает буфер (алгоритм brelse). Если бы она не освободила буфер, буфер остался бы заблокированным и по этой причине недоступным для всех процессов. Невозможно заранее разблокировать буфер, так как операция ввода-вывода, связанная с буфером, активна и, следовательно, содержимое буфера еще не адекватно. Позже, если процесс пожелает считать второй блок, он обнаружит его в буферном кеше, поскольку к тому времени операция ввода-вывода закончится. Если же, в начале выполнения алгоритма breada, первый блок обнаружился в буферном кеше, ядро тут же проверяет, находится там же и второй блок, и продолжает работу по только что описанной схеме.
  алгоритм bread /* чтение блока */
  входная информация: номер блока в файловой системе
  выходная информация: буфер, содержащий данные 
  {
   получить буфер для блока (алгоритм getblk);
   if (данные в буфере правильные)   return буфер;
   приступить к чтению с диска;
   sleep (до завершения операции чтения);
   return (буфер);
  }
  Рисунок 3.13. Алгоритм чтения дискового блока
Алгоритм записи содержимого буфера в дисковый блок (Рисунок 3.15) похож на алгоритм чтения. Ядро информирует дисковод о том, что есть буфер, содержимое которого должно быть выведено, и дисковод планирует операцию ввода-вывода блока. Если запись производится синхронно, вызывающий процесс приостанавливается, ожидая ее завершения и освобождая буфер в момент возобновления своего выполнения. Если запись производится асинхронно, ядро запускает операцию записи на диск, но не ждет ее завершения. Ядро освободит буфер, когда завершится ввод-вывод.
  алгоритм breada /* чтение блока с продвижением */
  входная информация: 
   (1) в файловой системе номер блока для немедленного считывания
   (2) в файловой системе номер блока для асинхронного считывания
  выходная информация: буфер с данными, считанными немедленно
  {
   if (первый блок отсутствует в кеше)   {
    получить буфер для первого блока (алгоритм getblk);
    if (данные в буфере неверные)   приступить к чтению с диска;
   }
   if (второй блок отсутствует в кеше)   {
    получить буфер для второго блока (алгоритм getblk);
    if (данные в буфере верные)   освободить буфер (алгоритм brelse);
    else   приступить к чтению с диска;
   }
   if (первый блок первоначально находился в кеше)   {
    считать первый блок (алгоритм bread);
    return буфер;
   }
   sleep (до того момента, когда первый буфер будет содержать верные данные);
   return буфер;
  }
  Рисунок 3.14. Алгоритм чтения блока с продвижением
Могут возникнуть ситуации, и это будет показано в следующих двух главах, когда ядро не записывает данные немедленно на диск. Если запись «откладывается», ядро соответствующим образом помечает буфер, освобождая его по алгоритму brelse, и продолжает работу без планирования ввода-вывода. Ядро записывает блок на диск перед тем, как другой процесс сможет переназначить буфер другому блоку, как показано в алгоритме getblk (случай 3). Между тем, ядро надеется на то, что процесс получает доступ до того, как буфер будет переписан на диск; если этот процесс впоследствии изменит содержимое буфера, ядро произведет дополнительную операцию по сохранению изменений на диске.
  алгоритм bwrite /* запись блока */
  входная информация: буфер
  выходная информация: отсутствует
  {
   приступить к записи на диск;
   if (ввод-вывод синхронный)   {
    sleep (до завершения ввода-вывода);
    освободить буфер (алгоритм brelse);
   }
   else   if (буфер помечен для отложенной записи)
    пометить буфер для последующего размещения в «голове» списка свободных буферов;
  }
  Рисунок 3.15. Алгоритм записи дискового блока
Отложенная запись отличается от асинхронной записи. Выполняя асинхронную запись, ядро запускает дисковую операцию немедленно, но не дожидается ее завершения. Что касается отложенной записи, ядро отдаляет момент физической переписи на диск насколько возможно; затем по алгоритму getblk (случай 3) оно помечает буфер как «старый» и записывает блок на диск асинхронно. После этого контроллер диска прерывает работу системы и освобождает буфер, используя алгоритм brelse; буфер помещается в «голову» списка свободных буферов, поскольку он имеет пометку «старый». Благодаря наличию двух выполняющихся асинхронно операций ввода-вывода — чтения блока с продвижением и отложенной записи — ядро может запускать программу brelse из программы обработки прерываний. Следовательно, ядро вынуждено препятствовать возникновению прерываний при выполнении любой процедуры, работающей со списком свободных буферов, поскольку brelse помещает буферы в этот список.

3.5 ПРЕИМУЩЕСТВА И НЕУДОБСТВА БУФЕРНОГО КЕША

Использование буферного кеша имеет, с одной стороны, несколько преимуществ и, с другой стороны, некоторые неудобства.
• Использование буферов позволяет внести единообразие в процедуру обращения к диску, поскольку ядру нет необходимости знать причину ввода-вывода. Вместо этого, ядро копирует данные в буфер и из буфера, невзирая на то, являются ли данные частью файла, индекса или суперблока. Буферизация ввода-вывода с диска повышает модульность разработки программ, поскольку те составные части ядра, которые занимаются вводом-выводом на диск, имеют один интерфейс на все случаи. Короче говоря, упрощается проектирование системы.
• Система не накладывает никаких ограничений на выравнивание информации пользовательскими процессами, выполняющими ввод-вывод, поскольку ядро производит внутреннее выравнивание информации. В различных аппаратных реализациях часто требуется выравнивать информацию для ввода-вывода с диска определенным образом, т. е. производить к примеру двухбайтное или четырехбайтное выравнивание данных в памяти. Без механизма буферизации программистам пришлось бы заботиться самим о правильном выравнивании данных. По этой причине на машинах с ограниченными возможностями в выравнивании адресов возникает большое количество ошибок программирования и, кроме того, становится проблемой перенос программ в операционную среду UNIX. Копируя информацию из пользовательских буферов в системные буферы (и обратно), ядро системы устраняет необходимость в специальном выравнивании пользовательских буферов, делая пользовательские программы более простыми и мобильными.
• Благодаря использованию буферного кеша, сокращается объем дискового трафика и время реакции и повышается общая производительность системы. Процессы, считывающие данные из файловой системы, могут обнаружить информационные блоки в кеше и им не придется прибегать ко вводу-выводу с диска. Ядро часто применяет «отложенную запись», чтобы избежать лишних обращений к диску, оставляя блок в буферном кеше и надеясь на попадание блока в кеш. Очевидно, что шансы на такое попадание выше в системах с большим количеством буферов. Тем не менее, число буферов, которые можно заложить в системе, ограничивается объемом памяти, доступной выполняющимся процессам: если под буферы задействовать слишком много памяти, то система будет работать медленнее в связи с тем, что ей придется заниматься подкачкой и замещением выполняющихся процессов.
• Алгоритмы буферизации помогают поддерживать целостность файловой системы, так как они сохраняют общий, первоначальный и единственный образ дисковых блоков, содержащихся в кеше. Если два процесса одновременно попытаются обратиться к одному и тому же дисковому блоку, алгоритмы буферизации (например, getblk) параллельный доступ преобразуют в последовательный, предотвращая разрушение данных.
• Сокращение дискового трафика является важным преимуществом с точки зрения обеспечения хорошей производительности или быстрой реакции системы, однако стратегия кеширования также имеет некоторые неудобства. Так как ядро в случае отложенной записи не переписывает данные на диск немедленно, такая система уязвима для сбоев, которые оставляют дисковые данные в некорректном виде. Хотя в последних версиях системы и сокращен ущерб, наносимый катастрофическими сбоями, основная проблема остается: пользователь, запрашивающий выполнение операции записи, никогда не знает, в какой момент данные завершат свой путь на диск [10] .
• Использование буферного кеша требует дополнительного копирования информации при ее считывании и записи пользовательскими процессами. Процесс, записывающий данные, передает их ядру и ядро копирует данные на диск; процесс, считывающий данные, получает их от ядра, которое читает данные с диска. При передаче большого количества данных дополнительное копирование отрицательным образом отражается на производительности системы, однако при передаче небольших объемов данных производительность повышается, поскольку ядро буферизует данные (используя алгоритм getblk и отложенную запись) до тех пор, пока это представляется эффективным с точки зрения экономии времени работы с диском.

3.6 ВЫВОДЫ

В данной главе была рассмотрена структура буферного кеша и различные способы, которыми ядро размещает блоки в кеше. В алгоритмах буферизации сочетаются несколько простых идей, которые в сумме обеспечивают работу механизма кеширования. При работе с блоками в буферном кеше ядро использует алгоритм замены буферов, к которым наиболее долго не было обращений, предполагая, что к блокам, к которым недавно было обращение, вероятно, вскоре обратятся снова. Очередность, в которой буферы появляются в списке свободных буферов, соответствует очередности их предыдущего использования. Остальные алгоритмы обслуживания буферов, типа «первым пришел — первым вышел» и замещения редко используемых, либо являются более сложными в реализации, либо снижают процент попадания в кеш. Использование функции хеширования и хеш-очередей дает ядру возможность ускорить поиск заданных блоков, а использование двунаправленных указателей в списках облегчает исключение буферов.
Ядро идентифицирует нужный ему блок по номеру логического устройства и номеру блока. Алгоритм getblk просматривает буферный кеш в поисках блока и, если буфер присутствует и свободен, блокирует буфер и возвращает его. Если буфер заблокирован, обратившийся к нему процесс приостанавливается до тех пор, пока буфер не освободится. Механизм блокирования гарантирует, что только один процесс в каждый момент времени работает с буфером. Если в кеше блок отсутствует, ядро назначает блоку свободный буфер, блокирует и возвращает его. Алгоритм bread выделяет блоку буфер и при необходимости читает туда информацию. Алгоритм bwrite копирует информацию в предварительно выделенный буфер. Если при выполнении указанных алгоритмов ядро не увидит необходимости в немедленном копировании данных на диск, оно пометит буфер для «отложенной записи», чтобы избежать излишнего ввода-вывода. К сожалению, процедура откладывания записи сопровождается тем, что процесс никогда не уверен, в какой момент данные физически попадают на диск. Если ядро записывает данные на диск синхронно, оно поручает драйверу диска передать блок файловой системе и ждет прерывания, сообщающего об окончании ввода-вывода.
Существует множество способов использования ядром буферного кеша. Посредством буферного кеша ядро обеспечивает обмен данными между прикладными программами и файловой системой, передачу дополнительной системной информации, например, индексов, между алгоритмами ядра и файловой системой. Ядро также использует буферный кеш, когда читает программы в память для выполнения. В следующих главах будет рассмотрено множество алгоритмов, использующих процедуры, описанные в данной главе. Другие алгоритмы, которые кешируют индексы и страницы памяти, также используют приемы, похожие на те, что описаны для буферного кеша.

3.7 УПРАЖНЕНИЯ

1. Рассмотрим функцию хеширования применительно к Рисунку 3.3. Наилучшей функцией хеширования является та, которая единым образом распределяет блоки между хеш-очередями. Что Вы могли бы предложить в качестве оптимальной функции хеширования? Должна ли эта функция в своих расчетах использовать логический номер устройства?
2. В алгоритме getblk, если ядро удаляет буфер из списка свободных буферов, оно должно повысить приоритет прерывания работы процессора так, чтобы блокировать прерывания до проверки списка. Почему?
*3. В алгоритме getblk ядро должно повысить приоритет прерывания работы процессора так, чтобы блокировать прерывания до проверки занятости блока. (Это не показано в тексте.) Почему?
4. В алгоритме brelse ядро помещает буфер в «голову» списка свободных буферов, если содержимое буфера неверно. Если содержимое буфера неверно, должен ли буфер появиться в хеш-очереди?
5. Предположим, что ядро выполняет отложенную запись блока. Что произойдет, когда другой процесс выберет этот блок из его хеш-очереди? Из списка свободных буферов?
*6. Если несколько процессов оспаривают буфер, ядро гарантирует, что ни один из них не приостановится навсегда, но не гарантирует, что процесс не «зависнет» и дождется получения буфера. Переделайте алгоритм getblk так, чтобы процессу было в конечном итоге гарантировано получение буфера.
7. Переделайте алгоритмы getblk и brelse так, чтобы ядро следовало не схеме замещения буферов, к которым наиболее долго не было обращений, а схеме «первым пришел — первым вышел». Повторите то же самое со схемой замещения редко используемых буферов.
8. Опишите ситуацию в алгоритме bread, когда информация в буфере уже верна.
*9. Опишите различные ситуации, встречающиеся в алгоритме breada. Что произойдет в случае следующего выполнения алгоритма bread или breada, когда текущий блок прочитан с продвижением? В алгоритме breada, если первый или второй блок отсутствует в кеше, в дальнейшем при проверке правильности содержимого буфера предполагается, что блок мог быть в буферном пуле. Как это может быть?
10. Опишите алгоритм, запрашивающий и получающий любой свободный буфер из буферного пула. Сравните этот алгоритм с getblk.
11. В различных системных операциях, таких как umount и sync (глава 5), требуется, чтобы ядро перекачивало на диск содержимое всех буферов, которые помечены для «отложенной записи» в данной файловой системе. Опишите алгоритм, реализующий перекачку буферов. Что произойдет с очередностью расположения буферов в списке свободных буферов в результате этой операции? Как ядро может гарантировать, что ни один другой процесс не подберется к буферу с пометкой «отложенная запись» и не сможет переписать его содержимое в файловую систему, пока процесс перекачки приостановлен в ожидании завершения операции ввода-вывода?
12. Определим время реакции системы как среднее время выполнения системного вызова. Определим пропускную способность системы как количество процессов, которые система может выполнять в данный период времени. Объясните, как буферный кеш может способствовать повышению реакции системы. Способствует ли он с неизбежностью увеличению пропускной способности системы?

0

5

https://forumupload.ru/uploads/001b/09/4d/2/67355.jpg
ГЛАВА 4. ВНУТРЕННЕЕ ПРЕДСТАВЛЕНИЕ ФАЙЛОВ

Как уже было замечено в главе 2, каждый файл в системе UNIX имеет уникальный индекс. Индекс содержит информацию, необходимую любому процессу для того, чтобы обратиться к файлу, например, права собственности на файл, права доступа к файлу, размер файла и расположение данных файла в файловой системе. Процессы обращаются к файлам, используя четко определенный набор системных вызовов и идентифицируя файл строкой символов, выступающих в качестве составного имени файла. Каждое составное имя однозначно определяет файл, благодаря чему ядро системы преобразует это имя в индекс файла.
Эта глава посвящена описанию внутренней структуры файлов в операционной системе UNIX, в следующей же главе рассматриваются обращения к операционной системе, связанные с обработкой файлов. Раздел 4.1 касается индекса и работы с ним ядра, раздел 4.2 — внутренней структуры обычных файлов и некоторых моментов, связанных с чтением и записью ядром информации файлов. В разделе 4.3 исследуется строение каталогов — структур данных, позволяющих ядру организовывать файловую систему в виде иерархии файлов, раздел 4.4 содержит алгоритм преобразования имен пользовательских файлов в индексы. В разделе 4.5 дается структура суперблока, а в разделах 4.6 и 4.7 представлены алгоритмы назначения файлам дисковых индексов и дисковых блоков. Наконец, в разделе 4.8 идет речь о других типах файлов в системе, а именно о каналах и файлах устройств.
Алгоритмы, описанные в этой главе, уровнем выше по сравнению с алгоритмами управления буферным кешем, рассмотренными в предыдущей главе (Рисунок 4.1). Алгоритм iget возвращает последний из идентифицированных индексов с возможностью считывания его с диска, используя буферный кеш, а алгоритм iput освобождает индекс. Алгоритм bmap устанавливает параметры ядра, связанные с обращением к файлу. Алгоритм namei преобразует составное имя пользовательского файла в имя индекса, используя алгоритмы iget, iput и bmap. Алгоритмы alloc и free выделяют и освобождают дисковые блоки для файлов, алгоритмы ialloc и ifree назначают и освобождают для файлов индексы.

Алгоритмы работы с файловой системой на нижнем уровне
namei alloc free ialloc ifree
iget iput bmap
алгоритмы работы с буферами
getblk brelse bread breada bwrite

  Рисунок 4.1. Алгоритмы файловой системы

4.1 ИНДЕКСЫ

4.1.1 Определение

Индексы существуют на диске в статической форме и ядро считывает их в память прежде, чем начать с ними работать. Дисковые индексы включают в себя следующие поля:
• Идентификатор владельца файла. Права собственности разделены между индивидуальным владельцем и «групповым» и тем самым помогают определить круг пользователей, имеющих права доступа к файлу. Суперпользователь имеет право доступа ко всем файлам в системе.
• Тип файла. Файл может быть файлом обычного типа, каталогом, специальным файлом, соответствующим устройствам ввода-вывода символами или блоками, а также абстрактным файлом канала (организующим обслуживание запросов в порядке поступления, «первым пришел — первым вышел»).
• Права доступа к файлу. Система разграничивает права доступа к файлу для трех классов пользователей: индивидуального владельца файла, группового владельца и прочих пользователей; каждому классу выделены определенные права на чтение, запись и исполнение файла, которые устанавливаются индивидуально. Поскольку каталоги как файлы не могут быть исполнены, разрешение на исполнение в данном случае интерпретируется как право производить поиск в каталоге по имени файла.
• Календарные сведения, характеризующие работу с файлом: время внесения последних изменений в файл, время последнего обращения к файлу, время внесения последних изменений в индекс.
• Число указателей на файл, означающее количество имен, используемых при поиске файла в иерархии каталогов. Указатели на файл подробно рассматриваются в главе 5.
• Таблица адресов на диске, в которых располагается информация файла. Хотя пользователи трактуют информацию в файле как логический поток байтов, ядро располагает эти данные в несоприкасающихся дисковых блоках. Дисковые блоки, содержащие информацию файла, указываются в индексе.
• Размер файла. Данные в файле адресуются с помощью смещения в байтах относительно начала файла, начиная со смещения, равного 0, поэтому размер файла в байтах на 1 больше максимального смещения. Например, если пользователь создает файл и записывает только 1 байт информации по адресу со смещением 1000 от начала файла, размер файла составит 1001 байт. В индексе отсутствует составное имя файла, необходимое для осуществления доступа к файлу.

владелец mjb
группа os
тип обычный файл
права доступа rwxr-xr-x
последнее обращение 23 Окт 1984 13:45
последнее изменение 22 Окт 1984 10:30
коррекция индекса 23 Окт 1984 13:30
размер 6030 байт
дисковые адреса

  Рисунок 4.2. Пример дискового индекса
На Рисунке 4.2 показан дисковый индекс некоторого файла. Этот индекс принадлежит обычному файлу, владелец которого — «mjb» и размер которого 6030 байт. Система разрешает пользователю «mjb» производить чтение, запись и исполнение файла; членам группы «os» и всем остальным пользователям разрешается только читать или исполнять файл, но не записывать в него данные. Последний раз файл был прочитан 23 октября 1984 года в 13:45, запись последний раз производилась 22 октября 1984 года в 10:30. Индекс изменялся последний раз 23 октября 1984 года в 13:30, хотя никакая информация в это время в файл не записывалась. Ядро кодирует все вышеперечисленные данные в индексе. Обратите внимание на различие в записи на диск содержимого индекса и содержимого файла. Содержимое файла меняется только тогда, когда в файл производится запись. Содержимое индекса меняется как при изменении содержимого файла, так и при изменении владельца файла, прав доступа и набора указателей. Изменение содержимого файла автоматически вызывает коррекцию индекса, однако коррекция индекса еще не означает изменения содержимого файла.
Копия индекса в памяти, кроме полей дискового индекса, включает в себя и следующие поля:
• Состояние индекса в памяти, отражающее
  - заблокирован ли индекс,
  - ждет ли снятия блокировки с индекса какой-либо процесс,
  - отличается ли представление индекса в памяти от своей дисковой копии в результате изменения содержимого индекса,
  - отличается ли представление индекса в памяти от своей дисковой копии в результате изменения содержимого файла,
  - находится ли файл в верхней точке (см. раздел 5.15).
• Логический номер устройства файловой системы, содержащей файл.
• Номер индекса. Так как индексы на диске хранятся в линейном массиве (см. раздел 2.2.1), ядро идентифицирует номер дискового индекса по его местоположению в массиве. В дисковом индексе это поле не нужно.
• Указатели на другие индексы в памяти. Ядро связывает индексы в хеш-очереди и включает их в список свободных индексов подобно тому, как связывает буферы в буферные хеш-очереди и включает их в список свободных буферов. Хеш-очередь идентифицируется в соответствии с логическим номером устройства и номером индекса. Ядро может располагать в памяти не более одной копии данного дискового индекса, но индексы могут находиться одновременно как в хеш-очереди, так и в списке свободных индексов.
• Счетчик ссылок, означающий количество активных экземпляров файла (таких, которые открыты).
Многие поля в копии индекса, с которой ядро работает в памяти, аналогичны полям в заголовке буфера, и управление индексами похоже на управление буферами. Индекс так же блокируется, в результате чего другим процессам запрещается работа с ним; эти процессы устанавливают в индексе специальный флаг, возвещающий о том, что выполнение обратившихся к индексу процессов следует возобновить, как только блокировка будет снята. Установкой других флагов ядро отмечает противоречия между дисковым индексом и его копией в памяти. Когда ядру нужно будет записать изменения в файл или индекс, ядро перепишет копию индекса из памяти на диск только после проверки этих флагов.
Наиболее разительным различием между копией индекса в памяти и заголовком буфера является наличие счетчика ссылок, подсчитывающего количество активных экземпляров файла. Индекс активен, когда процесс выделяет его, например, при открытии файла. Индекс находится в списке свободных индексов, только если значение его счетчика ссылок равно 0, и это значит, что ядро может переназначить свободный индекс в памяти другому дисковому индексу. Таким образом, список свободных индексов выступает в роли кеша для неактивных индексов. Если процесс пытается обратиться к файлу, чей индекс в этот момент отсутствует в индексном пуле, ядро переназначает свободный индекс из списка для использования этим процессом. С другой стороны, у буфера нет счетчика ссылок; он находится в списке свободных буферов тогда и только тогда, когда он разблокирован.

4.1.2 Обращение к индексам

Ядро идентифицирует индексы по имени файловой системы и номеру индекса и выделяет индексы в памяти по запросам соответствующих алгоритмов. Алгоритм iget назначает индексу место для копии в памяти (Рисунок 4.3); он почти идентичен алгоритму getblk для поиска дискового блока в буферном кеше. Ядро преобразует номера устройства и индекса в имя хеш-очереди и просматривает эту хеш-очередь в поисках индекса. Если индекс не обнаружен, ядро выделяет его из списка свободных индексов и блокирует его. Затем ядро готовится к чтению с диска в память индекса, к которому оно обращается. Ядро уже знает номера индекса и логического устройства и вычисляет номер логического блока на диске, содержащего индекс, с учетом того, сколько дисковых индексов помещается в одном дисковом блоке. Вычисления производятся по формуле
  номер блока = ((номер индекса — 1) / число индексов в блоке) + начальный блок в списке индексов
где операция деления возвращает целую часть частного. Например, предположим, что блок 2 является начальным в списке индексов и что в каждом блоке помещаются 8 индексов, тогда индекс с номером 8 находится в блоке 2, а индекс с номером 9 — в блоке 3. Если же в дисковом блоке помещаются 16 индексов, тогда индексы с номерами 8 и 9 располагаются в дисковом блоке с номером 2, а индекс с номером 17 является первым индексом в дисковом блоке 3.
  алгоритм iget
  входная информация: номер индекса в файловой системе
  выходная информация: заблокированный индекс
  {
   do   {
    if (индекс в индексном кеше)   {
     if (индекс заблокирован)   {
      sleep (до освобождения индекса);
      continue; /* цикл с условием продолжения */
     }
     /* специальная обработка для точек монтирования (глава 5) */
     if (индекс в списке свободных индексов)   убрать из списка свободных индексов;
     увеличить счетчик ссылок для индекса;
     return (индекс);
    }
    /* индекс отсутствует в индексном кеше */
    if (список свободных индексов пуст) return (ошибку);
    убрать новый индекс из списка свободных индексов;
    сбросить номер индекса и файловой системы;
    убрать индекс из старой хеш-очереди, поместить в новую;
    считать индекс с диска (алгоритм bread);
    инициализировать индекс (например, установив счетчик ссылок в 1);
    return(индекс);
   }
  }
  Рисунок 4.3. Алгоритм выделения индексов в памяти
Если ядро знает номера устройства и дискового блока, оно читает блок, используя алгоритм bread (глава 2), затем вычисляет смещение индекса в байтах внутри блока по формуле:
  ((номер индекса – 1) mod (число индексов в блоке)) * размер дискового индекса
Например, если каждый дисковый индекс занимает 64 байта и в блоке помещаются 8 индексов, тогда индекс с номером 8 имеет адрес со смещением 448 байт от начала дискового блока. Ядро убирает индекс в памяти из списка свободных индексов, помещает его в соответствующую хеш-очередь и устанавливает значение счетчика ссылок равным 1. Ядро переписывает поля типа файла и владельца файла, установки прав доступа, число указателей на файл, размер файла и таблицу адресов из дискового индекса в память и возвращает заблокированный в памяти индекс.
Ядро манипулирует с блокировкой индекса и счетчиком ссылок независимо один от другого. Блокировка — это установка, которая действует на время выполнения системного вызова и имеет целью запретить другим процессам обращаться к индексу пока тот в работе (и возможно хранит противоречивые данные). Ядро снимает блокировку по окончании обработки системного вызова: блокировка индекса никогда не выходит за границы системного вызова. Ядро увеличивает значение счетчика ссылок с появлением каждой активной ссылки на файл. Например, в разделе 5.1 будет показано, как ядро увеличивает значение счетчика ссылок тогда, когда процесс открывает файл. Оно уменьшает значение счетчика ссылок только тогда, когда ссылка становится неактивной, например, когда процесс закрывает файл. Таким образом, установка счетчика ссылок сохраняется для множества системных вызовов. Блокировка снимается между двумя обращениями к операционной системе, чтобы позволить процессам одновременно производить разделенный доступ к файлу; установка счетчика ссылок действует между обращениями к операционной системе, чтобы предупредить переназначение ядром активного в памяти индекса. Таким образом, ядро может заблокировать и разблокировать выделенный индекс независимо от значения счетчика ссылок. Выделением и освобождением индексов занимаются и отличные от open системные операции, в чем мы и убедимся в главе 5.
Возвращаясь к алгоритму iget, заметим, что если ядро пытается взять индекс из списка свободных индексов и обнаруживает список пустым, оно сообщает об ошибке. В этом отличие от идеологии, которой следует ядро при работе с дисковыми буферами, где процесс приостанавливает свое выполнение до тех пор, пока буфер не освободится. Процессы контролируют выделение индексов на пользовательском уровне посредством запуска системных операций open и close и поэтому ядро не может гарантировать момент, когда индекс станет доступным. Следовательно, процесс, приостанавливающий свое выполнение в ожидании освобождения индекса, может никогда не возобновиться. Ядро скорее прервет выполнение системного вызова, чем оставит такой процесс в «зависшем» состоянии. Однако, процессы не имеют такого контроля над буферами. Поскольку процесс не может удержать буфер заблокированным в течение выполнения нескольких системных операций, ядро здесь может гарантировать скорое освобождение буфера, и процесс поэтому приостанавливается до того момента, когда он станет доступным.
В предшествующих параграфах рассматривался случай, когда ядро выделяет индекс, отсутствующий в индексном кеше. Если индекс находится в кеше, процесс (A) обнаружит его в хеш-очереди и проверит, не заблокирован ли индекс другим процессом (B). Если индекс заблокирован, процесс A приостанавливается и выставляет флаг у индекса в памяти, показывая, что он ждет освобождения индекса. Когда позднее процесс B разблокирует индекс, он «разбудит» все процессы (включая процесс A), ожидающие освобождения индекса. Когда же наконец процесс A сможет использовать индекс, он заблокирует его, чтобы другие процессы не могли к нему обратиться. Если первоначально счетчик ссылок имел значение, равное 0, индекс также появится в списке свободных индексов, поэтому ядро уберет его оттуда: индекс больше не является свободным. Ядро увеличивает значение счетчика ссылок и возвращает заблокированный индекс.
Если суммировать все вышесказанное, можно отметить, что алгоритм iget имеет отношение к начальной стадии системных вызовов, когда процесс впервые обращается к файлу. Этот алгоритм возвращает заблокированную индексную структуру со значением счетчика ссылок, на 1 большим, чем оно было раньше. Индекс в памяти содержит текущую информацию о состоянии файла. Ядро снимает блокировку с индекса перед выходом из системной операции, поэтому другие системные вызовы могут обратиться к индексу, если пожелают. В главе 5 рассматриваются эти случаи более подробно.
  алгоритм iput /* разрешение доступа к индексу в памяти */
  входная информация: указатель на индекс в памяти
  выходная информация: отсутствует 
  {
   заблокировать индекс если он еще не заблокирован;
   уменьшить на 1 счетчик ссылок для индекса;
   if (значение счетчика ссылок == 0)   {
    if (значение счетчика связей == 0)   {
     освободить дисковые блоки для файла (алгоритм free, раздел 4.7); 
     установить тип файла равным 0;
     освободить индекс (алгоритм ifree, раздел 4.6);
    }
    if (к файлу обращались или изменился индекс или изменилось содержимое файла)
     скорректировать дисковый индекс;
    поместить индекс в список свободных индексов;
   }
   снять блокировку с индекса;
  }
  Рисунок 4.4. Освобождение индекса

4.1.3 Освобождение индексов

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

4.2 СТРУКТУРА ФАЙЛА ОБЫЧНОГО ТИПА

Как уже говорилось, индекс включает в себя таблицу адресов расположения информации файла на диске. Так как каждый блок на диске адресуется по своему номеру, в этой таблице хранится совокупность номеров дисковых блоков. Если бы данные файла занимали непрерывный участок на диске (то есть файл занимал бы линейную последовательность дисковых блоков), то для обращения к данным в файле было бы достаточно хранить в индексе адрес начального блока и размер файла. Однако, такая стратегия размещения данных не позволяет осуществлять простое расширение и сжатие файлов в файловой системе без риска фрагментации свободного пространства памяти на диске. Более того, ядру пришлось бы выделять и резервировать непрерывное пространство в файловой системе перед выполнением операций, могущих привести к увеличению размера файла.
  Рисунок 4.5. Размещение непрерывных файлов и фрагментация свободного пространства
Предположим, например, что пользователь создает три файла, A, B и C, каждый из которых занимает 10 дисковых блоков, а также что система выделила для размещения этих трех файлов непрерывное место. Если потом пользователь захочет добавить 5 блоков с информацией к среднему файлу, B, ядру придется скопировать файл B в то место в файловой системе, где найдется окно размером 15 блоков. В дополнение к затратам ресурсов на проведение этой операции дисковые блоки, занимаемые информацией файла B, станут неиспользуемыми, если только они не понадобятся файлам размером не более 10 блоков (Рисунок 4.5). Ядро могло бы минимизировать фрагментацию пространства памяти, периодически запуская процедуры чистки памяти, уплотняющие имеющуюся память, но это потребовало бы дополнительного расхода временных и системных ресурсов.
В целях повышения гибкости ядро присоединяет к файлу по одному блоку, позволяя информации файла быть разбросанной по всей файловой системе. Но такая схема размещения усложняет задачу поиска данных. Таблица адресов содержит список номеров блоков, содержащих принадлежащую файлу информацию, однако простые вычисления показывают, что линейным списком блоков файла в индексе трудно управлять. Если логический блок занимает 1 Кбайт, то файлу, состоящему из 10 Кбайт, потребовался бы индекс на 10 номеров блоков, а файлу, состоящему из 100 Кбайт, понадобился бы индекс на 100 номеров блоков. Либо пусть размер индекса будет варьироваться в зависимости от размера файла, либо пришлось бы установить относительно жесткое ограничение на размер файла.
Для того, чтобы небольшая структура индекса позволяла работать с большими файлами, таблица адресов дисковых блоков приводится в соответствие со структурой, представленной на Рисунке 4.6. Версия V системы UNIX работает с 13 точками входа в таблицу адресов индекса, но принципиальные моменты не зависят от количества точек входа. Блок, имеющий пометку «прямая адресация» на рисунке, содержит номера дисковых блоков, в которых хранятся реальные данные. Блок, имеющий пометку «одинарная косвенная адресация», указывает на блок, содержащий список номеров блоков прямой адресации. Чтобы обратиться к данным с помощью блока косвенной адресации, ядро должно считать этот блок, найти соответствующий вход в блок прямой адресации и, считав блок прямой адресации, обнаружить данные. Блок, имеющий пометку «двойная косвенная адресация», содержит список номеров блоков одинарной косвенной адресации, а блок, имеющий пометку «тройная косвенная адресация», содержит список номеров блоков двойной косвенной адресации.
В принципе, этот метод можно было бы распространить и на поддержку блоков четверной косвенной адресации, блоков пятерной косвенной адресации и так далее, но на практике оказывается достаточно имеющейся структуры. Предположим, что размер логического блока в файловой системе 1 Кбайт и что номер блока занимает 32 бита (4 байта). Тогда в блоке может храниться до 256 номеров блоков. Расчеты показывают (Рисунок 4.7), что максимальный размер файла превышает 16 Гбайт, если использовать в индексе 10 блоков прямой адресации и 1 одинарной косвенной адресации, 1 двойной косвенной адресации и 1 тройной косвенной адресации. Если же учесть, что длина поля «размер файла» в индексе — 32 бита, то размер файла в действительности ограничен 4 Гбайтами (2 в степени 32).
Процессы обращаются к информации в файле, задавая смещение в байтах. Они рассматривают файл как поток байтов и ведут подсчет байтов, начиная с нулевого адреса и заканчивая адресом, равным размеру файла. Ядро переходит от байтов к блокам: файл начинается с нулевого логического блока и заканчивается блоком, номер которого определяется исходя из размера файла. Ядро обращается к индексу и превращает логический блок, принадлежащий файлу, в соответствующий дисковый блок. На Рисунке 4.8 представлен алгоритм bmap пересчета смещения в байтах от начала файла в номер физического блока на диске.
  Рисунок 4.6. Блоки прямой и косвенной адресации в индексе
10 блоков прямой адресации по 1 Кбайту каждый = 10 Кбайт
1 блок косвенной адресации с 256 блоками прямой адресации = 256 Кбайт
1 блок двойной косвенной адресации с 256 блоками косвенной адресации = 64 Мбайта
1 блок тройной косвенной адресации с 256 блоками двойной косвенной адресации = 16 Гбайт
  Рисунок 4.7. Объем файла в байтах при размере блока 1 Кбайт
  алгоритм bmap /* отображение адреса смещения в байтах от начала логического файла на адрес блока в файловой системе */
  входная информация: 
   (1) индекс 
   (2) смещение в байтах
  выходная информация: 
   (1) номер блока в файловой системе 
   (2) смещение в байтах внутри блока 
   (3) число байт ввода-вывода в блок 
   (4) номер блока с продвижением 
  {
   вычислить номер логического блока в файле исходя из заданного смещения в байтах;
   вычислить номер начального байта в блоке для ввода-вывода; /* выходная информация 2 */
   вычислить количество байт для копирования пользователю; /* выходная информация 3 */
   проверить возможность чтения с продвижением, пометить индекс; /* выходная информация 4 */
   определить уровень косвенности;
   do (пока уровень косвенности другой)   {
    определить указатель в индексе или блок косвенной адресации исходя из номера логического блока в файле;
    получить номер дискового блока из индекса или из блока косвенной адресации;
    освободить буфер от данных, полученных в результате выполнения предыдущей операции чтения с диска (алгоритм brelse);
    if (число уровней косвенности исчерпано)   return (номер блока);
    считать дисковый блок косвенной адресации (алгоритм bread);
    установить номер логического блока в файле исходя из уровня косвенности;
   }
  }
  Рисунок 4.8. Преобразование адреса смещения в номер блока в файловой системе
Рассмотрим формат файла в блоках (Рисунок 4.9) и предположим, что дисковый блок занимает 1024 байта. Если процессу нужно обратиться к байту, имеющему смещение от начала файла, равное 9000, в результате вычислений ядро приходит к выводу, что этот байт располагается в блоке прямой адресации с номером 8 (начиная с 0). Затем ядро обращается к блоку с номером 367; 808-й байт в этом блоке (если вести отсчет с 0) и является 9000-м байтом в файле. Если процессу нужно обратиться по адресу, указанному смещением 350000 байт от начала файла, он должен считать блок двойной косвенной адресации, который на рисунке имеет номер 9156. Так как блок косвенной адресации имеет место для 256 номеров блоков, первым байтом, к которому будет получен доступ в результате обращения к блоку двойной косвенной адресации, будет байт с номером 272384 (256К + 10К); таким образом, байт с номером 350000 будет иметь в блоке двойной косвенной адресации номер 77616. Поскольку каждый блок одинарной косвенной адресации позволяет обращаться к 256 Кбайтам, байт с номером 350000 должен располагаться в нулевом блоке одинарной косвенной адресации для блока двойной косвенной адресации, а именно в блоке 331. Так как в каждом блоке прямой адресации для блока одинарной косвенной адресации хранится 1 Кбайт, байт с номером 77616 находится в 75-м блоке прямой адресации для блока одинарной косвенной адресации, а именно в блоке 3333. Наконец, байт с номером в файле 350000 имеет в блоке 3333 номер 816.
  Рисунок 4.9. Размещение блоков в файле и его индексе
При ближайшем рассмотрении Рисунка 4.9 обнаруживается, что несколько входов для блока в индексе имеют значение 0 и это значит, что в данных записях информация о логических блоках отсутствует. Такое имеет место, если в соответствующие блоки файла никогда не записывалась информация и по этой причине у номеров блоков остались их первоначальные нулевые значения. Для таких блоков пространство на диске не выделяется. Подобное расположение блоков в файле вызывается процессами, запускающими системные операции lseek и write (см. следующую главу). В следующей главе также объясняется, каким образом ядро обрабатывает системные вызовы операции read, с помощью которой производится обращение к блокам.
Преобразование адресов с большими смещениями, в частности с использованием блоков тройной косвенной адресации, является сложной процедурой, требующей от ядра обращения уже к трем дисковым блокам в дополнение к индексу и информационному блоку. Даже если ядро обнаружит блоки в буферном кеше, операция останется дорогостоящей, так как ядру придется многократно обращаться к буферному кешу и приостанавливать свою работу в ожидании снятия блокировки с буферов. Насколько эффективен этот алгоритм на практике? Это зависит от того, как используется система, а также от того, кто является пользователем и каков состав задач, вызывающий потребность в более частом обращении к большим или, наоборот, маленьким файлам. Однако, как уже было замечено [Mullender 84], большинство файлов в системе UNIX имеет размер, не превышающий 10 Кбайт и даже 1 Кбайта! [11] Поскольку 10 Кбайт файла располагаются в блоках прямой адресации, к большей части данных, хранящихся в файлах, доступ может производиться за одно обращение к диску. Поэтому в отличие от обращения к большим файлам, работа с файлами стандартного размера протекает быстро.
В двух модификациях только что описанной структуры индекса предпринимается попытка использовать размерные характеристики файла. Основной принцип в реализации файловой системы BSD 4.2 [McKusick 84] состоит в том, что чем больше объем данных, к которым ядро может получить доступ за одно обращение к диску, тем быстрее протекает работа с файлом. Это свидетельствует в пользу увеличения размера логического блока на диске, поэтому в системе BSD разрешается иметь логические блоки размером 4 или 8 Кбайт. Однако, увеличение размера блоков на диске приводит к увеличению фрагментации блоков, при которой значительные участки дискового пространства остаются неиспользуемыми. Например, если размер логического блока 8 Кбайт, тогда файл размером 12 Кбайт занимает 1 полный блок и половину второго блока. Другая половина второго блока (4 Кбайта) фактически теряется; другие файлы не могут использовать ее для хранения данных. Если размеры файлов таковы, что число байт, попавших в последний блок, является равномерно распределенной величиной, то средние потери дискового пространства составляют полблока на каждый файл; объем теряемого дискового пространства достигает в файловой системе с логическими блоками размером 4 Кбайта 45% [McKusick 84]. Выход из этой ситуации в системе BSD состоит в выделении только части блока (фрагмента) для размещения оставшейся информации файла. Один дисковый блок может включать в себя фрагменты, принадлежащие нескольким файлам. Некоторые подробности этой реализации исследуются на примере упражнения в главе 5.
Второй модификацией рассмотренной классической структуры индекса является идея хранения в индексе информации файла (см. [Mullender 84]). Если увеличить размер индекса так, чтобы индекс занимал весь дисковый блок, небольшая часть блока может быть использована для собственно индексных структур, а оставшаяся часть — для хранения конца файла и даже во многих случаях для хранения файла целиком. Основное преимущество такого подхода заключается в том, что необходимо только одно обращение к диску для считывания индекса и всей информации, если файл помещается в индексном блоке.

4.3 КАТАЛОГИ

Из главы 1 напомним, что каталоги являются файлами, из которых строится иерархическая структура файловой системы; они играют важную роль в превращении имени файла в номер индекса. Каталог — это файл, содержимым которого является набор записей, состоящих из номера индекса и имени файла, включенного в каталог. Составное имя — это строка символов, завершающаяся пустым символом и разделяемая наклонной чертой («/») на несколько компонент. Каждая компонента, кроме последней, должна быть именем каталога, но последняя компонента может быть именем файла, не являющегося каталогом. В версии V системы UNIX длина каждой компоненты ограничивается 14 символами; таким образом, вместе с 2 байтами, отводимыми на номер индекса, размер записи каталога составляет 16 байт.
  Смещение в байтах внутри каталога Номер индекса (2 байта) Имя файла

0 83 .
16 2 ..
32 1798 init
48 1276 fsck
64 85 clri
80 1268 motd
96 1799 mount
112 88 mknod
128 2114 passwd
144 1717 umount
160 1851 checklist
176 92 fsdbld
192 84 config
208 1432 getty
224 0 crash
240 95 mkfs
256 188 inittab

  Рисунок 4.10. Формат каталога /etc
На Рисунке 4.10 показан формат каталога «etc». В каждом каталоге имеются файлы, в качестве имен которых указаны точка и две точки ("." и «..») и номера индексов у которых совпадают с номерами индексов данного каталога и родительского каталога, соответственно. Номер индекса для файла "." в каталоге «/etc» имеет адрес со смещением 0 и значение 83. Номер индекса для файла «..» имеет адрес со смещением 16 от начала каталога и значение 2. Записи в каталоге могут быть пустыми, при этом номер индекса равен 0. Например, запись с адресом 224 в каталоге «/etc» пустая, несмотря на то, что она когда-то содержала точку входа для файла с именем «crash». Программа mkfs инициализирует файловую систему таким образом, что номера индексов для файлов "." и «..» в корневом каталоге совпадают с номером корневого индекса файловой системы.
Ядро хранит данные в каталоге так же, как оно это делает в файле обычного типа, используя индексную структуру и блоки с уровнями прямой и косвенной адресации. Процессы могут читать данные из каталогов таким же образом, как они читают обычные файлы, однако исключительное право записи в каталог резервируется ядром, благодаря чему обеспечивается правильность структуры каталога. Права доступа к каталогу имеют следующий смысл: право чтения дает процессам возможность читать данные из каталога; право записи позволяет процессу создавать новые записи в каталоге или удалять старые (с помощью системных операций creat, mknod, link и unlink), в результате чего изменяется содержимое каталога; право исполнения позволяет процессу производить поиск в каталоге по имени файла (поскольку «исполнять» каталог бессмысленно). На примере Упражнения 4.6 показана разница между чтением и поиском в каталоге.

4.4 ПРЕВРАЩЕНИЕ СОСТАВНОГО ИМЕНИ ФАЙЛА (ПУТИ ПОИСКА) В ИДЕНТИФИКАТОР ИНДЕКСА

Начальное обращение к файлу производится по его составному имени (имени пути поиска), как в командах open, chdir (изменить каталог) или link. Поскольку внутри системы ядро работает с индексами, а не с именами путей поиска, оно преобразует имена путей поиска в идентификаторы индексов, чтобы производить доступ к файлам. Алгоритм namei производит поэлементный анализ составного имени, ставя в соответствие каждой компоненте имени индекс и каталог и в конце концов возвращая идентификатор индекса для введенного имени пути поиска (Рисунок 4.11).
Из главы 2 напомним, что каждый процесс связан с текущим каталогом (и протекает в его рамках); рабочая область, отведенная под задачу пользователя, содержит указатель на индекс текущего каталога. Текущим каталогом первого из процессов в системе, нулевого процесса, является корневой каталог. Путь к текущему каталогу каждого нового процесса берет начало от текущего каталога процесса, являющегося родительским по отношению к данному (см. раздел 5.10). Процессы изменяют текущий каталог, запрашивая выполнение системной операции chdir (изменить каталог). Все поиски файлов по имени начинаются с текущего каталога процесса, если только имя пути поиска не предваряется наклонной чертой, указывая, что поиск нужно начинать с корневого каталога. В любом случае ядро может легко обнаружить индекс каталога, с которого начинается поиск. Текущий каталог хранится в рабочей области процесса, а корневой индекс системы хранится в глобальной переменной [12] .
  алгоритм namei /* превращение имени пути поиска в индекс */
  входная информация: имя пути поиска
  выходная информация: заблокированный индекс 
  {
   if (путь поиска берет начало с корня)   рабочий индекс = индексу корня (алгоритм iget);
   else   индекс = индексу текущего каталога (алгоритм iget);
   выполнить (пока путь поиска не кончился)   {
    считать следующую компоненту имени пути поиска;
    проверить соответствие рабочего индекса каталогу и права доступа;
    if (рабочий индекс соответствует корню и компонента имени «..»)   continue; /* цикл с условием продолжения */
    считать каталог (рабочий индекс), повторяя алгоритмы bmap, bread и brelse;
    if (компонента соответствует записи в каталоге (рабочем индексе)) {
     получить номер индекса для совпавшей компоненты;
     освободить рабочий индекс (алгоритм iput);
     рабочий индекс = индексу совпавшей компоненты (алгоритм iget);
    }
    else /* компонента отсутствует в каталоге */   return (нет индекса);
   }
   return (рабочий индекс);
  }
  Рисунок 4.11. Алгоритм превращения имени пути поиска в индекс
Алгоритм namei использует при анализе составного имени пути поиска промежуточные индексы; назовем их рабочими индексами. Индекс каталога, откуда поиск берет начало, является первым рабочим индексом. На каждой итерации цикла алгоритма ядро проверяет совпадение рабочего индекса с индексом каталога. В противном случае, нарушилось бы утверждение, что только файлы, не являющиеся каталогами, могут быть листьями дерева файловой системы. Процесс также должен иметь право производить поиск в каталоге (разрешения на чтение недостаточно). Код идентификации пользователя для процесса должен соответствовать коду индивидуального или группового владельца файла и должно быть предоставлено право исполнения, либо поиск нужно разрешить всем пользователям. В противном случае, поиск не получится.
Ядро выполняет линейный поиск файла в каталоге, ассоциированном с рабочим индексом, пытаясь найти для компоненты имени пути поиска подходящую запись в каталоге. Исходя из адреса смещения в байтах внутри каталога (начиная с 0), оно определяет местоположение дискового блока в соответствии с алгоритмом bmap и считывает этот блок, используя алгоритм bread. По имени компоненты ядро производит в блоке поиск, представляя содержимое блока как последовательность записей каталога. При обнаружении совпадения ядро переписывает номер индекса из данной точки входа, освобождает блок (алгоритм brelse) и старый рабочий индекс (алгоритм iput), и переназначает индекс найденной компоненты (алгоритм iget). Новый индекс становится рабочим индексом. Если ядро не находит в блоке подходящего имени, оно освобождает блок, прибавляет к адресу смещения число байтов в блоке, превращает новый адрес смещения в номер дискового блока (алгоритм bmap) и читает следующий блок. Ядро повторяет эту процедуру до тех пор, пока имя компоненты пути поиска не совпадет с именем точки входа в каталоге, либо до тех пор, пока не будет достигнут конец каталога.
Предположим, например, что процессу нужно открыть файл «/etc/passwd». Когда ядро начинает анализировать имя файла, оно наталкивается на наклонную черту («/») и получает индекс корня системы. Сделав корень текущим рабочим индексом, ядро наталкивается на строку «etc». Проверив соответствие текущего индекса каталогу («/») и наличие у процесса права производить поиск в каталоге, ядро ищет в корневом каталоге файл с именем «etc». Оно просматривает корневой каталог блок за блоком и исследует каждую запись в блоке, пока не обнаружит точку входа для файла «etc». Найдя эту точку входа, ядро освобождает индекс, отведенный для корня (алгоритм iput), и выделяет индекс файлу «etc» (алгоритм iget) в соответствии с номером индекса в обнаруженной записи. Удостоверившись в том, что «etc» является каталогом, а также в том, что имеются необходимые права производить поиск, ядро просматривает каталог «etc» блок за блоком в поисках записи, соответствующей файлу «passwd». Если посмотреть на Рисунок 4.10, можно увидеть, что запись о файле «passwd» является девятой записью в каталоге. Обнаружив ее, ядро освобождает индекс, выделенный файлу «etc», и выделяет индекс файлу «passwd», после чего — поскольку имя пути поиска исчерпано — возвращает этот индекс процессу.
Естественно задать вопрос об эффективности линейного поиска в каталоге записи, соответствующей компоненте имени пути поиска. Ричи показывает (см. [Ritchie 78b], стр.1968), что линейный поиск эффективен, поскольку он ограничен размером каталога. Более того, ранние версии системы UNIX не работали еще на машинах с большим объемом памяти, поэтому значительный упор был сделан на простые алгоритмы, такие как алгоритмы линейного поиска. Более сложные схемы поиска потребовали бы отличной, более сложной, структуры каталога, и возможно работали бы медленнее даже в небольших каталогах по сравнению со схемой линейного поиска.

4.5 СУПЕРБЛОК

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

4.6 НАЗНАЧЕНИЕ ИНДЕКСА НОВОМУ ФАЙЛУ

Для выделения известного индекса, то есть индекса, для которого предварительно определен собственный номер (и номер файловой системы), ядро использует алгоритм iget. В алгоритме namei, например, ядро определяет номер индекса, устанавливая соответствие между компонентой имени пути поиска и именем в каталоге. Другой алгоритм, ialloc, выполняет назначение дискового индекса вновь создаваемому файлу.
Как уже говорилось в главе 2, в файловой системе имеется линейный список индексов. Индекс считается свободным, если поле его типа хранит нулевое значение. Если процессу понадобился новый индекс, ядро теоретически могло бы произвести поиск свободного индекса в списке индексов. Однако, такой поиск обошелся бы дорого, поскольку потребовал бы по меньшей мере одну операцию чтения (допустим, с диска) на каждый индекс. Для повышения производительности в суперблоке файловой системы хранится массив номеров свободных индексов в файловой системе.
  алгоритм ialloc /* выделение индекса */
  входная информация: файловая система
  выходная информация: заблокированный индекс 
  {
   do   {
    if (суперблок заблокирован)   {
     sleep (пока суперблок не освободится);
     continue; /* цикл с условием продолжения */
    }
    if (список индексов в суперблоке пуст)   {
     заблокировать суперблок; выбрать запомненный индекс для поиска свободных индексов;
    искать на диске свободные индексы до тех пор, пока суперблок не заполнится или пока не будут найдены все свободные индексы (алгоритмы bread и brelse);
     снять блокировку с суперблока;
     возобновить выполнение процесса (как только суперблок освободится);
     if (на диске отсутствуют свободные индексы)    return (нет индексов);
     запомнить индекс с наибольшим номером среди найденных для последующих поисков свободных индексов;
    }
    /* список индексов в суперблоке не пуст */
    выбрать номер индекса из списка индексов в суперблоке;
    получить индекс (алгоритм iget);
    if (индекс после всего этого не свободен) { /*!!! */ 
     переписать индекс на диск;
     освободить индекс (алгоритм iput);
     continue; /* цикл с условием продолжения */
    }
    /* индекс свободен */
    инициализировать индекс;
    переписать индекс на диск;
    уменьшить счетчик свободных индексов в файловой системе;
    return (индекс);
   }
  }
  Рисунок 4.12. Алгоритм назначения новых индексов
На Рисунке 4.12 приведен алгоритм ialloc назначения новых индексов. По причинам, о которых пойдет речь ниже, ядро сначала проверяет, не заблокировал ли какой-либо процесс своим обращением список свободных индексов в суперблоке. Если список номеров индексов в суперблоке не пуст, ядро назначает номер следующего индекса, выделяет для вновь назначенного дискового индекса свободный индекс в памяти, используя алгоритм iget (читая индекс с диска, если необходимо), копирует дисковый индекс в память, инициализирует поля в индексе и возвращает индекс заблокированным. Затем ядро корректирует дисковый индекс, указывая, что к индексу произошло обращение. Ненулевое значение поля типа файла говорит о том, что дисковый индекс назначен. В простейшем случае с индексом все в порядке, но в условиях конкуренции делается необходимым проведение дополнительных проверок, на чем мы еще кратко остановимся. Грубо говоря, конкуренция возникает, когда несколько процессов вносят изменения в общие информационные структуры, так что результат зависит от очередности выполнения процессов, пусть даже все процессы будут подчиняться протоколу блокировки. Здесь предполагается, например, что процесс мог бы получить уже используемый индекс. Конкуренция связана с проблемой взаимного исключения, описанной в главе 2, с одним замечанием: различные схемы блокировки решают проблему взаимного исключения, но не могут сами по себе решить все проблемы конкуренции.
Если список свободных индексов в суперблоке пуст, ядро просматривает диск и помещает в суперблок как можно больше номеров свободных индексов. При этом ядро блок за блоком считывает индексы с диска и наполняет список номеров индексов в суперблоке до отказа, запоминая индекс с номером, наибольшим среди найденных. Назовем этот индекс «запомненным»; это последний индекс, записанный в суперблок. В следующий раз, когда ядро будет искать на диске свободные индексы, оно использует запомненный индекс в качестве стартовой точки, благодаря чему гарантируется, что ядру не придется зря тратить время на считывание дисковых блоков, в которых свободные индексы наверняка отсутствуют. После формирования нового набора номеров свободных индексов ядро запускает алгоритм назначения индекса с самого начала. Всякий раз, когда ядро назначает дисковый индекс, оно уменьшает значение счетчика свободных индексов, записанное в суперблоке.
Рассмотрим две пары массивов номеров свободных индексов (Рисунок 4.13). Если список свободных индексов в суперблоке имеет вид первого массива на Рисунке 4.13(а) при назначении индекса ядром, то значение указателя на следующий номер индекса уменьшается до 18 и выбирается индекс с номером 48. Если же список выглядит как первый массив на Рисунке 4.13(б), ядро заметит, что массив пуст и обратится в поисках свободных индексов к диску, при этом поиск будет производиться, начиная с индекса с номером 470, который был ранее запомнен. Когда ядро заполнит список свободных индексов в суперблоке до отказа, оно запомнит последний индекс в качестве начальной точки для последующих просмотров диска. Ядро производит назначение файлу только что выбранного с диска индекса (под номером 471 на рисунке) и продолжает прерванную обработку.
  Рисунок 4.13. Два массива номеров свободных индексов
  алгоритм ifree /* освобождение индекса */
  входная информация: номер индекса в файловой системе
  выходная информация: отсутствует
  {
   увеличить на 1 счетчик свободных индексов в файловой системе;
   if (суперблок заблокирован)   return;
   if (список индексов заполнен)   {
    if (номер индекса меньше номера индекса, запомненного для последующего просмотра)
     запомнить для последующего просмотра номер введенного индекса;
   }
   в противном случае сохранить номер индекса в списке индексов;
   return;
  }
  Рисунок 4.14. Алгоритм освобождения индекса
Алгоритм освобождения индекса построен значительно проще. Увеличив на единицу общее количество доступных в файловой системе индексов, ядро проверяет наличие блокировки у суперблока. Если он заблокирован, ядро, чтобы предотвратить конкуренцию, немедленно сообщает: номер индекса отсутствует в суперблоке, но индекс может быть найден на диске и доступен для переназначения. Если список не заблокирован, ядро проверяет, имеется ли место для новых номеров индексов и если да, помещает номер индекса в список и выходит из алгоритма. Если список полон, ядро не сможет в нем сохранить вновь освобожденный индекс. Оно сравнивает номер освобожденного индекса с номером запомненного индекса. Если номер освобожденного индекса меньше номера запомненного, ядро запоминает номер вновь освобожденного индекса, выбрасывая из суперблока номер старого запомненного индекса. Индекс не теряется, поскольку ядро может найти его, просматривая список индексов на диске. Ядро поддерживает структуру списка в суперблоке таким образом, что последний номер, выбираемый им из списка, и есть номер запомненного индекса. В идеале не должно быть свободных индексов с номерами, меньшими, чем номер запомненного индекса, но возможны и исключения.
Рассмотрим два примера освобождения индексов. Если в списке свободных индексов в суперблоке еще есть место для новых номеров свободных индексов (как на Рисунке 4.13(а)), ядро помещает в список новый номер, переставляет указатель на следующий свободный индекс и продолжает выполнение процесса. Но если список свободных индексов заполнен (Рисунок 4.15), ядро сравнивает номер освобожденного индекса с номером запомненного индекса, с которого начнется просмотр диска в следующий раз. Если вначале список свободных индексов имел вид, как на Рисунке 4.15(а), то когда ядро освобождает индекс с номером 499, оно запоминает его и выталкивает номер 535 из списка. Если затем ядро освобождает индекс с номером 601, содержимое списка свободных индексов не изменится. Когда позднее ядро использует все индексы из списка свободных индексов в суперблоке, оно обратится в поисках свободных индексов к диску, при этом, начав просмотр с индекса с номером 499, оно снова обнаружит индексы 535 и 601.
  Рисунок 4.15. Размещение номеров свободных индексов в суперблоке
  Рисунок 4.16. Конкуренция в назначении индексов
В предыдущем параграфе описывались простые случаи работы алгоритмов. Теперь рассмотрим случай, когда ядро назначает новый индекс и затем копирует его в память. В алгоритме предполагается, что ядро может и обнаружить, что индекс уже назначен. Несмотря на редкость такой ситуации, обсудим этот случай (с помощью Рисунков 4.16 и 4.17). Пусть у нас есть три процесса, A, B и C, и пусть ядро, действуя от имени процесса A [13] , назначает индекс I, но приостанавливает выполнение процесса перед тем, как скопировать дисковый индекс в память. Алгоритмы iget (вызванный алгоритмом ialloc) и bread (вызванный алгоритмом iget) дают процессу A достаточно возможностей для приостановления своей работы. Предположим, что пока процесс A приостановлен, процесс B пытается назначить новый индекс, но обнаруживает, что список свободных индексов в суперблоке пуст. Процесс B просматривает диск в поисках свободных индексов, и начинает это делать с индекса, имеющего меньший номер по сравнению с индексом, назначенным процессом A. Возможно, что процесс B обнаружит индекс I на диске свободным, так как процесс A все еще приостановлен, а ядро еще не знает, что этот индекс собираются назначить. Процесс B, не осознавая опасности, заканчивает просмотр диска, заполняет суперблок свободными (предположительно) индексами, назначает индекс и уходит со сцены. Однако, индекс I остается в списке номеров свободных индексов в суперблоке. Когда процесс A возобновляет выполнение, он заканчивает назначение индекса I. Теперь допустим, что процесс C затем затребовал индекс и случайно выбрал индекс I из списка в суперблоке. Когда он обратится к копии индекса в памяти, он обнаружит из установки типа файла, что индекс уже назначен. Ядро проверяет это условие и, обнаружив, что этот индекс назначен, пытается назначить другой. Немедленная перепись скорректированного индекса на диск после его назначения в соответствии с алгоритмом ialloc снижает опасность конкуренции, поскольку поле типа файла будет содержать пометку о том, что индекс использован.
  Рисунок 4.17. Конкуренция в назначении индексов (продолжение)
Блокировка списка индексов в суперблоке при чтении с диска устраняет другие возможности для конкуренции. Если суперблок не заблокирован, процесс может обнаружить, что он пуст, и попытаться заполнить его с диска, время от времени приостанавливая свое выполнение до завершения операции ввода-вывода. Предположим, что второй процесс так же пытается назначить новый индекс и обнаруживает, что список пуст. Он тоже попытается заполнить список с диска. В лучшем случае, оба процесса продублируют друг друга и потратят энергию центрального процессора. В худшем, участится конкуренция, подобная той, которая описана в предыдущем параграфе. Сходным образом, если процесс, освобождая индекс, не проверил наличие блокировки списка, он может затереть номера индексов уже в списке свободных индексов, пока другой процесс будет заполнять этот список информацией с диска. И опять участится конкуренция вышеописанного типа. Несмотря на то, что ядро более или менее удачно управляется с ней, производительность системы снижается. Установка блокировки для списка свободных индексов в суперблоке устраняет такую конкуренцию.

4.7 ВЫДЕЛЕНИЕ ДИСКОВЫХ БЛОКОВ

Когда процесс записывает данные в файл, ядро должно выделять из файловой системы дисковые блоки под информационные блоки прямой адресации и иногда под блоки косвенной адресации. Суперблок файловой системы содержит массив, используемый для хранения номеров свободных дисковых блоков в файловой системе. Сервисная программа mkfs («make file system» — создать файловую систему) организует информационные блоки в файловой системе в виде списка с указателями так, что каждый элемент списка указывает на дисковый блок, в котором хранится массив номеров свободных дисковых блоков, а один из элементов массива хранит номер следующего блока данного списка.
Когда ядру нужно выделить блок из файловой системы (алгоритм alloc, Рисунок 4.19), оно выделяет следующий из блоков, имеющихся в списке в суперблоке. Выделенный однажды, блок не может быть переназначен до тех пор, пока не освободится. Если выделенный блок является последним блоком, имеющимся в кеше суперблока, ядро трактует его как указатель на блок, в котором хранится список свободных блоков. Ядро читает блок, заполняет массив в суперблоке новым списком номеров блоков и после этого продолжает работу с первоначальным номером блока. Оно выделяет буфер для блока и очищает содержимое буфера (обнуляет его). Дисковый блок теперь считается назначенным и у ядра есть буфер для работы с ним. Если в файловой системе нет свободных блоков, вызывающий процесс получает сообщение об ошибке.
Если процесс записывает в файл большой объем информации, он неоднократно запрашивает у системы блоки для хранения информации, но ядро назначает каждый раз только по одному блоку. Программа mkfs пытается организовать первоначальный связанный список номеров свободных блоков так, чтобы номера блоков, передаваемых файлу, были рядом друг с другом. Благодаря этому повышается производительность, поскольку сокращается время поиска на диске и время ожидания при последовательном чтении файла процессом. На Рисунке 4.18 номера блоков даны в настоящем формате, определяемом скоростью вращения диска. К сожалению, очередность номеров блоков в списке свободных блоков перепутана в связи с частыми обращениями к списку со стороны процессов, ведущих запись в файлы и удаляющих их, в результате чего номера блоков поступают в список и покидают его в случайном порядке. Ядро не предпринимает попыток сортировать номера блоков в списке.
  Рисунок 4.18. Список номеров свободных дисковых блоков с указателями
Алгоритм освобождения блока free — обратный алгоритму выделения блока. Если список в суперблоке не полон, номер вновь освобожденного блока включается в этот список. Если, однако, список полон, вновь освобожденный блок становится связным блоком; ядро переписывает в него список из суперблока и копирует блок на диск. Затем номер вновь освобожденного блока включается в список свободных блоков в суперблоке. Этот номер становится единственным номером в списке.
На Рисунке 4.20 показана последовательность операций alloc и free для случая, когда в исходный момент список свободных блоков содержал один элемент. Ядро освобождает блок 949 и включает номер блока в список. Затем оно выделяет этот блок и удаляет его номер из списка. Наконец, оно выделяет блок 109 и удаляет его номер из списка. Поскольку список свободных блоков в суперблоке теперь пуст, ядро снова наполняет список, копируя в него содержимое блока 109, являющегося следующей связью в списке с указателями. На Рисунке 4.20(г) показан заполненный список в суперблоке и следующий связной блок с номером 211.
  алгоритм alloc /* выделение блока файловой системы */
  входная информация: номер файловой системы
  выходная информация: буфер для нового блока
  {
   do (пока суперблок заблокирован)
    sleep (до того момента, когда с суперблока будет снята блокировка);
   удалить блок из списка свободных блоков в суперблоке;
   if (из списка удален последний блок)   {
    заблокировать суперблок;
    прочитать блок, только что взятый из списка свободных (алгоритм bread);
    скопировать номера блоков, хранящиеся в данном блоке, в суперблок;
    освободить блочный буфер (алгоритм brelse);
    снять блокировку с суперблока;
    возобновить выполнение процессов (после снятия блокировки с суперблока);
   }
   получить буфер для блока, удаленного из списка (алгоритм getblk);
   обнулить содержимое буфера;
   уменьшить общее число свободных блоков;
   пометить суперблок как «измененный»;
   return буфер;
  }
  Рисунок 4.19. Алгоритм выделения дискового блока
Алгоритмы назначения и освобождения индексов и дисковых блоков сходятся в том, что ядро использует суперблок в качестве кеша, хранящего указатели на свободные ресурсы — номера блоков и номера индексов. Оно поддерживает список номеров блоков с указателями, такой, что каждый номер свободного блока в файловой системе появляется в некотором элементе списка, но ядро не поддерживает такого списка для свободных индексов. Тому есть три причины.
Ядро устанавливает, свободен ли индекс или нет, проверяя: если поле типа файла очищено, индекс свободен. Ядро не нуждается в другом механизме описания свободных индексов. Тем не менее, оно не может определить, свободен ли блок или нет, только взглянув на него. Ядро не может уловить различия между маской, показывающей, что блок свободен, и информацией, случайно имеющей сходную маску. Следовательно, ядро нуждается во внешнем механизме идентификации свободных блоков, в качестве него в традиционных реализациях системы используется список с указателями.
Сама конструкция дисковых блоков наводит на мысль об использовании списков с указателями: в дисковом блоке легко разместить большие списки номеров свободных блоков. Но индексы не имеют подходящего места для массового хранения списков номеров свободных индексов.
Пользователи имеют склонность чаще расходовать дисковые блоки, нежели индексы, поэтому кажущееся запаздывание в работе при просмотре диска в поисках свободных индексов не является таким критическим, как если бы оно имело место при поисках свободных дисковых блоков.
  Рисунок 4.20. Запрашивание и освобождение дисковых блоков

4.8 ДРУГИЕ ТИПЫ ФАЙЛОВ

В системе UNIX поддерживаются и два других типа файлов: каналы и специальные файлы. Канал, иногда называемый fifo (сокращенно от «first-in-first-out» — «первым пришел — первым вышел» — поскольку обслуживает запросы в порядке поступления), отличается от обычного файла тем, что содержит временные данные: информация, однажды считанная из канала, не может быть прочитана вновь. Кроме того, информация читается в том порядке, в котором она была записана в канале, и система не допускает никаких отклонений от данного порядка. Способ хранения ядром информации в канале не отличается от способа ее хранения в обычном файле, за исключением того, что здесь используются только блоки прямой, а не косвенной, адресации. Конкретное представление о каналах можно будет получить в следующей главе.
Последним типом файлов в системе UNIX являются специальные файлы, к которым относятся специальные файлы устройств ввода-вывода блоками и специальные файлы устройств посимвольного ввода-вывода. Оба подтипа обозначают устройства, и поэтому индексы таких файлов не связаны ни с какой информацией. Вместо этого индекс содержит два номера — старший и младший номера устройства. Старший номер устройства указывает его тип, например, терминал или диск, а младший номер устройства — числовой код, идентифицирующий устройство в группе однородных устройств. Более подробно специальные файлы устройств рассматриваются в главе 10.

4.9 ВЫВОДЫ

Индекс представляет собой структуру данных, в которой описываются атрибуты файла, в том числе расположение информации файла на диске. Существует две разновидности индекса: копия на диске, в которой хранится информация индекса, пока файл находится в работе, и копия в памяти, где хранится информация об активных файлах. Алгоритмы ialloc и ifree управляют назначением файлу дискового индекса во время выполнения системных операций creat, mknod, pipe и unlink (см. следующую главу), а алгоритмы iget и iput управляют выделением индексов в памяти в момент обращения процесса к файлу. Алгоритм bmap определяет местонахождение дисковых блоков, принадлежащих файлу, используя предварительно заданное смещение в байтах от начала файла. Каталоги представляют собой файлы, которые устанавливают соответствие между компонентами имен файлов и номерами индексов. Алгоритм namei преобразует имена файлов, с которыми работают процессы, в идентификаторы индексов, с которыми работает ядро. Наконец, ядро управляет назначением файлу новых дисковых блоков, используя алгоритмы alloc и free.
Структуры данных, рассмотренные в настоящей главе, состоят из связанных списков, хеш-очередей и линейных массивов, и поэтому алгоритмы, работающие с рассмотренными структурами данных, достаточно просты. Сложности появляются тогда, когда возникает конкуренция, вызываемая взаимодействием алгоритмов между собой, и некоторые из этих проблем синхронизации рассмотрены в тексте. Тем не менее, алгоритмы не настолько детально разработаны и могут служить иллюстрацией простоты конструкции системы.
Вышеописанные структуры и алгоритмы работают внутри ядра и невидимы для пользователя. С точки зрения общей архитектуры системы (Рисунок 2.1), алгоритмы, рассмотренные в данной главе, имеют отношение к нижней половине подсистемы управления файлами. Следующая глава посвящена разбору обращений к операционной системе, обеспечивающих функционирование пользовательского интерфейса, и описанию верхней половины подсистемы управления файлами, из которой вызывается выполнение рассмотренных здесь алгоритмов.

4.10 УПРАЖНЕНИЯ

1. В версии V системы UNIX разрешается использовать не более 14 символов на каждую компоненту имени пути поиска. Алгоритм namei отсекает лишние символы в компоненте. Что нужно сделать в файловой системе и в соответствующих алгоритмах, чтобы стали допустимыми имена компонент произвольной длины?
2. Предположим, что пользователь имеет закрытую версию системы UNIX, причем он внес в нее такие изменения, что имя компоненты теперь может состоять из 30 символов; закрытая версия системы обеспечивает тот же способ хранения записей каталогов, как и стандартная операционная система, за исключением того, что записи каталогов имеют длину 32 байта вместо 16. Если пользователь смонтирует закрытую файловую систему в стандартной операционной среде, что произойдет во время работы алгоритма namei, когда процесс обратится к файлу?
*3. Рассмотрим работу алгоритма namei по преобразованию имени пути поиска в идентификатор индекса. В течение всего просмотра ядро проверяет соответствие текущего рабочего индекса индексу каталога. Может ли другой процесс удалить (unlink) каталог? Каким образом ядро предупреждает такие действия? В следующей главе мы вернемся к этой проблеме.
*4. Разработайте структуру каталога, повышающую эффективность поиска имен файлов без использования линейного просмотра. Рассмотрите два способа: хеширование и n-арные деревья.
*5. Разработайте алгоритм сокращения количества просмотров каталога в поисках имени файла, используя запоминание часто употребляемых имен.
*6. В идеальном случае в файловой системе не должно быть свободных индексов с номерами, меньшими, чем номер «запомненного» индекса, используемый алгоритмом ialloc. Как случается, что это утверждение бывает ложным?
7. Суперблок является дисковым блоком и содержит кроме списка свободных блоков и другую информацию, как показано в данной главе. Поэтому список свободных блоков в суперблоке не может содержать больше номеров свободных блоков, чем может поместиться в одном дисковом блоке в связанном списке свободных дисковых блоков. Какое число номеров свободных блоков было бы оптимальным для хранения в одном блоке из связанного списка?

0

6

https://forumupload.ru/uploads/001b/09/4d/2/67355.jpg
ГЛАВА 5. СИСТЕМНЫЕ ОПЕРАЦИИ ДЛЯ РАБОТЫ С ФАЙЛОВОЙ СИСТЕМОЙ

В последней главе рассматривались внутренние структуры данных для файловой системы и алгоритмы работы с ними. В этой главе речь пойдет о системных функциях для работы с файловой системой с использованием понятий, введенных в предыдущей главе. Рассматриваются системные функции, обеспечивающие обращение к существующим файлам, такие как open, read, write, lseek и close, затем функции создания новых файлов, а именно, creat и mknod, и, наконец, функции для работы с индексом или для передвижения по файловой системе: chdir, chroot, chown, stat и fstat. Исследуются более сложные системные функции: pipe и dup имеют важное значение для реализации каналов в shell'е; mount и umount расширяют видимое для пользователя дерево файловых систем; link и unlink изменяют иерархическую структуру файловой системы. Затем дается представление об абстракциях, связанных с файловой системой, в отношении поддержки различных файловых систем, подчиняющихся стандартным интерфейсам. В последнем разделе главы речь пойдет о сопровождении файловой системы. Глава знакомит с тремя структурами данных ядра: таблицей файлов, в которой каждая запись связана с одним из открытых в системе файлов, таблицей пользовательских дескрипторов файлов, в которой каждая запись связана с файловым дескриптором, известным процессу, и таблицей монтирования, в которой содержится информация по каждой активной файловой системе.

Функции для работы с файловой системой
Возвращают дескрипторы файла Используют алгоритм namei Назначают индексы Работают с атрибутами файла Ввод-вывод из файла Работают со структурой файловых систем Управление деревьями
open creat dup pipe close open stat creat link chdir chroot chown chmod unlink mknod mount umount creat mknod link unlink chown chmod stat read write lseek mount umount chdir chown
Алгоритмы работы с файловой системой на нижнем уровне
namei iget iput bmap ialloc ifree alloc free bmap
Алгоритмы работы с буферами
getblk brelse bread breada bwrite

  Рисунок 5.1. Функции для работы с файловой системой и их связь с другими алгоритмами
На Рисунке 5.1 показана взаимосвязь между системными функциями и алгоритмами, описанными ранее. Системные функции классифицируются на несколько категорий, хотя некоторые из функций присутствуют более, чем в одной категории:
• Системные функции, возвращающие дескрипторы файлов для использования другими системными функциями;
• Системные функции, использующие алгоритм namei для анализа имени пути поиска;
• Системные функции, назначающие и освобождающие индекс с использованием алгоритмов ialloc и ifree;
• Системные функции, устанавливающие или изменяющие атрибуты файла;
• Системные функции, позволяющие процессу производить ввод-вывод данных с использованием алгоритмов alloc, free и алгоритмов выделения буфера;
• Системные функции, изменяющие структуру файловой системы;
• Системные функции, позволяющие процессу изменять собственное представление о структуре дерева файловой системы.

5.1 OPEN

Вызов системной функции open (открыть файл) — это первый шаг, который должен сделать процесс, чтобы обратиться к данным в файле. Синтаксис вызова функции open:
fd = open(pathname, flags, modes);
где pathname — имя файла, flags указывает режим открытия (например, для чтения или записи), а modes содержит права доступа к файлу в случае, если файл создается. Системная функция open возвращает целое число [14] , именуемое пользовательским дескриптором файла. Другие операции над файлами, такие как чтение, запись, позиционирование головок чтения-записи, воспроизведение дескриптора файла, установка параметров ввода-вывода, определение статуса файла и закрытие файла, используют значение дескриптора файла, возвращаемое системной функцией open.
Ядро просматривает файловую систему в поисках файла по его имени, используя алгоритм namei (см. Рисунок 5.2). Оно проверяет права на открытие файла после того, как обнаружит копию индекса файла в памяти, и выделяет открываемому файлу запись в таблице файлов. Запись таблицы файлов содержит указатель на индекс открытого файла и поле, в котором хранится смещение в байтах от начала файла до места, откуда предполагается начинать выполнение последующих операций чтения или записи. Ядро сбрасывает это смещение в 0 во время открытия файла, имея в виду, что исходная операция чтения или записи по умолчанию будет производиться с начала файла. С другой стороны, процесс может открыть файл в режиме записи в конец, в этом случае ядро устанавливает значение смещения, равное размеру файла. Ядро выделяет запись в личной (закрытой) таблице в адресном пространстве задачи, выделенном процессу (таблица эта называется таблицей пользовательских дескрипторов файлов), и запоминает указатель на эту запись. Указателем выступает дескриптор файла, возвращаемый пользователю. Запись в таблице пользовательских файлов указывает на запись в глобальной таблице файлов.
  алгоритм open
  входная информация: 
   имя файла
   режим открытия
   права доступа (при создании файла)
  выходная информация: дескриптор файла
  {
   превратить имя файла в идентификатор индекса (алгоритм namei);
   if (файл не существует или к нему не разрешен доступ) return   (код ошибки);
   выделить для индекса запись в таблице файлов, инициализировать счетчик, смещение;
   выделить запись в таблице пользовательских дескрипторов файла, установить указатель на запись в таблице файлов;
   if (режим открытия подразумевает усечение файла)   освободить все блоки файла (алгоритм free);
   снять блокировку (с индекса); /* индекс заблокирован выше, в алгоритме namei */
   return (пользовательский дескриптор файла);
  }
  Рисунок 5.2. Алгоритм открытия файла
Предположим, что процесс, открывая файл «/etc/passwd» дважды, один раз только для чтения и один раз только для записи, и однажды файл «local» для чтения и для записи [15] , выполняет следующий набор операторов:
fd1 = open("/etc/passwd", O_RDONLY);
fd2 = open("local", O_RDWR);
fd3 = open("/etc/passwd", O_WRONLY);
На Рисунке 5.3 показана взаимосвязь между таблицей индексов, таблицей файлов и таблицей пользовательских дескрипторов файла. Каждый вызов функции open возвращает процессу дескриптор файла, а соответствующая запись в таблице пользовательских дескрипторов файла указывает на уникальную запись в таблице файлов ядра, пусть даже один и тот же файл ("/etc/passwd") открывается дважды. Записи в таблице файлов для всех экземпляров одного и того же открытого файла указывают на одну запись в таблице индексов, хранящихся в памяти. Процесс может обращаться к файлу «/etc/passwd» с чтением или записью, но только через дескрипторы файла, имеющие значения 3 и 5 (см. Рисунок). Ядро запоминает разрешение на чтение или запись в файл в строке таблицы файлов, выделенной во время выполнения функции open. Предположим, что второй процесс выполняет следующий набор операторов:
fd1 = open("/etc/passwd", O_RDONLY);
fd2 = open("private", O_RDONLY);
  Рисунок 5.3. Структуры данных после открытия
На Рисунке 5.4 показана взаимосвязь между соответствующими структурами данных, когда оба процесса (и больше никто) имеют открытые файлы. Снова результатом каждого вызова функции open является выделение уникальной точки входа в таблице пользовательских дескрипторов файла и в таблице файлов ядра, и ядро хранит не более одной записи на каждый файл в таблице индексов, размещенных в памяти.
Запись в таблице пользовательских дескрипторов файла по умолчанию хранит смещение в файле до адреса следующей операции ввода-вывода и указывает непосредственно на точку входа в таблице индексов для файла, устраняя необходимость в отдельной таблице файлов ядра. Вышеприведенные примеры показывают взаимосвязь между записями таблицы пользовательских дескрипторов файла и записями в таблице файлов ядра типа «один к одному». Томпсон, однако, отмечает, что им была реализована таблица файлов как отдельная структура, позволяющая совместно использовать один и тот же указатель смещения нескольким пользовательским дескрипторам файла (см. [Thompson 78], стр.1943). В системных функциях dup и fork, рассматриваемых в разделах 5.13 и 7.1, при работе со структурами данных допускается такое совместное использование.
  Рисунок 5.4. Структуры данных после того, как два процесса произвели открытие файлов
Первые три пользовательских дескриптора (0, 1 и 2) именуются дескрипторами файлов: стандартного ввода, стандартного вывода и стандартного файла ошибок. Процессы в системе UNIX по договоренности используют дескриптор файла стандартного ввода при чтении вводимой информации, дескриптор файла стандартного вывода при записи выводимой информации и дескриптор стандартного файла ошибок для записи сообщений об ошибках. В операционной системе нет никакого указания на то, что эти дескрипторы файлов являются специальными. Группа пользователей может условиться о том, что файловые дескрипторы, имеющие значения 4, 6 и 11, являются специальными, но более естественно начинать отсчет с 0 (как в языке Си). Принятие соглашения сразу всеми пользовательскими программами облегчит связь между ними при использовании каналов, в чем мы убедимся в дальнейшем, изучая главу 7. Обычно операторский терминал (см. главу 10) служит и в качестве стандартного ввода, и в качестве стандартного вывода и в качестве стандартного устройства вывода сообщений об ошибках.

5.2 READ

Синтаксис вызова системной функции read (читать):
number = read(fd, buffer, count)
где fd — дескриптор файла, возвращаемый функцией open, buffer — адрес структуры данных в пользовательском процессе, где будут размещаться считанные данные в случае успешного завершения выполнения функции read, count — количество байт, которые пользователю нужно прочитать, number — количество фактически прочитанных байт. На Рисунке 5.5 приведен алгоритм read, выполняющий чтение обычного файла. Ядро обращается в таблице файлов к записи, которая соответствует значению пользовательского дескриптора файла, следуя за указателем (см. Рисунок 5.3). Затем оно устанавливает значения нескольких параметров ввода-вывода в адресном пространстве процесса (Рисунок 5.6), тем самым устраняя необходимость в их передаче в качестве параметров функции. В частности, ядро указывает в качестве режима ввода-вывода «чтение», устанавливает флаг, свидетельствующий о том, что ввод-вывод направляется в адресное пространство пользователя, значение поля счетчика байтов приравнивает количеству байт, которые будут прочитаны, устанавливает адрес пользовательского буфера данных и, наконец, значение смещения (из таблицы файлов), равное смещению в байтах внутри файла до места, откуда начинается ввод-вывод. После того, как ядро установит значения параметров ввода-вывода в адресном пространстве процесса, оно обращается к индексу, используя указатель из таблицы файлов, и блокирует его прежде, чем начать чтение из файла.
  алгоритм read
  входная информация:
   пользовательский дескриптор файла
   адрес буфера в пользовательском процессе
   количество байт, которые нужно прочитать
  выходная информация: количество байт, скопированных в пользовательское пространство
  {
   обратиться к записи в таблице файлов по значению пользовательского дескриптора файла;
   проверить доступность файла;
   установить параметры в адресном пространстве процесса, указав адрес пользователя, счетчик байтов, параметры ввода-вывода для пользователя;
   получить индекс по записи в таблице файлов;
   заблокировать индекс;
   установить значение смещения в байтах для адресного пространства процесса по значению смещения в таблице файлов;
   do (пока значение счетчика байтов не станет удовлетворительным)    {
    превратить смещение в файле в номер дискового блока (алгоритм bmap);
    вычислить смещение внутри блока и количество байт, которые будут прочитаны;
    if (количество байт для чтения == 0) /* попытка чтения конца файла */   break; /* выход из цикла */
    прочитать блок (алгоритм breada, если производится чтение с продвижением, и алгоритм bread — в противном случае);
    скопировать данные из системного буфера по адресу пользователя;
    скорректировать значения полей в адресном пространстве процесса, указывающие смещение в байтах внутри файла, количество прочитанных байт и адрес для передачи в пространство пользователя;
    освободить буфер; /* заблокированный в алгоритме bread */
   }
   разблокировать индекс;
   скорректировать значение смещения в таблице файлов для следующей операции чтения;
   return (общее число прочитанных байт);
  }
  Рисунок 5.5. Алгоритм чтения из файла
  mode чтение или запись
  count количество байт для чтения или записи
  offset смещение в байтах внутри файла
  address адрес места, куда будут копироваться данные, в памяти пользователя или ядра
  flag отношение адреса к памяти пользователя или к памяти ядра
  Рисунок 5.6. Параметры ввода-вывода, хранящиеся в пространстве процесса
Затем в алгоритме начинается цикл, выполняющийся до тех пор, пока операция чтения не будет произведена до конца. Ядро преобразует смещение в байтах внутри файла в номер блока, используя алгоритм bmap, и вычисляет смещение внутри блока до места, откуда следует начать ввод-вывод, а также количество байт, которые будут прочитаны из блока. После считывания блока в буфер, возможно, с продвижением (алгоритмы bread и breada) ядро копирует данные из блока по назначенному адресу в пользовательском процессе. Оно корректирует параметры ввода-вывода в адресном пространстве процесса в соответствии с количеством прочитанных байт, увеличивая значение смещения в байтах внутри файла и адрес места в пользовательском процессе, куда будет доставлена следующая порция данных, и уменьшая число байт, которые необходимо прочитать, чтобы выполнить запрос пользователя. Если запрос пользователя не удовлетворен, ядро повторяет весь цикл, преобразуя смещение в байтах внутри файла в номер блока, считывая блок с диска в системный буфер, копируя данные из буфера в пользовательский процесс, освобождая буфер и корректируя значения параметров ввода-вывода в адресном пространстве процесса. Цикл завершается, либо когда ядро выполнит запрос пользователя полностью, либо когда в файле больше не будет данных, либо если ядро обнаружит ошибку при чтении данных с диска или при копировании данных в пространство пользователя. Ядро корректирует значение смещения в таблице файлов в соответствии с количеством фактически прочитанных байт; поэтому успешное выполнение операций чтения выглядит как последовательное считывание данных из файла. Системная операция lseek (раздел 5.6) устанавливает значение смещения в таблице файлов и изменяет порядок, в котором процесс читает или записывает данные в файле.
  #include ‹fcntl.h›
  main()   {
   int fd;
   char lilbuf[20], bigbuf[1024];
   fd = open("/etc/passwd", O_RDONLY);
   read(fd, lilbuf, 20);
   read(fd, bigbuf, 1024);
   read(fd, lilbuf, 20);
  }
  Рисунок 5.7. Пример программы чтения из файла
Рассмотрим программу, приведенную на Рисунке 5.7. Функция open возвращает дескриптор файла, который пользователь засылает в переменную fd и использует в последующих вызовах функции read. Выполняя функцию read, ядро проверяет, правильно ли задан параметр «дескриптор файла», а также был ли файл предварительно открыт процессом для чтения. Оно сохраняет значение адреса пользовательского буфера, количество считываемых байт и начальное смещение в байтах внутри файла (соответственно: lilbuf, 20 и 0), в пространстве процесса. В результате вычислений оказывается, что нулевое значение смещения соответствует нулевому блоку файла, и ядро возвращает точку входа в индекс, соответствующую нулевому блоку. Предполагая, что такой блок существует, ядро считывает полный блок размером 1024 байта в буфер, но по адресу lilbuf копирует только 20 байт. Оно увеличивает смещение внутри пространства процесса на 20 байт и сбрасывает счетчик данных в 0. Поскольку операция read выполнилась, ядро переустанавливает значение смещения в таблице файлов на 20, так что последующие операции чтения из файла с данным дескриптором начнутся с места, расположенного со смещением 20 байт от начала файла, а системная функция возвращает число байт, фактически прочитанных, т. е. 20.
При повторном вызове функции read ядро вновь проверяет корректность указания дескриптора и наличие соответствующего файла, открытого процессом для чтения, поскольку оно никак не может узнать, что запрос пользователя на чтение касается того же самого файла, существование которого было установлено во время последнего вызова функции. Ядро сохраняет в пространстве процесса пользовательский адрес bigbuf, количество байт, которые нужно прочитать процессу (1024), и начальное смещение в файле (20), взятое из таблицы файлов. Ядро преобразует смещение внутри файла в номер дискового блока, как раньше, и считывает блок. Если между вызовами функции read прошло непродолжительное время, есть шансы, что блок находится в буферном кеше. Однако, ядро не может полностью удовлетворить запрос пользователя на чтение за счет содержимого буфера, поскольку только 1004 байта из 1024 для данного запроса находятся в буфере. Поэтому оно копирует оставшиеся 1004 байта из буфера в пользовательскую структуру данных bigbuf и корректирует параметры в пространстве процесса таким образом, чтобы следующий шаг цикла чтения начинался в файле с байта 1024, при этом данные следует копировать по адресу байта 1004 в bigbuf в объеме 20 байт, чтобы удовлетворить запрос на чтение.
Теперь ядро переходит к началу цикла, содержащегося в алгоритме read. Оно преобразует смещение в байтах (1024) в номер логического блока (1), обращается ко второму блоку прямой адресации, номер которого хранится в индексе, и отыскивает точный дисковый блок, из которого будет производиться чтение. Ядро считывает блок из буферного кеша или с диска, если в кеше данный блок отсутствует. Наконец, оно копирует 20 байт из буфера по уточненному адресу в пользовательский процесс. Прежде чем выйти из системной функции, ядро устанавливает значение поля смещения в таблице файлов равным 1044, то есть равным значению смещения в байтах до места, куда будет производиться следующее обращение. В последнем вызове функции read из примера ядро ведет себя, как и в первом обращении к функции, за исключением того, что чтение из файла в данном случае начинается с байта 1044, так как именно это значение будет обнаружено в поле смещения той записи таблицы файлов, которая соответствует указанному дескриптору.
Пример показывает, насколько выгодно для запросов ввода-вывода работать с данными, начинающимися на границах блоков файловой системы и имеющими размер, кратный размеру блока. Это позволяет ядру избегать дополнительных итераций при выполнении цикла в алгоритме read и всех вытекающих последствий, связанных с дополнительными обращениями к индексу в поисках номера блока, который содержит данные, и с конкуренцией за использование буферного пула. Библиотека стандартных модулей ввода-вывода создана таким образом, чтобы скрыть от пользователей размеры буферов ядра; ее использование позволяет избежать потерь производительности, присущих процессам, работающим с небольшими порциями данных, из-за чего их функционирование на уровне файловой системы неэффективно (см. упражнение 5.4).
Выполняя цикл чтения, ядро определяет, является ли файл объектом чтения с продвижением: если процесс считывает последовательно два блока, ядро предполагает, что все очередные операции будут производить последовательное чтение, до тех пор, пока не будет утверждено обратное. На каждом шаге цикла ядро запоминает номер следующего логического блока в копии индекса, хранящейся в памяти, и на следующем шаге сравнивает номер текущего логического блока со значением, запомненным ранее. Если эти номера равны, ядро вычисляет номер физического блока для чтения с продвижением и сохраняет это значение в пространстве процесса для использования в алгоритме breada. Конечно же, пока процесс не считал конец блока, ядро не запустит алгоритм чтения с продвижением для следующего блока.
Обратившись к Рисунку 4.9, вспомним, что номера некоторых блоков в индексе или в блоках косвенной адресации могут иметь нулевое значение, пусть даже номера последующих блоков и ненулевые. Если процесс попытается прочитать данные из такого блока, ядро выполнит запрос, выделяя произвольный буфер в цикле read, очищая его содержимое и копируя данные из него по адресу пользователя. Этот случай не имеет ничего общего с тем случаем, когда процесс обнаруживает конец файла, говорящий о том, что после этого места запись информации никогда не производилась. Обнаружив конец файла, ядро не возвращает процессу никакой информации (см. упражнение 5.1).
Когда процесс вызывает системную функцию read, ядро блокирует индекс на время выполнения вызова. Впоследствии, этот процесс может приостановиться во время чтения из буфера, ассоциированного с данными или с блоками косвенной адресации в индексе. Если еще одному процессу дать возможность вносить изменения в файл в то время, когда первый процесс приостановлен, функция read может возвратить несогласованные данные. Например, процесс может считать из файла несколько блоков; если он приостановился во время чтения первого блока, а второй процесс собирался вести запись в другие блоки, возвращаемые данные будут содержать старые данные вперемешку с новыми. Таким образом, индекс остается заблокированным на все время выполнения вызова функции read для того, чтобы процессы могли иметь целостное видение файла, то есть видение того образа, который был у файла перед вызовом функции.
Ядро может выгружать процесс, ведущий чтение, в режим задачи на время между двумя вызовами функций и планировать запуск других процессов. Так как по окончании выполнения системной функции с индекса снимается блокировка, ничто не мешает другим процессам обращаться к файлу и изменять его содержимое. Со стороны системы было бы несправедливо держать индекс заблокированным все время от момента, когда процесс открыл файл, и до того момента, когда файл будет закрыт этим процессом, поскольку тогда один процесс будет держать все время файл открытым, тем самым не давая другим процессам возможности обратиться к файлу. Если файл имеет имя «/etc/ passwd», то есть является файлом, используемым в процессе регистрации для проверки пользовательского пароля, один пользователь может умышленно (или, возможно, неумышленно) воспрепятствовать регистрации в системе всех остальных пользователей. Чтобы предотвратить возникновение подобных проблем, ядро снимает с индекса блокировку по окончании выполнения каждого вызова системной функции, использующей индекс. Если второй процесс внесет изменения в файл между двумя вызовами функции read, производимыми первым процессом, первый процесс может прочитать непредвиденные данные, однако структуры данных ядра сохранят свою согласованность.
Предположим, к примеру, что ядро выполняет два процесса, конкурирующие между собой (Рисунок 5.8). Если допустить, что оба процесса выполняют операцию open до того, как любой из них вызывает системную функцию read или write, ядро может выполнять функции чтения и записи в любой из шести последовательностей: чтение1, чтение2, запись1, запись2, или чтение1, запись1, чтение2, запись2, или чтение1, запись1, запись2, чтение2 и т. д. Состав информации, считываемой процессом A, зависит от последовательности, в которой система выполняет функции, вызываемые двумя процессами; система не гарантирует, что данные в файле останутся такими же, какими они были после открытия файла. Использование возможности захвата файла и записей (раздел 5.4) позволяет процессу гарантировать сохранение целостности файла после его открытия.
  #include ‹fcntl.h›
  /* процесс A */
  main()   {
   int fd;
   char buf[512];
   fd = open("/etc/passwd", O_RDONLY);
   read(fd, buf, sizeof(buf)); /* чтение1 */
   read(fd, buf, sizeof(buf)); /* чтение2 */ 
  }
  /* процесс B */
  main()   {
   int fd, i;
   char buf[512];
   for (i = 0; i ‹ sizeof(buf); i++)   buf[i] = 'a';
   fd = open("/etc/passwd", O_WRONLY);
   write(fd, buf, sizeof(buf)); /* запись1 */
   write(fd, buf, sizeof(buf)); /* запись2 */
  }
  Рисунок 5.8. Процессы, ведущие чтение и запись файла
Наконец, программа на Рисунке 5.9 показывает, как процесс может открывать файл более одного раза и читать из него, используя разные файловые дескрипторы. Ядро работает со значениями смещений в таблице файлов, ассоциированными с двумя файловыми дескрипторами, независимо, и поэтому массивы buf1 и buf2 будут по завершении выполнения процесса идентичны друг другу при условии, что ни один процесс в это время не производил запись в файл «/etc/passwd».
  #include ‹fcntl.h›
  main()   {
   int fd1, fd2;
   char buf1[512], buf2[512];
   fd1 = open("/etc/passwd", O_RDONLY); 
   fd2 = open("/etc/passwd", O_RDONLY); 
   read(fd1, buf1, sizeof(buf1));
   read(fd2, buf2, sizeof(buf2)); 
  }
  Рисунок 5.9. Чтение из файла с использованием двух дескрипторов

5.3 WRIТЕ

Синтаксис вызова системной функции write (писать):
number = write(fd, buffer, count);
где переменные fd, buffer, count и number имеют тот же смысл, что и для вызова системной функции read. Алгоритм записи в обычный файл похож на алгоритм чтения из обычного файла. Однако, если в файле отсутствует блок, соответствующий смещению в байтах до места, куда должна производиться запись, ядро выделяет блок, используя алгоритм alloc, и присваивает ему номер в соответствии с точным указанием места в таблице содержимого индекса. Если смещение в байтах совпадает со смещением для блока косвенной адресации, ядру, возможно, придется выделить несколько блоков для использования их в качестве блоков косвенной адресации и информационных блоков. Индекс блокируется на все время выполнения функции write, так как ядро может изменить индекс, выделяя новые блоки; разрешение другим процессам обращаться к файлу может разрушить индекс, если несколько процессов выделяют блоки одновременно, используя одни и те же значения смещений. Когда запись завершается, ядро корректирует размер файла в индексе, если файл увеличился в размере.
Предположим, к примеру, что процесс записывает в файл байт с номером 10240, наибольшим номером среди уже записанных в файле. Обратившись к байту в файле по алгоритму bmap, ядро обнаружит, что в файле отсутствует не только соответствующий этому байту блок, но также и нужный блок косвенной адресации. Ядро назначает дисковый блок в качестве блока косвенной адресации и записывает номер блока в копии индекса, хранящейся в памяти. Затем оно выделяет дисковый блок под данные и записывает его номер в первую позицию вновь созданного блока косвенной адресации.
Так же, как в алгоритме read, ядро входит в цикл, записывая на диск по одному блоку на каждой итерации. При этом на каждой итерации ядро определяет, будет ли производиться запись целого блока или только его части. Если записывается только часть блока, ядро в первую очередь считывает блок с диска для того, чтобы не затереть те части, которые остались без изменений, а если записывается целый блок, ядру не нужно читать весь блок, так как в любом случае оно затрет предыдущее содержимое блока. Запись осуществляется поблочно, однако ядро использует отложенную запись (раздел 3.4) данных на диск, запоминая их в кеше на случай, если они понадобятся вскоре другому процессу для чтения или записи, а также для того, чтобы избежать лишних обращений к диску. Отложенная запись, вероятно, наиболее эффективна для каналов, так как другой процесс читает канал и удаляет из него данные (раздел 5.12). Но даже для обычных файлов отложенная запись эффективна, если файл создается временно и вскоре будет прочитан. Например, многие программы, такие как редакторы и электронная почта, создают временные файлы в каталоге «/tmp» и быстро удаляют их. Использование отложенной записи может сократить количество обращений к диску для записи во временные файлы.

5.4 ЗАХВАТ ФАЙЛА И ЗАПИСИ

В первой версии системы UNIX, разработанной Томпсоном и Ричи, отсутствовал внутренний механизм, с помощью которого процессу мог бы быть обеспечен исключительный доступ к файлу. Механизм захвата был признан излишним, поскольку, как отмечает Ричи, «мы не имеем дела с большими базами данных, состоящими из одного файла, которые поддерживаются независимыми процессами» (см. [Ritchie 81]). Для того, чтобы повысить привлекательность системы UNIX для коммерческих пользователей, работающих с базами данных, в версию V системы ныне включены механизмы захвата файла и записи. Захват файла — это средство, позволяющее запретить другим процессам производить чтение или запись любой части файла, а захват записи — это средство, позволяющее запретить другим процессам производить ввод-вывод указанных записей (частей файла между указанными смещениями). В упражнении 5.9 рассматривается реализация механизма захвата файла и записи.

5.5 УКАЗАНИЕ МЕСТА В ФАЙЛЕ, ГДЕ БУДЕТ ВЫПОЛНЯТЬСЯ ВВОД-ВЫВОД — LSEEК

Обычное использование системных функций read и write обеспечивает последовательный доступ к файлу, однако процессы могут использовать вызов системной функции lseek для указания места в файле, где будет производиться ввод-вывод, и осуществления произвольного доступа к файлу. Синтаксис вызова системной функции:
position = lseek(fd, offset, reference);
где fd — дескриптор файла, идентифицирующий файл, offset — смещение в байтах, а reference указывает, является ли значение offset смещением от начала файла, смещением от текущей позиции ввода-вывода или смещением от конца файла. Возвращаемое значение, position, является смещением в байтах до места, где будет начинаться следующая операция чтения или записи. Например, в программе, приведенной на Рисунке 5.10, процесс открывает файл, считывает байт, а затем вызывает функцию lseek, чтобы заменить значение поля смещения в таблице файлов величиной, равной 1023 (с переменной reference, имеющей значение 1), и выполняет цикл. Таким образом, программа считывает каждый 1024-й байт файла. Если reference имеет значение 0, ядро осуществляет поиск от начала файла, а если 2, ядро ведет поиск от конца файла. Функция lseek ничего не должна делать, кроме операции поиска, которая позиционирует головку чтения-записи на указанный дисковый сектор. Для того, чтобы выполнить функцию lseek, ядро просто выбирает значение смещения из таблицы файлов; в последующих вызовах функций read и write смещение из таблицы файлов используется в качестве начального смещения.
  #include ‹fcntl.h›
  main(argc, argv)
  int argc; char *argv[];
  {
   int fd, skval; 
   char c;
   if (argc != 2) exit();
   fd = open(argv[1], O_RDONLY);
   if (fd == -1) exit();
   while ((skval = read(fd, &c,1 )) == 1)    {
    printf("char %c\n", c);
    skval = lseek(fd, 1023L, 1);
    printf("new seek val %d\n",   skval);
   }
  }
  Рисунок 5.10. Программа, содержащая вызов системной функции lseek

5.6 CLOSЕ

Процесс закрывает открытый файл, когда процессу больше не нужно обращаться к нему. Синтаксис вызова системной функции close (закрыть):
close(fd);
где fd — дескриптор открытого файла. Ядро выполняет операцию закрытия, используя дескриптор файла и информацию из соответствующих записей в таблице файлов и таблице индексов. Если счетчик ссылок в записи таблицы файлов имеет значение, большее, чем 1, в связи с тем, что были обращения к функциям dup или fork, то это означает, что на запись в таблице файлов делают ссылку другие пользовательские дескрипторы, что мы увидим далее; ядро уменьшает значение счетчика и операция закрытия завершается. Если счетчик ссылок в таблице файлов имеет значение, равное 1, ядро освобождает запись в таблице и индекс в памяти, ранее выделенный системной функцией open (алгоритм iput). Если другие процессы все еще ссылаются на индекс, ядро уменьшает значение счетчика ссылок на индекс, но оставляет индекс процессам; в противном случае индекс освобождается для переназначения, так как его счетчик ссылок содержит 0. Когда выполнение системной функции close завершается, запись в таблице пользовательских дескрипторов файла становится пустой. Попытки процесса использовать данный дескриптор заканчиваются ошибкой до тех пор, пока дескриптор не будет переназначен другому файлу в результате выполнения другой системной функции. Когда процесс завершается, ядро проверяет наличие активных пользовательских дескрипторов файла, принадлежавших процессу, и закрывает каждый из них. Таким образом, ни один процесс не может оставить файл открытым после своего завершения.
На Рисунке 5.11, например, показаны записи из таблиц, приведенных на Рисунке 5.4, после того, как второй процесс закрывает соответствующие им файлы. Записи, соответствующие дескрипторам 3 и 4 в таблице пользовательских дескрипторов файлов, пусты. Счетчики в записях таблицы файлов теперь имеют значение 0, а сами записи пусты. Счетчики ссылок на файлы «/etc/passwd» и «private» в индексах также уменьшились. Индекс для файла «private» находится в списке свободных индексов, поскольку счетчик ссылок на него равен 0, но запись о нем не пуста. Если еще какой-нибудь процесс обратится к файлу «private», пока индекс еще находится в списке свободных индексов, ядро востребует индекс обратно, как показано в разделе 4.1.2.
  Рисунок 5.11. Таблицы после закрытия файла

5.7 СОЗДАНИЕ ФАЙЛА

Системная функция open дает процессу доступ к существующему файлу, а системная функция creat создает в системе новый файл. Синтаксис вызова системной функции creat:
fd = creat(pathname, modes);
где переменные pathname, modes и fd имеют тот же смысл, что и в системной функции open. Если прежде такого файла не существовало, ядро создает новый файл с указанным именем и указанными правами доступа к нему; если же такой файл уже существовал, ядро усекает файл (освобождает все существующие блоки данных и устанавливает размер файла равным 0) при наличии соответствующих прав доступа к нему [16] . На Рисунке 5.12 приведен алгоритм создания файла.
  алгоритм creat
  входная информация:
   имя файла
   установки прав доступа к файлу
  выходная информация: дескриптор файла
  {
   получить индекс для данного имени файла (алгоритм namei);
   if (файл уже существует)   {
    if (доступ не разрешен)   {
     освободить индекс (алгоритм iput);
     return (ошибку);
    }
   }
   else { /* файл еще не существует */
    назначить свободный индекс из файловой системы (алгоритм ialloc);
    создать новую точку входа в родительском каталоге;
    включить имя нового файла и номер вновь назначенного индекса;
   }
   выделить для индекса запись в таблице файлов, инициализировать счетчик;
   if (файл существовал к моменту создания)   освободить все блоки файла (алгоритм free);
   снять блокировку (с индекса);
   return (пользовательский дескриптор файла);
  }
  Рисунок 5.12. Алгоритм создания файла
Ядро проводит синтаксический анализ имени пути поиска, используя алгоритм namei и следуя этому алгоритму буквально, когда речь идет о разборе имен каталогов. Однако, когда дело касается последней компоненты имени пути поиска, а именно идентификатора создаваемого файла, namei отмечает смещение в байтах до первой пустой позиции в каталоге и запоминает это смещение в пространстве процесса. Если ядро не обнаружило в каталоге компоненту имени пути поиска, оно в конечном счете впишет имя компоненты в только что найденную пустую позицию. Если в каталоге нет пустых позиций, ядро запоминает смещение до конца каталога и создает новую позицию там. Оно также запоминает в пространстве процесса индекс просматриваемого каталога и держит индекс заблокированным; каталог становится по отношению к новому файлу родительским каталогом. Ядро не записывает пока имя нового файла в каталог, так что в случае возникновения ошибок ядру приходится меньше переделывать. Оно проверяет наличие у процесса разрешения на запись в каталог. Поскольку процесс будет производить запись в каталог в результате выполнения функции creat, наличие разрешения на запись в каталог означает, что процессам дозволяется создавать файлы в каталоге.
Предположив, что под данным именем ранее не существовало файла, ядро назначает новому файлу индекс, используя алгоритм ialloc (раздел 4.6). Затем оно записывает имя нового файла и номер вновь выделенного индекса в родительский каталог, а смещение в байтах сохраняет в пространстве процесса. Впоследствии ядро освобождает индекс родительского каталога, удерживаемый с того времени, когда в каталоге производился поиск имени файла. Родительский каталог теперь содержит имя нового файла и его индекс. Ядро записывает вновь выделенный индекс на диск (алгоритм bwrite), прежде чем записать на диск каталог с новым именем. Если между операциями записи индекса и каталога произойдет сбой системы, в итоге окажется, что выделен индекс, на который не ссылается ни одно из имен путей поиска в системе, однако система будет функционировать нормально. Если, с другой стороны, каталог был записан раньше вновь выделенного индекса и сбой системы произошел между ними, файловая система будет содержать имя пути поиска, ссылающееся на неверный индекс (более подробно об этом см. в разделе 5.16.1).
Если данный файл уже существовал до вызова функции creat, ядро обнаруживает его индекс во время поиска имени файла. Старый файл должен позволять процессу производить запись в него, чтобы можно было создать «новый» файл с тем же самым именем, так как ядро изменяет содержимое файла при выполнении функции creat: оно усекает файл, освобождая все информационные блоки по алгоритму free, так что файл будет выглядеть как вновь созданный. Тем не менее, владелец и права доступа к файлу остаются прежними: ядро не передает право собственности на файл владельцу процесса и игнорирует права доступа, указанные процессом в вызове функции. Наконец, ядро не проверяет наличие разрешения на запись в каталог, являющийся родительским для существующего файла, поскольку оно не меняет содержимого каталога.
Функция creat продолжает работу, выполняя тот же алгоритм, что и функция open. Ядро выделяет созданному файлу запись в таблице файлов, чтобы процесс мог читать из файла, а также запись в таблице пользовательских дескрипторов файла, и в конце концов возвращает указатель на последнюю запись в виде пользовательского дескриптора файла.)

5.8 СОЗДАНИЕ СПЕЦИАЛЬНЫХ ФАЙЛОВ

Системная функция mknod создает в системе специальные файлы, в число которых включаются поименованные каналы, файлы устройств и каталоги. Она похожа на функцию creat в том, что ядро выделяет для файла индекс. Синтаксис вызова системной функции mknod:
mknod(pathname, type and permissions, dev)
где pathname — имя создаваемой вершины в иерархической структуре файловой системы, type and permissions — тип вершины (например, каталог) и права доступа к создаваемому файлу, а dev указывает старший и младший номера устройства для блочных и символьных специальных файлов (глава 10). На Рисунке 5.13 приведен алгоритм, реализуемый функцией mknod при создании новой вершины.
  алгоритм создания новой вершины
  входная информация:
   вершина (имя файла)
   тип файла
   права доступа
   старший, младший номера устройства (для блочных и символьных специальных файлов)
  выходная информация: отсутствует
  {
   if (новая вершина не является поименованным каналом и пользователь не является суперпользователем)
    return (ошибку);
   получить индекс вершины, являющейся родительской для новой вершины (алгоритм namei);
   if (новая вершина уже существует)   {
    освободить родительский индекс (алгоритм iput);
    return (ошибку);
   }
   назначить для новой вершины свободный индекс из файловой системы (алгоритм ialloc);
   создать новую запись в родительском каталоге;
   включить имя новой вершины и номер вновь назначенного индекса;
   освободить индекс родительского каталога (алгоритм iput);
   if (новая вершина является блочным или символьным специальным файлом)
    записать старший и младший номера в структуру индекса;
   освободить индекс новой вершины (алгоритм iput);
  }
  Рисунок 5.13. Алгоритм создания новой вершины
Ядро просматривает файловую систему в поисках имени файла, который оно собирается создать. Если файл еще пока не существует, ядро назначает ему новый индекс на диске и записывает имя нового файла и номер индекса в родительский каталог. Оно устанавливает значение поля типа файла в индексе, указывая, что файл является каналом, каталогом или специальным файлом. Наконец, если файл является специальным файлом устройства блочного или символьного типа, ядро записывает в индекс старший и младший номера устройства. Если функция mknod создает каталог, он будет существовать по завершении выполнения функции, но его содержимое будет иметь неверный формат (в каталоге будут отсутствовать записи с именами «.» и «..»). В упражнении 5.33 рассматриваются шаги, необходимые для преобразования содержимого каталога в правильный формат.

5.9 СМЕНА ТЕКУЩЕГО И КОРНЕВОГО КАТАЛОГА

Когда система загружается впервые, нулевой процесс делает корневой каталог файловой системы текущим на время инициализации. Для индекса корневого каталога нулевой процесс выполняет алгоритм iget, сохраняет этот индекс в пространстве процесса в качестве индекса текущего каталога и снимает с индекса блокировку. Когда с помощью функции fork создается новый процесс, он наследует текущий каталог старого процесса в своем адресном пространстве, а ядро, соответственно, увеличивает значение счетчика ссылок в индексе.
  алгоритм смены каталога
  входная информация: имя нового каталога 
  выходная информация: отсутствует 
  {
   получить индекс для каталога с новым именем (алгоритм namei);
   if (индекс не является индексом каталога или же процессу не разрешен доступ к файлу)    {
    освободить индекс (алгоритм iput);
    return (ошибку);
   }
   снять блокировку с индекса;
   освободить индекс прежнего текущего каталога (алгоритм iput);
   поместить новый индекс в позицию для текущего каталога в пространстве процесса;
  }
  Рисунок 5.14. Алгоритм смены текущего каталога
Алгоритм chdir (Рисунок 5.14) изменяет имя текущего каталога для процесса. Синтаксис вызова системной функции chdir:
chdir(pathname);
где pathname — каталог, который становится текущим для процесса. Ядро анализирует имя каталога, используя алгоритм namei, и проверяет, является ли данный файл каталогом и имеет ли владелец процесса право доступа к каталога. Ядро снимает с нового индекса блокировку, но удерживает индекс в качестве выделенного и оставляет счетчик ссылок без изменений, освобождает индекс прежнего текущего каталога (алгоритм iput), хранящийся в пространстве процесса, и запоминает в этом пространстве новый индекс. После смены процессом текущего каталога алгоритм namei использует индекс в качестве начального каталога при анализе всех имен путей, которые не берут начало от корня. По окончании выполнения системной функции chdir счетчик ссылок на индекс нового каталога имеет значение, как минимум, 1, а счетчик ссылок на индекс прежнего текущего каталога может стать равным 0. В этом отношении функция chdir похожа на функцию open, поскольку обе функции обращаются к файлу и оставляют его индекс в качестве выделенного. Индекс, выделенный во время выполнения функции chdir, освобождается только тогда, когда процесс меняет текущий каталог еще раз или когда процесс завершается.
Процессы обычно используют глобальный корневой каталог файловой системы для всех имен путей поиска, начинающихся с «/». Ядро хранит глобальную переменную, которая указывает на индекс глобального корня, выделяемый по алгоритму iget при загрузке системы. Процессы могут менять свое представление о корневом каталоге файловой системы с помощью системной функции chroot. Это бывает полезно, если пользователю нужно создать модель обычной иерархической структуры файловой системы и запустить процессы там. Синтаксис вызова функции:
chroot(pathname);
где pathname — каталог, который впоследствии будет рассматриваться ядром в качестве корневого каталога для процесса. Выполняя функцию chroot, ядро следует тому же алгоритму, что и при смене текущего каталога. Оно запоминает индекс нового корня в пространстве процесса, снимая с индекса блокировку по завершении выполнения функции. Тем не менее, так как умолчание на корень для ядра хранится в глобальной переменной, ядро освобождает индекс прежнего корня не автоматически, а только после того, как оно само или процесс-предок исполнят вызов функции chroot. Новый индекс становится логическим корнем файловой системы для процесса (и для всех порожденных им процессов) и это означает, что все пути поиска в алгоритме namei, начинающиеся с корня («/»), возьмут начало с данного индекса и что все попытки войти в каталог «..» над корнем приведут к тому, что рабочим каталогом процесса останется новый корень. Процесс передает всем вновь порождаемым процессам этот каталог в качестве корневого подобно тому, как передает свой текущий каталог.

0

7

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
5.10 CМЕНА ВЛАДЕЛЬЦА И РЕЖИМА ДОСТУПА К ФАЙЛУ

Смена владельца или режима (прав) доступа к файлу является операцией, производимой над индексом, а не над файлом. Синтаксис вызова соответствующих системных функций:
chown(pathname, owner, group)
chmod(pathname, mode)
Для того, чтобы поменять владельца файла, ядро преобразует имя файла в идентификатор индекса, используя алгоритм namei. Владелец процесса должен быть суперпользователем или владельцем файла (процесс не может распоряжаться тем, что не принадлежит ему). Затем ядро назначает файлу нового владельца и нового группового пользователя, сбрасывает флаги прежних установок (см. раздел 7.5) и освобождает индекс по алгоритму iput. После этого прежний владелец теряет право «собственности» на файл. Для того, чтобы поменять режим доступа к файлу, ядро выполняет процедуру, подобную описанной, вместо кода владельца меняя флаги, устанавливающие режим доступа.

5.11 STAT И FSTАТ

Системные функции stat и fstat позволяют процессам запрашивать информацию о статусе файла: типе файла, владельце файла, правах доступа, размере файла, числе связей, номере индекса и времени доступа к файлу. Синтаксис вызова функций:
stat(pathname, statbuffer);
fstat(fd, statbuffer);
где pathname — имя файла, fd — дескриптор файла, возвращаемый функцией open, statbuffer — адрес структуры данных пользовательского процесса, где будет храниться информация о статусе файла после завершения выполнения вызова. Системные функции просто переписывают поля из индекса в структуру statbuffer. Программа на Рисунке 5.33 иллюстрирует использование функций stat и fstat.
  Рисунок 5.15. Дерево процессов и совместное использование каналов

5.12 КАНАЛЫ

Каналы позволяют передавать данные между процессами в порядке поступления («первым пришел — первым вышел»), а также синхронизировать выполнение процессов. Их использование дает процессам возможность взаимодействовать между собой, пусть даже не известно, какие процессы находятся на другом конце канала. Традиционная реализация каналов использует файловую систему для хранения данных. Различают два вида каналов: поименованные каналы и, за отсутствием лучшего термина, непоименованные каналы, которые идентичны между собой во всем, кроме способа первоначального обращения к ним процессов. Для поименованных каналов процессы используют системную функцию open, а системную функцию pipe — для создания непоименованного канала. Впоследствии, при работе с каналами процессы пользуются обычными системными функциями для файлов, такими как read, write и close. Только связанные между собой процессы, являющиеся потомками того процесса, который вызвал функцию pipe, могут разделять доступ к непоименованным каналам. Например (см. Рисунок 5.15), если процесс B создает канал и порождает процессы D и E, эти три процесса разделяют между собой доступ к каналу, в отличие от процессов A и C. Однако, все процессы могут обращаться к поименованному каналу независимо от взаимоотношений между ними, при условии наличия обычных прав доступа к файлу. Поскольку непоименованные каналы встречаются чаще, они будут рассмотрены первыми.

5.12.1 Системная функция pipе

Синтаксис вызова функции создания канала:
pipe(fdptr);
где fdptr — указатель на массив из двух целых переменных, в котором будут храниться два дескриптора файла для чтения из канала и для записи в канал. Поскольку ядро реализует каналы внутри файловой системы и поскольку канал не существует до того, как его будут использовать, ядро должно при создании канала назначить ему индекс. Оно также назначает для канала пару пользовательских дескрипторов и соответствующие им записи в таблице файлов: один из дескрипторов для чтения из канала, а другой для записи в канал. Поскольку ядро пользуется таблицей файлов, интерфейс для вызова функций read, write и др. согласуется с интерфейсом для обычных файлов. В результате процессам нет надобности знать, ведут ли они чтение или запись в обычный файл или в канал.
  алгоритм pipe
  входная информация: отсутствует
  выходная информация: 
   дескриптор файла для чтения
   дескриптор файла для записи
  {
   назначить новый индекс из устройства канала (алгоритм ialloc);
   выделить одну запись в таблице файлов для чтения, одну — для переписи;
   инициализировать записи в таблице файлов таким образом, чтобы они указывали на новый индекс;
   выделить один пользовательский дескриптор файла для чтения, один — для записи, проинициализировать их таким образом, чтобы они указывали на соответствующие точки входа в таблице файлов;
   установить значение счетчика ссылок в индексе равным 2;
   установить значение счетчика числа процессов, производящих чтение, и процессов, производящих запись, равным 1;
  }
  Рисунок 5.16. Алгоритм создания каналов (непоименованных)
На Рисунке 5.16 показан алгоритм создания непоименованных каналов. Ядро назначает индекс для канала из файловой системы, обозначенной как «устройство канала», используя алгоритм ialloc. Устройство канала — это именно та файловая система, из которой ядро может назначать каналам индексы и выделять блоки для данных. Администраторы системы указывают устройство канала при конфигурировании системы и эти устройства могут совпадать у разных файловых систем. Пока канал активен, ядро не может переназначить индекс канала и информационные блоки канала другому файлу.
Затем ядро выделяет в таблице файлов две записи, соответствующие дескрипторам для чтения и записи в канал, и корректирует «бухгалтерскую» информацию в копии индекса в памяти. В каждой из выделенных записей в таблице файлов хранится информация о том, сколько экземпляров канала открыто для чтения или записи (первоначально 1), а счетчик ссылок в индексе указывает, сколько раз канал был «открыт» (первоначально 2 — по одному для каждой записи таблицы файлов). Наконец, в индексе записываются смещения в байтах внутри канала до места, где будет начинаться следующая операция записи или чтения. Благодаря сохранению этих смещений в индексе имеется возможность производить доступ к данным в канале в порядке их поступления в канал («первым пришел первым вышел»); этот момент является особенностью каналов, поскольку для обычных файлов смещения хранятся в таблице файлов. Процессы не могут менять эти смещения с помощью системной функции lseek и поэтому произвольный доступ к данным канала невозможен.

5.12.2 Открытие поименованного канала

Поименованный канал — это файл, имеющий почти такую же семантику, как и непоименованный канал, за исключением того, что этому файлу соответствует запись в каталоге и обращение к нему производится по имени. Процессы открывают поименованные каналы так же, как и обычные файлы, и, следовательно, с помощью поименованных каналов могут взаимодействовать между собой даже процессы, не имеющие друг к другу близкого отношения. Поименованные каналы постоянно присутствуют в иерархии файловой системы (из которой они удаляются с помощью системной функции unlink), а непоименованные каналы являются временными: когда все процессы заканчивают работу с каналом, ядро отбирает назад его индекс.
Алгоритм открытия поименованного канала идентичен алгоритму открытия обычного файла. Однако, перед выходом из функции ядро увеличивает значения тех счетчиков в индексе, которые показывают количество процессов, открывших поименованный канал для чтения или записи. Процесс, открывающий поименованный канал для чтения, приостановит свое выполнение до тех пор, пока другой процесс не откроет поименованный канал для записи, и наоборот. Не имеет смысла открывать канал для чтения, если процесс не надеется получить данные; то же самое касается записи. В зависимости от того, открывает ли процесс поименованный канал для записи или для чтения, ядро возобновляет выполнение тех процессов, которые были приостановлены в ожидании процесса, записывающего в поименованный канал или считывающего данные из канала (соответственно).
Если процесс открывает поименованный канал для чтения, причем процесс, записывающий в канал, существует, открытие завершается. Или если процесс открывает поименованный файл с параметром «no delay», функция open возвращает управление немедленно, даже когда нет ни одного записывающего процесса. Во всех остальных случаях процесс приостанавливается до тех пор, пока записывающий процесс не откроет канал. Аналогичные правила действуют для процесса, открывающего канал для записи.

5.12.3 Чтение из каналов и запись в каналы

Канал следует рассматривать под таким углом зрения, что процессы ведут запись на одном конце канала, а считывают данные на другом конце. Как уже говорилось выше, процессы обращаются к данным в канале в порядке их поступления в канал; это означает, что очередность, в которой данные записываются в канал, совпадает с очередностью их выборки из канала. Совпадение количества процессов, считывающих данные из канала, с количеством процессов, ведущих запись в канал, совсем не обязательно; если одно число отличается от другого более, чем на 1, процессы должны координировать свои действия по использованию канала с помощью других механизмов. Ядро обращается к данным в канале точно так же, как и к данным в обычном файле: оно сохраняет данные на устройстве канала и назначает каналу столько блоков, сколько нужно, во время выполнения функции write. Различие в выделении памяти для канала и для обычного файла состоит в том, что канал использует в индексе только блоки прямой адресации в целях повышения эффективности работы, хотя это и накладывает определенные ограничения на объем данных, одновременно помещающихся в канале. Ядро работает с блоками прямой адресации индекса как с циклической очередью, поддерживая в своей структуре указатели чтения и записи для обеспечения очередности обслуживания "первым пришел - первым вышел" (Рисунок 5.17).
Рассмотрим четыре примера ввода-вывода в канал: запись в канал, в котором есть место для записи данных; чтение из канала, в котором достаточно данных для удовлетворения запроса на чтение; чтение из канала, в котором данных недостаточно; и запись в канал, где нет места для записи.
Рисунок 5.17. Логическая схема чтения и записи в канал
Рассмотрим первый случай, в котором процесс ведет запись в канал, имеющий место для ввода данных: сумма количества записываемых байт с числом байт, уже находящихся в канале, меньше или равна емкости канала. Ядро следует алгоритму записи данных в обычный файл, за исключением того, что оно увеличивает размер канала автоматически после каждого выполнения функции write, поскольку по определению объем данных в канале растет с каждой операцией записи. Иначе происходит увеличение размера обычного файла: процесс увеличивает размер файла только тогда, когда он при записи данных переступает границу конца файла. Если следующее смещение в канале требует использования блока косвенной адресации, ядро устанавливает значение смещения в пространстве процесса таким образом, чтобы оно указывало на начало канала (смещение в байтах, равное 0). Ядро никогда не затирает данные в канале; оно может сбросить значение смещения в 0, поскольку оно уже установило, что данные не будут переполнять емкость канала. Когда процесс запишет в канал все свои данные, ядро откорректирует значение указателя записи (в индексе) канала таким образом, что следующий процесс продолжит запись в канал с того места, где остановилась предыдущая операция write. Затем ядро возобновит выполнение всех других процессов, приостановленных в ожидании считывания данных из канала.
Когда процесс запускает функцию чтения из канала, он проверяет, пустой ли канал или нет. Если в канале есть данные, ядро считывает их из канала так, как если бы канал был обычным файлом, выполняя соответствующий алгоритм. Однако, начальным смещением будет значение указателя чтения, хранящегося в индексе и показывающего протяженность прочитанных ранее данных. После считывания каждого блока ядро уменьшает размер канала в соответствии с количеством считанных данных и устанавливает значение смещения в пространстве процесса так, чтобы при достижении конца канала оно указывало на его начало. Когда выполнение системной функции read завершается, ядро возобновляет выполнение всех приостановленных процессов записи и запоминает текущее значение указателя чтения в индексе (а не в записи таблицы файлов).
Если процесс пытается считать больше информации, чем фактически есть в канале, функция read завершится успешно, возвратив все данные, находящиеся в данный момент в канале, пусть даже не полностью выполнив запрос пользователя. Если канал пуст, процесс обычно приостанавливается до тех пор, пока какой-нибудь другой процесс не запишет данные в канал, после чего все приостановленные процессы, ожидающие ввода данных, возобновят свое выполнение и начнут конкурировать за чтение из канала. Если, однако, процесс открывает поименованный канал с параметром "no delay" (без задержки), функция read возвратит управление немедленно, если в канале отсутствуют данные. Операции чтения и записи в канал имеют ту же семантику, что и аналогичные операции для терминальных устройств (глава 10), она позволяет процессам игнорировать тип тех файлов, с которыми эти программы имеют дело.
Если процесс ведет запись в канал и в канале нет места для всех данных, ядро помечает индекс и приостанавливает выполнение процесса до тех пор, пока канал не начнет очищаться от данных. Когда впоследствии другой процесс будет считывать данные из канала, ядро заметит существование процессов, приостановленных в ожидании очистки канала, и возобновит их выполнение подобно тому, как это было объяснено выше. Исключением из этого утверждения является ситуация, когда процесс записывает в канал данные, объем которых превышает емкость канала (то есть, объем данных, которые могут храниться в блоках прямой адресации); в этом случае ядро записывает в канал столько данных, сколько он может вместить в себя, и приостанавливает процесс до тех пор, пока не освободится дополнительное место. Таким образом, возможно положение, при котором записываемые данные не будут занимать непрерывное место в канале, если другие процессы ведут запись в канал в то время, на которое первый процесс прервал свою работу.
Анализируя реализацию каналов, можно заметить, что интерфейс процессов согласуется с интерфейсом обычных файлов, но его воплощение отличается, так как ядро запоминает смещения для чтения и записи в индексе вместо того, чтобы делать это в таблице файлов. Ядро вынуждено хранить значения смещений для поименованных каналов в индексе для того, чтобы процессы могли совместно использовать эти значения: они не могли бы совместно использовать значения, хранящиеся в таблице файлов, так как процесс получает новую запись в таблице файлов по каждому вызову функции open. Тем не менее, совместное использование смещений чтения и записи в индексе наблюдалось и до реализации поименованных каналов. Процессы, обращающиеся к непоименованным каналам, разделяют доступ к каналу через общие точки входа в таблицу файлов, поэтому они могли бы по умолчанию хранить смещения записи и чтения в таблице файлов, как это принято для обычных файлов. Это не было сделано, так как процедуры низкого уровня, работающие в ядре, больше не имеют доступа к записям в таблице файлов: программа упростилась за счет того, что процессы совместно используют значения смещений, хранящиеся в индексе.

5.12.4 Закрытие каналов

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

5.12.5 Примеры

Программа на Рисунке 5.18 иллюстрирует искусственное использование каналов. Процесс создает канал и входит в бесконечный цикл, записывая в канал строку символов «hello» и считывая ее из канала. Ядру не нужно ни знать о том, что процесс, ведущий запись в канал, является и процессом, считывающим из канала, ни проявлять по этому поводу какое-либо беспокойство.
  char string[] = "hello";
  main()   {
   char buf[1024];
   char *cp1,*cp2;
   int fds[2];
   cp1 = string;
   cp2 = buf;
   while(*cp1) *cp2++ = *cp1++;
   pipe(fds); 
   for (;;)   {
    write(fds[1], buf, 6);
    read(fds[0], buf, 6);
   }
  }
  Рисунок 5.18. Чтение из канала и запись в канал
Процесс, выполняющий программу, которая приведена на Рисунке 5.19, создает поименованный канал с именем «fifo». Если этот процесс запущен с указанием второго (формального) аргумента, он постоянно записывает в канал строку символов «hello»; будучи запущен без второго аргумента, он ведет чтение из поименованного канала. Два процесса запускаются по одной и той же программе, тайно договорившись взаимодействовать между собой через поименованный канал «fifo», но им нет необходимости быть родственными процессами. Другие пользователи могут выполнять программу и участвовать в диалоге (или мешать ему).
  #include ‹fcntl.h›
  char string[] = "hello";
  main(argc, argv)
  int argc;
  char *argv[];
  {
   int fd;
   char buf[256];
   /* создание поименованного канала с разрешением чтения и записи для всех пользователей */
   mknod("fifo", 010777, 0);
   if (argc == 2)    fd = open("fifo", O_WRONLY);
   else   fd = open("fifo", O_RDONLY);
   for (;;)
   if (argc == 2)   write(fd, string, 6);
    else   read(fd, buf, 6);
  }
  Рисунок 5.19. Чтение и запись в поименованный канал

5.13 DUР

Системная функция dup копирует дескриптор файла в первое свободное место в таблице пользовательских дескрипторов файла, возвращая новый дескриптор пользователю. Она действует для всех типов файла. Синтаксис вызова функции:
newfd = dup(fd);
где fd — дескриптор файла, копируемый функцией, а newfd — новый дескриптор, ссылающийся на файл. Поскольку функция dup дублирует дескриптор файла, она увеличивает значение счетчика в соответствующей записи таблицы файлов — записи, на которую указывают связанные с ней точки входа в таблице файловых дескрипторов, которых теперь стало на одну больше. Например, обзор структур данных, изображенных на Рисунке 5.20, показывает, что процесс вызывает следующую последовательность функций: он открывает (open) файл с именем «/etc/passwd» (файловый дескриптор 3), затем открывает файл с именем «local» (файловый дескриптор 4), снова файл с именем «/etc/passwd» (файловый дескриптор 5) и, наконец, дублирует (dup) файловый дескриптор 3, возвращая дескриптор 6.
  Рисунок 5.20. Структуры данных после выполнения функции dup
Возможно, dup — функция, не отличающаяся изяществом, поскольку она предполагает, что пользователь знает о том, что система возвратит свободную точку входа в таблице пользовательских дескрипторов, имеющую наименьший номер. Однако, она служит важной задаче конструирования сложных программ из более простых конструкционных блоков, что, в частности, имеет место при создании конвейеров, составленных из командных процессоров.
Рассмотрим программу, приведенную на Рисунке 5.21. В переменной i хранится дескриптор файла, возвращаемый в результате открытия файла «/etc/passwd», а в переменной j — дескриптор файла, возвращаемый системой в результате дублирования дескриптора i с помощью функции dup. В адресном пространстве процесса оба пользовательских дескриптора, представленные переменными i и j, ссылаются на одну и ту же запись в таблице файлов и поэтому используют одно и то же значение смещения внутри файла. Таким образом, первые два вызова процессом функции read реализуют последовательное считывание данных, и в буферах buf1 и buf2 будут располагаться разные данные. Совсем другой результат получается, когда процесс открывает один и тот же файл дважды и читает дважды одни и те же данные (раздел 5.2). Процесс может освободить с помощью функции close любой из файловых дескрипторов по своему желанию, и ввод-вывод получит нормальное продолжение по другому дескриптору, как показано на примере. В частности, процесс может «закрыть» дескриптор файла стандартного вывода (файловый дескриптор 1), снять с него копию, имеющую то же значение, и затем рассматривать новый файл в качестве файла стандартного вывода. В главе 7 будет представлен более реалистический пример использования функций pipe и dup при описании особенностей реализации командного процессора.
#include ‹fcntl.h›
main() {
 int i, j;
 char buf1[512], buf2[512];
 i = open("/etc/passwd", O_RDONLY);
 j = dup(i);
 read(i, buf1, sizeof(buf1));
 read(j, buf2, sizeof(buf2));
 close(i);
 read(j, buf2, sizeof(buf2));
}
  Рисунок 5.21. Программа на языке Си, иллюстрирующая использование функции dup

5.14 МОНТИРОВАНИЕ И ДЕМОНТИРОВАНИЕ ФАЙЛОВЫХ СИСТЕМ

Физический диск состоит из нескольких логических разделов, на которые он разбит дисковым драйвером, причем каждому разделу соответствует файл устройства, имеющий определенное имя. Процессы обращаются к данным раздела, открывая соответствующий файл устройства и затем ведя запись и чтение из этого «файла», представляя его себе в виде последовательности дисковых блоков. Это взаимодействие во всех деталях рассматривается в главе 10. Раздел диска может содержать логическую файловую систему, состоящую из блока начальной загрузки, суперблока, списка индексов и информационных блоков (см. главу 2). Системная функция mount (монтировать) связывает файловую систему из указанного раздела на диске с существующей иерархией файловых систем, а функция umount (демонтировать) выключает файловую систему из иерархии. Функция mount, таким образом, дает пользователям возможность обращаться к данным в дисковом разделе как к файловой системе, а не как к последовательности дисковых блоков.
Синтаксис вызова функции mount:
mount(special pathname, directory pathname, options);
где special pathname — имя специального файла устройства, соответствующего дисковому разделу с монтируемой файловой системой, directory pathname — каталог в существующей иерархии, где будет монтироваться файловая система (другими словами, точка или место монтирования), а options указывает, следует ли монтировать файловую систему «только для чтения» (при этом не будут выполняться такие функции, как write и creat, которые производят запись в файловую систему). Например, если процесс вызывает функцию mount следующим образом:
mount("/dev/dsk1", "/usr', 0);
ядро присоединяет файловую систему, находящуюся в дисковом разделе с именем «/dev/dsk1», к каталогу «/usr» в существующем дереве файловых систем (см. Рисунок 5.22). Файл «/dev/dsk1» является блочным специальным файлом, т. е. он носит имя устройства блочного типа, обычно имя раздела на диске. Ядро предполагает, что раздел на диске с указанным именем содержит файловую систему с суперблоком, списком индексов и корневым индексом. После выполнения функции mount к корню смонтированной файловой системы можно обращаться по имени «/usr». Процессы могут обращаться к файлам в монтированной файловой системе и игнорировать тот факт, что система может отсоединяться. Только системная функция link контролирует файловую систему, так как в версии V не разрешаются связи между файлами, принадлежащими разным файловым системам (см. раздел 5.15).
  Рисунок 5.22. Дерево файловых систем до и после выполнения функции mount
Ядро поддерживает таблицу монтирования с записями о каждой монтированной файловой системе. В каждой записи таблицы монтирования содержатся:
• номер устройства, идентифицирующий монтированную файловую систему (упомянутый выше логический номер файловой системы);
• указатель на буфер, где находится суперблок файловой системы;
• указатель на корневой индекс монтированной файловой системы («/» для файловой системы с именем «/dev/dsk1» на Рисунке 5.22);
• указатель на индекс каталога, ставшего точкой монтирования (на Рисунке 5.22 это каталог «usr», принадлежащий корневой файловой системе).
Связь индекса точки монтирования с корневым индексом монтированной файловой системы, возникшая в результате выполнения системной функции mount, дает ядру возможность легко двигаться по иерархии файловых систем без получения от пользователей дополнительных сведений.
  алгоритм mount
  входная информация:
   имя блочного специального файла
   имя каталога точки монтирования
   опции («только для чтения»)
  выходная информация: отсутствует 
  {
   if (пользователь не является суперпользователем)   return (ошибку);
   получить индекс для блочного специального файла (алгоритм namei);
   проверить допустимость значений параметров;
   получить индекс для имени каталога, где производится монтирование (алгоритм namei);
   if (индекс не является индексом каталога или счетчик ссылок имеет значение › 1)   {
    освободить индексы (алгоритм iput);
    return (ошибку);
   }
   найти свободное место в таблице монтирования;
   запустить процедуру открытия блочного устройства для данного драйвера;
   получить свободный буфер из буферного кеша;
   считать суперблок в свободный буфер;
   проинициализировать поля суперблока;
   получить корневой индекс монтируемой системы (алгоритм iget), сохранить его в таблице монтирования;
   сделать пометку в индексе каталога о том, что каталог является точкой монтирования;
   освободить индекс специального файла (алгоритм iput);
   снять блокировку с индекса каталога точки монтирования;
  }
  Рисунок 5.23. Алгоритм монтирования файловой системы
На Рисунке 5.23 показан алгоритм монтирования файловой системы. Ядро позволяет монтировать и демонтировать файловые системы только тем процессам, владельцем которых является суперпользователь. Предоставление возможности выполнять функции mount и umount всем пользователям привело бы к внесению с их стороны хаоса в работу файловой системы, как умышленному, так и явившемуся результатом неосторожности. Суперпользователи могут разрушить систему только случайно.
Ядро находит индекс специального файла, представляющего файловую систему, подлежащую монтированию, извлекает старший и младший номера, которые идентифицируют соответствующий дисковый раздел, и выбирает индекс каталога, в котором файловая система будет смонтирована. Счетчик ссылок в индексе каталога должен иметь значение, не превышающее 1 (и меньше 1 он не должен быть — почему?), в связи с наличием потенциально опасных побочных эффектов (см. упражнение 5.27). Затем ядро назначает свободное место в таблице монтирования, помечает его для использования и присваивает значение полю номера устройства в таблице. Вышеуказанные назначения производятся немедленно, поскольку вызывающий процесс может приостановиться, следуя процедуре открытия устройства или считывая суперблок файловой системы, а другой процесс тем временем попытался бы смонтировать файловую систему. Пометив для использования запись в таблице монтирования, ядро не допускает использования в двух вызовах функции mount одной и той же записи таблицы. Запоминая номер устройства с монтируемой системой, ядро может воспрепятствовать повторному монтированию одной и той же системы другими процессами, которое, будь оно допущено, могло бы привести к непредсказуемым последствиям (см. упражнение 5.26).
Ядро вызывает процедуру открытия для блочного устройства, содержащего файловую систему, точно так же, как оно делает это при непосредственном открытии блочного устройства (глава 10). Процедура открытия устройства обычно проверяет существование такого устройства, иногда производя инициализацию структур данных драйвера и посылая команды инициализации аппаратуре. Затем ядро выделяет из буферного пула свободный буфер (вариант алгоритма getblk) для хранения суперблока монтируемой файловой системы и считывает суперблок, используя один из вариантов алгоритма read. Ядро сохраняет указатель на индекс каталога, в котором монтируется система, давая возможность маршрутам поиска файловых имен, содержащих имя «…», пересекать точку монтирования, как мы увидим дальше. Оно находит корневой индекс монтируемой файловой системы и запоминает указатель на индекс в таблице монтирования. С точки зрения пользователя, место (точка) монтирования и корень файловой системы логически эквивалентны, и ядро упрочивает эту эквивалентность благодаря их сосуществованию в одной записи таблицы монтирования. Процессы больше не могут обращаться к индексу каталога — точки монтирования.
Ядро инициализирует поля в суперблоке файловой системы, очищая поля для списка свободных блоков и списка свободных индексов и устанавливая число свободных индексов в суперблоке равным 0. Целью инициализации (задания начальных значений полей) является сведение к минимуму опасности разрушить файловую систему, если монтирование осуществляется после аварийного завершения работы системы. Если ядро заставить думать, что в суперблоке отсутствуют свободные индексы, то это приведет к запуску алгоритма ialloc, ведущего поиск на диске свободных индексов. К сожалению, если список свободных дисковых блоков испорчен, ядро не исправляет этот список изнутри (см. раздел 5.17 о сопровождении файловой системы). Если пользователь монтирует файловую систему только для чтения, запрещая проведение всех операций записи в системе, ядро устанавливает в суперблоке соответствующий флаг. Наконец, ядро помечает индекс каталога как «точку монтирования», чтобы другие процессы позднее могли ссылаться на нее. На Рисунке 5.24 представлен вид различных структур данных по завершении выполнения функции mount.

5.14.1 Пересечение точек монтирования в маршрутах поиска имен файлов

Давайте повторно рассмотрим поведение алгоритмов namei и iget в случаях, когда маршрут поиска файлов проходит через точку монтирования. Точку монтирования можно пересечь двумя способами: из файловой системы, где производится монтирование, в файловую систему, которая монтируется (в направлении от глобального корня к листу), и в обратном направлении. Эти способы иллюстрирует следующая последовательность команд shell'а.
mount /dev/dsk1 /usr
cd /usr/src/uts
cd ../../..
По команде mount после выполнения некоторых логических проверок запускается системная функция mount, которая монтирует файловую систему в дисковом разделе с именем «/dev/dsk1» под управлением каталога «/usr». Первая из команд cd (сменить каталог) побуждает командный процессор shell вызвать системную функцию chdir, выполняя которую, ядро анализирует имя пути поиска, пересекающего точку монтирования в «/usr». Вторая из команд cd приводит к тому, что ядро анализирует имя пути поиска и пересекает точку монтирования в третьей компоненте «..» имени.
  Рисунок 5.24. Структуры данных после монтирования
Для случая пересечения точки монтирования в направлении из файловой системы, где производится монтирование, в файловую систему, которая монтируется, рассмотрим модификацию алгоритма iget (Рисунок 5.25), которая идентична версии алгоритма, приведенной на Рисунке 4.3, почти во всем, за исключением того, что в данной модификации производится проверка, является ли индекс индексом точки монтирования. Если индекс имеет соответствующую пометку, ядро соглашается, что это индекс точки монтирования. Оно обнаруживает в таблице монтирования запись с указанным индексом точки монтирования и запоминает номер устройства монтируемой файловой системы. Затем, используя номер устройства и номер индекса корня, общего для всех файловых систем, ядро обращается к индексу корня монтируемого устройства и возвращает при выходе из функции этот индекс. В первом примере смены каталога ядро обращается к индексу каталога «/usr» из файловой системы, в которой производится монтирование, обнаруживает, что этот индекс имеет пометку «точка монтирования», находит в таблице монтирования индекс корня монтируемой файловой системы и обращается к этому индексу.
  алгоритм iget
  входная информация: номер индекса в файловой системе
  выходная информация: заблокированный индекс
  {
   do   {
    if (индекс в индексном кеше)   {
     if (индекс заблокирован)   {
      sleep (до освобождения индекса);
      continue; /* цикл с условием продолжения */
     }
     /* специальная обработка для точек монтирования */
     if (индекс является индексом точки монтирования)   {
      найти запись в таблице монтирования для точки монтирования;
      получить новый номер файловой системы из таблицы монтирования;
      использовать номер индекса корня для просмотра;
      continue; /* продолжение цикла */
     }
     if (индекс в списке свободных индексов)   убрать из списка свободных индексов;
     увеличить счетчик ссылок для индекса;
     return (индекс);
    }
    /* индекс отсутствует в индексном кеше */
    убрать новый индекс из списка свободных индексов;
    сбросить номер индекса и файловой системы;
    убрать индекс из старой хеш-очереди, поместить в новую;
    считать индекс с диска (алгоритм bread);
    инициализировать индекс (например, установив счетчик ссылок в 1);
    return (индекс);
   }
  }
  Рисунок 5.25. Модификация алгоритма получения доступа к индексу
Для второго случая пересечения точки монтирования в направлении из файловой системы, которая монтируется, в файловую систему, где выполняется монтирование, рассмотрим модификацию алгоритма namei (Рисунок 5.26). Она похожа на версию алгоритма, приведенную на Рисунке 4.11. Однако, после обнаружения в каталоге номера индекса для данной компоненты пути поиска ядро проверяет, не указывает ли номер индекса на то, что это корневой индекс файловой системы. Если это так и если текущий рабочий индекс так же является корневым, а компонента пути поиска, в свою очередь, имеет имя «..», ядро идентифицирует индекс как точку монтирования. Оно находит в таблице монтирования запись, номер устройства в которой совпадает с номером устройства для последнего из найденных индексов, получает индекс для каталога, в котором производится монтирование, и продолжает поиск компоненты с именем «..», используя только что полученный индекс в качестве рабочего. В корне файловой системы, тем не менее, корневым каталогом является «..»
  алгоритм namei /* превращение имени пути поиска в индекс */
  входная информация: имя пути поиска
  выходная информация: заблокированный индекс
  {
   if (путь поиска берет начало с корня)   рабочий индекс = индексу корня (алгоритм iget);
   else   рабочий индекс = индексу текущего каталога (алгоритм iget);
   do (пока путь поиска не кончился)   {
    считать следующую компоненту имени пути поиска;
    проверить соответствие рабочего индекса каталогу и права доступа;
    if (рабочий индекс соответствует корню и компонента имени «..»)   continue; /* цикл с условием продолжения */
  поиск компоненты:
    считать каталог (рабочий индекс), повторяя алгоритмы bmap, bread и brelse;
    if (компонента соответствует записи в каталоге (рабочем индексе))   {
     получить номер индекса для совпавшей компоненты;
     if (найденный индекс является индексом корня и рабочий индекс является индексом корня и имя компоненты «..»)   {
      /* пересечение точки монтирования */
      получить запись в таблице монтирования для рабочего индекса;
      освободить рабочий индекс (алгоритм iput);
      рабочий индекс = индексу точки монтирования;
      заблокировать индекс точки монтирования;
      увеличить значение счетчика ссылок на рабочий индекс;
      перейти к поиску компоненты (для «..»);
     }
     освободить рабочий индекс (алгоритм iput);
     рабочий индекс = индексу с новым номером (алгоритм iget);
    }
    else /* компонента отсутствует в каталоге */   return (нет индекса);
   }
   return (рабочий индекс);
  }
  Рисунок 5.26. Модификация алгоритма синтаксического анализа имени файла
В вышеприведенном примере (cd «../../..») предполагается, что в начале процесс имеет текущий каталог с именем «/usr/src/uts». Когда имя пути поиска подвергается анализу в алгоритме namei, начальным рабочим индексом является индекс текущего каталога. Ядро меняет текущий рабочий индекс на индекс каталога с именем «/usr/src» в результате расшифровки первой компоненты «..» в имени пути поиска. Затем ядро анализирует вторую компоненту «..» в имени пути поиска, находит корневой индекс смонтированной (перед этим) файловой системы — индекс каталога «usr» — и делает его рабочим индексом при анализе имени с помощью алгоритма namei. Наконец, оно расшифровывает третью компоненту «..» в имени пути поиска. Ядро обнаруживает, что номер индекса для «..» совпадает с номером корневого индекса, рабочим индексом является корневой индекс, а «..» является текущей компонентой имени пути поиска. Ядро находит запись в таблице монтирования, соответствующую точке монтирования «usr», освобождает текущий рабочий индекс (корень файловой системы, смонтированной в каталоге «usr») и назначает индекс точки монтирования (каталога «usr» в корневой файловой системе) в качестве нового рабочего индекса. Затем оно просматривает записи в каталоге точки монтирования «/usr» в поисках имени «..» и находит номер индекса для корня файловой системы («/»). После этого системная функция chdir завершается как обычно, вызывающий процесс не обращает внимания на тот факт, что он пересек точку монтирования.

0

8

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
5.14.2 Демонтирование файловой системы

Синтаксис вызова системной функции umount:
umount(special filename);
где special filename указывает демонтируемую файловую систему. При демонтировании файловой системы (Рисунок 5.27) ядро обращается к индексу демонтируемого устройства, восстанавливает номер устройства для специального файла, освобождает индекс (алгоритм iput) и находит в таблице монтирования запись с номером устройства, равным номеру устройства для специального файла. Прежде чем ядро действительно демонтирует файловую систему, оно должно удостовериться в том, что в системе не осталось используемых файлов, для этого ядро просматривает таблицу индексов в поисках всех файлов, чей номер устройства совпадает с номером демонтируемой системы. Активным файлам соответствует положительное значение счетчика ссылок и в их число входят текущий каталог процесса, файлы с разделяемым текстом, которые исполняются в текущий момент (глава 7), и открытые когда-то файлы, которые потом не были закрыты. Если какие-нибудь файлы из файловой системы активны, функция umount завершается неудачно: если бы она прошла успешно, активные файлы сделались бы недоступными.
Буферный пул все еще содержит блоки с «отложенной записью», не переписанные на диск, поэтому ядро «вымывает» их из буферного пула. Ядро удаляет записи с разделяемым текстом, которые находятся в таблице областей, но не являются действующими (подробности в главе 7), записывает на диск все недавно скорректированные суперблоки и корректирует дисковые копии всех индексов, которые требуют этого. Казалось, было бы достаточно откорректировать дисковые блоки, суперблок и индексы только для демонтируемой файловой системы, однако в целях сохранения преемственности изменений ядро выполняет аналогичные действия для всей системы в целом. Затем ядро освобождает корневой индекс монтированной файловой системы, удерживаемый с момента первого обращения к нему во время выполнения функции mount, и запускает из драйвера процедуру закрытия устройства, содержащего файловую систему. Впоследствии ядро просматривает буферы в буферном кеше и делает недействительными те из них, в которых находятся блоки демонтируемой файловой системы; в хранении информации из этих блоков в кеше больше нет необходимости. Делая буферы недействительными, ядро вставляет их в начало списка свободных буферов, в то время как блоки с актуальной информацией остаются в буферном кеше. Ядро сбрасывает в индексе системы, где производилось монтирование, флаг «точки монтирования», установленный функцией mount, и освобождает индекс. Пометив запись в таблице монтирования свободной для общего использования, функция umount завершает работу.
  алгоритм umount
  входная информация: имя специального файла, соответствующего демонтируемой файловой системе
  выходная информация: отсутствует
  {
   if (пользователь не является суперпользователем)   return (ошибку);
   получить индекс специального файла (алгоритм namei);
   извлечь старший и младший номера демонтируемого устройства;
   получить в таблице монтирования запись для демонтируемой системы, исходя из старшего и младшего номеров;
   освободить индекс специального файла (алгоритм iput);
   удалить из таблицы областей записи с разделяемым текстом для файлов, принадлежащих файловой системе;
   /* глава 7ххх */
   скорректировать суперблок, индексы, выгрузить буферы на диск;
   if (какие-то файлы из файловой системы все еще используются) return (ошибку);
   получить из таблицы монтирования корневой индекс монтированной файловой системы;
   заблокировать индекс;
   освободить индекс (алгоритм iput);
   /* iget был при монтировании */
   запустить процедуру закрытия для специального устройства;
   сделать недействительными (отменить) в пуле буферы из демонтируемой файловой системы;
   получить из таблицы монтирования индекс точки монтирования;
   заблокировать индекс;
   очистить флаг, помечающий индекс как «точку монтирования»;
   освободить индекс (алгоритм iput);
   /* iget был при монтировании */
   освободить буфер, используемый под суперблок;
   освободить в таблице монтирования место, занятое ранее;
  }
  Рисунок 5.27. Алгоритм демонтирования файловой системы
  Рисунок 5.28. Файлы в дереве файловой системы, связанные с помощью функции link

5.15 LINК

Системная функция link связывает файл с новым именем в структуре каталогов файловой системы, создавая для существующего индекса новую запись в каталоге. Синтаксис вызова функции link:
link(source file name, target file name);
где source file name — существующее имя файла, а target file name — новое (дополнительное) имя, присваиваемое файлу после выполнения функции link. Файловая система хранит имя пути поиска для каждой связи, имеющейся у файла, и процессы могут обращаться к файлу по любому из этих имен. Ядро не знает, какое из имен файла является его подлинным именем, поэтому имя файла специально не обрабатывается. Например, после выполнения набора функций:
link("/usr/src/uts/sys", "/usr/include/sys");
link("/usr/include/realfile.h", "/usr/src/uts/sys/testfile.h");
на один и тот же файл будут указывать три имени пути поиска: «/usr/src/uts/sys/testfile.h», «/usr/include/sys/testfile.h» и «/usr/include/realfile» (см. Рисунок 5.28).
Ядро позволяет суперпользователю (и только ему) связывать каталоги, упрощая написание программ, требующих пересечения дерева файловой системы. Если бы это было разрешено произвольному пользователю, программам, пересекающим иерархическую структуру файлов, пришлось бы заботиться о том, чтобы не попасть в бесконечный цикл в том случае, если пользователь связал каталог с вершиной, стоящей ниже в иерархии. Предполагается, что суперпользователи более осторожны в указании таких связей. Возможность связывать между собой каталоги должна была поддерживаться в ранних версиях системы, так как эта возможность требуется для реализации команды mkdir, которая создает новый каталог. Включение функции mkdir устраняет необходимость в связывании каталогов.
  алгоритм link
  входная информация:
   существующее имя файла
   новое имя файла
  выходная информация: отсутствует 
  {
   получить индекс для существующего имени файла (алгоритм namei);
   if (у файла слишком много связей или производится связывание каталога без разрешения суперпользователя)   {
    освободить индекс (алгоритм iput);
    return (ошибку);
   }
   увеличить значение счетчика связей в индексе;
   откорректировать дисковую копию индекса;
   снять блокировку с индекса;
   получить индекс родительского каталога для включения нового имени файла (алгоритм namei);
   if (файл с новым именем уже существует или существующий файл и новый файл находятся в разных файловых системах)   {
    отменить корректировку, сделанную выше;
    return (ошибку);
   }
   создать запись в родительском каталоге для файла с новым именем;
   включить в нее новое имя и номер индекса существующего файла;
   освободить индекс родительского каталога (алгоритм iput);
   освободить индекс существующего файла (алгоритм iput);
  }
  Рисунок 5.29. Алгоритм связывания файлов
На Рисунке 5.29 показан алгоритм функции link. Сначала ядро, используя алгоритм namei, определяет местонахождение индекса исходного файла, увеличивает значение счетчика связей в индексе, корректирует дисковую копию индекса (для обеспечения согласованности) и снимает с индекса блокировку. Затем ядро ищет файл с новым именем; если он существует, функция link завершается неудачно и ядро восстанавливает прежнее значение счетчика связей, измененное ранее. В противном случае ядро находит в родительском каталоге свободную запись для файла с новым именем, записывает в нее новое имя и номер индекса исходного файла и освобождает индекс родительского каталога, используя алгоритм iput. Поскольку файл с новым именем ранее не существовал, освобождать еще какой-нибудь индекс не нужно. Ядро, освобождая индекс исходного файла, делает заключение: счетчик связей в индексе имеет значение, на 1 большее, чем то значение, которое счетчик имел перед вызовом функции, и обращение к файлу теперь может производиться по еще одному имени в файловой системе. Счетчик связей хранит количество записей в каталогах, которые (записи) указывают на файл, и тем самым отличается от счетчика ссылок в индексе. Если по завершении выполнения функции link к файлу нет обращений со стороны других процессов, счетчик ссылок в индексе принимает значение, равное 0, а счетчик связей — значение, большее или равное 2.
Например, выполняя функцию, вызванную как:
link("source", "/dir/target");
ядро обнаруживает индекс для файла «source», увеличивает в нем значение счетчика связей, запоминает номер индекса, скажем 74, и снимает с индекса блокировку. Ядро также находит индекс каталога «dir», являющегося родительским каталогом для файла «target», ищет свободное место в каталоге «dir» и записывает в него имя файла «target» и номер индекса 74. По окончании этих действий оно освобождает индекс файла «source» по алгоритму iput. Если значение счетчика связей файла «source» раньше было равно 1, то теперь оно равно 2.
Стоит упомянуть о двух тупиковых ситуациях, явившихся причиной того, что процесс снимает с индекса исходного файла блокировку после увеличения значения счетчика связей. Если бы ядро не снимало с индекса блокировку, два процесса, выполняющие одновременно следующие функции:
процесс A: link("a/b/c/d", "e/f/g");
процесс B: link("e/f", "a/b/c/d/ee");
зашли бы в тупик (взаимная блокировка). Предположим, что процесс A обнаружил индекс файла «a/b/c/d» в тот самый момент, когда процесс B обнаружил индекс файла «e/f». Фраза «в тот же самый момент» означает, что системой достигнуто состояние, при котором каждый процесс получил искомый индекс. (Рисунок 5.30 иллюстрирует стадии выполнения процессов.) Когда же теперь процесс A попытается получить индекс файла «e/f», он приостановит свое выполнение до тех пор, пока индекс файла «f» не освободится. В то же время процесс B пытается получить индекс каталога «a/b/c/d» и приостанавливается в ожидании освобождения индекса файла «d». Процесс A будет удерживать заблокированным индекс, нужный процессу B, а процесс B, в свою очередь, будет удерживать заблокированным индекс, нужный процессу A. На практике этот классический пример взаимной блокировки невозможен благодаря тому, что ядро освобождает индекс исходного файла после увеличения значения счетчика связей. Поскольку первый из ресурсов (индекс) свободен при обращении к следующему ресурсу, взаимная блокировка не происходит.
Следующий пример показывает, как два процесса могут зайти в тупик, если с индекса не была снята блокировка. Одиночный процесс может также заблокировать самого себя. Если он вызывает функцию:
link("a/b/c", "a/b/c/d");
то в начале алгоритма он получает индекс для файла «c»; если бы ядро не снимало бы с индекса блокировку, процесс зашел бы в тупик, запросив индекс «c» при поиске файла «d». Если бы два процесса, или даже один процесс, не могли продолжать свое выполнение из-за взаимной блокировки (или самоблокировки), что в результате произошло бы в системе? Поскольку индексы являются теми ресурсами, которые предоставляются системой за конечное время, получение сигнала не может быть причиной возобновления процессом своей работы (глава 7). Следовательно, система не может выйти из тупика без перезагрузки. Если к файлам, заблокированным процессами, нет обращений со стороны других процессов, взаимная блокировка не затрагивает остальные процессы в системе. Однако, любые процессы, обратившиеся к этим файлам (или обратившиеся к другим файлам через заблокированный каталог), непременно зайдут в тупик. Таким образом, если заблокированы файлы «/bin» или «/usr/bin» (обычные хранилища команд) или файл «/bin/sh» (командный процессор shell), последствия для системы будут гибельными.

5.16 UNLINК

Системная функция unlink удаляет из каталога точку входа для файла. Синтаксис вызова функции unlink:
unlink(pathname);
где pathname указывает имя файла, удаляемое из иерархии каталогов. Если процесс разрывает данную связь файла с каталогом при помощи функции unlink, по указанному в вызове функции имени файл не будет доступен, пока в каталоге не создана еще одна запись с этим именем. Например, при выполнении следующего фрагмента программы:
unlink("myfile");
fd = open("myfile", O_RDONLY);
функция open завершится неудачно, поскольку к моменту ее выполнения в текущем каталоге больше не будет файла с именем myfile. Если удаляемое имя является последней связью файла с каталогом, ядро в итоге освобождает все информационные блоки файла. Однако, если у файла было несколько связей, он остается все еще доступным под другими именами.
  Рисунок 5.30. Взаимная блокировка процессов при выполнении функции link
На Рисунке 5.31 представлен алгоритм функции unlink. Сначала для поиска файла с удаляемой связью ядро использует модификацию алгоритма namei, которая вместо индекса файла возвращает индекс родительского каталога. Ядро обращается к индексу файла в памяти, используя алгоритм iget. (Особый случай, связанный с удалением имени файла».», будет рассмотрен в упражнении). После проверки отсутствия ошибок и (для исполняемых файлов) удаления из таблицы областей записей с неактивным разделяемым текстом (глава 7) ядро стирает имя файла из родительского каталога: сделать значение номера индекса равным 0 достаточно для очистки места, занимаемого именем файла в каталоге. Затем ядро производит синхронную запись каталога на диск, гарантируя тем самым, что под своим прежним именем файл уже не будет доступен, уменьшает значение счетчика связей и с помощью алгоритма iput освобождает в памяти индексы родительского каталога и файла с удаляемой связью.
При освобождении в памяти по алгоритму iput индекса файла с удаляемой связью, если значения счетчика ссылок и счетчика связей становятся равными 0, ядро забирает у файла обратно дисковые блоки, которые он занимал. На этот индекс больше не указывает ни одно из файловых имен и индекс неактивен. Для того, чтобы забрать дисковые блоки, ядро в цикле просматривает таблицу содержимого индекса, освобождая все блоки прямой адресации немедленно (в соответствии с алгоритмом free). Что касается блоков косвенной адресации, ядро освобождает все блоки, появляющиеся на различных уровнях косвенности, рекурсивно, причем в первую очередь освобождаются блоки с меньшим уровнем. Оно обнуляет номера блоков в таблице содержимого индекса и устанавливает размер файла в индексе равным 0. Затем ядро очищает в индексе поле типа файла, указывая тем самым, что индекс свободен, и освобождает индекс по алгоритму ifree. Ядро делает необходимую коррекцию на диске, так как дисковая копия индекса все еще указывает на то, что индекс используется; теперь индекс свободен для назначения другим файлам.
  алгоритм unlink
  входная информация: имя файла
  выходная информация: отсутствует
  {
   получить родительский индекс для файла с удаляемой связью (алгоритм namei);
   /* если в качестве файла выступает текущий каталог… */
   if (последней компонентой имени файла является ".")
     увеличить значение счетчика ссылок в индексе;
   else   получить индекс для файла с удаляемой связью (алгоритм iget);
   if (файл является каталогом, но пользователь не является суперпользователем)   {
    освободить индексы (алгоритм iput);
    return (ошибку);
   }
   if (файл имеет разделяемый текст и текущее значение счетчика связей равно 1)
    удалить записи из таблицы областей;
  в родительском каталоге:
   обнулить номер индекса для удаляемой связи;
   освободить индекс родительского каталога (алгоритм iput);
   уменьшить число связей файла;
   освободить индекс файла (алгоритм iput);
   /* iput проверяет, равно ли число связей 0, если да, освобождает блоки файла (алгоритм free) и освобождает индекс (алгоритм ifree); */
  }
  Рисунок 5.31. Алгоритм удаления связи файла с каталогом

5.16.1 Целостность файловой системы

Ядро посылает свои записи на диск для того, чтобы свести к минимуму опасность искажения файловой системы в случае системного сбоя. Например, когда ядро удаляет имя файла из родительского каталога, оно синхронно переписывает каталог на диск — перед тем, как уничтожить содержимое файла и освободить его индекс. Если система дала сбой до того, как произошло удаление содержимого файла, ущерб файловой системе будет нанесен минимальный: один из индексов будет иметь число связей, на 1 превышающее число записей в каталоге, которые ссылаются на этот индекс, но все остальные имена путей поиска файла останутся допустимыми. Если запись на диск не была сделана синхронно, точка входа в каталог на диске после системного сбоя может указывать на свободный (или переназначенный) индекс. Таким образом, число записей в каталоге на диске, которые ссылаются на индекс, превысило бы значение счетчика ссылок в индексе. В частности, если имя файла было именем последней связи файла, это имя указывало бы на не назначенный индекс. Не вызывает сомнения, что в первом случае ущерб, наносимый системе, менее серьезен и легко устраним (см. раздел 5.18).
Предположим, например, что у файла есть две связи с именами «a» и «b», одна из которых — «a» — разрывается процессом с помощью функции unlink. Если ядро записывает на диске результаты всех своих действий, то оно, очищая точку входа в каталог для файла «a», делает то же самое на диске. Если система дала сбой после завершения записи результатов на диск, число связей у файла «b» будет равно 2, но файл «a» уже не будет существовать, поскольку прежняя запись о нем была очищена перед сбоем системы. Файл «b», таким образом, будет иметь лишнюю связь, но после перезагрузки число связей переустановится и система будет работать надлежащим образом.
Теперь предположим, что ядро записывало на диск результаты своих действий в обратном порядке и система дала сбой: то есть, ядро уменьшило значение счетчика связей для файла «b», сделав его равным 1, записало индекс на диск и дало сбой перед тем, как очистить в каталоге точку входа для файла «a». После перезагрузки системы записи о файлах «a» и «b» в соответствующих каталогах будут существовать, но счетчик связей у того файла, на который они указывают, будет иметь значение 1. Если затем процесс запустит функцию unlink для файла «a», значение счетчика связей станет равным 0, несмотря на то, что файл «b» ссылается на тот же индекс. Если позднее ядро переназначит индекс в результате выполнения функции creat, счетчик связей для нового файла будет иметь значение, равное 1, но на файл будут ссылаться два имени пути поиска. Система не может выправить ситуацию, не прибегая к помощи программ сопровождения (fsck, описанной в разделе 5.18), обращающихся к файловой системе через блочный или строковый интерфейс.
Для того, чтобы свести к минимуму опасность искажения файловой системы в случае системного сбоя, ядро освобождает индексы и дисковые блоки также в особом порядке. При удалении содержимого файла и очистке его индекса можно сначала освободить блоки, содержащие данные файла, а можно освободить индекс и заново переписать его. Результат в обоих случаях, как правило, одинаковый, однако, если где-то в середине произойдет системный сбой, они будут различаться. Предположим, что ядро сначала освободило дисковые блоки, принадлежавшие файлу, и дало сбой. После перезагрузки системы индекс все еще содержит ссылки на дисковые блоки, занимаемые файлом прежде и ныне не хранящие относящуюся к файлу информацию. Ядру файл показался бы вполне удовлетворительным, но пользователь при обращении к файлу заметит искажение данных. Эти дисковые блоки к тому же могут быть переназначены другим файлам. Чтобы очистить файловую систему программой fsck, потребовались бы большие усилия. Однако, если система сначала переписала индекс на диск, а потом дала сбой, пользователь не заметит каких-либо искажений в файловой системе после перезагрузки. Информационные блоки, ранее принадлежавшие файлу, станут недоступны для системы, но каких-нибудь явных изменений при этом пользователи не увидят. Программе fsck так же было бы проще забрать назад освободившиеся после удаления связи дисковые блоки, нежели производить очистку, необходимую в первом из рассматриваемых случаев.

5.16.2 Поводы для конкуренции

Поводов для конкуренции при выполнении системной функции unlink очень много, особенно при удалении имен каталогов. Команда rmdir удаляет каталог, убедившись предварительно в том, что в каталоге отсутствуют файлы (она считывает каталог и проверяет значения индексов во всех записях каталога на равенство нулю). Но так как команда rmdir запускается на пользовательском уровне, действия по проверке содержимого каталога и удаления каталога выполняются не так уж просто; система должна переключать контекст между выполнением функций read и unlink. Однако, после того, как команда rmdir обнаружила, что каталог пуст, другой процесс может предпринять попытку создать файл в каталоге функцией creat. Избежать этого пользователи могут только путем использования механизма захвата файла и записи. Тем не менее, раз процесс приступил к выполнению функции unlink, никакой другой процесс не может обратиться к файлу с удаляемой связью, поскольку индексы родительского каталога и файла заблокированы.
Обратимся еще раз к алгоритму функции link и посмотрим, каким образом система снимает с индекса блокировку до завершения выполнения функции. Если бы другой процесс удалил связь файла пока его индекс свободен, он бы тем самым только уменьшил значение счетчика связей; так как значение счетчика связей было увеличено перед удалением связи, это значение останется положительным. Следовательно, файл не может быть удален и система работает надежно. Эта ситуация аналогична той, когда функция unlink вызывается сразу после завершения выполнения функции link.
Другой повод для конкуренции имеет место в том случае, когда один процесс преобразует имя пути поиска файла в индекс файла по алгоритму namei, а другой процесс удаляет каталог, имя которого входит в путь поиска. Допустим, процесс A делает разбор имени «a/ b/c/d» и приостанавливается во время получения индекса для файла «c». Он может приостановиться при попытке заблокировать индекс или при попытке обратиться к дисковому блоку, где этот индекс хранится (см. алгоритмы iget и bread). Если процессу B нужно удалить связь для каталога с именем «c», он может приостановиться по той же самой причине, что и процесс A. Пусть ядро впоследствии решит возобновить процесс B раньше процесса A. Прежде чем процесс A продолжит свое выполнение, процесс B завершится, удалив связь каталога «c» и его содержимое по этой связи. Позднее, процесс A попытается обратиться к несуществующему индексу, который уже был удален. Алгоритм namei, проверяющий в первую очередь неравенство значения счетчика связей нулю, сообщит об ошибке.
Такой проверки, однако, не всегда достаточно, поскольку можно предположить, что какой-нибудь другой процесс создаст в любом месте файловой системы новый каталог и получит тот индекс, который ранее использовался для «c». Процесс A будет заблуждаться, думая, что он обратился к нужному индексу (см. Рисунок 5.32). Как бы то ни было, система сохраняет свою целостность; самое худшее, что может произойти, это обращение не к тому файлу — с возможным нарушением защиты — но соперничества такого рода на практике довольно редки.
  Рисунок 5.32. Соперничество процессов за индекс при выполнении функции unlink
  #include ‹sys/types.h›
  #include ‹sys/stat.h›
  #include ‹fcntl.h›
  main(argc, argv)
  int argc;
  char *argv[];
  {
   int fd;
   char buf[1024];
   struct stat statbuf;
   if (argc != 2) /* нужен параметр */   exit();
   fd = open(argv[1], O_RDONLY);
   if (fd == -1) /* open завершилась неудачно */   exit();
   if (unlink(argv[1]) == -1) /* удалить связь с только что открытым файлом */   exit();
   if (stat(argv[1], &statbuf) == -1) /* узнать состояние файла по имени */
    printf("stat %s завершилась неудачно\n", argv[1]); /* как и следовало бы */
   else   printf("stat %s завершилась успешно!\n", argv[1]);
   if (fstat(fd, &statbuf) == -1) /* узнать состояние файла по идентификатору */
    printf("fstat %s сработала неудачно!\n", argv[1]);
   else   printf("fstat %s завершилась успешно\n", argv[1]); /* как и следовало бы */
   while (read(fd, buf, sizeof(buf)) › 0) /* чтение открытого файла с удаленной связью */
    printf("%1024s", buf); /* вывод на печать поля размером 1 Кбайт */
  }
  Рисунок 5.33. Удаление связи с открытым файлом
Процесс может удалить связь файла в то время, как другому процессу нужно, чтобы файл оставался открытым. (Даже процесс, удаляющий связь, может быть процессом, выполнившим это открытие). Поскольку ядро снимает с индекса блокировку по окончании выполнения функции open, функция unlink завершится успешно. Ядро будет выполнять алгоритм unlink точно так же, как если бы файл не был открыт, и удалит из каталога запись о файле. Теперь по имени удаленной связи к файлу не сможет обратиться никакой другой процесс. Однако, так как системная функция open увеличила значение счетчика ссылок в индексе, ядро не очищает содержимое файла при выполнении алгоритма iput перед завершением функции unlink. Поэтому процесс, открывший файл, может производить над файлом все обычные действия по его дескриптору, включая чтение из файла и запись в файл. Но когда процесс закрывает файл, значение счетчика ссылок в алгоритме iput становится равным 0, и ядро очищает содержимое файла. Короче говоря, процесс, открывший файл, продолжает работу так, как если бы функция unlink не выполнялась, а unlink, в свою очередь, работает так, как если бы файл не был открыт. Другие системные функции также могут продолжать выполняться в процессе, открывшем файл.
В приведенном на Рисунке 5.33 примере процесс открывает файл, указанный в качестве параметра, и затем удаляет связь только что открытого файла. Функция stat завершится неудачно, поскольку первоначальное имя после unlink больше не указывает на файл (предполагается, что тем временем никакой другой процесс не создал файл с тем же именем), но функция fstat завершится успешно, так как она выбирает индекс по дескриптору файла. Процесс выполняет цикл, считывая на каждом шаге по 1024 байта и пересылая файл в стандартный вывод. Когда при чтении будет обнаружен конец файла, процесс завершает работу: после завершения процесса файл перестает существовать. Процессы часто создают временные файлы и сразу же удаляют связь с ними; они могут продолжать ввод-вывод в эти файлы, но имена файлов больше не появляются в иерархии каталогов. Если процесс по какой-либо причине завершается аварийно, он не оставляет от временных файлов никакого следа.

5.17 АБСТРАКТНЫЕ ОБРАЩЕНИЯ К ФАЙЛОВЫМ СИСТЕМАМ

Уайнбергером было введено понятие «тип файловой системы» для объяснения механизма работы принадлежавшей ему сетевой файловой системы (см. краткое описание этого механизма в [Killian 84]) и в позднейшей версии системы V поддерживаются основополагающие принципы его схемы. Наличие типа файловой системы дает ядру возможность поддерживать одновременно множество файловых систем, таких как сетевые файловые системы (глава 13) или даже файловые системы из других операционных систем. Процессы пользуются для обращения к файлам обычными функциями системы UNIX, а ядро устанавливает соответствие между общим набором файловых операций и операциями, специфичными для каждого типа файловой системы.
  Рисунок 5.34. Индексы для файловых систем различных типов
Индекс выступает интерфейсом между абстрактной файловой системой и отдельной файловой системой. Общая копия индекса в памяти содержит информацию, не зависящую от отдельной файловой системы, а также указатель на частный индекс файловой системы, который уже содержит информацию, специфичную для нее. Частный индекс файловой системы содержит такую информацию, как права доступа и расположение блоков, а общий индекс содержит номер устройства, номер индекса на диске, тип файла, размер, информацию о владельце и счетчик ссылок. Другая частная информация, описывающая отдельную файловую систему, содержится в суперблоке и структуре каталогов. На Рисунке 5.34 изображены таблица общих индексов в памяти и две таблицы частных индексов отдельных файловых систем, одна для структур файловой системы версии V, а другая для индекса удаленной (сетевой) системы. Предполагается, что последний индекс содержит достаточно информации для того, чтобы идентифицировать файл, находящийся в удаленной системе. У файловой системы может отсутствовать структура, подобная индексу; но исходный текст программ отдельной файловой системы позволяет создать объектный код, удовлетворяющий семантическим требованиям файловой системы UNIX и назначающий свой «индекс», который соответствует общему индексу, назначаемому ядром.
Файловая система каждого типа имеет некую структуру, в которой хранятся адреса функций, реализующих абстрактные действия. Когда ядру нужно обратиться к файлу, оно вызывает косвенную функцию в зависимости от типа файловой системы и абстрактного действия (см. Рисунок 5.34). Примерами абстрактных действий являются: открытие и закрытие файла, чтение и запись данных, возвращение индекса для компоненты имени файла (подобно namei и iget), освобождение индекса (подобно iput), коррекция индекса, проверка прав доступа, установка атрибутов файла (прав доступа к нему), а также монтирование и демонтирование файловых систем. В главе 13 будет проиллюстрировано использование системных абстракций при рассмотрении распределенной файловой системы.

5.18 СОПРОВОЖДЕНИЕ ФАЙЛОВОЙ СИСТЕМЫ

Ядро поддерживает целостность системы в своей обычной работе. Тем не менее, такие чрезвычайные обстоятельства, как отказ питания, могут привести к фатальному сбою системы, в результате которого содержимое системы утрачивает свою согласованность: большинство данных в файловой системе доступно для использования, но некоторая несогласованность между ними имеет место. Команда fsck проверяет согласованность данных и в случае необходимости вносит в файловую систему исправления. Она обращается к файловой системе через блочный или строковый интерфейс (глава 10) в обход традиционных методов доступа к файлам. В этом разделе рассматриваются некоторые примеры противоречивости данных, которая обнаруживается командой fsck.
Дисковый блок может принадлежать более чем одному индексу или списку свободных блоков. Когда файловая система открывается в первый раз, все дисковые блоки находятся в списке свободных блоков. Когда дисковый блок выбирается для использования, ядро удаляет его номер из списка свободных блоков и назначает блок индексу. Ядро не может переназначить дисковый блок другому индексу до тех пор, пока блок не будет возвращен в список свободных блоков. Таким образом, дисковый блок может либо находиться в списке свободных блоков, либо быть назначенным одному из индексов. Рассмотрим различные ситуации, могущие иметь место при освобождении ядром дискового блока, принадлежавшего файлу, с возвращением номера блока в суперблок, находящийся в памяти, и при выделении дискового блока новому файлу. Если ядро записывало на диск индекс и блоки нового файла, но перед внесением изменений в индекс прежнего файла на диске произошел сбой, оба индекса будут адресовать к одному и тому же номеру дискового блока. Подобным же образом, если ядро переписывало на диск суперблок и его списки свободных ресурсов и перед переписью старого индекса случился сбой, дисковый блок появится одновременно и в списке свободных блоков, и в старом индексе.
Если блок отсутствует как в списке свободных блоков, так и в файле, файловая система является несогласованной, ибо, как уже говорилось выше, все блоки обязаны где-нибудь присутствовать. Такая ситуация могла бы произойти, если бы блок был удален из файла и помещен в список свободных блоков в суперблоке. Если производилась запись прежнего файла на диск и система дала сбой перед записью суперблока, блок будет отсутствовать во всех списках, хранящихся на диске.
Индекс может иметь счетчик связей с ненулевым значением при том, что его номер отсутствует во всех каталогах файловой системы. Все файлы, за исключением каналов (непоименованных), должны присутствовать в древовидной структуре файловой системы. Если система дала сбой после создания канала или обычного файла, но перед созданием соответствующей этому каналу или файлу точки входа в каталог, индекс будет иметь в поле счетчика связей установленное значение, пусть даже он явно не присутствует в файловой системе. Еще одна проблема может возникнуть, если с помощью функции unlink была удалена связь каталога без проверки удаления из каталога всех содержащихся в нем связей с отдельными файлами.
Если формат индекса неверен (например, если значение поля типа файла не определено), значит где-то имеется ошибка. Это может произойти, если администратор смонтировал файловую систему, которая отформатирована неправильно. Ядро обращается к тем дисковым блокам, которые, как кажется ядру, содержат индексы, но в действительности оказывается, что они содержат данные.
Если номер индекса присутствует в записи каталога, но сам индекс свободен, файловая система является несогласованной, поскольку номер индекса в записи каталога должен быть номером назначенного индекса. Это могло бы произойти, если бы ядро, создавая новый файл и записывая на диск новую точку входа в каталог, не успела бы скопировать на диск индекс файла из-за сбоя. Также это может случиться, если процесс, удаляя связь файла с каталогом, запишет освободившийся индекс на диск, но не успеет откорректировать каталог из-за сбоя. Возникновение подобных ситуаций можно предотвратить, копируя на диск результаты работы в надлежащем порядке.
Если число свободных блоков или свободных индексов, записанное в суперблоке, не совпадает с их количеством на диске, файловая система так же является несогласованной. Итоговая информация в суперблоке всегда должна соответствовать информации о текущем состоянии файловой системы.

5.19 ВЫВОДЫ

Этой главой завершается первая часть книги, посвященная рассмотрению особенностей файловой системы. Глава познакомила пользователя с тремя таблицами, принадлежащими ядру: таблицей пользовательских дескрипторов файла, системной таблицей файлов и таблицей монтирования. В ней рассмотрены алгоритмы выполнения системных функций, имеющих отношение к файловой системе, и взаимодействие между этими функциями. Исследованы некоторые абстрактные свойства файловой системы, позволяющие системе UNIX поддерживать файловые системы различных типов. Наконец, описан механизм выполнения команды fsck, контролирующей целостность и согласованность данных в файловой системе.

5.20 УПРАЖНЕНИЯ

1. Рассмотрим программу, приведенную на Рисунке 5.35. Какое значение возвращает каждая операция read и что при этом содержится в буфере? Опишите, что происходит в ядре во время выполнения каждого вызова read.
2. Вновь вернемся к программе на Рисунке 5.35 и предположим, что оператор lseek(fd, 9000L, 0); стоит перед первым обращением к функции read. Что ищет процесс и что при этом происходит в ядре?
3. Процесс может открыть файл для работы в режиме добавления записей в конец файла, при этом имеется в виду, что каждая операция записи располагает данные по адресу смещения, указывающего текущий конец файла. Таким образом, два процесса могут открыть файл для работы в режиме добавления записей в конец файла и вводить данные, не опасаясь затереть записи друг другу. Что произойдет, если процесс откроет файл в режиме добавления в конец, а записывающую головку установит на начало файла?
4. Библиотека стандартных подпрограмм ввода-вывода повышает эффективность выполнения пользователем операций чтения и записи благодаря буферизации данных в библиотеке и сохранению большого количества модулей обращения к операционной системе, необходимых пользователю. Как бы вы реализовали библиотечные функции fread и fwrite? Что должны делать библиотечные функции fopen и fclose?
  #include ‹fcntl.h›
  main()   {
   int fd;
   char buf[1024];
   fd = creat("junk", 0666);
   lseek(fd, 2000L, 2); /* ищется байт с номером 2000 */
   write(fd, "hello", 5);
   close(fd);
   fd = open("junk", O_RDONLY);
   read(fd, buf, 1024); /* читает нули */
   read(fd, buf, 1024); /* считывает нечто, отличное от 0 */
   read(fd, buf, 1024);
  }
  Рисунок 5.35. Считывание нулей и конца файла
5. Если процесс читает данные из файла последовательно, ядро запоминает значение блока, прочитанного с продвижением, в индексе, хранящемся в памяти. Что произойдет, если несколько процессов будут одновременно вести последовательное считывание данных из одного и того же файла?
  #include ‹fcntl.h›
  main()   {
   int fd; 
   char buf[256];
   fd = open("/etc/passwd", O_RDONLY);
   if (read(fd, buf, 1024) ‹ 0)   printf("чтение завершается неудачно\n");
  }
  Рисунок 5.36. Чтение большой порции данных в маленький буфер
6. Рассмотрим программу, приведенную на Рисунке 5.36. Что произойдет в результате выполнения программы? Обоснуйте ответ. Что произошло бы, если бы объявление массива buf было вставлено между объявлениями двух других массивов размером 1024 элемента каждый? Каким образом ядро устанавливает, что прочитанная порция данных слишком велика для буфера?
*7. В файловой системе BSD разрешается фрагментировать последний блок файла в соответствии со следующими правилами:
• Свободные фрагменты отслеживаются в структурах, подобных суперблоку;
• Ядро не поддерживает пул ранее выделенных свободных фрагментов, а разбивает на фрагменты в случае необходимости свободный блок;
• Ядро может назначать фрагменты блока только для последнего блока в файле;
• Если блок разбит на несколько фрагментов, ядро может назначить их различным файлам;
• Количество фрагментов в блоке не должно превышать величину, фиксированную для данной файловой системы;
• Ядро назначает фрагменты во время выполнения системной функции write.
Разработайте алгоритм, присоединяющий к файлу фрагменты блока. Какие изменения должны быть сделаны в индексе, чтобы позволить использование фрагментов? Какие преимущества с системной точки зрения предоставляет использование фрагментов для тех файлов, которые используют блоки косвенной адресации? Не выгоднее ли было бы назначать фрагменты во время выполнения функции close вместо того, чтобы назначать их при выполнении функции write?
*8. Вернемся к обсуждению, начатому в главе 4 и касающемуся расположения данных в индексе файла. Для того случая, когда индекс имеет размер дискового блока, разработайте алгоритм, по которому остаток данных файла переписывается в индексный блок, если помещается туда. Сравните этот метод с методом, предложенным для решения предыдущей проблемы.
*9. В версии V системы функция fcntl используется для реализации механизма захвата файла и записи и имеет следующий формат: fcntl(fd, cmd, arg); где fd — дескриптор файла, cmd — тип блокирующей операции, а в arg указываются различные параметры, такие как тип блокировки (записи или чтения) и смещения в байтах (см. приложение). К блокирующим операциям относятся
• Проверка наличия блокировок, принадлежащих другим процессам, с немедленным возвратом управления в случае обнаружения таких блокировок,
• Установка блокировки и приостанов до успешного завершения,
• Установка блокировки с немедленным возвратом управления в случае неудачи.
Ядро автоматически снимает блокировки, установленные процессом, при закрытии файла. Опишите работу алгоритма, реализующего захват файла и записи. Если блокировки являются обязательными, другим процессам следует запретить доступ к файлу. Какие изменения следует сделать в операциях чтения и записи?
*10. Если процесс приостановил свою работу в ожидании снятия с файла блокировки, возникает опасность взаимной блокировки: процесс A может заблокировать файл «one» и попытаться заблокировать файл «two», а процесс B может заблокировать файл «two» и попытаться заблокировать файл «one». Оба процесса перейдут в состояние, при котором они не смогут продолжить свою работу. Расширьте алгоритм решения предыдущей проблемы таким образом, чтобы ядро могло обнаруживать ситуации взаимной блокировки и прерывать выполнение системных функций. Следует ли поручать обнаружение взаимных блокировок ядру?
11. До существования специальной системной функции захвата файла пользователям приходилось прибегать к услугам параллельно действующих процессов для реализации механизма захвата путем вызова системных функций, выполняющих элементарные действия. Какие из системных функций, описанных в этой главе, могли бы использоваться? Какие опасности подстерегают при использовании этих методов?
12. Ричи заявлял (см. [Ritchie 81]), что захвата файла недостаточно для того, чтобы предотвратить путаницу, вызываемую такими программами, как редакторы, которые создают копию файла при редактировании и переписывают первоначальный файл по окончании работы. Объясните, что он имел в виду, и прокомментируйте.
13. Рассмотрим еще один способ блокировки файлов, предотвращающий разрушительные последствия корректировки. Предположим, что в индексе содержится новая установка прав доступа, позволяющая только одному процессу в текущий момент открывать файл для записи и нескольким процессам открывать файл для чтения. Опишите реализацию этого способа.
  main(argc, argv)
  int argc;
  char *argv[];
  {
   if (argc != 2)   {
    printf("введите: команда имя каталога\n");
    exit();
   }
   /* права доступа к каталогу: запись, чтение и исполнение разрешены для всех */
   /* только суперпользователь может делать следующее */
   if (mknod(argv[1], 040777, 0) == -1)   printf("mknod завершилась неудачно\n");
  }
  Рисунок 5.37. Каталог, создание которого не завершено
*14. Рассмотрим программу (Рисунок 5.37), которая создает каталог с неверным форматом (в каталоге отсутствуют записи с именами "." и ".."). Попробуйте, находясь в этом каталоге, выполнить несколько команд, таких как ls - l, ls - ld, или cd. Что произойдет при этом?
15. Напишите программу, которая выводит для файлов, имена которых указаны в качестве параметров, информацию о владельце, типе файла, правах доступа и времени доступа. Если файл (параметр) является каталогом, программа должна читать записи из каталога и выводить вышеуказанную информацию для всех файлов в каталоге.
16. Предположим, что у пользователя есть разрешение на чтение из каталога, но нет разрешения на исполнение. Что произойдет, если каталог использовать в качестве параметра команды ls, заданной с опцией «-i»? Что будет, если указана опция «-l»? Поясните свои ответы. Ответьте на вопрос, сформулированный для случая, когда есть разрешение на исполнение, но нет разрешения на чтение из каталога.
17. Сравните права доступа, которые должны быть у процесса для выполнения следующих действий, и прокомментируйте:
• Для создания нового файла требуется разрешение на запись в каталог.
• Для «создания» существующего файла требуется разрешение на запись в файл.
• Для удаления связи файла с каталогом требуется разрешение на запись в каталог, а не в файл.
*18. Напишите программу, которая навещает все каталоги, начиная с текущего. Как она должна управлять циклами в иерархии каталогов?
19. Выполните программу, приведенную на Рисунке 5.38, и объясните, что при этом происходит в ядре. (Намек: выполните команду pwd, когда программа закончится).
20. Напишите программу, которая заменяет корневой каталог указанным каталогом, и исследуйте дерево каталогов, доступное для этой программы.
21. Почему процесс не может отменить предыдущий вызов функции chroot? Измените конкретную реализацию процесса таким образом, чтобы он мог менять текущее значение корня на предыдущее. Какие у этой возможности преимущества и неудобства?
22. Рассмотрим простой пример канала (Рисунок 5.19), когда процесс записывает в канал строку «hello» и затем считывает ее. Что произошло бы, если бы массив для записи данных в канал имел размер 1024 байта вместо 6 (а объем считываемых за одну операцию данных оставался равным 6)? Что произойдет, если порядок вызова функций read и write в программе изменить, поменяв функции местами?
  main(argc,argv)
  int argc;
  char *argv[];
  {
   if (argc != 2)   {
    printf("нужен 1 аргумент — имя каталога\n");
    exit();
   }
   if (chdir(argv[1]) == -1) printf("%s файл не является каталогом\n", argv[1]);
  }
  Рисунок 5.38. Пример программы с использованием функции chdir
23. Что произойдет при выполнении программы, иллюстрирующей использование поименованных каналов (Рисунок 5.19), если функция mknod обнаружит, что канал с таким именем уже существует? Как этот момент реализуется ядром? Что произошло бы, если бы вместо подразумеваемых в тексте программы одного считывающего и одного записывающего процессов связь между собой через канал попытались установить несколько считывающих и записывающих процессов? Как в этом случае гарантировалась бы связь одного считывающего процесса с одним записывающим процессом?
24. Открывая поименованный канал для чтения, процесс приостанавливается до тех пор, пока еще один процесс не откроет канал для записи. Почему? Не мог бы процесс успешно пройти функцию open, продолжить работу до того момента, когда им будет предпринята попытка чтения данных из канала, и приостановиться при выполнении функции read?
25. Как бы вы реализовали алгоритм выполнения системной функции dup2 (из версии 7), вызываемой следующим образом: dup2(oldfd, newfd); где oldfd — файловый дескриптор, который дублируется дескриптором newfd? Что произошло бы, если бы дескриптор newfd уже принадлежал открытому файлу?
*26. Какие последствия имело бы решение ядра позволить двум процессам одновременно смонтировать одну и ту же файловую систему в двух точках монтирования?
27. Предположим, что один процесс меняет свой текущий каталог на каталог «/mnt/a/b/c», после чего другой процесс в каталоге «/mnt» монтирует файловую систему. Завершится ли функция mount успешно? Что произойдет, если первый процесс выполнит команду pwd? Ядро не позволит функции mount успешно завершиться, если значение счетчика ссылок в индексе каталога «/mnt» превышает 1. Прокомментируйте этот момент.
28. При исполнении алгоритма пересечения точки монтирования по имени «..» в маршруте поиска файла ядро проверяет выполнение трех условий, связанных с точкой монтирования: что номер обнаруженного индекса совпадает с номером корневого индекса, что рабочий индекс является корнем файловой системы и что имя компоненты маршрута поиска — «..». Почему необходимо проверять выполнение всех трех условий? Докажите, что проверки любых двух условий недостаточно для того, чтобы разрешить процессу пересечь точку монтирования.
29. Если пользователь монтирует файловую систему только для чтения, ядро устанавливает соответствующий флаг в суперблоке. Как ядро может воспрепятствовать выполнению операций записи в функциях write, creat, link, unlink, chown и chmod? Какого рода информацию записывают в файловую систему все перечисленные функции?
*30. Предположим, что один процесс пытается демонтировать файловую систему, в то время как другой процесс пытается создать в файловой системе новый файл. Только одна из функций umount и creat выполнится успешно. Подробно рассмотрите возникшую конкуренцию.
*31. Когда функция umount проверяет отсутствие в файловой системе активных файлов, возникает одна проблема, связанная с тем, что корневой индекс файловой системы, назначаемый при выполнении функции mount с помощью алгоритма iget, имеет счетчик ссылок с положительным значением. Как функция umount сможет убедиться в отсутствии активных файлов и отчитаться перед корнем файловой системы? Рассмотрите два случая:
• функция umount освобождает корневой индекс по алгоритму iput перед проверкой активных индексов. (Как функции вернуть этот индекс обратно, если будут обнаружены активные файлы?)
• функция umount проверяет отсутствие активных файлов до того, как освободить корневой индекс, и разрешая корневому индексу оставаться активным. (Насколько активным может быть корневой индекс?)
32. Обратите внимание на то, что при выполнении команды ls — ld количество связей с каталогом никогда не равно 1. Почему?
33. Как работает команда mkdir (создать новый каталог)? (Наводящий вопрос: какие номера по завершении выполнения команды имеют индексы для файлов "." и ".."?)
*34. Понятие «символические связи» имеет отношение к возможности указания с помощью функции link связей между файлами, принадлежащими к различным файловым системам. С файлом символической связи ассоциирован указатель нового типа; содержимым файла является имя пути поиска того файла, с которым он связан. Опишите реализацию символических связей.
*35. Что произойдет, если процесс вызовет функцию unlink("."); Каким будет текущий каталог процесса? Предполагается, что процесс обладает правами суперпользователя.
36. Разработайте системную функцию, которая усекает существующий файл до произвольных размеров, указанных в качестве аргумента, и опишите ее работу. Реализуйте системную функцию, которая позволяла бы пользователю удалять сегмент файла, расположенный между двумя адресами, заданными в виде смещений, и сжимать файл. Напишите программу, которая не вызывала бы эти функции, но обладала бы теми же функциональными возможностями.
37. Опишите все условия, при которых счетчик ссылок в индексе может превышать значение 1.
38. Затрагивая тему абстрактных обращений к файловым системам, ответьте на вопрос: следует ли файловой системе каждого типа иметь личную операцию блокирования, вызываемую из общей программы, или же достаточно общей операции блокирования?

0

9

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
ГЛАВА 6. СТРУКТУРА ПРОЦЕССОВ

В главе 2 были сформулированы характеристики процессов. В настоящей главе на более формальном уровне определяется понятие «контекст процесса» и показывается, каким образом ядро идентифицирует процесс и определяет его местонахождение. В разделе 6.1 описаны модель состояний процессов для системы UNIX и последовательность возможных переходов из состояния в состояние. В ядре находится таблица процессов, каждая запись которой описывает состояние одного из активных процессов в системе. В пространстве процесса хранится дополнительная информация, используемая в управлении протеканием процесса. Запись в таблице процессов и пространство процесса составляют в совокупности контекст процесса. Аспектом контекста процесса, наиболее явно отличающим данный контекст от контекста другого процесса, без сомнения является содержимое адресного пространства процесса. В разделе 6.2 описываются принципы управления распределением памяти для процессов и ядра, а также взаимодействие операционной системы с аппаратными средствами при трансляции виртуальных адресов в физические. Раздел 6.3 посвящен рассмотрению составных элементов контекста процесса, а также описанию алгоритмов управления контекстом процесса. Раздел 6.4 демонстрирует, каким образом осуществляется сохранение контекста процесса ядром в случае прерывания, вызова системной функции или переключения контекста, а также каким образом возобновляется выполнение приостановленного процесса. В разделе 6.5 приводятся различные алгоритмы, используемые в тех системных функциях, которые работают с адресным пространством процесса и которые будут рассмотрены в следующей главе. И, наконец, в разделе 6.6 рассматриваются алгоритмы приостанова и возобновления выполнения процессов.

6.1 СОСТОЯНИЯ ПРОЦЕССА И ПЕРЕХОДЫ МЕЖДУ НИМИ

Как уже отмечалось в главе 2, время жизни процесса можно теоретически разбить на несколько состояний, описывающих процесс. Полный набор состояний процесса содержится в следующем перечне:
1. Процесс выполняется в режиме задачи.
2. Процесс выполняется в режиме ядра.
3. Процесс не выполняется, но готов к запуску под управлением ядра.
4. Процесс приостановлен и находится в оперативной памяти.
5. Процесс готов к запуску, но программа подкачки (нулевой процесс) должна еще загрузить процесс в оперативную память, прежде чем он будет запущен под управлением ядра. Это состояние будет предметом обсуждения в главе 9 при рассмотрении системы подкачки.
6. Процесс приостановлен и программа подкачки выгрузила его во внешнюю память, чтобы в оперативной памяти освободить место для других процессов.
7. Процесс возвращен из привилегированного режима (режима ядра) в непривилегированный (режим задачи), ядро резервирует его и переключает контекст на другой процесс. Об отличии этого состояния от состояния 3 (готовность к запуску) пойдет речь ниже.
8. Процесс вновь создан и находится в переходном состоянии; процесс существует, но не готов к выполнению, хотя и не приостановлен. Это состояние является начальным состоянием всех процессов, кроме нулевого.
9. Процесс вызывает системную функцию exit и прекращает существование. Однако, после него осталась запись, содержащая код выхода, и некоторая хронометрическая статистика, собираемая родительским процессом. Это состояние является последним состоянием процесса.
Рисунок 6.1 представляет собой полную диаграмму переходов процесса из состояния в состояние. Рассмотрим с помощью модели переходов типичное поведение процесса. Ситуации, которые будут обсуждаться, несколько искусственны и процессы не всегда имеют дело с ними, но эти ситуации вполне применимы для иллюстрации различных переходов. Начальным состоянием модели является создание процесса родительским процессом с помощью системной функции fork; из этого состояния процесс неминуемо переходит в состояние готовности к запуску (3 или 5). Для простоты предположим, что процесс перешел в состояние «готовности к запуску в памяти» (3). Планировщик процессов в конечном счете выберет процесс для выполнения и процесс перейдет в состояние «выполнения в режиме ядра», где доиграет до конца роль, отведенную ему функцией fork.
  Рисунок 6.1. Диаграмма переходов процесса из состояния в состояние
После всего этого процесс может перейти в состояние «выполнения в режиме задачи». По прохождении определенного периода времени может произойти прерывание работы процессора по таймеру и процесс снова перейдет в состояние «выполнения в режиме ядра». Как только программа обработки прерывания закончит работу, ядру может понадобиться подготовить к запуску другой процесс, поэтому первый процесс перейдет в состояние «резервирования», уступив дорогу второму процессу. Состояние «резервирования» в действительности не отличается от состояния «готовности к запуску в памяти» (пунктирная линия на рисунке, соединяющая между собой оба состояния, подчеркивает их эквивалентность), но они выделяются в отдельные состояния, чтобы подчеркнуть, что процесс, выполняющийся в режиме ядра, может быть зарезервирован только в том случае, если он собирается вернуться в режим задачи. Следовательно, ядро может при необходимости подкачивать процесс из состояния «резервирования». При известных условиях планировщик выберет процесс для исполнения и тот снова вернется в состояние «выполнения в режиме задачи».
Когда процесс выполняет вызов системной функции, он из состояния «выполнения в режиме задачи» переходит в состояние «выполнения в режиме ядра». Предположим, что системной функции требуется ввод-вывод с диска и поэтому процесс вынужден дожидаться завершения ввода-вывода. Он переходит в состояние «приостанова в памяти», в котором будет находиться до тех пор, пока не получит извещения об окончании ввода-вывода. Когда ввод-вывод завершится, произойдет аппаратное прерывание работы центрального процессора и программа обработки прерывания возобновит выполнение процесса, в результате чего он перейдет в состояние «готовности к запуску в памяти».
Предположим, что система выполняет множество процессов, которые одновременно никак не могут поместиться в оперативной памяти, и программа подкачки (нулевой процесс) выгружает один процесс, чтобы освободить место для другого процесса, находящегося в состоянии «готов к запуску, но выгружен». Первый процесс, выгруженный из оперативной памяти, переходит в то же состояние. Когда программа подкачки выбирает наиболее подходящий процесс для загрузки в оперативную память, этот процесс переходит в состояние «готовности к запуску в памяти». Планировщик выбирает процесс для исполнения и он переходит в состояние «выполнения в режиме ядра». Когда процесс завершается, он исполняет системную функцию exit, последовательно переходя в состояния «выполнения в режиме ядра» и, наконец, в состояние «прекращения существования».
Процесс может управлять некоторыми из переходов на уровне задачи. Во-первых, один процесс может создать другой процесс. Тем не менее, в какое из состояний процесс перейдет после создания (т. е. в состояние «готов к выполнению, находясь в памяти» или в состояние «готов к выполнению, но выгружен») зависит уже от ядра. Процессу эти состояния не подконтрольны. Во-вторых, процесс может обратиться к различным системным функциям, чтобы перейти из состояния «выполнения в режиме задачи» в состояние «выполнения в режиме ядра», а также перейти в режим ядра по своей собственной воле. Тем не менее, момент возвращения из режима ядра от процесса уже не зависит; в результате каких-то событий он может никогда не вернуться из этого режима и из него перейдет в состояние «прекращения существования» (см. раздел 7.2, где говорится о сигналах). Наконец, процесс может завершиться с помощью функции exit по своей собственной воле, но как указывалось ранее, внешние события могут потребовать завершения процесса без явного обращения к функции exit. Все остальные переходы относятся к жестко закрепленной части модели, закодированной в ядре, и являются результатом определенных событий, реагируя на них в соответствии с правилами, сформулированными в этой и последующих главах. Некоторые из правил уже упоминались: например, то, что процесс может выгрузить другой процесс, выполняющийся в ядре.
Две принадлежащие ядру структуры данных описывают процесс: запись в таблице процессов и пространство процесса. Таблица процессов содержит поля, которые должны быть всегда доступны ядру, а пространство процесса — поля, необходимость в которых возникает только у выполняющегося процесса. Поэтому ядро выделяет место для пространства процесса только при создании процесса: в нем нет необходимости, если записи в таблице процессов не соответствует конкретный процесс.
Запись в таблице процессов состоит из следующих полей:
• Поле состояния, которое идентифицирует состояние процесса.
• Поля, используемые ядром при размещении процесса и его пространства в основной или внешней памяти. Ядро использует информацию этих полей для переключения контекста на процесс, когда процесс переходит из состояния «готов к выполнению, находясь в памяти» в состояние «выполнения в режиме ядра» или из состояния «резервирования» в состояние «выполнения в режиме задачи». Кроме того, ядро использует эту информацию при перекачки процессов из и в оперативную память (между двумя состояниями «в памяти» и двумя состояниями «выгружен»). Запись в таблице процессов содержит также поле, описывающее размер процесса и позволяющее ядру планировать выделение пространства для процесса.
• Несколько пользовательских идентификаторов (UID), устанавливающих различные привилегии процесса. Поля UID, например, описывают совокупность процессов, могущих обмениваться сигналами (см. следующую главу).
• Идентификаторы процесса (PID), указывающие взаимосвязь между процессами. Значения полей PID задаются при переходе процесса в состояние «создан» во время выполнения функции fork.
• Дескриптор события (устанавливается тогда, когда процесс приостановлен). В данной главе будет рассмотрено использование дескриптора события в алгоритмах функций sleep и wakeup.
• Параметры планирования, позволяющие ядру устанавливать порядок перехода процессов из состояния «выполнения в режиме ядра» в состояние «выполнения в режиме задачи».
• Поле сигналов, в котором перечисляются сигналы, посланные процессу, но еще не обработанные (раздел 7.2).
• Различные таймеры, описывающие время выполнения процесса и использование ресурсов ядра и позволяющие осуществлять слежение за выполнением и вычислять приоритет планирования процесса. Одно из полей является таймером, который устанавливает пользователь и который необходим для посылки процессу сигнала тревоги (раздел 8.3). Пространство процесса содержит поля, дополнительно характеризующие состояния процесса. В предыдущих главах были рассмотрены последние семь из приводимых ниже полей пространства процесса, которые мы для полноты вновь кратко перечислим:
• Указатель на таблицу процессов, который идентифицирует запись, соответствующую процессу.
• Пользовательские идентификаторы, устанавливающие различные привилегии процесса, в частности, права доступа к файлу (см. раздел 7.6).
• Поля таймеров, хранящие время выполнения процесса (и его потомков) в режиме задачи и в режиме ядра.
• Вектор, описывающий реакцию процесса на сигналы.
• Поле операторского терминала, идентифицирующее «регистрационный терминал», который связан с процессом.
• Поле ошибок, в которое записываются ошибки, имевшие место при выполнении системной функции.
• Поле возвращенного значения, хранящее результат выполнения системной функции.
• Параметры ввода-вывода: объем передаваемых данных, адрес источника (или приемника) данных в пространстве задачи, смещения в файле (которыми пользуются операции ввода-вывода) и т. д.
• Имена текущего каталога и текущего корня, описывающие файловую систему, в которой выполняется процесс.
• Таблица пользовательских дескрипторов файла, которая описывает файлы, открытые процессом.
• Поля границ, накладывающие ограничения на размерные характеристики процесса и на размер файла, в который процесс может вести запись.
• Поле прав доступа, хранящее двоичную маску установок прав доступа к файлам, которые создаются процессом. Пространство состояний процесса и переходов между ними рассматривалось в данном разделе на логическом уровне. Каждое состояние имеет также физические характеристики, управляемые ядром, в частности, виртуальное адресное пространство процесса. Следующий раздел посвящен описанию модели распределения памяти; в остальных разделах состояния процесса и переходы между ними рассматриваются на физическом уровне, особое внимание при этом уделяется состояниям «выполнения в режиме задачи», «выполнения в режиме ядра», «резервирования» и «приостанова (в памяти)». В следующей главе затрагиваются состояния «создания» и «прекращения существования», а в главе 8 — состояние «готовности к запуску в памяти». В главе 9 обсуждаются два состояния выгруженного процесса и организация подкачки по обращению.

6.2 ФОРМАТ ПАМЯТИ СИСТЕМЫ

Предположим, что физическая память машины имеет адреса, начиная с 0 и кончая адресом, равным объему памяти в байтах. Как уже отмечалось в главе 2, процесс в системе UNIX состоит из трех логических секций: команд, данных и стека. (Общую память, которая рассматривается в главе 11, можно считать в данном контексте частью секции данных). В секции команд хранится набор машинных инструкций, исполняемых под управлением процесса; адресами в секции команд выступают адреса команд (для команд перехода и обращений к подпрограммам), адреса данных (для обращения к глобальным переменным) и адреса стека (для обращения к структурам данных, которые локализованы в подпрограммах). Если адреса в сгенерированном коде трактовать как адреса в физической памяти, два процесса не смогут параллельно выполняться, если их адреса перекрываются. Компилятор мог бы генерировать адреса, непересекающиеся у разных программ, но на универсальных ЭВМ такой порядок не практикуется, поскольку объем памяти машины ограничен, а количество транслируемых программы неограничено. Даже если для того, чтобы избежать излишнего пересечения адресов в процессе их генерации, машина будет использовать некоторый набор эвристических процедур, подобная реализация не будет достаточно гибкой и не сможет удовлетворять предъявляемым к ней требованиям.
Поэтому компилятор генерирует адреса для виртуального адресного пространства заданного диапазона, а устройство управления памятью, называемое диспетчером памяти, транслирует виртуальные адреса, сгенерированные компилятором, в адреса ячеек, расположенных в физической памяти. Компилятору нет необходимости знать, в какое место в памяти ядро потом загрузит выполняемую программу. На самом деле, в памяти одновременно могут существовать несколько копий программы: все они могут выполняться, используя одни и те же виртуальные адреса, фактически же ссылаясь на разные физические ячейки. Те подсистемы ядра и аппаратные средства, которые сотрудничают в трансляции виртуальных адресов в физические, образуют подсистему управления памятью.

6.2.1 Области

Ядро в версии V делит виртуальное адресное пространство процесса на совокупность логических областей. Область — это непрерывная зона виртуального адресного пространства процесса, рассматриваемая в качестве отдельного объекта для совместного использования и защиты. Таким образом, команды, данные и стек обычно образуют автономные области, принадлежащие процессу. Несколько процессов могут использовать одну и ту же область. Например, если несколько процессов выполняют одну и ту же программу, вполне естественно, что они используют одну и ту же область команд. Точно так же, несколько процессов могут объединиться и использовать общую область разделяемой памяти.
Ядро поддерживает таблицу областей и выделяет запись в таблице для каждой активной области в системе. В разделе 6.5 описываются поля таблицы областей и операции над областями более подробно, но на данный момент предположим, что таблица областей содержит информацию, позволяющую определить местоположение области в физической памяти. Каждый процесс имеет частную таблицу областей процесса. Записи этой таблицы могут располагаться, в зависимости от конкретной реализации, в таблице процессов, в адресном пространстве процесса или в отдельной области памяти; для простоты предположим, что они являются частью таблицы процессов. Каждая запись частной таблицы областей содержит указатель на соответствующую запись общей таблицы областей и первый виртуальный адрес процесса в данной области. Разделяемые области могут иметь разные виртуальные адреса в каждом процессе. Запись частной таблицы областей также содержит поле прав доступа, в котором указывается тип доступа, разрешенный процессу: только чтение, только запись или только исполнение. Частная таблица областей и структура области аналогичны таблице файлов и структуре индекса в файловой системе: несколько процессов могут совместно использовать адресное пространство через область, подобно тому, как они разделяют доступ к файлу с помощью индекса; каждый процесс имеет доступ к области благодаря использованию записи в частной таблице областей, точно так же он обращается к индексу, используя соответствующие записи в таблице пользовательских дескрипторов файла и в таблице файлов, принадлежащей ядру.
На Рисунке 6.2 изображены два процесса, A и B, показаны их области, частные таблицы областей и виртуальные адреса, в которых эти области соединяются. Процессы разделяют область команд 'a' с виртуальными адресами 8К и 4К соответственно. Если процесс A читает ячейку памяти с адресом 8К, а процесс
  Рисунок 6.2. Процессы и области
B читает ячейку с адресом 4К, то они читают одну и ту же ячейку в области 'a'. Область данных и область стека у каждого процесса свои.
Область является понятием, не зависящим от способа реализации управления памятью в операционной системе. Управление памятью представляет собой совокупность действий, выполняемых ядром с целью повышения эффективности совместного использования оперативной памяти процессами. Примерами способов управления памятью могут служить рассматриваемые в главе 9 замещение страниц памяти и подкачка по обращению. Понятие области также не зависит и от собственно распределения памяти: например, от того, делится ли память на страницы или на сегменты. С тем, чтобы заложить фундамент для перехода к описанию алгоритмов подкачки по обращению (глава 9), все приводимые здесь рассуждения относятся, в первую очередь, к организации памяти, базирующейся на страницах, однако это не предполагает, что система управления памятью основывается на указанных алгоритмах.

6.2.2 Страницы и таблицы страниц

В этом разделе описывается модель организации памяти, которой мы будем пользоваться на протяжении всей книги, но которая не является особенностью системы UNIX. В организации памяти, базирующейся на страницах, физическая память разделяется на блоки одинакового размера, называемые страницами. Обычный размер страниц составляет от 512 байт до 4 Кбайт и определяется конфигурацией технических средств. Каждая адресуемая ячейка памяти содержится в некоторой странице и, следовательно, каждая ячейка памяти может адресоваться парой (номер страницы, смещение внутри страницы в байтах). Например, если объем машинной памяти составляет 2 в 32-й степени байт, а размер страницы 1 Кбайт, общее число страниц — 2 в 22-й степени; можно считать, что каждый 32-разрядный адрес состоит из 22-разрядного номера страницы и 10-разрядного смещения внутри страницы (Рисунок 6.3).
Когда ядро назначает области физические страницы памяти, необходимости в назначении смежных страниц и вообще в соблюдении какой-либо очередности при назначении не возникает. Целью страничной организации памяти является повышение гибкости назначения физической памяти, которое строится по аналогии с назначением дисковых блоков файлам в файловой системе. Как и при назначении блоков файлу, так и при назначении области страниц памяти, преследуется задача повышения гибкости и сокращения неиспользуемого (вследствие фрагментации) пространства памяти.

Шестнадцатиричный адрес 58432
Двоичный 0101 1000 0100 0011 0010
Номер страницы, смещение внутри страницы 01 0110 0001 00 0011 0010
В шестнадцатиричной системе 161 32

  Рисунок 6.3. Адресация физической памяти по страницам

Логический номер страницы Физический номер страницы
0 177
1 54
2 209
3 17

  Рисунок 6.4. Отображение логических номеров страниц на физические
Ядро устанавливает соотношение между виртуальными адресами области и машинными физическими адресами посредством отображения логических номеров страниц в области на физические номера страниц в машине, как это показано на Рисунке 6.4. Поскольку область это непрерывное пространство виртуальных адресов программы, логический номер страницы служит указателем на элемент массива физических номеров страниц. Запись таблицы областей содержит указатель на таблицу физических номеров страниц, именуемую таблицей страниц. Записи таблицы страниц содержат машинно-зависимую информацию, такую как права доступа на чтение или запись страницы. Ядро поддерживает таблицы страниц в памяти и обращается к ним так же, как и ко всем остальным структурам данных ядра.
На Рисунке 6.5 приведен пример отображения процесса в физические адреса памяти. Пусть размер страницы составляет 1 Кбайт и пусть процессу нужно обратиться к объекту в памяти, имеющему виртуальный адрес 68432. Из таблицы областей видно, что виртуальный адрес начала области стека — 65536 (64К), если предположить, что стек растет в направлении увеличения адресов. После вычитания этого адреса из адреса 68432 получаем смещение в байтах внутри области, равное 2896. Так как каждая страница имеет размер 1 Кбайт, адрес указывает со смещением 848 на 2-ю (начиная с 0) страницу области, расположенной по физическому адресу 986К. В разделе 6.5.5 (где идет речь о загрузке области) рассматривается случай, когда запись таблицы страниц помечается «пустой».
В современных машинах используются разнообразные аппаратные регистры и кеши, которые повышают скорость выполнения вышеописанной процедуры трансляции адресов и без которых пересылки в памяти и адресные вычисления чересчур бы замедлились. Возобновляя выполнение процесса, ядро посредством загрузки соответствующих регистров сообщает техническим средствам управления памятью о том, в каких физических адресах выполняется процесс и где располагаются таблицы страниц. Поскольку такие операции являются машинно-зависимыми и в разных версиях реализуются по-разному, здесь мы их рассматривать не будем. Часть вопросов, связанных с архитектурой вычислительных систем, затрагивается в упражнениях.
  Рисунок 6.5. Преобразование виртуальных адресов в физические
Организацию управления памятью попробуем пояснить на следующем простом примере. Пусть память разбита на страницы размером 1 Кбайт каждая, обращение к которым осуществляется через описанные ранее таблицы страниц. Регистры управления памятью в системе группируются по три; первый регистр в тройке содержит адрес таблицы страниц в физической памяти, второй регистр содержит первый виртуальный адрес, отображаемый с помощью тройки регистров, третий регистр содержит управляющую информацию, такую как номера страниц в таблице страниц и права доступа к страницам (только чтение, чтение и запись). Такая модель соответствует вышеописанной модели области. Когда ядро готовит процесс к выполнению, оно загружает тройки регистров соответствующей информацией из записей частной таблицы областей процесса.
Если процесс обращается к ячейкам памяти, расположенным за пределами принадлежащего ему виртуального пространства, создается исключительная ситуация. Например, если область команд имеет размер 16 Кбайт (Рисунок 6.5), а процесс обращается к виртуальному адресу 26К, создается исключительная ситуация, обрабатываемая операционной системой. То же самое происходит, если процесс пытается обратиться к памяти, не имея соответствующих прав доступа, например, пытается записать адрес в защищенную от записи область команд. И в том, и в другом примере процесс обычно завершается (более подробно об этом в следующей главе).

6.2.3 Размещение ядра

Несмотря на то, что ядро работает в контексте процесса, отображение виртуальных адресов, связанных с ядром, осуществляется независимо от всех процессов. Программы и структуры данных ядра резидентны в системе и совместно используются всеми процессами. При запуске системы происходит загрузка программ ядра в память с установкой соответствующих таблиц и регистров для отображения виртуальных адресов ядра в физические. Таблицы страниц для ядра имеют структуру, аналогичную структуре таблицы страниц, связанной с процессом, а механизмы отображения виртуальных адресов ядра похожи на механизмы, используемые для отображения пользовательских адресов. На многих машинах виртуальное адресное пространство процесса разбивается на несколько классов, в том числе системный и пользовательский, и каждый класс имеет свои собственные таблицы страниц. При работе в режиме ядра система разрешает доступ к адресам ядра, при работе же в режиме задачи такого рода доступ запрещен. Поэтому, когда в результате прерывания или выполнения системной функции происходит переход из режима задачи в режим ядра, операционная система по договоренности с техническими средствами разрешает ссылки на адреса ядра, а при возврате в режим ядра эти ссылки уже запрещены. В других машинах можно менять преобразование виртуальных адресов, загружая специальные регистры во время работы в режиме ядра.
На Рисунке 6.6 приведен пример, в котором виртуальные адреса от 0 до 4М-1 принадлежат ядру, а начиная с 4М — процессу. Имеются две группы регистров управления памятью, одна для адресов ядра и одна для адресов процесса, причем каждой группе соответствует таблица страниц, хранящая номера физических страниц со ссылкой на адреса виртуальных страниц. Адресные ссылки с использованием группы регистров ядра допускаются системой только в режиме ядра; следовательно, для перехода между режимом ядра и режимом задачи требуется только, чтобы система разрешила или запретила адресные ссылки с использованием группы регистров ядра.
В некоторых системах ядро загружается в память таким образом, что большая часть виртуальных адресов ядра совпадает с физическими адресами и функция преобразования виртуальных адресов в физические превращается в функцию тождественности. Работа с пространством процесса, тем не менее, требует, чтобы преобразование виртуальных адресов в физические производилось ядром.
  Рисунок 6.6. Переключение режима работы с непривилегированного (режима задачи) на привилегированный (режим ядра)

6.2.4 Пространство процесса

Каждый процесс имеет свое собственное пространство, однако ядро обращается к пространству выполняющегося процесса так, как если бы в системе оно было единственным. Ядро подбирает для текущего процесса карту трансляции виртуальных адресов, необходимую для работы с пространством процесса. При компиляции загрузчик назначает переменной 'u' (имени пространства процесса) фиксированный виртуальный адрес. Этот адрес известен остальным компонентам ядра, в частности модулю, выполняющему переключение контекста (раздел 6.4.3). Ядру также известно, какие таблицы управления памятью используются при трансляции виртуальных адресов, принадлежащих пространству процесса, и благодаря этому ядро может быстро перетранслировать виртуальный адрес пространства процесса в другой физический адрес. По одному и тому же виртуальному адресу ядро может получить доступ к двум разным физическим адресам, описывающим пространства двух процессов.
Процесс имеет доступ к своему пространству, когда выполняется в режиме ядра, но не тогда, когда выполняется в режиме задачи. Поскольку ядро в каждый момент времени работает только с одним пространством процесса, используя для доступа виртуальный адрес, пространство процесса частично описывает контекст процесса, выполняющегося в системе. Когда ядро выбирает процесс для исполнения, оно ищет в физической памяти соответствующее процессу пространство и делает его доступным по виртуальному адресу.
  Рисунок 6.7. Карта памяти пространства процесса в ядре
Предположим, например, что пространство процесса имеет размер 4 Кбайта и помещается по виртуальному адресу 2М. На Рисунке 6.7 показана карта памяти, где первые два регистра из группы относятся к программам и данным ядра (адреса и указатели не показаны), а третий регистр адресует к пространству процесса D. Если ядру нужно обратиться к пространству процесса A, оно копирует связанную с этим пространством информацию из соответствующей таблицы страниц в третий регистр. В любой момент третий регистр ядра описывает пространство текущего процесса, но ядро может сослаться на пространство другого процесса, переписав записи в таблице страниц с новым адресом. Информация в регистрах 1 и 2 для ядра неизменна, поскольку все процессы совместно используют программы и данные ядра.

6.3 КОНТЕКСТ ПРОЦЕССА

Контекст процесса включает в себя содержимое адресного пространства задачи, выделенного процессу, а также содержимое относящихся к процессу аппаратных регистров и структур данных ядра. С формальной точки зрения, контекст процесса объединяет в себе пользовательский контекст, регистровый контекст и системный контекст [17] . Пользовательский контекст состоит из команд и данных процесса, стека задачи и содержимого совместно используемого пространства памяти в виртуальных адресах процесса. Те части виртуального адресного пространства процесса, которые периодически отсутствуют в оперативной памяти вследствие выгрузки или замещения страниц, также включаются в пользовательский контекст.
Регистровый контекст состоит из следующих компонент:
• Счетчика команд, указывающего адрес следующей команды, которую будет выполнять центральный процессор; этот адрес является виртуальным адресом внутри пространства ядра или пространства задачи.
• Регистра состояния процессора (PS), который указывает аппаратный статус машины по отношению к процессу. Регистр PS, например, обычно содержит подполя, которые указывают, является ли результат последних вычислений нулевым, положительным или отрицательным, переполнен ли регистр с установкой бита переноса и т. д. Операции, влияющие на установку регистра PS, выполняются для отдельного процесса, потому-то в регистре PS и содержится аппаратный статус машины по отношению к процессу. В других имеющих важное значение подполях регистра PS указывается текущий уровень прерывания процессора, а также текущий и предыдущий режимы выполнения процесса (режим ядра/задачи). По значению подполя текущего режима выполнения процесса устанавливается, может ли процесс выполнять привилегированные команды и обращаться к адресному пространству ядра.
• Указателя вершины стека, в котором содержится адрес следующего элемента стека ядра или стека задачи, в соответствии с режимом выполнения процесса. В зависимости от архитектуры машины указатель вершины стека показывает на следующий свободный элемент стека или на последний используемый элемент. От архитектуры машины также зависит направление увеличения стека (к старшим или младшим адресам), но для нас сейчас эти вопросы несущественны.
• Регистров общего назначения, в которых содержится информация, сгенерированная процессом во время его выполнения. Чтобы облегчить последующие объяснения, выделим среди них два регистра — регистр 0 и регистр 1 — для дополнительного использования при передаче информации между процессами и ядром.
Системный контекст процесса имеет «статическую часть» (первые три элемента в нижеследующем списке) и «динамическую часть» (последние два элемента). На протяжении всего времени выполнения процесс постоянно располагает одной статической частью системного контекста, но может иметь переменное число динамических частей. Динамическую часть системного контекста можно представить в виде стека, элементами которого являются контекстные уровни, которые помещаются в стек ядром или выталкиваются из стека при наступлении различных событий. Системный контекст включает в себя следующие компоненты:
• Запись в таблице процессов, описывающая состояние процесса (раздел 6.1) и содержащая различную управляющую информацию, к которой ядро всегда может обратиться.
• Часть адресного пространства задачи, выделенная процессу, где хранится управляющая информация о процессе, доступная только в контексте процесса. Общие управляющие параметры, такие как приоритет процесса, хранятся в таблице процессов, поскольку обращение к ним должно производиться за пределами контекста процесса.
• Записи частной таблицы областей процесса, общие таблицы областей и таблицы страниц, необходимые для преобразования виртуальных адресов в физические, в связи с чем в них описываются области команд, данных, стека и другие области, принадлежащие процессу. Если несколько процессов совместно используют общие области, эти области входят составной частью в контекст каждого процесса, поскольку каждый процесс работает с этими областями независимо от других процессов. В задачи управления памятью входит идентификация участков виртуального адресного пространства процесса, не являющихся резидентными в памяти.
• Стек ядра, в котором хранятся записи процедур ядра, если процесс выполняется в режиме ядра. Несмотря на то, что все процессы пользуются одними и теми же программами ядра, каждый из них имеет свою собственную копию стека ядра для хранения индивидуальных обращений к функциям ядра. Пусть, например, один процесс вызывает функцию creat и приостанавливается в ожидании назначения нового индекса, а другой процесс вызывает функцию read и приостанавливается в ожидании завершения передачи данных с диска в память. Оба процесса обращаются к функциям ядра и у каждого из них имеется в наличии отдельный стек, в котором хранится последовательность выполненных обращений. Ядро должно иметь возможность восстанавливать содержимое стека ядра и положение указателя вершины стека для того, чтобы возобновлять выполнение процесса в режиме ядра. В различных системах стек ядра часто располагается в пространстве процесса, однако этот стек является логически-независимым и, таким образом, может помещаться в самостоятельной области памяти. Когда процесс выполняется в режиме задачи, соответствующий ему стек ядра пуст.
• Динамическая часть системного контекста процесса, состоящая из нескольких уровней и имеющая вид стека, который освобождается от элементов в порядке, обратном порядку их поступления. На каждом уровне системного контекста содержится информация, необходимая для восстановления предыдущего уровня и включающая в себя регистровый контекст предыдущего уровня.
Ядро помещает контекстный уровень в стек при возникновении прерывания, при обращении к системной функции или при переключении контекста процесса. Контекстный уровень выталкивается из стека после завершения обработки прерывания, при возврате процесса в режим задачи после выполнения системной функции, или при переключении контекста. Таким образом, переключение контекста влечет за собой как помещение контекстного уровня в стек, так и извлечение уровня из стека: ядро помещает в стек контекстный уровень старого процесса, а извлекает из стека контекстный уровень нового процесса. Информация, необходимая для восстановления текущего контекстного уровня, хранится в записи таблицы процессов.
На Рисунке 6.8 изображены компоненты контекста процесса. Слева на рисунке изображена статическая часть контекста. В нее входят: пользовательский контекст, состоящий из программ процесса (машинных инструкций), данных, стека и разделяемой памяти (если она имеется), а также статическая часть системного контекста, состоящая из записи таблицы процессов, пространства процесса и записей частной таблицы областей (информации, необходимой для трансляции виртуальных адресов пользовательского контекста). Справа на рисунке изображена динамическая часть контекста. Она имеет вид стека и включает в себя несколько элементов, хранящих регистровый контекст предыдущего уровня и стек ядра для текущего уровня. Нулевой контекстный уровень представляет собой пустой уровень, относящийся к пользовательскому контексту; увеличение стека здесь идет в адресном пространстве задачи, стек ядра недействителен. Стрелка, соединяющая между собой статическую часть системного контекста и верхний уровень динамической части контекста, означает то, что в таблице процессов хранится информация, позволяющая ядру восстанавливать текущий контекстный уровень процесса.
  Рисунок 6.8. Компоненты контекста процесса
Процесс выполняется в рамках своего контекста или, если говорить более точно, в рамках своего текущего контекстного уровня. Количество контекстных уровней ограничивается числом поддерживаемых в машине уровней прерывания. Например, если в машине поддерживаются разные уровни прерываний для программ, терминалов, дисков, всех остальных периферийных устройств и таймера, то есть 5 уровней прерывания, то, следовательно, у процесса может быть не более 7 контекстных уровней: по одному на каждый уровень прерывания, 1 для системных функций и 1 для пользовательского контекста. 7 уровней будет достаточно, даже если прерывания будут поступать в «наихудшем» из возможных порядков, поскольку прерывание данного уровня блокируется (то есть его обработка откладывается центральным процессором) до тех пор, пока ядро не обработает все прерывания этого и более высоких уровней.
Несмотря на то, что ядро всегда исполняет контекст какого-нибудь процесса, логическая функция, которую ядро реализует в каждый момент, не всегда имеет отношение к данному процессу. Например, если возвращая данные, дисковое запоминающее устройство посылает прерывание, то прерывается выполнение текущего процесса и ядро обрабатывает прерывание на новом контекстном уровне этого процесса, даже если данные относятся к другому процессу. Программы обработки прерываний обычно не обращаются к статическим составляющим контекста процесса и не видоизменяют их, так как эти части не связаны с прерываниями.

6.4 СОХРАНЕНИЕ КОНТЕКСТА ПРОЦЕССА

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

6.4.1 Прерывания и особые ситуации

Система отвечает за обработку всех прерываний, поступили ли они от аппаратуры (например, от таймера или от периферийных устройств), от программ (в связи с выполнением инструкций, вызывающих возникновение «программных прерываний») или явились результатом особых ситуаций (таких как обращение к отсутствующей странице). Если центральный процессор ведет обработку на более низком уровне по сравнению с уровнем поступившего прерывания, то перед выполнением следующей инструкции его работа прерывается, а уровень прерывания процессора повышается, чтобы другие прерывания с тем же (или более низким) уровнем не могли иметь места до тех пор, пока ядро не обработает текущее прерывание, благодаря чему обеспечивается сохранение целостности структур данных ядра. В процессе обработки прерывания ядро выполняет следующую последовательность действий:
1. Сохраняет текущий регистровый контекст выполняющегося процесса и создает в стеке (помещает в стек) новый контекстный уровень.
2. Устанавливает «источник» прерывания, идентифицируя тип прерывания (например, прерывание по таймеру или от диска) и номер устройства, вызвавшего прерывание (например, если прерывание вызвано дисковым запоминающим устройством). При возникновении прерывания система получает от машины число, которое использует в качестве смещения в таблице векторов прерывания. Содержимое векторов прерывания в разных машинах различно, но, как правило, в них хранится адрес программы обработки прерывания, соответствующей источнику прерывания, и указывается путь поиска параметра для программы. В качестве примера рассмотрим таблицу векторов прерывания, приведенную на Рисунке 6.9. Если источником прерывания явился терминал, ядро получает от аппаратуры номер прерывания, равный 2, и вызывает программу обработки прерываний от терминала, именуемую ttyintr.

Номер прерывания Программа обработки прерывания
0 clockintr
1 diskintr
2 ttyintr
3 devintr
4 softintr
5 otherintr

  Рисунок 6.9. Пример векторов прерывания
3. Вызов программы обработки прерывания. Стек ядра для нового контекстного уровня, если рассуждать логически, должен отличаться от стека ядра предыдущего контекстного уровня. В некоторых разработках стек ядра текущего процесса используется для хранения элементов, соответствующих программам обработки прерываний, в других разработках эти элементы хранятся в глобальном стеке прерываний, благодаря чему обеспечивается возврат из программы без переключения контекста.
4. Программа завершает свою работу и возвращает управление ядру. Ядро исполняет набор машинных команд по сохранению регистрового контекста и стека ядра предыдущего контекстного уровня в том виде, который они имели в момент прерывания, после чего возобновляет выполнение восстановленного контекстного уровня. Программа обработки прерываний может повлиять на поведение процесса, поскольку она может внести изменения в глобальные структуры данных ядра и возобновить выполнение приостановленных процессов. Однако, обычно процесс продолжает выполняться так, как если бы прерывание никогда не происходило.
  алгоритм inthand /* обработка прерываний */
  входная информация: отсутствует
  выходная информация: отсутствует 
  {
   сохранить (поместить в стек) текущий контекстный уровень;
   установить источник прерывания;
   найти вектор прерывания;
   вызвать программу обработки прерывания;
   восстановить (извлечь из стека) предыдущий контекстный уровень;
  }
  Рисунок 6.10. Алгоритм обработки прерываний
На Рисунке 6.10 кратко изложено, каким образом ядро обрабатывает прерывания. С помощью использования в отдельных случаях последовательности машинных операций или микрокоманд на некоторых машинах достигается больший эффект по сравнению с тем, когда все операции выполняются программным обеспечением, однако имеются узкие места, связанные с числом сохраняемых контекстных уровней и скоростью выполнения машинных команд, реализующих сохранение контекста. По этой причине определенные операции, выполнения которых требует реализация системы UNIX, являются машинно-зависимыми.
На Рисунке 6.11 показан пример, в котором процесс запрашивает выполнение системной функции (см. следующий раздел) и получает прерывание от диска при ее выполнении. Запустив программу обработки прерывания от диска, система получает прерывание по таймеру и вызывает уже программу обработки прерывания по таймеру. Каждый раз, когда система получает прерывание (или вызывает системную функцию), она создает в стеке новый контекстный уровень и сохраняет регистровый контекст предыдущего уровня.

6.4.2 Взаимодействие с операционной системой через вызовы системных функций

Такого рода взаимодействие с ядром было предметом рассмотрения в предыдущих главах, где шла речь об обычном вызове функций. Очевидно, что обычная последовательность команд обращения к функции не в состоянии переключить выполнения процесса с режима задачи на режим ядра. Компилятор с языка Си использует библиотеку функций, имена которых совпадают с именами системных функций, иначе ссылки на системные функции в пользовательских программах были бы ссылками на неопределенные имена. В библиотечных функциях обычно исполняется команда, переводящая выполнение процесса в режим ядра и побуждающая ядро к запуску исполняемого кода системной функции. В дальнейшем эта команда именуется «внутренним прерыванием операционной системы». Библиотечные процедуры исполняются в режиме задачи, а взаимодействие с операционной системой через вызов системной функции можно определить в нескольких словах как особый случай программы обработки прерывания. Библиотечные функции передают ядру уникальный номер системной функции одним из машинно-зависимых способов — либо как параметр внутреннего прерывания операционной системы, либо через отдельный регистр, либо через стек — а ядро таким образом определяет тип вызываемой функции.
  Рисунок 6.11. Примеры прерываний
Обрабатывая внутреннее прерывание операционной системы, ядро по номеру системной функции ведет в таблице поиск адреса соответствующей процедуры ядра, то есть точки входа системной функции, и количества передаваемых функции параметров (Рисунок 6.12). Ядро вычисляет адрес (пользовательский) первого параметра функции, прибавляя (или вычитая, в зависимости от направления увеличения стека) смещение к указателю вершины стека задачи (аналогично для всех параметров функции). Наконец, ядро копирует параметры задачи в пространство процесса и вызывает соответствующую процедуру, которая выполняет системную функцию. После исполнения процедуры ядро выясняет, не было ли ошибки. Если ошибка была, ядро делает соответствующие установки в сохраненном регистровом контексте задачи, при этом в регистре PS обычно устанавливается бит переноса, а в нулевой регистр заносится номер ошибки. Если при выполнении системной функции не было ошибок, ядро очищает в регистре PS бит переноса и заносит возвращаемые функцией значения в регистры 0 и 1 в сохраненном регистровом контексте задачи. Когда ядро возвращается после обработки внутреннего прерывания операционной системы в режим задачи, оно попадает в следующую библиотечную инструкцию после прерывания. Библиотечная функция интерпретирует возвращенные ядром значения и передает их программе пользователя.
  алгоритм syscall /* алгоритм запуска системной функции */
  входная информация: номер системной функции
  выходная информация: результат системной функции
  {
   найти запись в таблице системных функций, соответствующую указанному номеру функции;
   определить количество параметров, передаваемых функции;
   скопировать параметры из адресного пространства задачи в пространство процесса;
   сохранить текущий контекст для аварийного завершения (см. раздел 6.44);
   запустить в ядре исполняемый код системной функции;
   if (во время выполнения функции произошла ошибка)   {
    установить номер ошибки в нулевом регистре сохраненного регистрового контекста задачи;
    включить бит переноса в регистре PS сохраненного регистрового контекста задачи;
   }
   else
    занести возвращаемые функцией значения в регистры 0 и 1 в сохраненном регистровом контексте задачи;
  }
  Рисунок 6.12. Алгоритм обращения к системным функциям
В качестве примера рассмотрим программу, которая создает файл с разрешением чтения и записи в него для всех пользователей (режим доступа 0666) и которая приведена в верхней части Рисунка 6.13. Далее на рисунке изображен отредактированный фрагмент сгенерированного кода программы после компиляции и дисассемблирования (создания по объектному коду эквивалентной программы на языке ассемблера) в системе Motorola 68000. На Рисунке 6.14 изображена конфигурация стека для системной функции создания. Компилятор генерирует программу помещения в стек задачи двух параметров, один из которых содержит установку прав доступа (0666), а другой — переменную «имя файла» [18] . Затем из адреса 64 процесс вызывает библиотечную функцию creat (адрес 7a), аналогичную соответствующей системной функции. Адрес точки возврата из функции 6a, этот адрес помещается процессом в стек. Библиотечная функция creat засылает в регистр 0 константу 8 и исполняет команду прерывания (trap), которая переключает процесс из режима задачи в режим ядра и заставляет его обратиться к системной функции. Заметив, что процесс вызывает системную функцию, ядро выбирает из регистра 0 номер функции (8) и определяет таким образом, что вызвана функция creat. Просматривая внутреннюю таблицу, ядро обнаруживает, что системной функции creat необходимы два параметра; восстанавливая регистровый контекст предыдущего уровня, ядро копирует параметры из пользовательского пространства в пространство процесса. Процедуры ядра, которым понадобятся эти параметры, могут найти их в определенных местах адресного пространства процесса. По завершении исполнения кода функции creat управление возвращается программе обработки обращений к операционной системе, которая проверяет, установлено ли поле ошибки в пространстве процесса (то есть имела ли место во время выполнения функции ошибка); если да, программа устанавливает в регистре PS бит переноса, заносит в регистр 0 код ошибки и возвращает управление ядру. Если ошибок не было, в регистры 0 и 1 ядро заносит код завершения. Возвращая управление из программы обработки обращений к операционной системе в режим задачи, библиотечная функция проверяет состояние бита переноса в регистре PS (по адресу 7): если бит установлен, управление передается по адресу 13c, из нулевого регистра выбирается код ошибки и помещается в глобальную переменную errno по адресу 20, в регистр 0 заносится -1, и управление возвращается на следующую после адреса 64 (где производится вызов функции) команду. Код завершения функции имеет значение -1, что указывает на ошибку в выполнении системной функции. Если же бит переноса в регистре PS при переходе из режима ядра в режим задачи имеет нулевое значение, процесс с адреса 7 переходит по адресу 86 и возвращает управление вызвавшей программе (адрес 64); регистр 0 содержит возвращаемое функцией значение.
  char name[] = "file";
  main()   {
   int fd;
   fd = creat(name, 0666);
  }
Фрагменты ассемблерной программы, сгенерированной в системе Motorola 68000
  Адрес Команда
           —
  # текст главной программы
           —
  58: mov &0x1b6, (%sp) # поместить код 0666 в стек
  5e: mov &0x204, -(%sp) # поместить указатель вершины стека и переменную «имя файла» в стек 
  64: jsr 0x7a # вызов библиотечной функции создания файла
           —
  # текст библиотечной функции создания файла
  7a: movq &0x8, %d0 # занести значение 8 в регистр 0
  7c: trap &0x0 # внутреннее прерывание операционной системы
  7e: bcc &0x6 ‹86› # если бит переноса очищен, перейти по адресу 86
  80: jmp 0x13c # перейти по адресу 13c
  86: rts # возврат из подпрограммы
          —
  # текст обработки ошибок функции
  13c: mov %d0, &0x20e # поместить содержимое регистра 0 в ячейку 20e (переменная errno)
  142: movq &-0x1, %d0 # занести в регистр 0 константу -1
  144: mova %d0, %a0
  146: rts # возврат из подпрограммы
  Рисунок 6.13. Системная функция creat и сгенерированная программа ее выполнения в системе Motorola 68000
  Рисунок 6.14. Конфигурация стека для системной функции creat
Несколько библиотечных функций могут отображаться на одну точку входа в список системных функций. Каждая точка входа определяет точные синтаксис и семантику обращения к системной функции, однако более удобный интерфейс обеспечивается с помощью библиотек. Существует, например, несколько конструкций системной функции exec, таких как execl и execle, выполняющих одни и те же действия с небольшими отличиями. Библиотечные функции, соответствующие этим конструкциям, при обработке параметров реализуют заявленные свойства, но в конечном итоге, отображаются на одну и ту же функцию ядра.

0

10

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
6.4.3 Переключение контекста

Если обратиться к диаграмме состояний процесса (Рисунок 6.1), можно увидеть, что ядро разрешает производить переключение контекста в четырех случаях: когда процесс приостанавливает свое выполнение, когда он завершается, когда он возвращается после вызова системной функции в режим задачи, но не является наиболее подходящим для запуска, или когда он возвращается в режим задачи после завершения ядром обработки прерывания, но так же не является наиболее подходящим для запуска. Как уже было показано в главе 2, ядро поддерживает целостность и согласованность своих внутренних структур данных, запрещая произвольно переключать контекст. Прежде чем переключать контекст, ядро должно удостовериться в согласованности своих структур данных: то есть в том, что сделаны все необходимые корректировки, все очереди выстроены надлежащим образом, установлены соответствующие блокировки, позволяющие избежать вмешательства со стороны других процессов, что нет излишних блокировок и т. д. Например, если ядро выделяет буфер, считывает блок из файла и приостанавливает выполнение до завершения передачи данных с диска, оно оставляет буфер заблокированным, чтобы другие процессы не смогли обратиться к буферу. Но если процесс исполняет системную функцию link, ядро снимает блокировку с первого индекса перед тем, как снять ее со второго индекса, и тем самым предотвращает возникновение тупиковых ситуаций (взаимной блокировки).
Ядро выполняет переключение контекста по завершении системной функции exit, поскольку в этом случае больше ничего не остается делать. Кроме того, переключение контекста допускается, когда процесс приостанавливает свою работу, поскольку до момента возобновления может пройти немало времени, в течение которого могли бы выполняться другие процессы. Переключение контекста допускается и тогда, когда процесс не имеет преимуществ перед другими процессами при исполнении, с тем, чтобы обеспечить более справедливое планирование процессов: если по выходе процесса из системной функции или из прерывания обнаруживается, что существует еще один процесс, который имеет более высокий приоритет и ждет выполнения, то было бы несправедливо оставлять его в ожидании.
Процедура переключения контекста похожа на процедуры обработки прерываний и обращения к системным функциям, если не считать того, что ядро вместо предыдущего контекстного уровня текущего процесса восстанавливает контекстный уровень другого процесса. Причины, вызвавшие переключение контекста, при этом не имеют значения. На механизм переключения контекста не влияет и метод выбора следующего процесса для исполнения.
1. Принять решение относительно необходимости переклю- чения контекста и его допустимости в данный момент.
2. Сохранить контекст «прежнего» процесса.
3. Выбрать процесс, наиболее подходящий для исполнения, используя алгоритм диспетчеризации процессов, приведенный в главе 8.
4. Восстановить его контекст.
  Рисунок 6.15. Последовательность шагов, выполняемых при переключении контекста
Текст программы, реализующей переключение контекста в системе UNIX, из всех программ операционной системы самый трудный для понимания, ибо при рассмотрении обращений к функциям создается впечатление, что они в одних случаях не возвращают управление, а в других — возникают непонятно откуда. Причиной этого является то, что ядро во многих системных реализациях сохраняет контекст процесса в одном месте программы, но продолжает работу, выполняя переключение контекста и алгоритмы диспетчеризации в контексте «прежнего» процесса. Когда позднее ядро восстанавливает контекст процесса, оно возобновляет его выполнение в соответствии с ранее сохраненным контекстом. Чтобы различать между собой те случаи, когда ядро восстанавливает контекст нового процесса, и когда оно продолжает исполнять ранее сохраненный контекст, можно варьировать значения, возвращаемые критическими функциями, или устанавливать искусственным образом текущее значение счетчика команд.
На Рисунке 6.16 приведена схема переключения контекста. Функция save_context сохраняет информацию о контексте исполняемого процесса и возвращает значение 1. Кроме всего прочего, ядро сохраняет текущее значение счетчика команд (в функции save_context) и значение 0 в нулевом регистре при выходе из функции. Ядро продолжает исполнять контекст «прежнего» процесса (A), выбирая для выполнения следующий процесс (B) и вызывая функцию resume_context для восстановления его контекста. После восстановления контекста система выполняет процесс B; прежний процесс (A) больше не исполняется, но он оставил после себя сохраненный контекст. Позже, когда будет выполняться переключение контекста, ядро снова изберет процесс A (если только, разумеется, он не был завершен). В результате восстановления контекста A ядро присвоит счетчику команд то значение, которое было сохранено процессом A ранее в функции save_context, и возвратит в регистре 0 значение 0. Ядро возобновляет выполнение процесса A из функции save_context, пусть даже при выполнении программы переключения контекста оно не добралось еще до функции resume_context. В конечном итоге, процесс A возвращается из функции save_context со значением 0 (в нулевом регистре) и возобновляет выполнение после строки комментария «возобновление выполнение процесса начинается отсюда».
  if (save_context()) { /* сохранение контекста выполняющегося процесса */
   /* выбор следующего процесса для выполнения */
   ...
   resume_context(new_process);
   /* сюда программа не попадает! */
  }
   /* возобновление выполнение процесса начинается отсюда */
  Рисунок 6.16. Псевдопрограмма переключения контекста

6.4.4 Сохранение контекста на случай аварийного завершения

Существуют ситуации, когда ядро вынуждено аварийно прерывать текущий порядок выполнения и немедленно переходить к исполнению ранее сохраненного контекста. В последующих разделах, где пойдет речь о приостановлении выполнения и о сигналах, будут описаны обстоятельства, при которых процессу приходится внезапно изменять свой контекст; в данном же разделе рассматривается механизм исполнения предыдущего контекста. Алгоритм сохранения контекста называется setjmp, а алгоритм восстановления контекста — longjmp [19] . Механизм работы алгоритма setjmp похож на механизм функции save_context, рассмотренный в предыдущем разделе, если не считать того, что функция save_context помещает новый контекстный уровень в стек, в то время как setjmp сохраняет контекст в пространстве процесса и после выхода из него выполнение продолжается в прежнем контекстном уровне. Когда ядру понадобится восстановить контекст, сохраненный в результате работы алгоритма setjmp, оно исполнит алгоритм longjmp, который восстанавливает контекст из пространства процесса и имеет, как и setjmp, код завершения, равный 1.

6.4.5 Копирование данных между адресным пространством системы и адресным пространством задачи

До сих пор речь шла о том, что процесс выполняется в режиме ядра или в режиме задачи без каких-либо перекрытий (пересечений) между режимами. Однако, при выполнении большинства системных функций, рассмотренных в последней главе, между пространством ядра и пространством задачи осуществляется пересылка данных, например, когда идет копирование параметров вызываемой функции из пространства задачи в пространство ядра или когда производится передача данных из буферов ввода-вывода в процессе выполнения функции read. На многих машинах ядро системы может непосредственно ссылаться на адреса, принадлежащие адресному пространству задачи. Ядро должно убедиться в том, что адрес, по которому производится запись или считывание, доступен, как будто бы работа ведется в режиме задачи; в противном случае произошло бы нарушение стандартных методов защиты и ядро, пусть неумышленно, стало бы обращаться к адресам, которые находятся за пределами адресного пространства задачи (и, возможно, принадлежат структурам данных ядра). Поэтому передача данных между пространством ядра и пространством задачи является «дорогим предприятием», требующим для своей реализации нескольких команд.
  fubyte: # пересылка байта из пространства задачи
    prober $3, $1, *4(ap) # байт доступен? 
    beql eret # нет
    movzbl *4(ap), r0
    ret
  eret:
    mnegl $1, r0 # возврат ошибки (-1)
    ret
  Рисунок 6.17. Пересылка данных из пространства задачи в пространство ядра в системе VAX
На Рисунке 6.17 показан пример реализованной в системе VAX программы пересылки символа из адресного пространства задачи в адресное пространство ядра. Команда prober проверяет, может ли байт по адресу, равному (регистр указателя аргумента + 4), быть считан в режиме задачи (режиме 3), и если нет, ядро передает управление по адресу eret, сохраняет в нулевом регистре -1 и выходит из программы; при этом пересылки символа не происходит. В противном случае ядро пересылает один байт, находящийся по указанному адресу, в регистр 0 и возвращает его в вызывающую программу. Пересылка 1 символа потребовала пяти команд (включая вызов функции с именем fubyte).

6.5 УПРАВЛЕНИЕ АДРЕСНЫМ ПРОСТРАНСТВОМ ПРОЦЕССА

В этой главе мы пока говорили о том, каким образом осуществляется переключение контекста между процессами и как контекстные уровни запоминаются в стеке и выбираются из стека, представляя контекст пользовательского уровня как статический объект, не претерпевающий изменений при восстановлении контекста процесса. Однако, с виртуальным адресным пространством процесса работают различные системные функции и, как будет показано в следующей главе, выполняют при этом операции над областями. В этом разделе рассматривается информационная структура области; системные функции, реализующие операции над областями, будут рассмотрены в следующей главе.
• Запись таблицы областей содержит информацию, необходимую для описания области. В частности, она включает в себя следующие поля:
• Указатель на индекс файла, содержимое которого было первоначально загружено в область
• Тип области (область команд, разделяемая память, область частных данных или стека)
• Размер области
• Местоположение области в физической памяти
• Статус (состояние) области, представляющий собой комбинацию из следующих признаков:
 - заблокирована
 - запрошена
 - идет процесс ее загрузки в память
 - готова, загружена в память
• Счетчик ссылок, в котором хранится количество процессов, ссылающихся на данную область.
К операциям работы с областями относятся: блокировка области, снятие блокировки с области, выделение области, присоединение области к пространству памяти процесса, изменение размера области, загрузка области из файла в пространство памяти процесса, освобождение области, отсоединение области от пространства памяти процесса и копирование содержимого области. Например, системная функция exec, в которой содержимое исполняемого файла накладывается на адресное пространство задачи, отсоединяет старые области, освобождает их в том случае, если они не являются разделяемыми, выделяет новые области, присоединяет их и загружает содержимым файла. В остальной части раздела операции над областями описываются более детально с ориентацией на модель управления памятью, рассмотренную ранее (с таблицами страниц и группами аппаратных регистров), и с ориентацией на алгоритмы назначения страниц физической памяти и таблиц страниц (глава 9).

6.5.1 Блокировка области и снятие блокировки

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

6.5.2 Выделение области

Ядро выделяет новую область (по алгоритму allocreg, Рисунок 6.18) во время выполнения системных функций fork, exec и shmget (получить разделяемую память). Ядро поддерживает таблицу областей, записям которой соответствуют точки входа либо в списке свободных областей, либо в списке активных областей. При выделении записи в таблице областей ядро выбирает из списка свободных областей первую доступную запись, включает ее в список активных областей, блокирует область и делает пометку о ее типе (разделяемая или частная). За некоторым исключением каждый процесс ассоциируется с исполняемым файлом (после того, как была выполнена команда exec), и в алгоритме allocreg поле индекса в записи таблицы областей устанавливается таким образом, чтобы оно указывало на индекс исполняемого файла. Индекс идентифицирует область для ядра, поэтому другие процессы могут при желании разделять область. Ядро увеличивает значение счетчика ссылок на индекс, чтобы помешать другим процессам удалять содержимое файла при выполнении функции unlink, об этом еще будет идти речь в разделе 7.5. Результатом алгоритма allocreg является назначение и блокировка области.
  алгоритм allocreg /* разместить информационную структуру области */
  входная информация:
   (1) указатель индекса
   (2) тип области
  выходная информация: заблокированная область
  {
   выбрать область из списка свободных областей;
   назначить области тип;
   присвоить значение указателю индекса;
   if (указатель индекса имеет ненулевое значение)
    увеличить значение счетчика ссылок на индекс;
   включить область в список активных областей;
   return (заблокированную область);
  }
  Рисунок 6.18. Алгоритм выделения области

6.5.3 Присоединение области к процессу

Ядро присоединяет область к адресному пространству процесса во время выполнения системных функций fork, exec и shmat (алгоритм attachreg, Рисунок 6.19). Область может быть вновь назначаемой или уже существующей, которую процесс будет использовать совместно с другими процессами. Ядро выбирает свободную запись в частной таблице областей процесса, устанавливает в ней поле типа таким образом, чтобы оно указывало на область команд, данных, разделяемую память или область стека, и записывает виртуальный адрес, по которому область будет размещаться в адресном пространстве процесса. Процесс не должен выходить за предел установленного системой ограничения на максимальный виртуальный адрес, а виртуальные адреса новой области не должны пересекаться с адресами существующих уже областей. Например, если система ограничила максимально-допустимое значение виртуального адреса процесса 8 мегабайтами, то привязать область размером 1 мегабайт к виртуальному адресу 7.5M не удастся. Если же присоединение области допустимо, ядро увеличивает значение поля, описывающего размер области процесса в записи таблицы процессов, на величину присоединяемой области, а также увеличивает значение счетчика ссылок на область.
Кроме того, в алгоритме attachreg устанавливаются начальные значения группы регистров управления памятью, выделенных процессу. Если область ранее не присоединялась к какому-либо процессу, ядро с помощью функции growreg (см. следующий раздел) заводит для области новые таблицы страниц; в противном случае используются уже существующие таблицы страниц. Алгоритм завершает работу, возвращая указатель на точку входа в частную таблицу областей процесса, соответствующую вновь присоединенной области. Допустим, например, что ядру нужно подключить к процессу по виртуальному адресу 0 существующую (разделяемую) область, имеющую размер 7 Кбайт (Рисунок 6.20). Оно выделяет новую группу регистров управления памятью и заносит в них адрес таблицы страниц области, виртуальный адрес области в пространстве процесса (0) и размер таблицы страниц (9 записей).
  алгоритм attachreg /* присоединение области к процессу */
  входная информация:
   (1) указатель на присоединяемую область (заблокированную)
   (2) процесс, к которому присоединяется область
   (3) виртуальный адрес внутри процесса, по которому будет присоединена область
   (4) тип области 
  выходная информация: точка входа в частную таблицу областей процесса 
  {
   выделить новую запись в частной таблице областей процесса;
  проинициализировать значения полей записи:
    установить указатель на присоединяемую область;
    установить тип области;
    установить виртуальный адрес области;
   проверить правильность указания виртуального адреса и размера области;
   увеличить значение счетчика ссылок на область;
   увеличить размер процесса с учетом присоединения области;
   записать начальные значения в новую группу аппаратных регистров;
   return (точку входа в частную таблицу областей процесса);
  }
  Рисунок 6.19. Алгоритм присоединения области

6.5.4 Изменение размера области

Процесс может расширять или сужать свое виртуальное адресное пространство с помощью функции sbrk. Точно так же и стек процесса расширяется автоматически (то есть для этого процессу не нужно явно обращаться к определенной функции) в соответствии с глубиной вложенности обращений к подпрограммам. Изменение размера области производится внутри ядра по алгоритму growreg (Рисунок 6.21). При расширении области ядро проверяет, не будут ли виртуальные адреса расширяемой области пересекаться с адресами какой-нибудь другой области и не повлечет ли расширение области за собой выход процесса за пределы максимально-допустимого виртуального пространства памяти. Ядро никогда не использует алгоритм growreg для увеличения размера разделяемой области, уже присоединенной к нескольким процессам; поэтому оно не беспокоится о том, не приведет ли увеличение размера области для одного процесса к превышению другим процессом системного ограничения, накладываемого на размер процесса. При работе с существующей областью ядро использует алгоритм growreg в двух случаях: выполняя функцию sbrk по отношению к области данных процесса и реализуя автоматическое увеличение стека задачи. Обе эти области (данных и стека) частного типа. Области команд и разделяемой памяти после инициализации не могут расширяться. Этот момент будет пояснен в следующей главе.
  Рисунок 6.20. Пример присоединения существующей области команд
Чтобы разместить расширенную память, ядро выделяет новые таблицы страниц (или расширяет существующие) или отводит дополнительную физическую память в тех системах, где не поддерживается подкачка страниц по обращению. При выделении дополнительной физической памяти ядро проверяет ее наличие перед выполнением алгоритма growreg; если же памяти больше нет, ядро прибегает к другим средствам увеличения размера области (см. главу 9). Если процесс сокращает размер области, ядро просто освобождает память, отведенную под область. Во всех этих случаях ядро переопределяет размеры процесса и области и переустанавливает значения полей записи частной таблицы областей процесса и регистров управления памятью (так, чтобы они согласовались с новым отображением памяти).
Предположим, например, что область стека процесса начинается с виртуального адреса 128К и имеет размер 6 Кбайт и что ядру нужно расширить эту область на 1 Кбайт (1 страницу). Если размер процесса позволяет это делать и если виртуальные адреса в диапазоне от 134К до 135К-1 не принадлежат какой-либо области, ранее присоединенной к процессу, ядро увеличивает размер стека. При этом ядро расширяет таблицу страниц, выделяет новую страницу памяти и инициализирует новую запись таблицы. Этот случай проиллюстрирован с помощью Рисунка 6.22

6.5.5 Загрузка области

В системе, где поддерживается подкачка страниц по обращению, ядро может «отображать» файл в адресное пространство процесса во время выполнения функции exec, подготавливая последующее чтение по запросу отдельных физических страниц (см. главу 9). Если же подкачка страниц по обращению не поддерживается, ядру приходится копировать исполняемый файл в память, загружая области процесса по указанным в файле виртуальным адресам. Ядро может присоединить область к разным виртуальным адресам, по которым будет загружаться содержимое файла, создавая таким образом «разрыв» в таблице страниц (вспомним Рисунок 6.20). Эта возможность может пригодиться, например, когда требуется проявлять ошибку памяти (memory fault) в случае обращения пользовательских программ к нулевому адресу (если последнее запрещено). Переменные указатели в программах иногда задаются неверно (отсутствует проверка их значений на равенство 0) и в результате не могут использоваться в качестве указателей адресов. Если страницу с нулевым адресом соответствующим образом защитить, процессы, случайно обратившиеся к этому адресу, натолкнутся на ошибку и будут аварийно завершены, и это ускорит обнаружение подобных ошибок в программах.
  алгоритм growreg /* изменение размера области */
  входная информация:
   (1) указатель на точку входа в частной таблице областей процесса
   (2) величина, на которую нужно изменить размер области (может быть как положительной, так и отрицательной)
  выходная информация: отсутствует
  {
   if (размер области увеличивается)   {
    проверить допустимость нового размера области;
    выделить вспомогательные таблицы (страниц);
    if (в системе не поддерживается замещение страниц по обращению)   {
     выделить дополнительную память;
     проинициализировать при необходимости значения полей в дополнительных таблицах;
    }
   }
   else { /* размер области уменьшается */
    освободить физическую память;
    освободить вспомогательные таблицы;
   }
   провести в случае необходимости инициализацию других вспомогательных таблиц;
   переустановить значение поля размера в таблице процессов;
  }
  Рисунок 6.21. Алгоритм изменения размера области
При загрузке файла в область алгоритм loadreg (Рисунок 6.23) проверяет разрыв между виртуальным адресом, по которому область присоединяется к процессу, и виртуальным адресом, с которого располагаются данные области, и расширяет область в соответствии с требуемым объемом памяти. Затем область переводится в состояние «загрузки в память», при котором данные для области считываются из файла в память с помощью встроенной модификации алгоритма системной функции read.
  Рисунок 6.22. Увеличение области стека на 1 Кбайт
Если ядро загружает область команд, которая может разделяться несколькими процессами, возможна ситуация, когда процесс попытается воспользоваться областью до того, как ее содержимое будет полностью загружено, так как процесс загрузки может приостановиться во время чтения файла. Подробно о том, как это происходит и почему при этом нельзя использовать блокировки, мы поговорим, когда будем вести речь о функции exec в следующей главе и в главе 9. Чтобы устранить эту проблему, ядро проверяет статус области и не разрешает к ней доступ до тех пор, пока загрузка области не будет закончена. По завершении реализации алгоритма loadreg ядро возобновляет выполнение всех процессов, ожидающих окончания загрузки области, и изменяет статус области («готова, загружена в память»).
Предположим, например, что ядру нужно загрузить текст размером 7K в область, присоединенную к процессу по виртуальному адресу 0, но при этом оставить промежуток размером 1 Кбайт от начала области (Рисунок 6.24). К этому времени ядро уже выделило запись в таблице областей и присоединило область по адресу 0 с помощью алгоритмов allocreg и attachreg. Теперь же ядро запускает алгоритм loadreg, в котором действия алгоритма growreg выполняются дважды — во-первых, при выделении в начале области промежутка в 1 Кбайт, и во-вторых, при выделении места для содержимого области — и алгоритм growreg назначает для области таблицу страниц. Затем ядро заносит в соответствующие поля пространства процесса установочные значения для чтения данных из файла: считываются 7 Кбайт, начиная с адреса, указанного в виде смещения внутри файла (параметр алгоритма), и записываются в виртуальное пространство процесса по адресу 1K.
  алгоритм loadreg /* загрузка части файла в область */
  входная информация:
   (1) указатель на точку входа в частную таблицу областей процесса
   (2) виртуальный адрес загрузки
   (3) указатель индекса файла
   (4)   смещение в байтах до начала считываемой части файла
   (5) объем загружаемых данных в байтах
  выходная информация: отсутствует
  {
   увеличить размер области до требуемой величины (алгоритм growreg);
   записать статус области как «загружаемой в память»;
   снять блокировку с области;
  установить в пространстве процесса значения параметров чтения из файла:
     виртуальный адрес, по которому будут размещены считываемые данные;
     смещение до начала считываемой части файла;
     объем данных, считываемых из файла, в байтах;
   загрузить файл в область (встроенная модификация алгоритма read);
   заблокировать область;
   записать статус области как «полностью загруженной в память»;
   возобновить выполнение всех процессов, ожидающих окончания загрузки области;
  }
  Рисунок 6.23. Алгоритм загрузки данных области из файла
  Рисунок 6.24. Загрузка области команд (текста)
  алгоритм freereg /* освобождение выделенной области */
  входная информация: указатель на (заблокированную) область
  выходная информация: отсутствует 
  {
   if (счетчик ссылок на область имеет ненулевое значение)   {
    /* область все еще используется одним из процессов */
    снять блокировку с области;
    if (область ассоциирована с индексом)  снять блокировку с индекса;
    return;
   }
   if (область ассоциирована с индексом)   освободить индекс (алгоритм iput);
   освободить связанную с областью физическую память;
   освободить связанные с областью вспомогательные таблицы;
   очистить поля области;
   включить область в список свободных областей;
   снять блокировку с области;
  }
  Рисунок 6.25. Алгоритм освобождения области

6.5.6 Освобождение области

Если область не присоединена уже ни к какому процессу, она может быть освобождена ядром и возвращена в список свободных областей (Рисунок 6.25). Если область связана с индексом, ядро освобождает и индекс с помощью алгоритма iput, учитывая значение счетчика ссылок на индекс, установленное в алгоритме allocreg. Ядро освобождает все связанные с областью физические ресурсы, такие как таблицы страниц и собственно страницы физической памяти. Предположим, например, что ядру нужно освободить область стека, описанную на Рисунке 6.22. Если счетчик ссылок на область имеет нулевое значение, ядро освободит 7 страниц физической памяти вместе с таблицей страниц.
  алгоритм detachreg /* отсоединить область от процесса */
  входная информация:   указатель на точку входа в частной таблице областей процесса
  выходная информация: отсутствует 
  {
   обратиться к вспомогательным таблицам процесса, имеющим отношение к распределению памяти, освободить те из них, которые связаны с областью;
   уменьшить размер процесса;
   уменьшить значение счетчика ссылок на область;
   if (значение счетчика стало нулевым и область не является неотъемлемой частью процесса)
    освободить область (алгоритм freereg);
   else { /* либо значение счетчика отлично от 0, либо область является неотъемлемой частью процесса */ 
    снять блокировку с индекса (ассоциированного с областью);
    снять блокировку с области;
   }
  }
  Рисунок 6.26. Алгоритм отсоединения области

6.5.7 Отсоединение области от процесса

Ядро отсоединяет области при выполнении системных функций exec, exit и shmdt (отсоединить разделяемую память). При этом ядро корректирует соответствующую запись и разъединяет связь с физической памятью, делая недействительными связанные с областью регистры управления памятью (алгоритм detachreg, Рисунок 6.26). Механизм преобразования адресов после этого будет относиться уже к процессу, а не к области (как в алгоритме freereg). Ядро уменьшает значение счетчика ссылок на область и значение поля, описывающего размер процесса в записи таблицы процессов, в соответствии с размером области. Если значение счетчика становится равным 0 и если нет причины оставлять область без изменений (область не является областью разделяемой памяти или областью команд с признаками неотъемлемой части процесса, о чем будет идти речь в разделе 7.5), ядро освобождает область по алгоритму freereg. В противном случае ядро снимает с индекса и с области блокировку, установленную для того, чтобы предотвратить конкуренцию между параллельно выполняющимися процессами (см. раздел 7.5), но оставляет область и ее ресурсы без изменений.

6.5.8 Копирование содержимого области

  Рисунок 6.27. Копирование содержимого области
  алгоритм dupreg /* копирование содержимого существующей области */
  входная информация: указатель на точку входа в таблице областей
  выходная информация: указатель на область, являющуюся точной копией существующей области
  {
   if (область разделяемая) /* в вызывающей программе счетчик ссылок на область будет увеличен, после чего будет исполнен алгоритм attachreg */
    return (указатель на исходную область);
   выделить новую область (алгоритм allocreg);
   установить значения вспомогательных структур управления памятью в точном соответствии со значениями существующих структур исходной области;
   выделить для содержимого области физическую память;
   «скопировать» содержимое исходной области во вновь созданную область;
   return (указатель на выделенную область);
  }
  Рисунок 6.28. Алгоритм копирования содержимого существующей области
Системная функция fork требует, чтобы ядро скопировало содержимое областей процесса. Если же область разделяемая (разделяемый текст команд или разделяемая память), ядру нет надобности копировать область физически; вместо этого оно увеличивает значение счетчика ссылок на область, позволяя родительскому и порожденному процессам использовать область совместно. Если область не является разделяемой и ядру нужно физически копировать ее содержимое, оно выделяет новую запись в таблице областей, новую таблицу страниц и отводит под создаваемую область физическую память. В качестве примера рассмотрим Рисунок 6.27, где процесс A порождает с помощью функции fork процесс B и копирует области родительского процесса. Область команд процесса A является разделяемой, поэтому процесс B может использовать эту область совместно с процессом A. Однако области данных и стека родительского процесса являются его личной принадлежностью (имеют частный тип), поэтому процессу B нужно скопировать их содержимое во вновь выделенные области. При этом даже для областей частного типа физическое копирование области не всегда необходимо, в чем мы убедимся позже (глава 9). На Рисунке 6.28 приведен алгоритм копирования содержимого области (dupreg).

0

11

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
6.6 ПРИОСТАНОВКА ВЫПОЛНЕНИЯ

К настоящему моменту мы рассмотрели все функции работы с внутренними структурами процесса, выполняющиеся на нижнем уровне взаимодействия с процессом и обеспечивающие переход в состояние «выполнения в режиме ядра» и выход из этого состояния в другие состояния, за исключением функций, переводящих процесс в состояние «приостанова выполнения». Теперь перейдем к рассмотрению алгоритмов, с помощью которых процесс переводится из состояния «выполнения в режиме ядра» в состояние «приостанова в памяти» и из состояния приостанова в состояния «готовности к запуску» с выгрузкой и без выгрузки из памяти.
  Рисунок 6.29. Стандартные контекстные уровни приостановленного процесса
Выполнение процесса приостанавливается обычно во время исполнения запрошенной им системной функции: процесс переходит в режим ядра (контекстный уровень 1), исполняя внутреннее прерывание операционной системы, и приостанавливается в ожидании ресурсов. При этом процесс переключает контекст, запоминая в стеке свой текущий контекстный уровень и исполняясь далее в рамках системного контекстного уровня 2 (Рисунок 6.29). Выполнение процессов приостанавливается также и в том случае, когда оно наталкивается на отсутствие страницы в результате обращения к виртуальным адресам, не загруженным физически; процессы не будут выполняться, пока ядро не считает содержимое страниц.

6.6.1 События, вызывающие приостанов выполнения, и их адреса

Как уже говорилось во второй главе, процессы приостанавливаются до наступления определенного события, после которого они «пробуждаются» и переходят в состояние «готовности к выполнению» (с выгрузкой и без выгрузки из памяти). Такого рода абстрактное рассуждение недалеко от истины, ибо в конкретном воплощении совокупность событий отображается на совокупность виртуальных адресов (ядра). Адреса, с которыми связаны события, закодированы в ядре, и их единственное назначение состоит в их использовании в процессе отображения ожидаемого события на конкретный адрес. Как для абстрактного рассмотрения, так и для конкретной реализации события безразлично, сколько процессов одновременно ожидают его наступления. Как результат, возможно возникновение некоторых противоречий. Во-первых, когда событие наступает и процессы, ожидающие его, соответствующим образом оповещаются об этом, все они «пробуждаются» и переходят в состояние «готовности к выполнению». Ядро выводит процессы из состояния приостанова все сразу, а не по одному, несмотря на то, что они в принципе могут конкурировать за одну и ту же заблокированную структуру данных и большинство из них через небольшой промежуток времени опять вернется в состояние приостанова (более подробно об этом шла речь в главах 2 и 3). На Рисунке 6.30 изображены несколько процессов, приостановленных до наступления определенных событий.
  Рисунок 6.30. Процессы, приостановленные до наступления событий, и отображение событий на конкретные адреса
Еще одно противоречие связано с тем, что на один и тот же адрес могут отображаться несколько событий. На Рисунке 6.30, например, события «освобождение буфера» и «завершение ввода-вывода» отображаются на адрес буфера («адрес A»). Когда ввод-вывод в буфер завершается, ядро возобновляет выполнение всех процессов, приостановленных в ожидании наступления как того, так и другого события. Поскольку процесс, ожидающий завершения ввода-вывода, удерживает буфер заблокированным, другие процессы, которые ждали освобождения буфера, вновь приостановятся, ибо буфер все еще занят. Функционирование системы было бы более эффективным, если бы отображение событий на адреса было однозначным. Однако на практике такого рода противоречие на производительности системы не отражается, поскольку отображение на один адрес более одного события имеет место довольно редко, а также поскольку выполняющийся процесс обычно освобождает заблокированные ресурсы до того, как начнут выполняться другие процессы. Стилистически, тем не менее, механизм функционирования ядра стал бы более понятен, если бы отображение было однозначным.

6.6.2 Алгоритмы приостанова и возобновления выполнения

  алгоритм sleep
  входная информация:
   (1) адрес приостанова
   (2) приоритет
  выходная информация:
   1, если процесс возобновляется по сигналу, который ему удалось уловить;
   вызов алгоритма longjump, если процесс возобновляется по сигналу, который ему не удалось уловить;
   0 — во всех остальных случаях;
  {
   поднять приоритет работы процессора таким образом, чтобы заблокировать все прерывания;
   перевести процесс в состояние приостанова;
   включить процесс в хеш-очередь приостановленных процессов, базирующуюся на адресах приостанова;
   сохранить адрес приостанова в таблице процессов;
   сделать ввод для процесса приоритетным;
   if (приостанов процесса НЕ допускает прерываний)   {
    выполнить переключение контекста;
     /* с этого места процесс возобновляет выполнение, когда «пробуждается» */
    снизить приоритет работы процессора так, чтобы вновь разрешить прерывания (как было до приостанова процесса);
    return (0);
   }
   /* приостанов процесса принимает прерывания, вызванные сигналами */
   if (к процессу не имеет отношения ни один из сигналов)   {
    выполнить переключение контекста;
     /* с этого места процесс возобновляет выполнение, когда «пробуждается» */
    if (к процессу не имеет отношения ни один из сигналов)   {
     восстановить приоритет работы процессора таким, каким он был в момент приостанова процесса;
     return (0);
    }
   }
   удалить процесс из хеш-очереди приостановленных процессов, если он все еще находится там;
   восстановить приоритет работы процессора таким, каким он был в момент приостанова процесса;
   if (приоритет приостановленного процесса позволяет принимать сигналы)   return (1);
   запустить алгоритм longjump;
  }
  Рисунок 6.31. Алгоритм приостанова процесса
На Рисунке 6.31 приведен алгоритм приостанова процесса. Сначала ядро повышает приоритет работы процессора так, чтобы заблокировать все прерывания, которые могли бы (путем создания конкуренции) помешать работе с очередями приостановленных процессов, и запоминает старый приоритет, чтобы восстановить его, когда выполнение процесса будет возобновлено. Процесс получает пометку «приостановленного», адрес приостанова и приоритет запоминаются в таблице процессов, а процесс помещается в хеш-очередь приостановленных процессов. В простейшем случае (когда приостанов не допускает прерываний) процесс выполняет переключение контекста и благополучно «засыпает». Когда приостановленный процесс «пробуждается», ядро начинает планировать его запуск: процесс возвращает сохраненный в алгоритме sleep контекст, восстанавливает старый приоритет работы процессора (который был у него до начала выполнения алгоритма) и возвращает управление ядру.
  алгоритм wakeup /* возобновление приостановленного процесса */
  входная информация: адрес приостанова
  выходная информация: отсутствует 
  {
   повысить приоритет работы процессора таким образом, чтобы заблокировать все прерывания;
   найти хеш-очередь приостановленных процессов с указанным адресом приостанова;
   for (каждого процесса, приостановленного по указанному адресу)   {
    удалить процесс из хеш-очереди;
    сделать пометку о том, что процесс находится в состоянии «готовности к запуску»;
    включить процесс в список процессов, готовых к запуску (для планировщика процессов);
    очистить поле, содержащее адрес приостанова, в записи таблицы процессов;
    if (процесс не загружен в память)
     возобновить выполнение программы подкачки (нулевой процесс);
    else   if (возобновляемый процесс более подходит для исполнения, чем ныне выполняющийся)
      установить соответствующий флаг для планировщика;
   }
   восстановить первоначальный приоритет работы процессора;
  }
  Рисунок 6.32. Алгоритм возобновления приостановленного процесса
Чтобы возобновить выполнение приостановленных процессов, ядро обращается к алгоритму wakeup (Рисунок 6.32), причем делает это как во время исполнения алгоритмов реализации стандартных системных функций, так и в случае обработки прерываний. Алгоритм iput, например, освобождает заблокированный индекс и возобновляет выполнение всех процессов, ожидающих снятия блокировки. Точно так же и программа обработки прерываний от диска возобновляет выполнение процессов, ожидающих завершения ввода-вывода. В алгоритме wakeup ядро сначала повышает приоритет работы процессора, чтобы заблокировать прерывания. Затем для каждого процесса, приостановленного по указанному адресу, выполняются следующие действия: делается пометка в поле, описывающем состояние процесса, о том, что процесс готов к запуску; процесс удаляется из списка приостановленных процессов и помещается в список процессов, готовых к запуску; поле в записи таблицы процессов, содержащее адрес приостанова, очищается. Если возобновляемый процесс не загружен в память, ядро запускает процесс подкачки, обеспечивающий подкачку возобновляемого процесса в память (подразумевается система, в которой подкачка страниц по обращению не поддерживается); в противном случае, если возобновляемый процесс более подходит для исполнения, чем ныне выполняющийся, ядро устанавливает для планировщика специальный флаг, сообщающий о том, что процессу по возвращении в режим задачи следует пройти через алгоритм планирования (глава 8). Наконец, ядро восстанавливает первоначальный приоритет работы процессора. При этом на ядро не оказывается никакого давления: «пробуждение» (wakeup) процесса не вызывает его немедленного исполнения; благодаря «пробуждению», процесс становится только доступным для запуска.
Все, о чем говорилось выше, касается простейшего случая выполнения алгоритмов sleep и wakeup, поскольку предполагается, что процесс приостанавливается до наступления соответствующего события. Во многих случаях процессы приостанавливаются в ожидании событий, которые «должны» наступить, например, в ожидании освобождения ресурса (индексов или буферов) или в ожидании завершения ввода-вывода, связанного с диском. Уверенность процесса в неминуемом возобновлении основана на том, что подобные ресурсы могут быть предоставлены только во временное пользование. Тем не менее, иногда процесс может приостановиться в ожидании события, не будучи уверенным в неизбежном наступлении последнего, в таком случае у процесса должна быть возможность в любом случае вернуть себе управление и продолжить выполнение. В подобных ситуациях ядро немедленно нарушает «сон» приостановленного процесса, посылая ему сигнал. Более подробно о сигналах мы поговорим в следующей главе; здесь же примем допущение, что ядро может (выборочно) возобновлять приостановленные процессы по сигналу и что процесс может распознавать получаемые сигналы.
Например, если процесс обратился к системной функции чтения с терминала, ядро не будет в состоянии выполнить запрос процесса до тех пор, пока пользователь не введет данные с клавиатуры терминала (глава 10). Тем не менее, пользователь, запустивший процесс, может оставить терминал на весь день, при этом процесс останется приостановленным в ожидании ввода, а терминал может понадобиться другому пользователю. Если другой пользователь прибегнет к решительным мерам (таким как выключение терминала), ядро должно иметь возможность восстановить отключенный процесс: в качестве первого шага ядру следует возобновить приостановленный процесс по сигналу. В том, что процессы могут приостановиться на длительное время, нет ничего плохого. Приостановленный процесс занимает позицию в таблице процессов и может поэтому удлинять время поиска (ожидания) путем выполнения определенных алгоритмов, которые не занимают время центрального процессора и поэтому выполняются практически незаметно.
Чтобы как-то различать между собой состояния приостанова, ядро устанавливает для приостанавливаемого процесса (при входе в это состояние) приоритет планирования на основании соответствующего параметра алгоритма sleep. То есть ядро запускает алгоритм sleep с параметром «приоритет», в котором отражается наличие уверенности в неизбежном наступлении ожидаемого события. Если приоритет превышает пороговое значение, процесс не будет преждевременно выходить из приостанова по получении сигнала, а будет продолжать ожидать наступления события. Если же значение приоритета ниже порогового, процесс будет немедленно возобновлен по получении сигнала [20] .
Проверка того, имеет ли процесс уже сигнал при входе в алгоритм sleep, позволяет выяснить, приостанавливался ли процесс ранее. Например, если значение приоритета в вызове алгоритма sleep превышает пороговое значение, процесс приостанавливается в ожидании выполнения алгоритма wakeup. Если же значение приоритета ниже порогового, выполнение процесса не приостанавливается, но на сигнал процесс реагирует точно так же, как если бы он был приостановлен. Если ядро не проверит наличие сигналов перед приостановом, возможна опасность, что сигнал больше не поступит вновь и в этом случае процесс никогда не возобновится.
Когда процесс «пробуждается» по сигналу (или когда он не переходит в состояние приостанова из-за наличия сигнала), ядро может выполнить алгоритм longjump (в зависимости от причины, по которой процесс был приостановлен). С помощью алгоритма longjump ядро восстанавливает ранее сохраненный контекст, если нет возможности завершить выполняемую системную функцию. Например, если из-за того, что пользователь отключил терминал, было прервано чтение данных с терминала, функция read не будет завершена, но возвратит признак ошибки. Это касается всех системных функций, которые могут быть прерваны во время приостанова. После выхода из приостанова процесс не сможет нормально продолжаться, поскольку ожидаемое событие не наступило. Перед выполнением большинства системных функций ядро сохраняет контекст процесса, используя алгоритм setjump и вызывая тем самым необходимость в последующем выполнении алгоритма longjump.
Встречаются ситуации, когда ядро требует, чтобы процесс возобновился по получении сигнала, но не выполняет алгоритм longjump. Ядро запускает алгоритм sleep со специальным значением параметра «приоритет», подавляющим исполнение алгоритма longjump и заставляющим алгоритм sleep возвращать код, равный 1. Такая мера более эффективна по сравнению с немедленным выполнением алгоритма setjump перед вызовом sleep и последующим выполнением алгоритма longjump для восстановления первоначального контекста процесса. Задача заключается в том, чтобы позволить ядру очищать локальные структуры данных. Драйвер устройства, например, может выделить свои частные структуры данных и приостановиться с приоритетом, допускающим прерывания; если по сигналу его работа возобновляется, он освобождает выделенные структуры, а затем выполняет алгоритм longjump, если необходимо. Пользователь не имеет возможности проконтролировать, выполняет ли процесс алгоритм longjump; выполнение этого алгоритма зависит от причины приостановки процесса, а также от того, требуют ли структуры данных ядра внесения изменений перед выходом из системной функции.

6.7 ВЫВОДЫ

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

6.8 УПРАЖНЕНИЯ

1. Составьте алгоритм преобразования виртуальных адресов в физические, на входе которого задаются виртуальный адрес и адрес точки входа в частную таблицу областей.
2. В машинах AT&T 3B2 и NSC серии 32000 используется двухуровневая схема трансляции виртуальных адресов в физические (с сегментацией). То есть в системе поддерживается указатель на таблицу страниц, каждая запись которой может адресовать фиксированную часть адресного пространства процесса по смещению в таблице. Сравните алгоритм трансляции виртуальных адресов на этих машинах с алгоритмом, изложенным в тексте при обсуждении модели управления памятью. Подумайте над проблемами производительности и потребности в памяти для размещения вспомогательных таблиц.
3. В архитектуре системы VAX-11 поддерживаются два набора регистров защиты памяти, используемых машиной в процессе трансляции пользовательских адресов. Механизм трансляции используется тот же, что и в предыдущем пункте, за одним исключением: указателей на таблицу страниц здесь два. Если процесс располагает тремя областями — команд, данных и стека — то каким образом, используя два набора регистров, следует производить отображение областей на таблицы страниц? Увеличение стека в архитектуре системы VAX-11 идет в направлении младших виртуальных адресов. Какой тогда вид имела бы область стека? В главе 11 будет рассмотрена область разделяемой памяти: как она может быть реализована в архитектуре системы VAX-11?
4. Составьте алгоритм выделения и освобождения страниц памяти и таблиц страниц. Какие структуры данных следует использовать, чтобы достичь наивысшей производительности или наибольшей простоты реализации алгоритма?
5. Устройство управления памятью MC68451 для семейства микропроцессоров Motorola 68000 допускает выделение сегментов памяти размером от 256 байт до 16 мегабайт. Каждое (физическое) устройство управления памятью поддерживает 32 дескриптора сегментов. Опишите эффективный метод выделения памяти для этого случая. Каким образом осуществлялась бы реализация областей?
6. Рассмотрим отображение виртуальных адресов, представленное на Рисунке 6.5. Предположим, что ядро выгружает процесс (в системе с подкачкой процессов) или откачивает в область стека большое количество страниц (в системе с замещением страниц). Если через какое-то время процесс обратится к виртуальному адресу 68432, будет ли он должен обратиться к соответствующей ячейке физической памяти, из которой он считывал данные до того, как была выполнена операция выгрузки (откачки)? Если нижние уровни системы управления памятью реализуются с использованием таблицы страниц, следует ли эти таблицы располагать в тех же, что и сами страницы, местах физической памяти?
*7. Можно реализовать систему, в которой стек ядра располагается над вершиной стека задачи. Подумайте о достоинствах и недостатках подобной системы.
8. Каким образом, присоединяя область к процессу, ядро может проверить то, что эта область не накладывается на виртуальные адреса областей, уже присоединенных к процессу?
9. Обратимся к алгоритму переключения контекста. Допустим, что в системе готов к выполнению только один процесс. Другими словами, ядро выбирает для выполнения процесс с только что сохраненным контекстом. Объясните, что произойдет при этом.
10. Предположим, что процесс приостановился, но в системе нет процессов, готовых к выполнению. Что произойдет, когда приостановившийся процесс переключит контекст?
11. Предположим, что процесс, выполняемый в режиме задачи, израсходовал выделенный ему квант времени и в результате прерывания по таймеру ядро выбирает для выполнения новый процесс. Объясните, почему переключение контекста произойдет на системном контекстном уровне 2.
12. В системе с замещением страниц процесс, выполняемый в режиме задачи, может столкнуться с отсутствием нужной страницы, которая не была загружена в память. В ходе обработки прерывания ядро считывает страницу из области подкачки и приостанавливается. Объясните, почему переключение контекста (в момент приостанова) произойдет на системном контекстном уровне 2.
13. Процесс использует системную функцию read с форматом вызова read(fd,buf,1024);
в системе с замещением страниц памяти. Предположим, что ядро исполняет алгоритм read для считывания данных в системный буфер, однако при попытке копирования данных в адресное пространство задачи сталкивается с отсутствием нужной страницы, содержащей структуру buf, вследствие того, что она была ранее выгружена из памяти. Ядро обрабатывает возникшее прерывание, считывая отсутствующую страницу в память. Что происходит на каждом из системных контекстных уровней? Что произойдет, если программа обработки прерывания приостановится в ожидании завершения считывания страницы?
14. Что произошло бы, если бы во время копирования данных из адресного пространства задачи в память ядра (Рисунок 6.17) обнаружилось, что указанный пользователем адрес неверен?
*15. При выполнении алгоритмов sleep и wakeup ядро повышает приоритет работы процессора так, чтобы не допустить прерываний, препятствующих ей. Какие отрицательные последствия могли бы возникнуть, если бы ядро не предпринимало этих действий? (Намек: ядро зачастую возобновляет приостановленные процессы прямо из программ обработки прерываний).
*16. Предположим, что процесс пытается приостановиться до наступления события A, но, запуская алгоритм sleep, еще не заблокировал прерывания; допустим, что в этот момент происходит прерывание и программа его обработки пытается возобновить все процессы, приостановленные до наступления события A. Что случится с первым процессом? Не представляет ли эта ситуация опасность? Если да, то может ли ядро избежать ее возникновения?
17. Что произойдет, если ядро запустит алгоритм wakeup для всех процессов, приостановленных по адресу A, в то время, когда по этому адресу не окажется ни одного приостановленного процесса?
18. По одному адресу может приостановиться множество процессов, но ядру может потребоваться возобновление только некоторых из них — тех, которым будет послан соответствующий сигнал. С помощью механизма посылки сигналов можно идентифицировать отдельные процессы. Подумайте, какие изменения следует произвести в алгоритме wakeup для того, чтобы можно было возобновлять выполнение только одного процесса, а не всех процессов, приостановленных по заданному адресу.
19. Обращения к алгоритмам sleep и wakeup в системе Multics имеют следующий синтаксис:
sleep(событие);
wakeup(событие, приоритет);
Таким образом, в алгоритме wakeup возобновляемому процессу присваивается приоритет. Сравните форму вызова этих алгоритмов с формой вызова события.

0

12

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
ГЛАВА 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 attachreg detachreg allocreg attachreg growreg loadreg mapreg growreg detachreg

  Рисунок 7.1. Системные функции управления процессом и их связь с другими алгоритмами

7.1 СОЗДАНИЕ ПРОЦЕССА

Единственным способом создания пользователем нового процесса в операционной системе UNIX является выполнение системной функции fork. Процесс, вызывающий функцию fork, называется родительским (процесс-родитель), вновь создаваемый процесс называется порожденным (процесс-потомок). Синтаксис вызова функции fork:
pid = fork();
В результате выполнения функции fork пользовательский контекст и того, и другого процессов совпадает во всем, кроме возвращаемого значения переменной pid. Для родительского процесса в pid возвращается идентификатор порожденного процесса, для порожденного — pid имеет нулевое значение. Нулевой процесс, возникающий внутри ядра при загрузке системы, является единственным процессом, не создаваемым с помощью функции fork.
В ходе выполнения функции ядро производит следующую последовательность действий:
1. Отводит место в таблице процессов под новый процесс.
2. Присваивает порождаемому процессу уникальный код идентификации.
3. Делает логическую копию контекста родительского процесса. Поскольку те или иные составляющие процесса, такие как область команд, могут разделяться другими процессами, ядро может иногда вместо копирования области в новый физический участок памяти просто увеличить значение счетчика ссылок на область.
4. Увеличивает значения счетчика числа файлов, связанных с процессом, как в таблице файлов, так и в таблице индексов.
5. Возвращает родительскому процессу код идентификации порожденного процесса, а порожденному процессу — нулевое значение.
Реализацию системной функции fork, пожалуй, нельзя назвать тривиальной, так как порожденный процесс начинает свое выполнение, возникая как бы из воздуха. Алгоритм реализации функции для систем с замещением страниц по запросу и для систем с подкачкой процессов имеет лишь незначительные различия; все изложенное ниже в отношении этого алгоритма касается в первую очередь традиционных систем с подкачкой процессов, но с непременным акцентированием внимания на тех моментах, которые в системах с замещением страниц по запросу реализуются иначе. Кроме того, конечно, предполагается, что в системе имеется свободная оперативная память, достаточная для размещения порожденного процесса. В главе 9 будет отдельно рассмотрен случай, когда для порожденного процесса не хватает памяти, и там же будут даны разъяснения относительно реализации алгоритма fork в системах с замещением страниц.
На Рисунке 7.2 приведен алгоритм создания процесса. Сначала ядро должно удостовериться в том, что для успешного выполнения алгоритма fork есть все необходимые ресурсы. В системе с подкачкой процессов для размещения порождаемого процесса требуется место либо в памяти, либо на диске; в системе с замещением страниц следует выделить память для вспомогательных таблиц (в частности, таблиц страниц). Если свободных ресурсов нет, алгоритм fork завершается неудачно. Ядро ищет место в таблице процессов для конструирования контекста порождаемого процесса и проверяет, не превысил ли пользователь, выполняющий fork, ограничение на максимально-допустимое количество параллельно запущенных процессов. Ядро также подбирает для нового процесса уникальный идентификатор, значение которого превышает на единицу максимальный из существующих идентификаторов. Если предлагаемый идентификатор уже присвоен другому процессу, ядро берет идентификатор, следующий по порядку. Как только будет достигнуто максимально-допустимое значение, отсчет идентификаторов опять начнется с 0. Поскольку большинство процессов имеет короткое время жизни, при переходе к началу отсчета значительная часть идентификаторов оказывается свободной.
На количество одновременно выполняющихся процессов накладывается ограничение (конфигурируемое), отсюда ни один из пользователей не может занимать в таблице процессов слишком много места, мешая тем самым другим пользователям создавать новые процессы. Кроме того, простым пользователям не разрешается создавать процесс, занимающий последнее свободное место в таблице процессов, в противном случае система зашла бы в тупик. Другими словами, поскольку в таблице процессов нет свободного места, то ядро не может гарантировать, что все существующие процессы завершатся естественным образом, поэтому новые процессы создаваться не будут. С другой стороны, суперпользователю нужно дать возможность исполнять столько процессов, сколько ему потребуется, конечно, учитывая размер таблицы процессов, при этом процесс, исполняемый суперпользователем, может занять в таблице и последнее свободное место. Предполагается, что суперпользователь может прибегать к решительным мерам и запускать процесс, побуждающий остальные процессы к завершению, если это вызывается необходимостью (см. раздел 7.2.3, где говорится о системной функции kill).
  алгоритм fork
  входная информация: отсутствует
  выходная информация:
   для родительского процесса — идентификатор (PID) порожденного процесса
   для порожденного процесса — 0
  {
   проверить доступность ресурсов ядра;
   получить свободное место в таблице процессов и уникальный код идентификации (PID);
   проверить, не запустил ли пользователь слишком много процессов;
   сделать пометку о том, что порождаемый процесс находится в состоянии «создания»;
   скопировать информацию в таблице процессов из записи, соответствующей родительскому процессу, в запись, соответствующую порожденному процессу;
   увеличить значения счетчиков ссылок на текущий каталог и на корневой каталог (если он был изменен);
   увеличить значение счетчика открытий файла в таблице файлов;
   сделать копию контекста родительского процесса (адресное пространство, команды, данные, стек) в памяти;
   поместить в стек фиктивный уровень системного контекста над уровнем системного контекста, соответствующим порожденному процессу;
  /* фиктивный контекстный уровень содержит информацию, необходимую порожденному процессу для того, чтобы знать все о себе и будучи выбранным для исполнения запускаться с этого места; */
   if (в данный момент выполняется родительский процесс)   {
    перевести порожденный процесс в состояние «готовности к выполнению»;
    return (идентификатор порожденного процесса);    /* из системы пользователю */
   }
   else { /* выполняется порожденный процесс */ 
    записать начальные значения в поля синхронизации адресного пространства процесса;
    return (0); /* пользователю */
   }
  }
  Рисунок 7.2. Алгоритм fork
Затем ядро присваивает начальные значения различным полям записи таблицы процессов, соответствующей порожденному процессу, копируя в них значения полей из записи родительского процесса. Например, порожденный процесс «наследует» у родительского процесса коды идентификации пользователя (реальный и тот, под которым исполняется процесс), группу процессов, управляемую родительским процессом, а также значение, заданное родительским процессом в функции nice и используемое при вычислении приоритета планирования. В следующих разделах мы поговорим о назначении этих полей. Ядро передает значение поля идентификатора родительского процесса в запись порожденного, включая последний в древовидную структуру процессов, и присваивает начальные значения различным параметрам планирования, таким как приоритет планирования, использование ресурсов центрального процессора и другие значения полей синхронизации. Начальным состоянием процесса является состояние «создания» (см. Рисунок 6.1).
После того ядро устанавливает значения счетчиков ссылок на файлы, с которыми автоматически связывается порождаемый процесс. Во-первых, порожденный процесс размещается в текущем каталоге родительского процесса. Число процессов, обращающихся в данный момент к каталогу, увеличивается на 1 и, соответственно, увеличивается значение счетчика ссылок на его индекс. Во-вторых, если родительский процесс или один из его предков уже выполнял смену корневого каталога с помощью функции chroot, порожденный процесс наследует и новый корень с соответствующим увеличением значения счетчика ссылок на индекс корня. Наконец, ядро просматривает таблицу пользовательских дескрипторов для родительского процесса в поисках открытых файлов, известных процессу, и увеличивает значение счетчика ссылок, ассоциированного с каждым из открытых файлов, в глобальной таблице файлов. Порожденный процесс не просто наследует права доступа к открытым файлам, но и разделяет доступ к файлам с родительским процессом, так как оба процесса обращаются в таблице файлов к одним и тем же записям. Действие fork в отношении открытых файлов подобно действию алгоритма dup: новая запись в таблице пользовательских дескрипторов файла указывает на запись в глобальной таблице файлов, соответствующую открытому файлу. Для dup, однако, записи в таблице пользовательских дескрипторов файла относятся к одному процессу; для fork — к разным процессам.
После завершения всех этих действий ядро готово к созданию для порожденного процесса пользовательского контекста. Ядро выделяет память для адресного пространства процесса, его областей и таблиц страниц, создает с помощью алгоритма dupreg копии всех областей родительского процесса и присоединяет с помощью алгоритма attachreg каждую область к порожденному процессу. В системе с подкачкой процессов ядро копирует содержимое областей, не являющихся областями разделяемой памяти, в новую зону оперативной памяти. Вспомним из раздела 6.2.4 о том, что в пространстве процесса хранится указатель на соответствующую запись в таблице процессов. За исключением этого поля, во всем остальном содержимое адресного пространства порожденного процесса в начале совпадает с содержимым пространства родительского процесса, но может расходиться после завершения алгоритма fork. Родительский процесс, например, после выполнения fork может открыть новый файл, к которому порожденный процесс уже не получит доступ автоматически.
Итак, ядро завершило создание статической части контекста порожденного процесса; теперь оно приступает к созданию динамической части. Ядро копирует в нее первый контекстный уровень родительского процесса, включающий в себя сохраненный регистровый контекст задачи и стек ядра в момент вызова функции fork. Если в данной реализации стек ядра является частью пространства процесса, ядро в момент создания пространства порожденного процесса автоматически создает и системный стек для него. В противном случае родительскому процессу придется скопировать в пространство памяти, ассоциированное с порожденным процессом, свой системный стек. В любом случае стек ядра для порожденного процесса совпадает с системным стеком его родителя. Далее ядро создает для порожденного процесса фиктивный контекстный уровень (2), в котором содержится сохраненный регистровый контекст из первого контекстного уровня. Значения счетчика команд (регистр PC) и других регистров, сохраняемые в регистровом контексте, устанавливаются таким образом, чтобы с их помощью можно было «восстанавливать» контекст порожденного процесса, пусть даже последний еще ни разу не исполнялся, и чтобы этот процесс при запуске всегда помнил о том, что он порожденный. Например, если программа ядра проверяет значение, хранящееся в регистре 0, для того, чтобы выяснить, является ли данный процесс родительским или же порожденным, то это значение переписывается в регистровый контекст порожденного процесса, сохраненный в составе первого уровня. Механизм сохранения используется тот же, что и при переключении контекста (см. предыдущую главу).
  Рисунок 7.3. Создание контекста нового процесса при выполнении функции fork
Если контекст порожденного процесса готов, родительский процесс завершает свою роль в выполнении алгоритма fork, переводя порожденный процесс в состояние «готовности к запуску, находясь в памяти» и возвращая пользователю его идентификатор. Затем, используя обычный алгоритм планирования, ядро выбирает порожденный процесс для исполнения и тот «доигрывает» свою роль в алгоритме fork. Контекст порожденного процесса был задан родительским процессом; с точки зрения ядра кажется, что порожденный процесс возобновляется после приостанова в ожидании ресурса. Порожденный процесс при выполнении функции fork реализует ту часть программы, на которую указывает счетчик команд, восстанавливаемый ядром из сохраненного на уровне 2 регистрового контекста, и по выходе из функции возвращает нулевое значение.
На Рисунке 7.3 представлена логическая схема взаимодействия родительского и порожденного процессов с другими структурами данных ядра сразу после завершения системной функции fork. Итак, оба процесса совместно пользуются файлами, которые были открыты родительским процессом к моменту исполнения функции fork, при этом значение счетчика ссылок на каждый из этих файлов в таблице файлов на единицу больше, чем до вызова функции. Порожденный процесс имеет те же, что и родительский процесс, текущий и корневой каталоги, значение же счетчика ссылок на индекс каждого из этих каталогов так же становится на единицу больше, чем до вызова функции. Содержимое областей команд, данных и стека (задачи) у обоих процессов совпадает; по типу области и версии системной реализации можно установить, могут ли процессы разделять саму область команд в физических адресах.
Рассмотрим приведенную на Рисунке 7.4 программу, которая представляет собой пример разделения доступа к файлу при исполнении функции fork. Пользователю следует передавать этой программе два параметра — имя существующего файла и имя создаваемого файла. Процесс открывает существующий файл, создает новый файл и — при условии отсутствия ошибок — порождает новый процесс. Внутри программы ядро делает копию контекста родительского процесса для порожденного, при этом родительский процесс исполняется в одном адресном пространстве, а порожденный — в другом. Каждый из процессов может работать со своими собственными копиями глобальных переменных fdrd, fdwt и c, а также со своими собственными копиями стековых переменных argc и argv, но ни один из них не может обращаться к переменным другого процесса. Тем не менее, при выполнении функции fork ядро делает копию адресного пространства первого процесса для второго, и порожденный процесс, таким образом, наследует доступ к файлам родительского (то есть к файлам, им ранее открытым и созданным) с правом использования тех же самых дескрипторов.
Родительский и порожденный процессы независимо друг от друга, конечно, вызывают функцию rdwrt и в цикле считывают по одному байту информацию из исходного файла и переписывают ее в файл вывода. Функция rdwrt возвращает управление, когда при считывании обнаруживается конец файла. Ядро перед тем уже увеличило значения счетчиков ссылок на исходный и результирующий файлы в таблице файлов, и дескрипторы, используемые в обоих процессах, адресуют к одним и тем же строкам в таблице. Таким образом, дескрипторы fdrd в том и в другом процессах указывают на запись в таблице файлов, соответствующую исходному файлу, а дескрипторы, подставляемые в качестве fdwt, — на запись, соответствующую результирующему файлу (файлу вывода). Поэтому оба процесса никогда не обратятся вместе на чтение или запись к одному и тому же адресу, вычисляемому с помощью смещения внутри файла, поскольку ядро смещает внутрифайловые указатели после каждой операции чтения или записи. Несмотря на то, что, казалось бы, из-за того, что процессы распределяют между собой рабочую нагрузку, они копируют исходный файл в два раза быстрее, содержимое результирующего файла зависит от очередности, в которой ядро запускает процессы. Если ядро запускает процессы так, что они исполняют системные функции попеременно (чередуя и спаренные вызовы функций read-write), содержимое результирующего файла будет совпадать с содержимым исходного файла. Рассмотрим, однако, случай, когда процессы собираются считать из исходного файла последовательность из двух символов «ab». Предположим, что родительский процесс считал символ «a», но не успел записать его, так как ядро переключилось на контекст порожденного процесса. Если порожденный процесс считывает символ «b» и записывает его в результирующий файл до возобновления родительского процесса, строка «ab» в результирующем файле будет иметь вид «ba». Ядро не гарантирует согласование темпов выполнения процессов.
  #include ‹fcntl.h›
  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. Программа, в которой родительский и порожденный процессы разделяют доступ к файлу
Теперь перейдем к программе, представленной на Рисунке 7.5, в которой процесс-потомок наследует от своего родителя файловые дескрипторы 0 и 1 (соответствующие стандартному вводу и стандартному выводу). При каждом выполнении системной функции pipe производится назначение двух файловых дескрипторов в массивах to_par и to_chil. Процесс вызывает функцию fork и делает копию своего контекста: каждый из процессов имеет доступ только к своим собственным данным, так же как и в предыдущем примере. Родительский процесс закрывает файл стандартного вывода (дескриптор 1) и дублирует дескриптор записи, возвращаемый в канал to_chil. Поскольку первое свободное место в таблице дескрипторов родительского процесса образовалось в результате только что выполненной операции закрытия (close) файла вывода, ядро переписывает туда дескриптор записи в канал и этот дескриптор становится дескриптором файла стандартного вывода для to_chil. Те же самые действия родительский процесс выполняет в отношении дескриптора файла стандартного ввода, заменяя его дескриптором чтения из канала to_par. И порожденный процесс закрывает файл стандартного ввода (дескриптор 0) и так же дублирует дескриптор чтения из канала to_chil. Поскольку первое свободное место в таблице дескрипторов файлов прежде было занято файлом стандартного ввода, его дескриптором становится дескриптор чтения из канала to_chil. Аналогичные действия выполняются и в отношении дескриптора файла стандартного вывода, заменяя его дескриптором записи в канал to_par. И тот, и другой процессы закрывают файлы, дескрипторы которых возвратила функция pipe — хорошая традиция, в чем нам еще предстоит убедиться. В результате, когда родительский процесс переписывает данные в стандартный вывод, запись ведется в канал to_chil и данные поступают к порожденному процессу, который считывает их через свой стандартный ввод. Когда же порожденный процесс пишет данные в стандартный вывод, запись ведется в канал to_par и данные поступают к родительскому процессу, считывающему их через свой стандартный ввод. Так через два канала оба процесса обмениваются сообщениями.
  #include ‹string.h›
  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
Результаты этой программы не зависят от того, в какой очередности процессы выполняют свои действия. Таким образом, нет никакой разницы, возвращается ли управление родительскому процессу из функции fork раньше или позже, чем порожденному процессу. И так же безразличен порядок, в котором процессы вызывают системные функции перед тем, как войти в свой собственный цикл, ибо они используют идентичные структуры ядра. Если процесс-потомок исполняет функцию read раньше, чем его родитель выполнит write, он будет приостановлен до тех пор, пока родительский процесс не произведет запись в канал и тем самым не возобновит выполнение потомка. Если родительский процесс записывает в канал до того, как его потомок приступит к чтению из канала, первый процесс не сможет в свою очередь считать данные из стандартного ввода, пока второй процесс не прочитает все из своего стандартного ввода и не произведет запись данных в стандартный вывод. С этого места порядок работы жестко фиксирован: каждый процесс завершает выполнение функций read и write и не может выполнить следующую операцию read до тех пор, пока другой процесс не выполнит пару read-write. Родительский процесс после 15 итераций завершает работу; порожденный процесс наталкивается на конец файла («end-of-file»), поскольку канал не связан больше ни с одним из записывающих процессов, и тоже завершает работу. Если порожденный процесс попытается произвести запись в канал после завершения родительского процесса, он получит сигнал о том, что канал не связан ни с одним из процессов чтения.
Мы упомянули о том, что хорошей традицией в программировании является закрытие ненужных файловых дескрипторов. В пользу этого говорят три довода. Во-первых, дескрипторы файлов постоянно находятся под контролем системы, которая накладывает ограничение на их количество. Во-вторых, во время исполнения порожденного процесса присвоение дескрипторов в новом контексте сохраняется (в чем мы еще убедимся). Закрытие ненужных файлов до запуска процесса открывает перед программами возможность исполнения в «стерильных» условиях, свободных от любых неожиданностей, имея открытыми только файлы стандартного ввода-вывода и ошибок. Наконец, функция read для канала возвращает признак конца файла только в том случае, если канал не был открыт для записи ни одним из процессов. Если считывающий процесс будет держать дескриптор записи в канал открытым, он никогда не узнает, закрыл ли записывающий процесс работу на своем конце канала или нет. Вышеприведенная программа не работала бы надлежащим образом, если бы перед входом в цикл выполнения процессом-потомком не были закрыты дескрипторы записи в канал.

7.2 СИГНАЛЫ

Сигналы сообщают процессам о возникновении асинхронных событий. Посылка сигналов производится процессами — друг другу, с помощью функции kill, — или ядром. В версии V (вторая редакция) системы UNIX существуют 19 различных сигналов, которые можно классифицировать следующим образом:
• Сигналы, посылаемые в случае завершения выполнения процесса, то есть тогда, когда процесс выполняет функцию exit или функцию signal с параметром death of child (гибель потомка);
• Сигналы, посылаемые в случае возникновения вызываемых процессом особых ситуаций, таких как обращение к адресу, находящемуся за пределами виртуального адресного пространства процесса, или попытка записи в область памяти, открытую только для чтения (например, текст программы), или попытка исполнения привилегированной команды, а также различные аппаратные ошибки;
• Сигналы, посылаемые во время выполнения системной функции при возникновении неисправимых ошибок, таких как исчерпание системных ресурсов во время выполнения функции exec после освобождения исходного адресного пространства (см. раздел 7.5);
• Сигналы, причиной которых служит возникновение во время выполнения системной функции совершенно неожиданных ошибок, таких как обращение к несуществующей системной функции (процесс передал номер системной функции, который не соответствует ни одной из имеющихся функций), запись в канал, не связанный ни с одним из процессов чтения, а также использование недопустимого значения в параметре «reference» системной функции lseek. Казалось бы, более логично в таких случаях вместо посылки сигнала возвращать код ошибки, однако с практической точки зрения для аварийного завершения процессов, в которых возникают подобные ошибки, более предпочтительным является именно использование сигналов [21] ;
• Сигналы, посылаемые процессу, который выполняется в режиме задачи, например, сигнал тревоги (alarm), посылаемый по истечении определенного периода времени, или произвольные сигналы, которыми обмениваются процессы, использующие функцию kill;
• Сигналы, связанные с терминальным взаимодействием, например, с «зависанием» терминала (когда сигнал-носитель на терминальной линии прекращается по любой причине) или с нажатием клавиш «break» и «delete» на клавиатуре терминала;
• Сигналы, с помощью которых производится трассировка выполнения процесса. Условия применения сигналов каждой группы будут рассмотрены в этой и последующих главах.
Концепция сигналов имеет несколько аспектов, связанных с тем, каким образом ядро посылает сигнал процессу, каким образом процесс обрабатывает сигнал и управляет реакцией на него. Посылая сигнал процессу, ядро устанавливает в единицу разряд в поле сигнала записи таблицы процессов, соответствующий типу сигнала. Если процесс находится в состоянии приостанова с приоритетом, допускающим прерывания, ядро возобновит его выполнение. На этом роль отправителя сигнала (процесса или ядра) исчерпывается. Процесс может запоминать сигналы различных типов, но не имеет возможности запоминать количество получаемых сигналов каждого типа. Например, если процесс получает сигнал о «зависании» или об удалении процесса из системы, он устанавливает в единицу соответствующие разряды в поле сигналов таблицы процессов, но не может сказать, сколько экземпляров сигнала каждого типа он получил.
Ядро проверяет получение сигнала, когда процесс собирается перейти из режима ядра в режим задачи, а также когда он переходит в состояние приостанова или выходит из этого состояния с достаточно низким приоритетом планирования (см.Рисунок 7.6). Ядро обрабатывает сигналы только тогда, когда процесс возвращается из режима ядра в режим задачи. Таким образом, сигнал не оказывает немедленного воздействия на поведение процесса, исполняемого в режиме ядра. Если процесс исполняется в режиме задачи, а ядро тем временем обрабатывает прерывание, послужившее поводом для посылки процессу сигнала, ядро распознает и обработает сигнал по выходе из прерывания. Таким образом, процесс не будет исполняться в режиме задачи, пока какие-то сигналы остаются необработанными.
На Рисунке 7.7 представлен алгоритм, с помощью которого ядро определяет, получил ли процесс сигнал или нет. Условия, в которых формируются сигналы типа «гибель потомка», будут рассмотрены позже. Мы также увидим, что процесс может игнорировать отдельные сигналы, если воспользуется функцией signal. В алгоритме issig ядро просто гасит индикацию тех сигналов, на которые процесс не желает обращать внимание, и привлекает внимание процесса ко всем остальным сигналам.
  Рисунок 7.6. Диаграмма переходов процесса из состояние в состояние с указанием моментов проверки и обработки сигналов
  алгоритм issig /* проверка получения сигналов */
  входная информация: отсутствует
  выходная информация:
   «истина», если процесс получил сигналы, которые его интересуют
   «ложь» — в противном случае
  {
   do while (поле в записи таблицы процессов, содержащее индикацию о получении сигнала, хранит ненулевое значение)   {
    найти номер сигнала, посланного процессу;
    if (сигнал типа «гибель потомка»)   {
     if (сигналы данного типа игнорируются)
      освободить записи таблицы процессов, которые соответствуют потомкам, прекратившим существование;
     else   if (сигналы данного типа принимаются)   return (true);
    }
    else   if (сигнал не игнорируется) return (true);
    сбросить (погасить) сигнальный разряд, установленный в соответствующем поле таблицы процессов, хранящем индикацию получения сигнала;
   }
   return (false);
  }
  Рисунок 7.7. Алгоритм опознания сигналов

7.2.1 Обработка сигналов

Ядро обрабатывает сигналы в контексте того процесса, который получает их, поэтому чтобы обработать сигналы, нужно запустить процесс. Существует три способа обработки сигналов: процесс завершается по получении сигнала, не обращает внимание на сигнал или выполняет особую (пользовательскую) функцию по его получении. Реакцией по умолчанию со стороны процесса, исполняемого в режиме ядра, является вызов функции exit, однако с помощью функции signal процесс может указать другие специальные действия, принимаемые по получении тех или иных сигналов.
Синтаксис вызова системной функции signal:
oldfunction = signal(signum, function);
где signum — номер сигнала, при получении которого будет выполнено действие, связанное с запуском пользовательской функции, function — адрес функции, oldfunction — возвращаемое функцией значение. Вместо адреса функции процесс может передавать вызываемой процедуре signal числа 1 и 0: если function = 1, процесс будет игнорировать все последующие поступления сигнала с номером signum (особый случай, связанный с игнорированием сигнала «гибель потомка», рассматривается в разделе 7.4), если = 0 (значение по умолчанию), процесс по получении сигнала в режиме ядра завершается. В пространстве процесса поддерживается массив полей для обработки сигналов, по одному полю на каждый определенный в системе сигнал. В поле, соответствующем сигналу с указанным номером, ядро сохраняет адрес пользовательской функции, вызываемой по получении сигнала процессом. Способ обработки сигналов одного типа не влияет на обработку сигналов других типов.
  алгоритм psig /* обработка сигналов после проверки их существования */
  входная информация: отсутствует
  выходная информация: отсутствует 
  {
   выбрать номер сигнала из записи таблицы процессов;
   очистить поле с номером сигнала;
   if (пользователь ранее вызывал функцию signal, с помощью которой сделал указание игнорировать сигнал данного типа)
    return; 
   if (пользователь указал функцию, которую нужно выполнить по получении сигнала)   {
    из пространства процесса выбрать пользовательский виртуальный адрес функции обработки сигнала;
    /* следующий оператор имеет нежелательные побочные эффекты */
    очистить поле в пространстве процесса, содержащее адрес функции обработки сигнала;
  внести изменения в пользовательский контекст: 
    искусственно создать в стеке задачи запись, имитирующую обращение к функции обработки сигнала;
  внести изменения в системный контекст:
    записать адрес функции обработки сигнала в поле счетчика команд, принадлежащее сохраненному регистровому контексту задачи;
    return;
   }
   if (сигнал требует дампирования образа процесса в памяти)   {
    создать в текущем каталоге файл с именем «core»;
    переписать в файл «core» содержимое пользовательского контекста;
   }
   немедленно запустить алгоритм exit;
  }
  Рисунок 7.8. Алгоритм обработки сигналов
Обрабатывая сигнал (Рисунок 7.8), ядро определяет тип сигнала и очищает (гасит) разряд в записи таблицы процессов, соответствующий данному типу сигнала и установленный в момент получения сигнала процессом. Если функции обработки сигнала присвоено значение по умолчанию, ядро в отдельных случаях перед завершением процесса сбрасывает на внешний носитель (дампирует) образ процесса в памяти (см. упражнение 7.7). Дампирование удобно для программистов тем, что позволяет установить причину завершения процесса и посредством этого вести отладку программ. Ядро дампирует состояние памяти при поступлении сигналов, которые сообщают о каких-нибудь ошибках в выполнении процессов, как например, попытка исполнения запрещенной команды или обращение к адресу, находящемуся за пределами виртуального адресного пространства процесса. Ядро не дампирует состояние памяти, если сигнал не связан с программной ошибкой. Например, прерывание, вызванное нажатием клавиш «delete» или «break» на терминале, имеет своим результатом посылку сигнала, который сообщает о том, что пользователь хочет раньше времени завершить процесс, в то время как сигнал о «зависании» является свидетельством нарушения связи с регистрационным терминалом. Эти сигналы не связаны с ошибками в протекании процесса. Сигнал о выходе (quit), однако, вызывает сброс состояния памяти, несмотря на то, что он возникает за пределами выполняемого процесса. Этот сигнал, обычно вызываемый одновременным нажатием клавиш ‹Ctrl/|›, дает программисту возможность получать дамп состояния памяти в любой момент после запуска процесса, что бывает необходимо, если процесс попадает в бесконечный цикл выполнения одних и тех же команд (зацикливается).
Если процесс получает сигнал, на который было решено не обращать внимание, выполнение процесса продолжается так, словно сигнала и не было. Поскольку ядро не сбрасывает значение соответствующего поля, свидетельствующего о необходимости игнорирования сигнала данного типа, то когда сигнал поступит вновь, процесс опять не обратит на него внимание. Если процесс получает сигнал, реагирование на который было признано необходимым, сразу по возвращении процесса в режим задачи выполняется заранее условленное действие, однако прежде чем перевести процесс в режим задачи, ядро еще должно предпринять следующие шаги:
1. Ядро обращается к сохраненному регистровому контексту задачи и выбирает значения счетчика команд и указателя вершины стека, которые будут возвращены пользовательскому процессу.
2. Сбрасывает в пространстве процесса прежнее значение поля функции обработки сигнала и присваивает ему значение по умолчанию.
3. Создает новую запись в стеке задачи, в которую, при необходимости выделяя дополнительную память, переписывает значения счетчика команд и указателя вершины стека, выбранные ранее из сохраненного регистрового контекста задачи. Стек задачи будет выглядеть так, как будто процесс произвел обращение к пользовательской функции (обработки сигнала) в той точке, где он вызывал системную функцию или где ядро прервало его выполнение (перед опознанием сигнала).
4. Вносит изменения в сохраненный регистровый контекст задачи: устанавливает значение счетчика команд равным адресу функции обработки сигнала, а значение указателя вершины стека равным глубине стека задачи.
Таким образом, по возвращении из режима ядра в режим задачи процесс приступит к выполнению функции обработки сигнала; после ее завершения управление будет передано на то место в программе пользователя, где было произведено обращение к системной функции или произошло прерывание, тем самым как бы имитируется выход из системной функции или прерывания.
В качестве примера можно привести программу (Рисунок 7.9), которая принимает сигналы о прерывании (SIGINT) и сама посылает их (в результате выполнения функции kill). На Рисунке 7.10 представлены фрагменты программного кода, полученные в результате дисассемблирования загрузочного модуля в операционной среде VAX 11/780. При выполнении процесса обращение к библиотечной процедуре kill имеет адрес (шестнадцатеричный) ee; эта процедура в свою очередь, прежде чем вызвать системную функцию kill, исполняет команду chmk (перевести процесс в режим ядра) по адресу 10a. Адрес возврата из системной функции — 10c. Во время исполнения системной функции ядро посылает процессу сигнал о прерывании. Ядро обращает внимание на этот сигнал тогда, когда процесс собирается вернуться в режим задачи, выбирая из сохраненного регистрового контекста адрес возврата 10c и помещая его в стек задачи. При этом адрес функции обработки сигнала, 104, ядро помещает в сохраненный регистровый контекст задачи. На Рисунке 7.11 показаны различные состояния стека задачи и сохраненного регистрового контекста.
В рассмотренном алгоритме обработки сигналов имеются некоторые несоответствия. Первое из них и наиболее важное связано с очисткой перед возвращением процесса в режим задачи того поля в пространстве процесса, которое содержит адрес пользовательской функции обработки сигнала. Если процессу снова понадобится обработать сигнал, ему опять придется прибегнуть к помощи системной функции signal. При этом могут возникнуть нежелательные последствия: например, могут создаться условия для конкуренции, если второй раз сигнал поступит до того, как процесс получит возможность запустить системную функцию. Поскольку процесс выполняется в режиме задачи, ядру следовало бы произвести переключение контекста, чтобы увеличить тем самым шансы процесса на получение сигнала до момента сброса значения поля функции обработки сигнала.
  #include ‹signal.h›
  main()   {
   extern catcher();
   signal(SIGINT, catcher);
   kill(0, SIGINT);
  }
  catcher()   {  }
  Рисунок 7.9. Исходный текст программы приема сигналов
  **** VAX DISASSEMBLER ****
  _main()
   e4:
   e6: pushab 0x18(pc)
   ec: pushl $0x2
  # в следующей строке вызывается функция signal
   ee: calls $0x2,0x23(pc)
   f5: pushl $0x2
   f7: clrl -(sp)
  # в следующей строке вызывается библиотечная процедура kill
   f9: calls $0x2,0x8(pc)
   100: ret
   101: halt
   102: halt
   103: halt
  _catcher()
   104:
   106: ret
   107: halt
  _kill()
   108:
  # в следующей строке вызывается внутреннее прерывание операционной системы
   10a: chmk $0x25
   10c: bgequ 0x6 ‹0x114›
   10e: jmp 0x14(pc)
   114: clrl r0
   116: ret
  Рисунок 7.10. Результат дисассемблирования программы приема сигналов
  Рисунок 7.11. Стек задачи и область сохранения структур ядра до и после получения сигнала
Эту ситуацию можно разобрать на примере программы, представленной на Рисунке 7.12. Процесс обращается к системной функции signal для того, чтобы дать указание принимать сигналы о прерываниях и исполнять по их получении функцию sigcatcher. Затем он порождает новый процесс, запускает системную функцию nice, позволяющую сделать приоритет запуска процесса-родителя ниже приоритета его потомка (см. главу 8), и входит в бесконечный цикл. Порожденный процесс задерживает свое выполнение на 5 секунд, чтобы дать родительскому процессу время исполнить системную функцию nice и снизить свой приоритет. После этого порожденный процесс входит в цикл, в каждой итерации которого он посылает родительскому процессу сигнал о прерывании (посредством обращения к функции kill). Если в результате ошибки, например, из-за того, что родительский процесс больше не существует, kill завершается, то завершается и порожденный процесс. Вся идея состоит в том, что родительскому процессу следует запускать функцию обработки сигнала при каждом получении сигнала о прерывании. Функция обработки сигнала выводит сообщение и снова обращается к функции signal при очередном появлении сигнала о прерывании, родительский же процесс продолжает исполнять циклический набор команд.
Однако, возможна и следующая очередность наступления событий:
1. Порожденный процесс посылает родительскому процессу сигнал о прерывании.
2. Родительский процесс принимает сигнал и вызывает функцию обработки сигнала, но резервируется ядром, которое производит переключение контекста до того, как функция signal будет вызвана повторно.
3. Снова запускается порожденный процесс, который посылает родительскому процессу еще один сигнал о прерывании.
4. Родительский процесс получает второй сигнал о прерывании, но перед тем он не успел сделать никаких распоряжений относительно способа обработки сигнала. Когда выполнение родительского процесса будет возобновлено, он завершится.
  #include ‹signal.h›
  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. Программа, демонстрирующая возникновение соперничества между процессами в ходе обработки сигналов
В программе описывается именно такое поведение процессов, поскольку вызов родительским процессом функции nice приводит к тому, что ядро будет чаще запускать на выполнение порожденный процесс.
По словам Ричи (эти сведения были получены в частной беседе), сигналы были задуманы как события, которые могут быть как фатальными, так и проходящими незаметно, которые не всегда обрабатываются, поэтому в ранних версиях системы конкуренция процессов, связанная с посылкой сигналов, не фиксировалась. Тем не менее, она представляет серьезную проблему в тех программах, где осуществляется прием сигналов. Эта проблема была бы устранена, если бы поле описания сигнала не очищалось по его получении. Однако, такое решение породило бы новую проблему: если поступающий сигнал принимается, а поле очищено, вложенные обращения к функции обработки сигнала могут переполнить стек. С другой стороны, ядро могло бы сбросить значение функции обработки сигнала, тем самым делая распоряжение игнорировать сигналы данного типа до тех пор, пока пользователь вновь не укажет, что нужно делать по получении подобных сигналов. Такое решение предполагает потерю информации, так как процесс не в состоянии узнать, сколько сигналов им было получено. Однако, информации при этом теряется не больше, чем в том случае, когда процесс получает большое количество сигналов одного типа до того, как получает возможность их обработать. В системе BSD, наконец, процесс имеет возможность блокировать получение сигналов и снимать блокировку при новом обращении к системной функции; когда процесс снимает блокировку сигналов, ядро посылает процессу все сигналы, отложенные (повисшие) с момента установки блокировки. Когда процесс получает сигнал, ядро автоматически блокирует получение следующего сигнала до тех пор, пока функция обработки сигнала не закончит работу. В этих действиях ядра наблюдается аналогия с тем, как ядро реагирует на аппаратные прерывания: оно блокирует появление новых прерываний на время обработки предыдущих.
Второе несоответствие в обработке сигналов связано с приемом сигналов, поступающих во время исполнения системной функции, когда процесс приостановлен с допускающим прерывания приоритетом. Сигнал побуждает процесс выйти из приостанова (с помощью longjump), вернуться в режим задачи и вызвать функцию обработки сигнала. Когда функция обработки сигнала завершает работу, происходит то, что процесс выходит из системной функции с ошибкой, сообщающей о прерывании ее выполнения. Узнав об ошибке, пользователь запускает системную функцию повторно, однако более удобно было бы, если бы это действие автоматически выполнялось ядром, как в системе BSD.
Третье несоответствие проявляется в том случае, когда процесс игнорирует поступивший сигнал. Если сигнал поступает в то время, когда процесс находится в состоянии приостанова с допускающим прерывания приоритетом, процесс возобновляется, но не выполняет longjump. Другими словами, ядро узнает о том, что процесс проигнорировал поступивший сигнал только после возобновления его выполнения. Логичнее было бы оставить процесс в состоянии приостанова. Однако, в момент посылки сигнала к пространству процесса, в котором ядро хранит адрес функции обработки сигнала, может отсутствовать доступ. Эта проблема может быть решена путем запоминания адреса функции обработки сигнала в записи таблицы процессов, обращаясь к которой, ядро получало бы возможность решать вопрос о необходимости возобновления процесса по получении сигнала. С другой стороны, процесс может немедленно вернуться в состояние приостанова (по алгоритму sleep), если обнаружит, что в его возобновлении не было необходимости. Однако, пользовательские процессы не имеют возможности осознавать собственное возобновление, поскольку ядро располагает точку входа в алгоритм sleep внутри цикла с условием продолжения (см. главу 2), переводя процесс вновь в состояние приостанова, если ожидаемое процессом событие в действительности не имело места.
Ко всему сказанному выше следует добавить, что ядро обрабатывает сигналы типа «гибель потомка» не так, как другие сигналы. В частности, когда процесс узнает о получении сигнала «гибель потомка», он выключает индикацию сигнала в соответствующем поле записи таблицы процессов и по умолчанию действует так, словно никакого сигнала и не поступало. Назначение сигнала «гибель потомка» состоит в возобновлении выполнения процесса, приостановленного с допускающим прерывания приоритетом. Если процесс принимает такой сигнал, он, как и во всех остальных случаях, запускает функцию обработки сигнала. Действия, предпринимаемые ядром в том случае, когда процесс игнорирует поступивший сигнал этого типа, будут описаны в разделе 7.4. Наконец, когда процесс вызвал функцию signal с параметром «гибель потомка» (death of child), ядро посылает ему соответствующий сигнал, если он имеет потомков, прекративших существование. В разделе 7.4 на этом моменте мы остановимся более подробно.

0

13

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
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 и посылает сигнал о прерывании всем процессам, входящим в одну с ним группу. Ядро посылает сигнал пяти «четным» процессам, не сбросившим унаследованное значение номера группы, при этом пять «нечетных» процессов продолжают свое выполнение.
  #include ‹signal.h›
  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, возглавляет группу процессов, ассоциированную с операторским терминалом (см. раздел 10.3.5), ядро делает предположение о том, что пользователь прекращает работу, и посылает всем процессам в группе сигнал о «зависании». Таким образом, если пользователь в регистрационном shell'е нажмет последовательность клавиш, означающую «конец файла» (Ctrl-d), при этом с терминалом остались связанными некоторые из существующих процессов, процесс, выполняющий функцию exit, пошлет им всем сигнал о «зависании». Кроме того, ядро сбрасывает в ноль значение кода группы процессов для всех процессов, входящих в данную группу, поскольку не исключена возможность того, что позднее текущий код идентификации процесса (процесса, который вызвал функцию exit) будет присвоен другому процессу и тогда последний возглавит группу с указанным кодом. Процессы, входившие в старую группу, в новую группу входить не будут. После этого ядро просматривает дескрипторы открытых файлов, закрывает каждый из этих файлов по алгоритму close и освобождает по алгоритму iput индексы текущего каталога и корня (если он изменялся).
  алгоритм exit
  входная информация: код, возвращаемый родительскому процессу
  выходная информация: отсутствует
  {
   игнорировать все сигналы;
   if (процесс возглавляет группу процессов, ассоциированную с операторским терминалом)   {
    послать всем процессам, входящим в группу, сигнал о «зависании»;
    сбросить в ноль код группы процессов;
   }
   закрыть все открытые файлы (внутренняя модификация алгоритма close);
   освободить текущий каталог (алгоритм iput);
   освободить области и память, ассоциированную с процессом (алгоритм freereg);
   создать запись с учетной информацией;
   прекратить существование процесса (перевести его в соответствующее состояние);
   назначить всем процессам-потомкам в качестве родителя процесс init (1);
   если кто-либо из потомков прекратил существование, послать процессу init сигнал «гибель потомка»;
   послать сигнал «гибель потомка» родителю данного процесса;
   переключить контекст;
  }
  Рисунок 7.14. Алгоритм функции exit
Наконец, ядро освобождает всю выделенную задаче память вместе с соответствующими областями (по алгоритму detachreg) и переводит процесс в состояние прекращения существования. Ядро сохраняет в таблице процессов код возврата функции exit (status), а также суммарное время исполнения процесса и его потомков в режиме ядра и режиме задачи. В разделе 7.4 при рассмотрении функции wait будет показано, каким образом процесс получает информацию о времени выполнения своих потомков. Ядро также создает в глобальном учетном файле запись, которая содержит различную статистическую информацию о выполнении процесса, такую как код идентификации пользователя, использование ресурсов центрального процессора и памяти, объем потоков ввода-вывода, связанных с процессом. Пользовательские программы могут в любой момент обратиться к учетному файлу за статистическими данными, представляющими интерес с точки зрения слежения за функционированием системы и организации расчетов с пользователями. Ядро удаляет процесс из дерева процессов, а его потомков передает процессу 1 (init). Таким образом, процесс 1 становится законным родителем всех продолжающих существование потомков завершающегося процесса. Если кто-либо из потомков прекращает существование, завершающийся процесс посылает процессу init сигнал «гибель потомка» для того, чтобы процесс начальной загрузки мог удалить запись о потомке из таблицы процессов (см. раздел 7.9); кроме того, завершающийся процесс посылает этот сигнал своему родителю. В типичной ситуации родительский процесс синхронизирует свое выполнение с завершающимся потомком с помощью системной функции wait. Прекращая существование, процесс переключает контекст и ядро может теперь выбирать для исполнения следующий процесс; ядро с этих пор уже не будет исполнять процесс, прекративший существование.
В программе, приведенной на Рисунке 7.15, процесс создает новый процесс, который печатает свой код идентификации и вызывает системную функцию pause, приостанавливаясь до получения сигнала. Процесс-родитель печатает PID своего потомка и завершается, возвращая только что выведенное значение через параметр status. Если бы вызов функции exit отсутствовал, начальная процедура сделала бы его по выходе процесса из функции main. Порожденный процесс продолжает ожидать получения сигнала, даже если его родитель уже завершился.

7.4 ОЖИДАНИЕ ЗАВЕРШЕНИЯ ВЫПОЛНЕНИЯ ПРОЦЕССА

Процесс может синхронизировать продолжение своего выполнения с моментом завершения потомка, если воспользуется системной функцией wait. Синтаксис вызова функции:
pid = wait(stat_addr);
где pid — значение кода идентификации (PID) прекратившего свое существование потомка, stat_addr — адрес переменной целого типа, в которую будет помещено возвращаемое функцией exit значение, в пространстве задачи.
  main()   {
   int child;
   if ((child = fork()) == 0)    {
    printf("PID потомка %d\n", getpid());
    pause(); /* приостанов выполнения до получения сигнала */
   } 
   /* родитель */
   printf("PID потомка %d\n", child);
   exit(child);
  }
  Рисунок 7.15. Пример использования функции exit
Алгоритм функции wait приведен на Рисунке 7.16. Ядро ведет поиск потомков процесса, прекративших существование, и в случае их отсутствия возвращает ошибку. Если потомок, прекративший существование, обнаружен, ядро передает его код идентификации и значение, возвращаемое через параметр функции exit, процессу, вызвавшему функцию wait. Таким образом, через параметр функции exit (status) завершающийся процесс может передавать различные значения, в закодированном виде содержащие информацию о причине завершения процесса, однако на практике этот параметр используется по назначению довольно редко. Ядро передает в соответствующие поля, принадлежащие пространству родительского процесса, накопленные значения продолжительности исполнения процесса-потомка в режиме ядра и в режиме задачи и, наконец, освобождает в таблице процессов место, которое в ней занимал прежде прекративший существование процесс. Это место будет предоставлено новому процессу.
Если процесс, выполняющий функцию wait, имеет потомков, продолжающих существование, он приостанавливается до получения ожидаемого сигнала. Ядро не возобновляет по своей инициативе процесс, приостановившийся с помощью функции wait: такой процесс может возобновиться только в случае получения сигнала. На все сигналы, кроме сигнала «гибель потомка», процесс реагирует ранее рассмотренным образом. Реакция процесса на сигнал «гибель потомка» проявляется по-разному в зависимости от обстоятельств:
• По умолчанию (то есть если специально не оговорены никакие другие действия) процесс выходит из состояния останова, в которое он вошел с помощью функции wait, и запускает алгоритм issig для опознания типа поступившего сигнала. Алгоритм issig (Рисунок 7.7) рассматривает особый случай поступления сигнала типа «гибель потомка» и возвращает «ложь». Поэтому ядро не выполняет longjump из функции sleep, а возвращает управление функции wait. Оно перезапускает функцию wait, находит потомков, прекративших существование (по крайней мере, одного), освобождает место в таблице процессов, занимаемое этими потомками, и выходит из функции wait, возвращая управление процессу, вызвавшему ее.
• Если процессы принимает сигналы данного типа, ядро делает все необходимые установки для запуска пользовательской функции обработки сигнала, как и в случае поступления сигнала любого другого типа.
• Если процесс игнорирует сигналы данного типа, ядро перезапускает функцию wait, освобождает в таблице процессов место, занимаемое потомками, прекратившими существование, и исследует оставшихся потомков.
  алгоритм wait
  входная информация:   адрес переменной для хранения значения status, возвращаемого завершающимся процессом
  выходная информация: идентификатор потомка и код возврата функции   exit
  {
   if (процесс, вызвавший функцию wait, не имеет потомков)   return (ошибку);
   for (;;) {  /* цикл с внутренним циклом */ 
    if (процесс, вызвавший функцию wait, имеет потомков, прекративших существование)   {
     выбрать произвольного потомка;
     передать его родителю информацию об использовании потомком ресурсов центрального процессора;
     освободить в таблице процессов место, занимаемое потомком;
     return (идентификатор потомка, код возврата функции exit, вызванной потомком);
    }
    if (у процесса нет потомков)   return ошибку;
    приостановиться с приоритетом, допускающим прерывания (до завершения потомка);
   }
  }
  Рисунок 7.16. Алгоритм функции 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, он посылает своему родителю сигнал «гибель потомка»; родительский процесс возобновляется, поскольку он был приостановлен с приоритетом, допускающим прерывания. Когда так или иначе родительский процесс продолжит свое выполнение, он обнаружит, что сигнал сообщал о «гибели» потомка; однако, поскольку он игнорирует сигналы этого типа и не обрабатывает их, ядро удаляет из таблицы процессов запись, соответствующую прекратившему существование потомку, и продолжает выполнение функции wait так, словно сигнала и не было. Ядро выполняет эти действия всякий раз, когда родительский процесс получает сигнал типа «гибель потомка», до тех пор, пока цикл выполнения функции wait не будет завершен и пока не будет установлено, что у процесса больше потомков нет. Тогда функция wait возвращает значение, равное -1. Разница между двумя способами запуска программы состоит в том, что в первом случае процесс-родитель ждет завершения любого из потомков, в то время как во втором случае он ждет, пока завершатся все его потомки.
  #include ‹signal.h›
  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 и игнорирования сигнала «гибель потомка»
В ранних версиях системы UNIX функции exit и wait не использовали и не рассматривали сигнал типа «гибель потомка». Вместо посылки сигнала функция exit возобновляла выполнение родительского процесса. Если родительский процесс при выполнении функции wait приостановился, он возобновляется, находит потомка, прекратившего существование, и возвращает управление. В противном случае возобновления не происходит; процесс-родитель обнаружит «погибшего» потомка при следующем обращении к функции wait. Точно так же и процесс начальной загрузки (init) может приостановиться, используя функцию wait, и завершающиеся по exit процессы будут возобновлять его, если он имеет усыновленных потомков, прекращающих существование.
В такой реализации функций exit и wait имеется одна нерешенная проблема, связанная с тем, что процессы, прекратившие существование, нельзя убирать из системы до тех пор, пока их родитель не исполнит функцию wait. Если процесс создал множество потомков, но так и не исполнил функцию wait, может произойти переполнение таблицы процессов из-за наличия потомков, прекративших существование с помощью функции exit. В качестве примера рассмотрим текст программы планировщика процессов, приведенный на Рисунке 7.18. Процесс производит считывание данных из файла стандартного ввода до тех пор, пока не будет обнаружен конец файла, создавая при каждом исполнении функции read нового потомка. Однако, процесс-родитель не дожидается завершения каждого потомка, поскольку он стремится запускать процессы на выполнение как можно быстрее, тем более, что может пройти довольно много времени, прежде чем процесс-потомок завершит свое выполнение. Если, обратившись к функции signal, процесс распорядился игнорировать сигналы типа «гибель потомка», ядро будет очищать записи, соответствующие прекратившим существование процессам, автоматически. Иначе в конечном итоге из-за таких процессов может произойти переполнение таблицы.
  #include ‹signal.h›
  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. Пример указания причины появления сигнала «гибель потомков»

7.5 ВЫЗОВ ДРУГИХ ПРОГРАММ

Системная функция exec дает возможность процессу запускать другую программу, при этом соответствующий этой программе исполняемый файл будет располагаться в пространстве памяти процесса. Содержимое пользовательского контекста после вызова функции становится недоступным, за исключением передаваемых функции параметров, которые переписываются ядром из старого адресного пространства в новое. Синтаксис вызова функции:
execve(filename, argv, envp)
где filename — имя исполняемого файла, argv — указатель на массив параметров, которые передаются вызываемой программе, а envp — указатель на массив параметров, составляющих среду выполнения вызываемой программы. Вызов системной функции exec осуществляют несколько библиотечных функций, таких как execl, execv, execle и т. д. В том случае, когда программа использует параметры командной строки
main(argc, argv),
массив argv является копией одноименного параметра, передаваемого функции exec. Символьные строки, описывающие среду выполнения вызываемой программы, имеют вид «имя=значение» и содержат полезную для программ информацию, такую как начальный каталог пользователя и путь поиска исполняемых программ. Процессы могут обращаться к параметрам описания среды выполнения, используя глобальную переменную environ, которую заводит начальная процедура Си-интерпретатора.
  алгоритм exec
  входная информация:
   (1) имя файла
   (2) список параметров
   (3) список переменных среды
  выходная информация: отсутствует 
  {
   получить индекс файла (алгоритм namei);
   проверить, является ли файл исполнимым и имеет ли пользователь право на его исполнение;
   прочитать информацию из заголовков файла и проверить, является ли он загрузочным модулем;
   скопировать параметры, переданные функции, из старого адресного пространства в системное пространство;
   for (каждой области, присоединенной к процессу)
    отсоединить все старые области (алгоритм detachreg);
   for (каждой области, определенной в загрузочном модуле)   {
    выделить новые области (алгоритм allocreg);
    присоединить области (алгоритм attachreg);
    загрузить область в память по готовности (алгоритм loadreg);
   }
   скопировать параметры, переданные функции, в новую область стека задачи;
   специальная обработка для setuid-программ, трассировка;
   проинициализировать область сохранения регистров задачи (в рамках подготовки к возвращению в режим задачи);
   освободить индекс файла (алгоритм iput);
  }
  Рисунок 7.19. Алгоритм функции exec
На Рисунке 7.19 представлен алгоритм выполнения системной функции exec. Сначала функция обращается к файлу по алгоритму namei, проверяя, является ли файл исполнимым и отличным от каталога, а также проверяя наличие у пользователя права исполнять программу. Затем ядро, считывая заголовок файла, определяет размещение информации в файле (формат файла).
На Рисунке 7.20 изображен логический формат исполняемого файла в файловой системе, обычно генерируемый транслятором или загрузчиком. Он разбивается на четыре части:
1. Главный заголовок, содержащий информацию о том, на сколько разделов делится файл, а также содержащий начальный адрес исполнения процесса и некоторое «магическое число», описывающее тип исполняемого файла.
2. Заголовки разделов, содержащие информацию, описывающую каждый раздел в файле: его размер, виртуальные адреса, в которых он располагается, и др.
3. Разделы, содержащие собственно «данные» файла (например, текстовые), которые загружаются в адресное пространство процесса.
4. Разделы, содержащие смешанную информацию, такую как таблицы идентификаторов и другие данные, используемые в процессе отладки.
  Рисунок 7.20. Образ исполняемого файла
Указанные составляющие с развитием самой системы видоизменяются, однако во всех исполняемых файлах обязательно присутствует главный заголовок с полем типа файла.
Тип файла обозначается коротким целым числом (представляется в машине полусловом), которое идентифицирует файл как загрузочный модуль, давая тем самым ядру возможность отслеживать динамические характеристики его выполнения. Например, в машине PDP 11/70 определение типа файла как загрузочного модуля свидетельствует о том, что процесс, исполняющий файл, может использовать до 128 Кбайт памяти вместо 64 Кбайт [22] , тем не менее в системах с замещением страниц тип файла все еще играет существенную роль, в чем нам предстоит убедиться во время знакомства с главой 9.
Вернемся к алгоритму. Мы остановились на том, что ядро обратилось к индексу файла и установило, что файл является исполнимым. Ядру следовало бы освободить память, занимаемую пользовательским контекстом процесса. Однако, поскольку в памяти, подлежащей освобождению, располагаются передаваемые новой программе параметры, ядро первым делом копирует их из адресного пространства в промежуточный буфер на время, пока не будут отведены области для нового пространства памяти.
Поскольку параметрами функции exec выступают пользовательские адреса массивов символьных строк, ядро по каждой строке сначала копирует в системную память адрес строки, а затем саму строку. Для хранения строки в разных версиях системы могут быть выбраны различные места. Чаще принято хранить строки в стеке ядра (локальная структура данных, принадлежащая программе ядра), на нераспределяемых участках памяти (таких как страницы), которые можно занимать только временно, а также во внешней памяти (на устройстве выгрузки).
С точки зрения реализации проще всего для копирования параметров в новый пользовательский контекст обратиться к стеку ядра. Однако, поскольку размер стека ядра, как правило, ограничивается системой, а также поскольку параметры функции exec могут иметь произвольную длину, этот подход следует сочетать с другими подходами. При рассмотрении других вариантов обычно останавливаются на способе хранения, обеспечивающем наиболее быстрый доступ к строкам. Если доступ к страницам памяти в системе реализуется довольно просто, строки следует размещать на страницах, поскольку обращение к оперативной памяти осуществляется быстрее, чем к внешней (устройству выгрузки).
После копирования параметров функции exec в системную память ядро отсоединяет области, ранее присоединенные к процессу, используя алгоритм detachreg. Несколько позже мы еще поговорим о специальных действиях, выполняемых в отношении областей команд. К рассматриваемому моменту процесс уже лишен пользовательского контекста и поэтому возникновение в дальнейшем любой ошибки неизбежно будет приводить к завершению процесса по сигналу. Такими ошибками могут быть обращение к пространству, не описанному в таблице областей ядра, попытка загрузить программу, имеющую недопустимо большой размер или использующую области с пересекающимися адресами, и др. Ядро выделяет и присоединяет к процессу области команд и данных, загружает в оперативную память содержимое исполняемого файла (алгоритмы allocreg, attachreg и loadreg, соответственно). Область данных процесса изначально поделена на две части: данные, инициализация которых была выполнена во время компиляции, и данные, не определенные компилятором («bss»). Область памяти первоначально выделяется для проинициализированных данных. Затем ядро увеличивает размер области данных для размещения данных типа «bss» (алгоритм growreg) и обнуляет их значения. Напоследок ядро выделяет и присоединяет к процессу область стека и отводит пространство памяти для хранения параметров функции exec. Если параметры функции размещаются на страницах, те же страницы могут быть использованы под стек. В противном случае параметры функции размещаются в стеке задачи.
В пространстве процесса ядро стирает адреса пользовательских функций обработки сигналов, поскольку в новом пользовательском контексте они теряют свое значение. Однако и в новом контексте рекомендации по игнорированию тех или иных сигналов остаются в силе. Ядро устанавливает в регистрах для режима задачи значения из сохраненного регистрового контекста, в частности первоначальное значение указателя вершины стека (sp) и счетчика команд (pc): первоначальное значение счетчика команд было занесено загрузчиком в заголовок файла. Для setuid-программ и для трассировки процесса ядро предпринимает особые действия, на которых мы еще остановимся во время рассмотрения глав 8 и 11, соответственно. Наконец, ядро запускает алгоритм iput, освобождая индекс, выделенный по алгоритму namei в самом начале выполнения функции exec. Алгоритмы namei и iput в функции exec выполняют роль, подобную той, которую они выполняют при открытии и закрытии файла; состояние файла во время выполнения функции exec похоже на состояние открытого файла, если не принимать во внимание отсутствие записи о файле в таблице файлов. По выходе из функции процесс исполняет текст новой программы. Тем не менее, процесс остается тем же, что и до выполнения функции; его идентификатор не изменился, как не изменилось и его место в иерархии процессов. Изменению подвергся только пользовательский контекст процесса.
  main()   {
   int status;
   if (fork() == 0)   execl("/bin/date", "date", 0);
   wait(&status);
  }
  Рисунок 7.21. Пример использования функции exec
В качестве примера можно привести программу (Рисунок 7.21), в которой создается процесс-потомок, запускающий функцию exec. Сразу по завершении функции fork процесс-родитель и процесс-потомок начинают исполнять независимо друг от друга копии одной и той же программы. К моменту вызова процессом-потомком функции exec в его области команд находятся инструкции этой программы, в области данных располагаются строки «/bin/date» и «date», а в стеке — записи, которые будут извлечены по выходе из exec. Ядро ищет файл «/bin/date» в файловой системе, обнаружив его, узнает, что его может исполнить любой пользователь, а также то, что он представляет собой загрузочный модуль, готовый для исполнения. По условию первым параметром функции exec, включаемым в список параметров argv, является имя исполняемого файла (последняя компонента имени пути поиска файла). Таким образом, процесс имеет доступ к имени программы на пользовательском уровне, что иногда может оказаться полезным [23] . Затем ядро копирует строки «/bin/date» и «date» во внутреннюю структуру хранения и освобождает области команд, данных и стека, занимаемые процессом. Процессу выделяются новые области команд, данных и стека, в область команд переписывается командная секция файла «/bin/date», в область данных — секция данных файла. Ядро восстанавливает первоначальный список параметров (в данном случае это строка символов «date») и помещает его в область стека. Вызвав функцию exec, процесс-потомок прекращает выполнение старой программы и переходит к выполнению программы «date»; когда программа «date» завершится, процесс-родитель, ожидающий этого момента, получит код завершения функции exit.
Вплоть до настоящего момента мы предполагали, что команды и данные размещаются в разных секциях исполняемой программы и, следовательно, в разных областях текущего процесса. Такое размещение имеет два основных преимущества: простота организации защиты от несанкционированного доступа и возможность разделения областей различными процессами. Если бы команды и данные находились в одной области, система не смогла бы предотвратить затирание команд, поскольку ей не были бы известны адреса, по которым они располагаются. Если же команды и данные находятся в разных областях, система имеет возможность пользоваться механизмами аппаратной защиты области команд процесса. Когда процесс случайно попытается что-то записать в область, занятую командами, он получит отказ, порожденный системой защиты и приводящий обычно к аварийному завершению процесса.
  #include ‹signal.h›
  main()   {
   int i, *ip;
   extern f(), sigcatch();
   ip = (int *)f; /* присвоение переменной ip значения адреса функции f */
   for (i = 0; i ‹ 20; i++)   signal(i, sigcatch);
   *ip = 1; /* попытка затереть адрес функции f */
   printf("после присвоения значения ip\n");
   f();
  }
  f()   {  }
  sigcatch(n)
  int n; 
  {
   printf("принят сигнал %d\n", n);
   exit(1);
  }
  Рисунок 7.22. Пример программы, ведущей запись в область команд
В качестве примера можно привести программу (Рисунок 7.22), которая присваивает переменной ip значение адреса функции f и затем делает распоряжение принимать все сигналы. Если программа скомпилирована так, что команды и данные располагаются в разных областях, процесс, исполняющий программу, при попытке записать что-то по адресу в ip встретит порожденный системой защиты отказ, поскольку область команд защищена от записи. При работе на компьютере AT&T 3B20 ядро посылает процессу сигнал SIGBUS, в других системах возможна посылка других сигналов. Процесс принимает сигнал и завершается, не дойдя до выполнения команды вывода на печать в процедуре main. Однако, если программа скомпилирована так, что команды и данные располагаются в одной области (в области данных), ядро не поймет, что процесс пытается затереть адрес функции f. Адрес f станет равным 1. Процесс исполнит команду вывода на печать в процедуре main, но когда запустит функцию f, произойдет ошибка, связанная с попыткой выполнения запрещенной команды. Ядро пошлет процессу сигнал SIGILL и процесс завершится.
Расположение команд и данных в разных областях облегчает поиск и предотвращение ошибок адресации. Тем не менее, в ранних версиях системы UNIX команды и данные разрешалось располагать в одной области, поскольку на машинах PDP размер процесса был сильно ограничен: программы имели меньший размер и существенно меньшую сегментацию, если команды и данные занимали одну и ту же область. В последних версиях системы таких строгих ограничений на размер процесса нет и в дальнейшем возможность загрузки команд и данных в одну область компиляторами не будет поддерживаться.
Второе преимущество раздельного хранения команд и данных состоит в возможности совместного использования областей процессами. Если процесс не может вести запись в область команд, команды процесса не претерпевают никаких изменений с того момента, как ядро загрузило их в область команд из командной секции исполняемого файла. Если один и тот же файл исполняется несколькими процессами, в целях экономии памяти они могут иметь одну область команд на всех. Таким образом, когда ядро при выполнении функции exec отводит область под команды процесса, оно проверяет, имеется ли возможность совместного использования процессами команд исполняемого файла, что определяется «магическим числом» в заголовке файла. Если да, то с помощью алгоритма xalloc ядро ищет существующую область с командами файла или назначает новую в случае ее отсутствия (см. Рисунок 7.23).
Исполняя алгоритм xalloc, ядро просматривает список активных областей в поисках области с командами файла, индекс которого совпадает с индексом исполняемого файла. В случае ее отсутствия ядро выделяет новую область (алгоритм allocreg), присоединяет ее к процессу (алгоритм attachreg), загружает ее в память (алгоритм loadreg) и защищает от записи (read-only). Последний шаг предполагает, что при попытке процесса записать что-либо в область команд будет получен отказ, вызванный системой защиты памяти. В случае обнаружения области с командами файла в списке активных областей осуществляется проверка ее наличия в памяти (она может быть либо загружена в память, либо выгружена из памяти) и присоединение ее к процессу. В завершение выполнения алгоритма xalloc ядро снимает с области блокировку, а позднее, следуя алгоритму detachreg при выполнении функций exit или exec, уменьшает значение счетчика областей. В традиционных реализациях системы поддерживается таблица команд, к которой ядро обращается в случаях, подобных описанному. Таким образом, совокупность областей команд можно рассматривать как новую версию этой таблицы.
Напомним, что если область при выполнении алгоритма allocreg (Раздел 6.5.2) выделяется впервые, ядро увеличивает значение счетчика ссылок на индекс, ассоциированный с областью, при этом значение счетчика ссылок нами уже было увеличено в самом начале выполнения функции exec (алгоритм namei). Поскольку ядро уменьшает значение счетчика только один раз в завершение выполнения функции exec (по алгоритму iput), значение счетчика ссылок на индекс файла, ассоциированного с разделяемой областью команд и исполняемого в настоящий момент, равно по меньшей мере 1. Поэтому когда процесс разрывает связь с файлом (функция unlink), содержимое файла остается нетронутым (не претерпевает изменений). После загрузки в память сам файл ядру становится ненужен, ядро интересует только указатель на копию индекса файла в памяти, содержащийся в таблице областей; этот указатель и будет идентифицировать файл, связанный с областью. Если бы значение счетчика ссылок стало равным 0, ядро могло бы передать копию индекса в памяти другому файлу, тем самым делая сомнительным значение указателя на индекс в записи таблицы областей: если бы пользователю пришлось исполнить новый файл, используя функцию exec, ядро по ошибке связало бы его с областью команд старого файла. Эта проблема устраняется благодаря тому, что ядро при выполнении алгоритма allocreg увеличивает значение счетчика ссылок на индекс, предупреждая тем самым переназначение индекса в памяти другому файлу. Когда процесс во время выполнения функций exit или exec отсоединяет область команд, ядро уменьшает значение счетчика ссылок на индекс (по алгоритму freereg), если только связь индекса с областью не помечена как «неотъемлемая».
  алгоритм xalloc /* выделение и инициализация области команд */
  входная информация: индекс исполняемого файла
  выходная информация: отсутствует 
  {
   if (исполняемый файл не имеет отдельной области команд)   return;
   if (уже имеется область команд, ассоциированная с индексом исполняемого файла)   {
    /* область команд уже существует… подключиться к ней */
    заблокировать область;
    do while (содержимое области еще не доступно)   {
     /* операции над счетчиком ссылок, предохраняющие от глобального удаления области */
     увеличить значение счетчика ссылок на область;
     снять с области блокировку;
     sleep (пока содержимое области не станет доступным);
     заблокировать область;
     уменьшить значение счетчика ссылок на область;
    }
    присоединить область к процессу (алгоритм attachreg);
    снять с области блокировку;
    return;
   }
   /* интересующая нас область команд не существует — создать новую */
   выделить область команд (алгоритм allocreg); /* область заблокирована */
   if (область помечена как «неотъемлемая»)
    отключить соответствующий флаг;
   подключить область к виртуальному адресу, указанному в заголовке файла (алгоритм attachreg);
   if (файл имеет специальный формат для системы с замещением страниц) /* этот случай будет рассмотрен в главе 9 */
   else /* файл не имеет специального формата */
    считать команды из файла в область (алгоритм loadreg);
   изменить режим защиты области в записи частной таблицы областей процесса на «read-only»;
   снять с области блокировку;
  }
  Рисунок 7.23. Алгоритм выделения областей команд
  Рисунок 7.24. Взаимосвязь между таблицей индексов и таблицей областей в случае совместного использования процессами одной области команд
Рассмотрим в качестве примера ситуацию, приведенную на Рисунке 7.21, где показана взаимосвязь между структурами данных в процессе выполнения функции exec по отношению к файлу «/bin/date» при условии расположения команд и данных файла в разных областях. Когда процесс исполняет файл «/bin/date» первый раз, ядро назначает для команд файла точку входа в таблице областей (Рисунок 7.24) и по завершении выполнения функции exec оставляет счетчик ссылок на индекс равным 1. Когда файл «/bin/date» завершается, ядро запускает алгоритмы detachreg и freereg, сбрасывая значение счетчика ссылок в 0. Однако, если ядро в первом случае не увеличило значение счетчика, оно по завершении функции exec останется равным 0 и индекс на всем протяжении выполнения процесса будет находиться в списке свободных индексов. Предположим, что в это время свободный индекс понадобился процессу, запустившему с помощью функции exec файл «/bin/who», тогда ядро может выделить этому процессу индекс, ранее принадлежавший файлу «/ bin/date». Просматривая таблицу областей в поисках индекса файла «/bin/who», ядро вместо него выбрало бы индекс файла «/bin/date». Считая, что область содержит команды файла «/bin/who», ядро исполнило бы совсем не ту программу. Поэтому значение счетчика ссылок на индекс активного файла, связанного с разделяемой областью команд, должно быть не меньше единицы, чтобы ядро не могло переназначить индекс другому файлу.
Возможность совместного использования различными процессами одних и тех же областей команд позволяет экономить время, затрачиваемое на запуск программы с помощью функции exec. Администраторы системы могут с помощью системной функции (и команды) chmod устанавливать для часто исполняемых файлов режим «sticky-bit», сущность которого заключается в следующем. Когда процесс исполняет файл, для которого установлен режим «sticky-bit», ядро не освобождает область памяти, отведенную под команды файла, отсоединяя область от процесса во время выполнения функций exit или exec, даже если значение счетчика ссылок на индекс становится равным 0. Ядро оставляет область команд в первоначальном виде, при этом значение счетчика ссылок на индекс равно 1, пусть даже область не подключена больше ни к одному из процессов. Если же файл будет еще раз запущен на выполнение (уже другим процессом), ядро в таблице областей обнаружит запись, соответствующую области с командами файла. Процесс затратит на запуск файла меньше времени, так как ему не придется читать команды из файловой системы. Если команды файла все еще находятся в памяти, в их перемещении не будет необходимости; если же команды выгружены во внешнюю память, будет гораздо быстрее загрузить их из внешней памяти, чем из файловой системы (см. об этом в главе 9).
Ядро удаляет из таблицы областей записи, соответствующие областям с командами файла, для которого установлен режим «sticky-bit» (иными словами, когда область помечена как «неотъемлемая» часть файла или процесса), в следующих случаях:
1. Если процесс открыл файл для записи, в результате соответствующих операций содержимое файла изменится, при этом будет затронуто и содержимое области.
2. Если процесс изменил права доступа к файлу (chmod), отменив режим «sticky-bit», файл не должен оставаться в таблице областей.
3. Если процесс разорвал связь с файлом (unlink), он не сможет больше исполнять этот файл, поскольку у файла не будет точки входа в файловую систему; следовательно, и все остальные процессы не будут иметь доступа к записи в таблице областей, соответствующей файлу. Поскольку область с командами файла больше не используется, ядро может освободить ее вместе с остальными ресурсами, занимаемыми файлом.
4. Если процесс демонтирует файловую систему, файл перестает быть доступным и ни один из процессов не может его исполнить. В остальном — все как в предыдущем случае.
5. Если ядро использовало уже все пространство внешней памяти, отведенное под выгрузку задач, оно пытается освободить часть памяти за счет областей, имеющих пометку «sticky-bit», но не используемых в настоящий момент. Несмотря на то, что эти области могут вскоре понадобиться другим процессам, потребности ядра являются более срочными.
В первых двух случаях область команд с пометкой «sticky-bit» должна быть освобождена, поскольку она больше не отражает текущее состояние файла. В остальных случаях это делается из практических соображений. Конечно же ядро освобождает область только при том условии, что она не используется ни одним из выполняющихся процессов (счетчик ссылок на нее имеет нулевое значение); в противном случае это привело бы к аварийному завершению выполнения системных функций open, unlink и umount (случаи 1, 3 и 4, соответственно).
Если процесс запускает с помощью функции exec самого себя, алгоритм выполнения функции несколько усложняется. По команде sh script командный процессор shell порождает новый процесс (новую ветвь), который инициирует запуск shell'а (с помощью функции exec) и исполняет команды файла «script». Если процесс запускает самого себя и при этом его область команд допускает совместное использование, ядру придется следить за тем, чтобы при обращении ветвей процесса к индексам и областям не возникали взаимные блокировки. Иначе говоря, ядро не может, не снимая блокировки со «старой» области команд, попытаться заблокировать «новую» область, поскольку на самом деле это одна и та же область. Вместо этого ядро просто оставляет «старую» область команд присоединенной к процессу, так как в любом случае ей предстоит повторное использование.
Обычно процессы вызывают функцию exec после функции fork; таким образом, во время выполнения функции fork процесс-потомок копирует адресное пространство своего родителя, но сбрасывает его во время выполнения функции exec и по сравнению с родителем исполняет образ уже другой программы. Не было бы более естественным объединить две системные функции в одну, которая бы загружала программу и исполняла ее под видом нового процесса? Ричи высказал предположение, что возникновение fork и exec как отдельных системных функций обязано тому, что при создании системы UNIX функция fork была добавлена к уже существующему образу ядра системы (см. [Ritchie 84a], стр.1584). Однако, разделение fork и exec важно и с функциональной точки зрения, поскольку в этом случае процессы могут работать с дескрипторами файлов стандартного ввода-вывода независимо, повышая тем самым «элегантность» использования каналов. Пример, показывающий использование этой возможности, приводится в разделе 7.8.

7.6 КОД ИДЕНТИФИКАЦИИ ПОЛЬЗОВАТЕЛЯ ПРОЦЕССА

Ядро связывает с процессом два кода идентификации пользователя, не зависящих от кода идентификации процесса: реальный (действительный) код идентификации пользователя и исполнительный код или setuid (от «set user ID» — установить код идентификации пользователя, под которым процесс будет исполняться). Реальный код идентифицирует пользователя, несущего ответственность за выполняющийся процесс. Исполнительный код используется для установки прав собственности на вновь создаваемые файлы, для проверки прав доступа к файлу и разрешения на посылку сигналов процессам через функцию kill. Процессы могут изменять исполнительный код, запуская с помощью функции exec программу setuid или запуская функцию setuid в явном виде.
Программа setuid представляет собой исполняемый файл, имеющий в поле режима доступа установленный бит setuid. Когда процесс запускает программу setuid на выполнение, ядро записывает в поля, содержащие реальные коды идентификации, в таблице процессов и в пространстве процесса код идентификации владельца файла. Чтобы как-то различать эти поля, назовем одно из них, которое хранится в таблице процессов, сохраненным кодом идентификации пользователя. Рассмотрим пример, иллюстрирующий разницу в содержимом этих полей.
Синтаксис вызова системной функции setuid:
setuid(uid)
где uid — новый код идентификации пользователя. Результат выполнения функции зависит от текущего значения реального кода идентификации. Если реальный код идентификации пользователя процесса, вызывающего функцию, указывает на суперпользователя, ядро записывает значение uid в поля, хранящие реальный и исполнительный коды идентификации, в таблице процессов и в пространстве процесса. Если это не так, ядро записывает uid в качестве значения исполнительного кода идентификации в пространстве процесса и то только в том случае, если значение uid равно значению реального кода или значению сохраненного кода. В противном случае функция возвращает вызывающему процессу ошибку. Процесс наследует реальный и исполнительный коды идентификации у своего родителя (в результате выполнения функции fork) и сохраняет их значения после вызова функции exec.
На Рисунке 7.25 приведена программа, демонстрирующая использование функции setuid. Предположим, что исполняемый файл, полученный в результате трансляции исходного текста программы, имеет владельца с именем «maury» (код идентификации 8319) и установленный бит setuid; право его исполнения предоставлено всем пользователям. Допустим также, что пользователи «mjb» (код идентификации 5088) и «maury» являются владельцами файлов с теми же именами, каждый из которых доступен только для чтения и только своему владельцу. Во время исполнения программы пользователю «mjb» выводится следующая информация:
uid 5088 euid 8319
 fdmjb -1 fdmaury 3
 after setuid(5088): uid 5088 euid 5088
 fdmjb 4 fdmaury -1
 after setuid(8319): uid 5088 euid 8319
Системные функции getuid и geteuid возвращают значения реального и исполнительного кодов идентификации пользователей процесса, для пользователя «mjb» это, соответственно, 5088 и 8319. Поэтому процесс не может открыть файл «mjb» (ибо он имеет исполнительный код идентификации пользователя (8319), не разрешающий производить чтение файла), но может открыть файл «maury». После вызова функции setuid, в результате выполнения которой в поле исполнительного кода идентификации пользователя («mjb») заносится значение реального кода идентификации, на печать выводятся значения и того, и другого кода идентификации пользователя "mjb": оба равны 5088. Теперь процесс может открыть файл „mjb“, поскольку он исполняется под кодом идентификации пользователя, имеющего право на чтение из файла, но не может открыть файл „maury“. Наконец, после занесения в поле исполнительного кода идентификации значения, сохраненного функцией setuid (8319), на печать снова выводятся значения 5088 и 8319. Мы показали, таким образом, как с помощью программы setuid процесс может изменять значение кода идентификации пользователя, под которым он исполняется.
  #include ‹fcntl.h›
  main()   {
   int uid, euid, fdmjb, fdmaury;
   uid = getuid(); /* получить реальный UID */
   euid = geteuid(); /* получить исполнительный UID */
   printf("uid %d euid %d\n", uid, euid);
   fdmjb = open("mjb", O_RDONLY);
   fdmaury = open("maury", O_RDONLY);
   printf("fdmjb %d fdmaury %d\n", fdmjb, fdmaury);
   setuid(uid);
   printf("after setuid(%d): uid %d euid %d\n", uid, getuid(), geteuid());
   fdmjb = open("mjb", O_RDONLY);
   fdmaury = open("maury", O_RDONLY);
   printf("fdmjb %d fdmaury %d\n", fdmjb, fdmaury);
   setuid(uid);
   printf("after setuid(%d): uid %d euid %d\n", euid, getuid(), geteuid());
  }
  Рисунок 7.25. Пример выполнения программы setuid
Во время выполнения программы пользователем „maury“ на печать выводится следующая информация:
uid 8319 euid 8319
 fdmjb -1 fdmaury 3
 after setuid(8319): uid 8319 euid 8319
 fdmjb -1 fdmaury 4
 after setuid(8319): uid 8319 euid 8319
Реальный и исполнительный коды идентификации пользователя во время выполнения программы остаются равны 8319: процесс может открыть файл „maury“, но не может открыть файл „mjb“. Исполнительный код, хранящийся в пространстве процесса, занесен туда в результате последнего исполнения функции или программы setuid; только его значением определяются права доступа процесса к файлу. С помощью функции setuid исполнительному коду может быть присвоено значение сохраненного кода (из таблицы процессов), т. е. то значение, которое исполнительный код имел в самом начале.
Примером программы, использующей вызов системной функции setuid, может служить программа регистрации пользователей в системе (login). Параметром функции setuid при этом является код идентификации суперпользователя, таким образом, программа login исполняется под кодом суперпользователя из корня системы. Она запрашивает у пользователя различную информацию, например, имя и пароль, и если эта информация принимается системой, программа запускает функцию setuid, чтобы установить значения реального и исполнительного кодов идентификации в соответствии с информацией, поступившей от пользователя (при этом используются данные файла „/etc/passwd“). В заключение программа login инициирует запуск командного процессора shell, который будет исполняться под указанными пользовательскими кодами идентификации.
Примером setuid-программы является программа, реализующая команду mkdir. В разделе 5.8 уже говорилось о том, что создать каталог может только процесс, выполняющийся под управлением суперпользователя. Для того, чтобы предоставить возможность создания каталогов простым пользователям, команда mkdir была выполнена в виде setuid-программы, принадлежащей корню системы и имеющей права суперпользователя. На время исполнения команды mkdir процесс получает права суперпользователя, создает каталог, используя функцию mknod, и предоставляет права собственности и доступа к каталогу истинному пользователю процесса.

0

14

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
7.7 ИЗМЕНЕНИЕ РАЗМЕРА ПРОЦЕССА

С помощью системной функции brk процесс может увеличивать и уменьшать размер области данных. Синтаксис вызова функции:
brk(endds);
где endds — старший виртуальный адрес области данных процесса (адрес верхней границы). С другой стороны, пользователь может обратиться к функции следующим образом:
oldendds = sbrk(increment);
где oldendds — текущий адрес верхней границы области, increment — число байт, на которое изменяется значение oldendds в результате выполнения функции. Sbrk — это имя стандартной библиотечной подпрограммы на Си, вызывающей функцию brk. Если размер области данных процесса в результате выполнения функции увеличивается, вновь выделяемое пространство имеет виртуальные адреса, смежные с адресами увеличиваемой области; таким образом, виртуальное адресное пространство процесса расширяется. При этом ядро проверяет, не превышает ли новый размер процесса максимально-допустимое значение, принятое для него в системе, а также не накладывается ли новая область данных процесса на виртуальное адресное пространство, отведенное ранее для других целей (Рисунок 7.26). Если все в порядке, ядро запускает алгоритм growreg, присоединяя к области данных внешнюю память (например, таблицы страниц) и увеличивая значение поля, описывающего размер процесса. В системе с замещением страниц ядро также отводит под новую область пространство основной памяти и обнуляет его содержимое; если свободной памяти нет, ядро освобождает память путем выгрузки процесса (более подробно об этом мы поговорим в главе 9). Если с помощью функции brk процесс уменьшает размер области данных, ядро освобождает часть ранее выделенного адресного пространства; когда процесс попытается обратиться к данным по виртуальным адресам, принадлежащим освобожденному пространству, он столкнется с ошибкой адресации.
  алгоритм brk
  входная информация: новый адрес верхней границы области данных
  выходная информация: старый адрес верхней границы области данных
  {
   заблокировать область данных процесса;
   if (размер области увеличивается)
    if (новый размер области имеет недопустимое значение)   {
     снять блокировку с области;
     return (ошибку);
    }
    изменить размер области (алгоритм growreg);
    обнулить содержимое присоединяемого пространства;
    снять блокировку с области данных;
  }
  Рисунок 7.26. Алгоритм выполнения функции brk
На Рисунке 7.27 приведен пример программы, использующей функцию brk, и выходные данные, полученные в результате ее прогона на машине AT&T 3B20. Вызвав функцию signal и распорядившись принимать сигналы о нарушении сегментации (segmentation violation), процесс обращается к подпрограмме sbrk и выводит на печать первоначальное значение адреса верхней границы области данных. Затем в цикле, используя счетчик символов, процесс заполняет область данных до тех пор, пока не обратится к адресу, расположенному за пределами области, тем самым давая повод для сигнала о нарушении сегментации. Получив сигнал, функция обработки сигнала вызывает подпрограмму sbrk для того, чтобы присоединить к области дополнительно 256 байт памяти; процесс продолжается с точки прерывания, заполняя информацией вновь выделенное пространство памяти и т. д. На машинах со страничной организацией памяти, таких как 3B20, наблюдается интересный феномен. Страница является наименьшей единицей памяти, с которой работают механизмы аппаратной защиты, поэтому аппаратные средства не в состоянии установить ошибку в граничной ситуации, когда процесс пытается записать информацию по адресам, превышающим верхнюю границу области данных, но принадлежащим т. н. „полулегальной“ странице (странице, не полностью занятой областью данных процесса). Это видно из результатов выполнения программы, выведенных на печать (Рисунок 7.27): первый раз подпрограмма sbrk возвращает значение 140924, то есть адрес, не дотягивающий 388 байт до конца страницы, которая на машине 3B20 имеет размер 2 Кбайта. Однако процесс получит ошибку только в том случае, если обратится к следующей странице памяти, то есть к любому адресу, начиная с 141312. Функция обработки сигнала прибавляет к адресу верхней границы области 256, делая его равным 141180 и, таким образом, оставляя его в пределах текущей страницы. Следовательно, процесс тут же снова получит ошибку, выдав на печать адрес 141312. Исполнив подпрограмму sbrk еще раз, ядро выделяет под данные процесса новую страницу памяти, так что процесс получает возможность адресовать дополнительно 2 Кбайта памяти, до адреса 143360, даже если верхняя граница области располагается ниже. Получив ошибку, процесс должен будет восемь раз обратиться к подпрограмме sbrk, прежде чем сможет продолжить выполнение основной программы. Таким образом, процесс может иногда выходить за официальную верхнюю границу области данных, хотя это и нежелательный момент в практике программирования.
Когда стек задачи переполняется, ядро автоматически увеличивает его размер, выполняя алгоритм, похожий на алгоритм функции brk. Первоначально стек задачи имеет размер, достаточный для хранения параметров функции exec, однако при выполнении процесса этот стек может переполниться. Переполнение стека приводит к ошибке адресации, свидетельствующей о попытке процесса обратиться к ячейке памяти за пределами отведенного адресного пространства. Ядро устанавливает причину возникновения ошибки, сравнивая текущее значение указателя вершины стека с размером области стека. При расширении области стека ядро использует точно такой же механизм, что и для области данных. На выходе из прерывания процесс имеет область стека необходимого для продолжения работы размера.
  #include ‹signal.h›
  char *cp;
  int callno;
  main()   {
   char *sbrk();
   extern catcher();
   signal(SIGSEGV, catcher);
   cp = sbrk(0);
   printf("original brk value %u\n", cp);
   for (;;)   *cp++ = 1;
  }
  catcher(signo)
  int signo;
  {
   callno++;
   printf("caught sig %d %dth call at addr %u\n", signo, callno, cp);
   sbrk(256);
   signal(SIGSEGV, catcher);
  }
  original brk value 140924
  caught sig 11 1th call at addr 141312
  caught sig 11 2th call at addr 141312
  caught sig 11 3th call at addr 143360
…(тот же адрес печатается до 10-го вызова подпрограммы sbrk)
  caught sig 11 10th call at addr 143360
  caught sig 11 11th call at addr 145408
…(тот же адрес печатается до 18-го вызова подпрограммы sbrk)
  caught sig 11 18th call at addr 145408
  caught sig 11 19th call at addr 145408
   -
   -
  Рисунок 7.27. Пример программы, использующей функцию brk, и результаты ее контрольного прогона

7.8 КОМАНДНЫЙ ПРОЦЕССОР SHELL

Теперь у нас есть достаточно материала, чтобы перейти к объяснению принципов работы командного процессора shell. Сам командный процессор намного сложнее, чем то, что мы о нем здесь будем излагать, однако взаимодействие процессов мы уже можем рассмотреть на примере реальной программы. На Рисунке 7.28 приведен фрагмент основного цикла программы shell, демонстрирующий асинхронное выполнение процессов, переназначение вывода и использование каналов.
  /* чтение командной строки до символа конца файла */
  while (read(stdin, buffer, numchars))   {
   /* синтаксический разбор командной строки */
   if (/* командная строка содержит & */)   amper = 1;
   else   amper = 0;
   /* для команд, не являющихся конструкциями командного языка shell */
   if (fork() == 0) {
    /* переадресация ввода-вывода? */
    if (/* переадресация вывода */)    {
     fd = creat(newfile, fmask);
     close(stdout);
     dup(fd);
     close(fd);   /* stdout теперь переадресован */
    }
    if (/* используются каналы */)   {
     pipe(fildes);
     if (fork() == 0)   {
      /* первая компонента командной строки */
      close(stdout);
      dup(fildes[1]);
      close(fildes[1]);
      close(fildes[0]); /* стандартный вывод направляется в канал */
      /* команду исполняет порожденный процесс */
      execlp(command1, command1, 0);
     }
     /* вторая компонента командной строки */
     close(stdin);
     dup(fildes[0])  ;
     close(fildes[0]);
     close(fildes[1]); /* стандартный ввод будет производиться из канала */
    }
    execve(command2, command2, 0);
   }
   /* с этого места продолжается выполнение родительского процесса… процесс-родитель ждет завершения выполнения потомка, если это вытекает из введенной строки * /
   if (amper == 0)   retid = wait(&status);
  }
  Рисунок 7.28. Основной цикл программы shell
Shell считывает командную строку из файла стандартного ввода и интерпретирует ее в соответствии с установленным набором правил. Дескрипторы файлов стандартного ввода и стандартного вывода, используемые регистрационным shell'ом, как правило, указывают на терминал, с которого пользователь регистрируется в системе (см. главу 10). Если shell узнает во введенной строке конструкцию собственного командного языка (например, одну из команд cd, for, while и т. п.), он исполняет команду своими силами, не прибегая к созданию новых процессов; в противном случае команда интерпретируется как имя исполняемого файла.
Командные строки простейшего вида содержат имя программы и несколько параметров, например:
who
grep -n include *.c
ls -l
Shell „ветвится“ (fork) и порождает новый процесс, который и запускает программу, указанную пользователем в командной строке. Родительский процесс (shell) дожидается завершения потомка и повторяет цикл считывания следующей команды.
Если процесс запускается асинхронно (на фоне основной программы), как в следующем примере
nroff -mm bigdocument&
shell анализирует наличие символа амперсанд (&) и заносит результат проверки во внутреннюю переменную amper. В конце основного цикла shell обращается к этой переменной и, если обнаруживает в ней признак наличия символа, не выполняет функцию wait, а тут же повторяет цикл считывания следующей команды.
Из рисунка видно, что процесс-потомок по завершении функции fork получает доступ к командной строке, принятой shell'ом. Для того, чтобы переадресовать стандартный вывод в файл, как в следующем примере
nroff -mm bigdocument › output
процесс-потомок создает файл вывода с указанным в командной строке именем; если файл не удается создать (например, не разрешен доступ к каталогу), процесс-потомок тут же завершается. В противном случае процесс-потомок закрывает старый файл стандартного вывода и переназначает с помощью функции dup дескриптор этого файла новому файлу. Старый дескриптор созданного файла закрывается и сохраняется для запускаемой программы. Подобным же образом shell переназначает и стандартный ввод и стандартный вывод ошибок.
  Рисунок 7.29. Взаимосвязь между процессами, исполняющими командную строку ls -l|wc
Из приведенного текста программы видно, как shell обрабатывает командную строку, используя один канал. Допустим, что командная строка имеет вид:
ls -l|wc
После создания родительским процессом нового процесса процесс-потомок создает канал. Затем процесс-потомок создает свое ответвление; он и его потомок обрабатывают по одной компоненте командной строки. „Внучатый“ процесс исполняет первую компоненту строки (ls): он собирается вести запись в канал, поэтому он закрывает старый файл стандартного вывода, передает его дескриптор каналу и закрывает старый дескриптор записи в канал, в котором (в дескрипторе) уже нет необходимости. Родитель (wc) „внучатого“ процесса (ls) является потомком основного процесса, реализующего программу shell'а (см.Рисунок 7.29). Этот процесс (wc) закрывает свой файл стандартного ввода и передает его дескриптор каналу, в результате чего канал становится файлом стандартного ввода. Затем закрывается старый и уже не нужный дескриптор чтения из канала и исполняется вторая компонента командной строки. Оба порожденных процесса выполняются асинхронно, причем выход одного процесса поступает на вход другого. Тем временем основной процесс дожидается завершения своего потомка (wc), после чего продолжает свою обычную работу: по завершении процесса, выполняющего команду wc, вся командная строка является обработанной. Shell возвращается в цикл и считывает следующую командную строку.

7.9 ЗАГРУЗКА СИСТЕМЫ И НАЧАЛЬНЫЙ ПРОЦЕСС

Для того, чтобы перевести систему из неактивное состояние в активное, администратор выполняет процедуру „начальной загрузки“. На разных машинах эта процедура имеет свои особенности, однако во всех случаях она реализует одну и ту же цель: загрузить копию операционной системы в основную память машины и запустить ее на исполнение. Обычно процедура начальной загрузки включает в себя несколько этапов. Переключением клавиш на пульте машины администратор может указать адрес специальной программы аппаратной загрузки, а может, нажав только одну клавишу, дать команду машине запустить процедуру загрузки, исполненную в виде микропрограммы. Эта программа может состоять из нескольких команд, подготавливающих запуск другой программы. В системе UNIX процедура начальной загрузки заканчивается считыванием с диска в память блока начальной загрузки (нулевого блока). Программа, содержащаяся в этом блоке, загружает из файловой системы ядро ОС (например, из файла с именем „/unix“ или с другим именем, указанным администратором). После загрузки ядра системы в память управление передается по стартовому адресу ядра и ядро запускается на выполнение (алгоритм start Рисунок 7.30).
Ядро инициализирует свои внутренние структуры данных. Среди прочих структур ядро создает связные списки свободных буферов и индексов, хеш-очереди для буферов и индексов, инициализирует структуры областей, точки входа в таблицы страниц и т. д. По окончании этой фазы ядро монтирует корневую файловую систему и формирует среду выполнения нулевого процесса, среди всего прочего создавая пространство процесса, инициализируя нулевую точку входа в таблице процесса и делая корневой каталог текущим для процесса.
Когда формирование среды выполнения процесса заканчивается, система исполняется уже в виде нулевого процесса. Нулевой процесс „ветвится“, запуская алгоритм fork прямо из ядра, поскольку сам процесс исполняется в режиме ядра. Порожденный нулевым новый процесс, процесс 1, запускается в том же режиме и создает свой пользовательский контекст, формируя область данных и присоединяя ее к своему адресному пространству. Он увеличивает размер области до надлежащей величины и переписывает программу загрузки из адресного пространства ядра в новую область: эта программа теперь будет определять контекст процесса 1. Затем процесс 1 сохраняет регистровый контекст задачи, „возвращается“ из режима ядра в режим задачи и исполняет только что переписанную программу. В отличие от нулевого процесса, который является процессом системного уровня, выполняющимся в режиме ядра, процесс 1 относится к пользовательскому уровню. Код, исполняемый процессом 1, включает в себя вызов системной функции exec, запускающей на выполнение программу из файла „/etc/init“. Обычно процесс 1 именуется процессом init, поскольку он отвечает за инициализацию новых процессов.
  алгоритм start /* процедура начальной загрузки системы */
  входная информация: отсутствует
  выходная информация: отсутствует
  {
   проинициализировать все структуры данных ядра;
   псевдо-монтирование корня;
   сформировать среду выполнения процесса 0;
   создать процесс 1;
   {
    /* процесс 1 */
    выделить область;
    подключить область к адресному пространству процесса init  ;
    увеличить размер области для копирования в нее исполняемого кода;
    скопировать из пространства ядра в адресное пространство процесса код программы, исполняемой процессом;
    изменить режим выполнения: вернуться из режима ядра в режим задачи;
   /* процесс init далее выполняется самостоятельно — в результате выхода в режим задачи, init исполняет файл „/etc/init“ и становится  „обычным“ пользовательским процессом, производящим обращения к системным функциям */
   }
   /* продолжение нулевого процесса */
   породить процессы ядра; 
   /* нулевой процесс запускает программу подкачки, управляющую распределением адресного пространства процессов между основной памятью и устройствами выгрузки. Это бесконечный цикл; нулевой процесс обычно приостанавливает свою работу, если необходимости в нем больше нет. */ 
   исполнить программу, реализующую алгоритм подкачки;
  }
  Рисунок 7.30. Алгоритм загрузки системы
Казалось бы, зачем ядру копировать программу, запускаемую с помощью функции exec, в адресное пространство процесса 1? Он мог бы обратиться к внутреннему варианту функции прямо из ядра, однако, по сравнению с уже описанным алгоритмом это было бы гораздо труднее реализовать, ибо в этом случае функции exec пришлось бы производить анализ имен файлов в пространстве ядра, а не в пространстве задачи. Подобная деталь, требующаяся только для процесса init, усложнила бы программу реализации функции exec и отрицательно отразилась бы на скорости выполнения функции в более общих случаях.
Процесс init (Рисунок 7.31) выступает диспетчером процессов, который порождает процессы, среди всего прочего позволяющие пользователю регистрироваться в системе. Инструкции о том, какие процессы нужно создать, считываются процессом init из файла „/etc/inittab“. Строки файла включают в себя идентификатор состояния „id“ (однопользовательский режим, многопользовательский и т. д.), предпринимаемое действие (см. упражнение 7.43) и спецификацию программы, реализующей это действие (см. Рисунок 7.32). Процесс init просматривает строки файла до тех пор, пока не обнаружит идентификатор состояния, соответствующего тому состоянию, в котором находится процесс, и создает процесс, исполняющий программу с указанной спецификацией. Например, при запуске в многопользовательском режиме (состояние 2) процесс init обычно порождает getty-процессы, управляющие функционированием терминальных линий, входящих в состав системы. Если регистрация пользователя прошла успешно, getty-процесс, пройдя через процедуру login, запускает на исполнение регистрационный shell (см. главу 10). Тем временем процесс init находится в состоянии ожидания (wait), наблюдая за прекращением существования своих потомков, а также „внучатых“ процессов, оставшихся „сиротами“ после гибели своих родителей.
Процессы в системе UNIX могут быть либо пользовательскими, либо управляющими, либо системными. Большинство из них составляют пользовательские процессы, связанные с пользователями через терминалы. Управляющие процессы не связаны с конкретными пользователями, они выполняют широкий спектр системных функций, таких как администрирование и управление сетями, различные периодические операции, буферизация данных для вывода на устройство построчной печати и т. д. Процесс init может порождать управляющие процессы, которые будут существовать на протяжении всего времени жизни системы, в различных случаях они могут быть созданы самими пользователями. Они похожи на пользовательские процессы тем, что они исполняются в режиме задачи и прибегают к услугам системы путем вызова соответствующих системных функций.
Системные процессы выполняются исключительно в режиме ядра. Они могут порождаться нулевым процессом (например, процесс замещения страниц vhand), который затем становится процессом подкачки. Системные процессы похожи на управляющие процессы тем, что они выполняют системные функции, при этом они обладают большими возможностями приоритетного выполнения, поскольку лежащие в их основе программные коды являются составной частью ядра. Они могут обращаться к структурам данных и алгоритмам ядра, не прибегая к вызову системных функций, отсюда вытекает их исключительность. Однако, они не обладают такой же гибкостью, как управляющие процессы, поскольку для того, чтобы внести изменения в их программы, придется еще раз перекомпилировать ядро.
  алгоритм init /* процесс init, в системе именуемый „процесс 1“ */
  входная информация: отсутствует
  выходная информация: отсутствует 
  {
   fd = open("/etc/inittab", O_RDONLY);
   while (line_read(fd, buffer)) { /* читать каждую строку файла */
    if (invoked state != buffer state) continue; /* остаться в цикле while */
     /* найден идентификатор соответствующего состояния */
    if (fork() == 0) {
     execl("процесс указан в буфере");
     exit();
    }
    /* процесс init не дожидается завершения потомка */
    /* возврат в цикл while */
   }
   while ((id = wait((int*) 0)) != -1) {
    /* проверка существования потомка; если потомок прекратил существование, рассматривается возможность его перезапуска, в противном случае, основной процесс просто продолжает работу */
   }
  }
  Рисунок 7.31. Алгоритм выполнения процесса init
Формат: идентификатор, состояние, действие, спецификация процесса
Поля разделены между собой двоеточиями
Комментарии в конце строки начинаются с символа '# '
   co::respawn:/etc/getty console console #Консоль в машзале
   46:2:respawn:/etc/getty -t 60 tty46 4800H #комментарии
  Рисунок 7.32. Фрагмент файла inittab

7.10 ВЫВОДЫ

В данной главе были рассмотрены системные функции, предназначенные для работы с контекстом процесса и для управления выполнением процесса. Системная функция fork создает новый процесс, копируя для него содержимое всех областей, подключенных к родительскому процессу. Особенность реализации функции fork состоит в том, что она выполняет инициализацию сохраненного регистрового контекста порожденного процесса, таким образом этот процесс начинает выполняться, не дожидаясь завершения функции, и уже в теле функции начинает осознавать свою предназначение как потомка. Все процессы завершают свое выполнение вызовом функции exit, которая отсоединяет области процесса и посылает его родителю сигнал „гибель потомка“. Процесс-родитель может совместить момент продолжения своего выполнения с моментом завершения процесса-потомка, используя системную функцию wait. Системная функция exec дает процессу возможность запускать на выполнение другие программы, накладывая содержимое исполняемого файла на свое адресное пространство. Ядро отсоединяет области, ранее занимаемые процессом, и назначает процессу новые области в соответствии с потребностями исполняемого файла. Совместное использование областей команд и наличие режима „sticky-bit“ дают возможность более рационально использовать память и экономить время, затрачиваемое на подготовку к запуску программ. Простым пользователям предоставляется возможность получать привилегии других пользователей, даже суперпользователя, благодаря обращению к услугам системной функции setuid и setuid-программ. С помощью функции brk процесс может изменять размер своей области данных. Функция signal дает процессам возможность управлять своей реакцией на поступающие сигналы. При получении сигнала производится обращение к специальной функции обработки сигнала с внесением соответствующих изменений в стек задачи и в сохраненный регистровый контекст задачи. Процессы могут сами посылать сигналы, используя системную функцию kill, они могут также контролировать получение сигналов, предназначенных группе процессов, прибегая к услугам функции setpgrp.
Командный процессор shell и процесс начальной загрузки init используют стандартные обращения к системным функциям, производя набор операций, в других системах обычно выполняемых ядром. Shell интерпретирует команды пользователя, переназначает стандартные файлы ввода-вывода данных и выдачи ошибок, порождает процессы, организует каналы между порожденными процессами, синхронизирует свое выполнение с этими процессами и формирует коды, возвращаемые командами. Процесс init тоже порождает различные процессы, в частности, управляющие работой пользователя за терминалом. Когда такой процесс завершается, init может породить для выполнения той же самой функции еще один процесс, если это вытекает из информации файла „/etc/inittab“.

7.11 УПРАЖНЕНИЯ

1. Запустите с терминала программу, приведенную на Рисунке 7.33. Переадресуйте стандартный вывод данных в файл и сравните результаты между собой.
  main() {
   printf("hello\n“);
   if (fork() == 0) printf("world\n“);
  }
  Рисунок 7.33. Пример модуля, содержащего вызов функции fork и обращение к стандартному выводу
2. Разберитесь в механизме работы программы, приведенной на Рисунке 7.34, и сравните ее результаты с результатами программы на Рисунке 7.4.
  #include ‹fcntl.h›
  int fdrd, fdwt;
  char c;
  main(argc, argv)
  int argc; char *argv[];
  {
   if (argc != 3) exit(1);
   fork();
   if ((fdrd = open(argv[1], O_RDONLY)) == -1) exit(1);
   if (((fdwt = creat(argv[2], 0666)) == -1) && ((fdwt = open(argv[2], O_WRONLY)) == -1)) exit(1);
   rdwrt();
  }
  rdwrt() {
   for (;;) {
    if (read(fdrd, &c, 1) != 1) return;
    write(fdwt, &c, 1);
   }
  }
  Рисунок 7.34. Пример программы, в которой процесс-родитель и процесс-потомок не разделяют доступ к файлу
3. Еще раз обратимся к программе, приведенной на Рисунке 7.5 и показывающей, как два процесса обмениваются сообщениями, используя спаренные каналы. Что произойдет, если они попытаются вести обмен сообщениями, используя один канал?
4. Возможна ли потеря информации в случае, когда процесс получает несколько сигналов прежде чем ему предоставляется возможность отреагировать на них надлежащим образом? (Рассмотрите случай, когда процесс подсчитывает количество полученных сигналов о прерывании.) Есть ли необходимость в решении этой проблемы?
5. Опишите механизм работы системной функции kill.
6. Процесс в программе на Рисунке 7.35 принимает сигналы типа „гибель потомка“ и устанавливает функцию обработки сигналов в исходное состояние. Что происходит при выполнении программы?
  #include ‹signal.h›
  main() {
   extern catcher();
   signal(SIGCLD, catcher);
   if (fork() == 0) exit();
   /* пауза до момента получения сигнала */
   pause();
  }
  catcher() {
   printf("процесс-родитель получил сигнал\n");
   signal(SIGCLD, catcher);
  }
  Рисунок 7.35. Программа, в которой процесс принимает сигналы типа „гибель потомка“
7. Когда процесс получает сигналы определенного типа и не обрабатывает их, ядро дампирует образ процесса в том виде, который был у него в момент получения сигнала. Ядро создает в текущем каталоге процесса файл с именем „core“ и копирует в него пространство процесса, области команд, данных и стека. Впоследствии пользователь может тщательно изучить дамп образа процесса с помощью стандартных средств отладки. Опишите алгоритм, которому на Ваш взгляд должно следовать ядро в процессе создания файла „core“. Что нужно предпринять в том случае, если в текущем каталоге файл с таким именем уже существует? Как должно вести себя ядро, когда в одном и том же каталоге дампируют свои образы сразу несколько процессов?
8. Еще раз обратимся к программе (Рисунок 7.12), описывающей, как один процесс забрасывает другой процесс сигналами, которые принимаются их адресатом. Подумайте, что произошло бы в том случае, если бы алгоритм обработки сигналов был переработан в любом из следующих направлений:
• ядро не заменяет функцию обработки сигналов до тех пор, пока пользователь явно не потребует этого;
• ядро заставляет процесс игнорировать сигналы до тех пор, пока пользователь не обратится к функции signal вновь.
9. Переработайте алгоритм обработки сигналов так, чтобы ядро автоматически перенастраивало процесс на игнорирование всех последующих поступлений сигналов по возвращении из функции, обрабатывающей их. Каким образом ядро может узнать о завершении функции обработки сигналов, выполняющейся в режиме задачи? Такого рода перенастройка приблизила бы нас к трактовке сигналов в системе BSD.
*10. Если процесс получает сигнал, находясь в состоянии приостанова во время выполнения системной функции с допускающим прерывания приоритетом, он выходит из функции по алгоритму longjump. Ядро производит необходимые установки для запуска функции обработки сигнала; когда процесс выйдет из функции обработки сигнала, в версии V это будет выглядеть так, словно он вернулся из системной функции с признаком ошибки (как бы прервав свое выполнение). В системе BSD системная функция в этом случае автоматически перезапускается. Каким образом можно реализовать этот момент в нашей системе?
11. В традиционной реализации команды mkdir для создания новой вершины в дереве каталогов используется системная функция mknod, после чего дважды вызывается системная функция link, привязывающая точки входа в каталог с именами "." и ".." к новой вершине и к ее родительскому каталогу. Без этих трех операций каталог не будет иметь надлежащий формат. Что произойдет, если во время исполнения команды mkdir процесс получит сигнал? Что если при этом будет получен сигнал SIGKILL, который процесс не распознает? Эту же проблему рассмотрите применительно к реализации системной функции mkdir.
12. Процесс проверяет наличие сигналов в моменты перехода в состояние приостанова и выхода из него (если в состоянии приостанова процесс находился с приоритетом, допускающим прерывания), а также в момент перехода в режим задачи из режима ядра по завершении исполнения системной функции или после обработки прерывания. Почему процесс не проверяет наличие сигналов в момент обращения к системной функции?
*13. Предположим, что после исполнения системной функции процесс готовится к возвращению в режим задачи и не обнаруживает ни одного необработанного сигнала. Сразу после этого ядро обрабатывает прерывание и посылает процессу сигнал. (Например, пользователем была нажата клавиша "break".) Что делает процесс после того, как ядро завершает обработку прерывания?
*14. Если процессу одновременно посылается несколько сигналов, ядро обрабатывает их в том порядке, в каком они перечислены в описании. Существуют три способа реагирования на получение сигнала — прием сигналов, завершение выполнения со сбросом на внешний носитель (дампированием) образа процесса в памяти и завершение выполнения без дампирования. Можно ли указать наилучший порядок обработки одновременно поступающих сигналов? Например, если процесс получает сигнал о выходе (вызывающий дампирование образа процесса в памяти) и сигнал о прерывании (выход без дампирования), то какой из этих сигналов имело бы смысл обработать первым?
15. Запомните новую системную функцию newpgrp(pid,ngrp); которая включает процесс с идентификатором pid в группу процессов с номером ngrp (устанавливает для процесса новую группу). Подумайте, для каких целей она может использоваться и какие опасности таит в себе ее вызов.
16. Прокомментируйте следующее утверждение: по алгоритму wait процесс может приостановиться до наступления какого-либо события и это не отразилось бы на работе всей системы.
17. Рассмотрим новую системную функцию
nowait(pid);
где pid — идентификатор процесса, являющегося потомком того процесса, который вызывает функцию. Вызывая функцию, процесс тем самым сообщает ядру о том, что он не собирается дожидаться завершения выполнения своего потомка, поэтому ядро может по окончании существования потомка сразу же очистить занимаемое им место в таблице процессов. Каким образом это реализуется на практике? Оцените достоинства новой функции и сравните ее использование с использованием сигналов типа "гибель потомка".
18. Загрузчик модулей на Си автоматически подключает к основному модулю начальную процедуру (startup), которая вызывает функцию main, принадлежащую программе пользователя. Если в пользовательской программе отсутствует вызов функции exit, процедура startup сама вызывает эту функцию при выходе из функции main. Что произошло бы в том случае, если бы и в процедуре startup отсутствовал вызов функции exit (из-за ошибки загрузчика)?
19. Какую информацию получит процесс, выполняющий функцию wait, если его потомок запустит функцию exit без параметра? Имеется в виду, что процесс-потомок вызовет функцию в формате exit() вместо exit(n). Если программист постоянно использует вызов функции exit без параметра, то насколько предсказуемо значение, ожидаемое функцией wait? Докажите свой ответ.
20. Объясните, что произойдет, если процесс, исполняющий программу на Рисунке 7.36 запустит с помощью функции exec самого себя. Как в таком случае ядро сможет избежать возникновения тупиковых ситуаций, связанных с блокировкой индексов?
  main(argc,argv)
  int argc;
  char *argv[];
  {
   execl(argv[0], argv[0], 0);
  }
  Рисунок 7.36
21. По условию первым аргументом функции exec является имя (последняя компонента имени пути поиска) исполняемого процессом файла. Что произойдет в результате выполнения программы, приведенной на Рисунке 7.37? Каков будет эффект, если в качестве файла "a.out" выступит загрузочный модуль, полученный в результате трансляции программы, приведенной на Рисунке 7.36?
  main() {
   if (fork() == 0) {
    execl("a.out", 0);
    printf("неудачное завершение функции exec\n");
   }
  }
  Рисунок 7.37
22. Предположим, что в языке Си поддерживается новый тип данных "read-only" (только для чтения), причем процесс, пытающийся записать информацию в поле с этим типом, получает отказ системы защиты. Опишите реализацию этого момента. (Намек: сравните это понятие с понятием "разделяемая область команд".) В какие из алгоритмов ядра потребуется внести изменения? Какие еще объекты могут быть реализованы аналогичным с областью образом?
23. Какие изменения имеют место в алгоритмах open, chmod, unlink и unmount при работе с файлами, для которых установлен режим "sticky-bit"? Какие действия, например, следует предпринять в отношении такого файла ядру, когда с файлом разрывается связь?
24. Суперпользователь является единственным пользователем, имеющим право на запись в файл паролей "/etc/passwd", благодаря чему содержимое файла предохраняется от умышленной или случайной порчи. Программа passwd дает пользователям возможность изменять свой собственный пароль, защищая от изменений чужие записи. Каким образом она работает?
*25. Поясните, какая угроза безопасности хранения данных возникает, если setuid-программа не защищена от записи.
26. Выполните следующую последовательность команд, в которой "a.out" — имя исполняемого файла:
chmod 4777 a.out
chown root a.out
Команда chmod "включает" бит setuid (4 в 4777); пользователь "root" традиционно является суперпользователем. Может ли в результате выполнения этой последовательности произойти нарушение защиты информации?
27. Что произойдет в процессе выполнения программы, представленной на Рисунке 7.38? Поясните свой ответ.
  main() {
   char *endpt;
   char *sbrk();
   int brk();
   endpt = sbrk(0);
   printf("endpt = %ud после sbrk\n", (int) endpt);
   while (endpt--) {
    if (brk(endpt) == -1) {
     printf("brk с параметром %ud завершилась неудачно\n", endpt);
     exit();
    }
   }
  }
  Рисунок 7.38
28. Библиотечная подпрограмма malloc увеличивает область данных процесса с помощью функции brk, а подпрограмма free освобождает память, выделенную подпрограммой malloc. Синтаксис вызова подпрограмм:
ptr = malloc(size);
free(ptr);
где size — целое число без знака, обозначающее количество выделяемых байт памяти, а ptr — символьная ссылка на вновь выделенное пространство. Прежде чем появиться в качестве параметра в вызове подпрограммы free, указатель ptr должен быть возвращен подпрограммой malloc. Выполните эти подпрограммы.
29. Что произойдет в процессе выполнения программы, представленной на Рисунке 7.39? Сравните результаты выполнения этой программы с результатами, предусмотренными в системном описании.
  main() {
   int i;
   char *cp;
   extern char *sbrk();
   cp = sbrk(10);
   for (i = 0; i ‹ 10; i++) *cp++ = 'a' + i;
   sbrk(-10);
   cp = sbrk(10);
   for (i = 0; i ‹ 10; i++) printf("char %d = %c\n", i, *cp++);
  }
  Рисунок 7.39. Пример программы, использующей подпрограмму sbrk
30. Каким образом командный процессор shell узнает о том, что файл исполняемый, когда для выполнения команды создает новый процесс? Если файл исполняемый, то как узнать, создан ли он в результате трансляции исходной программы или же представляет собой набор команд языка shell? В каком порядке следует выполнять проверку указанных условий?
31. В командном языке shell символы "››" используются для направления вывода данных в файл с указанной спецификацией, например, команда: run ››outfile открывает файл с именем "outfile" (а в случае отсутствия файла с таким именем создает его) и записывает в него данные. Напишите программу, в которой используется эта команда.
  main() { exit(0); }
  Рисунок 7.40
32. Процессор командного языка shell проверяет код, возвращаемый функцией exit, воспринимая нулевое значение как "истину", а любое другое значение как "ложь" (обратите внимание на несогласованность с языком Си). Предположим, что файл, исполняющий программу на Рисунке 7.40, имеет имя "truth". Поясните, что произойдет, когда shell будет исполнять следующий набор команд:
while truth
do
truth&
done
33. Вопрос по Рисунку 7.29: В связи с чем возникает необходимость в создании процессов для конвейерной обработки двухкомпонентной команды в указанном порядке?
34. Напишите более общую программу работы основного цикла процессора shell в части обработки каналов. Имеется в виду, что программа должна уметь обрабатывать случайное число каналов, указанных в командной строке.
35. Переменная среды PATH описывает порядок, в котором shell'у следует просматривать каталоги в поисках исполняемых файлов. В библиотечных функциях execlp и execvp перечисленные в PATH каталоги присоединяются к именам файлов, кроме тех, которые начинаются с символа "/". Выполните эти функции.
*36. Для того, чтобы shell в поисках исполняемых файлов не обращался к текущему каталогу, суперпользователь должен задать переменную среды PATH. Какая угроза безопасности хранения данных может возникнуть, если shell попытается исполнить файлы из текущего каталога?
37. Каким образом shell обрабатывает команду cd (создать каталог)? Какие действия предпринимает shell в процессе обработки следующей командной строки: cd pathname&?
38. Когда пользователь нажимает на клавиатуре терминала клавиши "delete" или "break", всем процессам, входящим в группу регистрационного shell'а, терминальный драйвер посылает сигнал о прерывании. Пользователь может иметь намерение остановить все процессы, порожденные shell'ом, без выхода из системы. Какие усовершенствования в связи с этим следует произвести в теле основного цикла программы shell (Рисунок 7.28)?
39. С помощью команды nohup command_line пользователь может отменить действие сигналов о "зависании" и о завершении (quit) в отношении процессов, реализующих командную строку (command_line). Как эта команда будет обрабатываться в основном цикле программы shell?
40. Рассмотрим набор команд языка shell:
nroff -mm bigfile1 › big1out&
nroff -mm bigfile2 › big2out
и вновь обратимся к основному циклу программы shell (Рисунок 7.28). Что произойдет, если выполнение первой команды nroff завершится раньше второй? Какие изменения следует внести в основной цикл программы shell на этот случай?
41. Часто во время выполнения из shell'а не протестированных программ появляется сообщение об ошибке следующего вида: "Bus error — core dumped" (Ошибка в магистрали — содержимое памяти сброшено на внешний носитель). Очевидно, что в программе выполняются какие-то недопустимые действия; откуда shell узнает о том, что ему нужно вывести сообщение об ошибке?
42. Процессом 1 в системе может выступать только процесс init. Тем не менее, запустив процесс init, администратор системы может тем самым изменить состояние системы. Например, при загрузке система может войти в однопользовательский режим, означающий, что в системе активен только консольный терминал. Для того, чтобы перевести процесс init в состояние 2 (многопользовательский режим), администратор системы вводит с консоли команду init 2. Консольный shell порождает свое ответвление и запускает init. Что имело бы место в системе в том случае, если бы активен был только один процесс init?
43. Формат записей в файле "/etc/inittab" допускает задание действия, связанного с каждым порождаемым процессом. Например, с getty-процессом связано действие "respawn" (возрождение), означающее, что процесс init должен возрождать getty-процесс, если последний прекращает существование. На практике, когда пользователь выходит из системы процесс init порождает новый getty-процесс, чтобы другой пользователь мог получить доступ к временно бездействующей терминальной линии. Каким образом это делает процесс init?
44. Некоторые из алгоритмов ядра прибегают к просмотру таблицы процессов. Время поиска данных можно сократить, если использовать указатели на: родителя процесса, любого из потомков, другой процесс, имеющий того же родителя. Процесс обнаруживает всех своих потомков, следуя сначала за указателем на любого из потомков, а затем используя указатели на другие процессы, имеющие того же родителя (циклы недопустимы). Какие из алгоритмов выиграют от этого? Какие из алгоритмов нужно оставить без изменений?

0

15

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
ГЛАВА 8. ДИСПЕТЧЕРИЗАЦИЯ ПРОЦЕССОВ И ЕЕ ВРЕМЕННЫЕ ХАРАКТЕРИСТИКИ

В системе разделения времени ядро предоставляет процессу ресурсы центрального процессора (ЦП) на интервал времени, называемый квантом, по истечении которого выгружает этот процесс и запускает другой, периодически переупорядочивая очередь процессов. Алгоритм планирования процессов в системе UNIX использует время выполнения в качестве параметра. Каждый активный процесс имеет приоритет планирования; ядро переключает контекст на процесс с наивысшим приоритетом. При переходе выполняющегося процесса из режима ядра в режим задачи ядро пересчитывает его приоритет, периодически и в режиме задачи переустанавливая приоритет каждого процесса, готового к выполнению.
Информация о времени, связанном с выполнением, нужна также и некоторым из пользовательских процессов: используемая ими, например, команда time позволяет узнать, сколько времени занимает выполнение другой команды, команда date выводит текущую дату и время суток. С помощью различных системных функций процессы могут устанавливать или получать временные характеристики выполнения в режиме ядра, а также степень загруженности центрального процессора. Время в системе поддерживается с помощью аппаратных часов, которые посылают ЦП прерывания с фиксированной, аппаратно-зависимой частотой, обычно 50-100 раз в секунду. Каждое поступление прерывания по таймеру (часам) именуется таймерным тиком. В настоящей главе рассматриваются особенности реализации процессов во времени, включая планирование процессов в системе UNIX, описание связанных со временем системных функций, а также функций, выполняемых программой обработки прерываний по таймеру.

8.1 ПЛАНИРОВАНИЕ ВЫПОЛНЕНИЯ ПРОЦЕССОВ

Планировщик процессов в системе UNIX принадлежит к общему классу планировщиков, работающих по принципу "карусели с многоуровневой обратной связью". В соответствии с этим принципом ядро предоставляет процессу ресурсы ЦП на квант времени, по истечении которого выгружает этот процесс и возвращает его в одну из нескольких очередей, регулируемых приоритетами. Прежде чем процесс завершится, ему может потребоваться множество раз пройти через цикл с обратной связью. Когда ядро выполняет переключение контекста и восстанавливает контекст процесса, процесс возобновляет выполнение с точки приостанова.

8.1.1 Алгоритм

Сразу после переключения контекста ядро запускает алгоритм планирования выполнения процессов (Рисунок 8.1), выбирая на выполнение процесс с наивысшим приоритетом среди процессов, находящихся в состояниях "резервирования" и "готовности к выполнению, будучи загруженным в память". Рассматривать процессы, не загруженные в память, не имеет смысла, поскольку не будучи загружен, процесс не может выполняться. Если наивысший приоритет имеют сразу несколько процессов, ядро, используя принцип кольцевого списка (карусели), выбирает среди них тот процесс, который находится в состоянии "готовности к выполнению" дольше остальных. Если ни один из процессов не может быть выбран для выполнения, ЦП простаивает до момента получения следующего прерывания, которое произойдет не позже чем через один таймерный тик; после обработки этого прерывания ядро снова запустит алгоритм планирования.
  алгоритм schedule_process
  входная информация: отсутствует
  выходная информация: отсутствует
  {
   выполнять пока (для запуска не будет выбран один из процессов)   {
    for (каждого процесса в очереди готовых к выполнению)
     выбрать процесс с наивысшим приоритетом из загруженных в память;
    if (ни один из процессов не может быть избран для выполнения)
     приостановить машину;
   /* машина выходит из состояния простоя по прерыванию */
   }
   удалить выбранный процесс из очереди готовых к выполнению;
   переключиться на контекст выбранного процесса, возобновить его выполнение;
  }
  Рисунок 8.1. Алгоритм планирования выполнения процессов

8.1.2 Параметры диспетчеризации

В каждой записи таблицы процессов есть поле приоритета, используемое планировщиком процессов. Приоритет процесса в режиме задачи зависит от того, как этот процесс перед этим использовал ресурсы ЦП. Можно выделить два класса приоритетов процесса (Рисунок 8.2): приоритеты выполнения в режиме ядра и приоритеты выполнения в режиме задачи. Каждый класс включает в себя ряд значений, с каждым значением логически ассоциирована некоторая очередь процессов. Приоритеты выполнения в режиме задачи оцениваются для процессов, выгруженных по возвращении из режима ядра в режим задачи, приоритеты выполнения в режиме ядра имеют смысл только в контексте алгоритма sleep. Приоритеты выполнения в режиме задачи имеют верхнее пороговое значение, приоритеты выполнения в режиме ядра имеют нижнее пороговое значение. Среди приоритетов выполнения в режиме ядра далее можно выделить высокие и низкие приоритеты: процессы с низким приоритетом возобновляются по получении сигнала, а процессы с высоким приоритетом продолжают оставаться в состоянии приостанова (см. раздел 7.2.1).
Пороговое значение между приоритетами выполнения в режимах ядра и задачи на Рисунке 8.2 отмечено двойной линией, проходящей между приоритетом ожидания завершения потомка (в режиме ядра) и нулевым приоритетом выполнения в режиме задачи. Приоритеты процесса подкачки, ожидания ввода-вывода, связанного с диском, ожидания буфера и индекса являются высокими, не допускающими прерывания системными приоритетами, с каждым из которых связана очередь из 1, 3, 2 и 1 процесса, соответственно, в то время как приоритеты ожидания ввода с терминала, вывода на терминал и завершения потомка являются низкими, допускающими прерывания системными приоритетами, с каждым из которых связана очередь из 4, 0 и 2 процессов, соответственно. На рисунке представлены также уровни приоритетов выполнения в режиме задачи [24] .
Ядро вычисляет приоритет процесса в следующих случаях:
• Непосредственно перед переходом процесса в состояние приостанова ядро назначает ему приоритет исходя из причины приостанова. Приоритет не зависит от динамических характеристик процесса (продолжительности ввода-вывода или времени счета), напротив, это постоянная величина, жестко устанавливаемая в момент приостанова и зависящая только от причины перехода процесса в данное состояние. Процессы, приостановленные алгоритмами низкого уровня, имеют тенденцию порождать тем больше узких мест в системе, чем дольше они находятся в этом состоянии; поэтому им назначается более высокий приоритет по сравнению с остальными процессами. Например, процесс, приостановленный в ожидании завершения ввода-вывода, связанного с диском, имеет более высокий приоритет по сравнению с процессом, ожидающим освобождения буфера, по нескольким причинам. Прежде всего, у первого процесса уже есть буфер, поэтому не исключена возможность, что когда он возобновится, он успеет освободить и буфер, и другие ресурсы. Чем больше ресурсов свободно, тем меньше шансов для возникновения взаимной блокировки процессов. Системе не придется часто переключать контекст, благодаря чему сократится время реакции процесса и увеличится производительность системы. Во-вторых, буфер, освобождения которого ожидает процесс, может быть занят процессом, ожидающим в свою очередь завершения ввода-вывода. По завершении ввода-вывода будут возобновлены оба процесса, поскольку они были приостановлены по одному и тому же адресу. Если первым запустить на выполнение процесс, ожидающий освобождения буфера, он в любом случае снова приостановится до тех пор, пока буфер не будет освобожден; следовательно, его приоритет должен быть ниже.
• По возвращении процесса из режима ядра в режим задачи ядро вновь вычисляет приоритет процесса. Процесс мог до этого находиться в состоянии приостанова, изменив свой приоритет на приоритет выполнения в режиме ядра, поэтому при переходе процесса из режима ядра в режим задачи ему должен быть возвращен приоритет выполнения в режиме задачи. Кроме того, ядро "штрафует" выполняющийся процесс в пользу остальных процессов, отбирая используемые им ценные системные ресурсы.
• Приоритеты всех процессов в режиме задачи с интервалом в 1 секунду (в версии V) пересчитывает программа обработки прерываний по таймеру, побуждая тем самым ядро выполнять алгоритм планирования, чтобы не допустить монопольного использования ресурсов ЦП одним процессом.
  Рисунок 8.2. Диапазон приоритетов процесса
В течение кванта времени таймер может послать процессу несколько прерываний; при каждом прерывании программа обработки прерываний по таймеру увеличивает значение, хранящееся в поле таблицы процессов, которое описывает продолжительность использования ресурсов центрального процессора (ИЦП). В версии V каждую секунду программа обработки прерываний переустанавливает значение этого поля, используя функцию полураспада (decay):
decay(ИЦП) = ИЦП/2;
После этого программа пересчитывает приоритет каждого процесса, находящегося в состоянии "зарезервирован, но готов к выполнению", по формуле
приоритет = (ИЦП/2) + (базовый уровень приоритета задачи)
где под "базовым уровнем приоритета задачи" понимается пороговое значение, расположенное между приоритетами выполнения в режимах ядра и задачи. Высокому приоритету планирования соответствует количественно низкое значение. Анализ функций пересчета продолжительности использования ресурсов ЦП и приоритета процесса показывает: чем ниже скорость полураспада значения ИЦП, тем медленнее приоритет процесса достигает значение базового уровня; поэтому процессы в состоянии "готовности к выполнению" имеют тенденцию занимать большое число уровней приоритетов.
Результатом ежесекундного пересчета приоритетов является перемещение процессов, находящихся в режиме задачи, от одной очереди к другой, как показано на Рисунке 8.3. По сравнению с Рисунком 8.2 один процесс перешел из очереди, соответствующей уровню 1, в очередь, соответствующую нулевому уровню. В реальной системе все процессы, имеющие приоритеты выполнения в режиме задачи, поменяли бы свое местоположение в очередях. При этом следует указать на невозможность изменения приоритета процесса в режиме ядра, а также на невозможность пересечения пороговой черты процессами, выполняющимися в режиме задачи, до тех пор, пока они не обратятся к операционной системе и не перейдут в состояние приостанова.
Ядро стремится производить пересчет приоритетов всех активных процессов ежесекундно, однако интервал между моментами пересчета может слегка варьироваться. Если прерывание по таймеру поступило тогда, когда ядро исполняло критический отрезок программы (другими словами, в то время, когда приоритет работы ЦП был повышен, но, очевидно, не настолько, чтобы воспрепятствовать прерыванию данного типа), ядро не пересчитывает приоритеты, иначе ему пришлось бы надолго задержаться на критическом отрезке. Вместо этого ядро запоминает то, что ему следует произвести пересчет приоритетов, и делает это при первом же прерывании по таймеру, поступающем после снижения приоритета работы ЦП. Периодический пересчет приоритета процессов гарантирует проведение стратегии планирования, основанной на использовании кольцевого списка процессов, выполняющихся в режиме задачи. При этом конечно же ядро откликается на интерактивные запросы таких программ, как текстовые редакторы или программы форматного ввода: процессы, их реализующие, имеют высокий коэффициент простоя (отношение времени простоя к продолжительности использования ЦП) и поэтому естественно было бы повышать их приоритет, когда они готовы для выполнения (см. [Thompson 78], стр.1937). В других механизмах планирования квант времени, выделяемый процессу на работу с ресурсами ЦП, динамически изменяется в интервале между 0 и 1 сек. в зависимости от степени загрузки системы. При этом время реакции на запросы процессов может сократиться за счет того, что на ожидание момента запуска процессам уже не нужно отводить по целой секунде; однако, с другой стороны, ядру приходится чаще прибегать к переключению контекстов.
  Рисунок 8.3. Переход процесса из одной очереди в другую

8.1.3 Примеры диспетчеризации процессов

На Рисунке 8.4 показана динамика изменений приоритетов процессов A, B и C в версии V при следующих допущениях: все эти процессы были созданы с первоначальным приоритетом 60, который является наивысшим приоритетом выполнения в режиме задачи, прерывания по таймеру поступают 60 раз в секунду, процессы не используют вызов системных функций, в системе нет других процессов, готовых к выполнению. Ядро вычисляет полураспад показателя ИЦП по формуле:
ИЦП = decay(ИЦП) = ИЦП/2;
а приоритет процесса по формуле:
приоритет = (ИЦП/2) + 60;
Если предположить, что первым запускается процесс A и ему выделяется квант времени, он выполняется в течение 1 секунды: за это время таймер посылает системе 60 прерываний и столько же раз программа обработки прерываний увеличивает для процесса A значение поля, содержащего показатель ИЦП (с 0 до 60). По прошествии секунды ядро переключает контекст и, произведя пересчет приоритетов для всех процессов, выбирает для выполнения процесс B. В течение следующей секунды программа обработки прерываний по таймеру 60 раз повышает значение поля ИЦП для процесса B, после чего ядро пересчитывает параметры диспетчеризации для всех процессов и вновь переключает контекст. Процедура повторяется многократно, сопровождаясь поочередным запуском процессов на выполнение.
  Рисунок 8.4. Пример диспетчеризации процессов
Теперь рассмотрим процессы с приоритетами, приведенными на Рисунке 8.5, и предположим, что в системе имеются и другие процессы. Ядро может выгрузить процесс A, оставив его в состоянии "готовности к выполнению", после того, как он получит подряд несколько квантов времени для работы с ЦП и снизит таким образом свой приоритет выполнения в режиме задачи (Рисунок 8.5а). Через некоторое время после запуска процесса A в состояние "готовности к выполнению" может перейти процесс B, приоритет которого в тот момент окажется выше приоритета процесса A (Рисунок 8.5б). Если ядро за это время не запланировало к выполнению любой другой процесс (из тех, что не показаны на рисунке), оба процесса (A и B) при известных обстоятельствах могут на некоторое время оказаться на одном уровне приоритетности, хотя процесс B попадет на этот уровень первым из-за того, что его первоначальный приоритет был ближе (Рисунок 8.5в и 8.5 г). Тем не менее, ядро запустит процесс A впереди процесса B, поскольку процесс A находился в состоянии "готовности к выполнению" более длительное время (Рисунок 8.5д) — это решающее условие, если выбор производится из процессов с одинаковыми приоритетами.
В разделе 6.4.3 уже говорилось о том, что ядро запускает процесс на выполнение после переключения контекста: прежде чем перейти в состояние приостанова или завершить свое выполнение процесс должен переключить контекст, кроме того он имеет возможность переключать контекст в момент перехода из режима ядра в режим задачи. Ядро выгружает процесс, который собирается перейти в режим задачи, если имеется готовый к выполнению процесс с более высоким приоритетом. Такая ситуация возникает, если ядро вывело из состояния приостанова процесс с приоритетом, превышающим приоритет текущего процесса, или если в результате обработки прерывания по таймеру изменились приоритеты всех готовых к выполнению процессов. В первом случае текущий процесс не может выполняться в режиме задачи, поскольку имеется процесс с более высоким приоритетом выполнения в режиме ядра. Во втором случае программа обработки прерываний по таймеру решает, что процесс использовал выделенный ему квант времени, и поскольку множество процессов при этом меняют свои приоритеты, ядро выполняет переключение контекста.

8.1.4 Управление приоритетами

Процессы могут управлять своими приоритетами с помощью системной функции nice:
nice(value);
где value — значение, в процессе пересчета прибавляемое к приоритету процесса:
приоритет = (ИЦП/константа) + (базовый приоритет) + (значение nice)
Системная функция nice увеличивает или уменьшает значение поля nice в таблице процессов на величину параметра функции, при этом только суперпользователю дозволено указывать значения, увеличивающие приоритет процесса. Кроме того, только суперпользователь может указывать значения, лежащие ниже определенного порога. Пользователи, вызывающие системную функцию nice для того, чтобы понизить приоритет во время выполнения интенсивных вычислительных работ, "удобны, приятны" (nice) для остальных пользователей системы, отсюда название функции. Процессы наследуют значение nice у своего родителя при выполнении системной функции fork. Функция nice действует только для выполняющихся процессов; процесс не может сбросить значение nice у другого процесса. С практической точки зрения это означает, что если администратору системы понадобилось понизить приоритеты различных процессов, требующих для своего выполнения слишком много времени, у него не будет другого способа сделать это быстро, кроме как вызвать функцию удаления (kill) для всех них сразу.
  Рисунок 8.5. Планирование на основе кольцевого списка и приоритеты процессов

8.1.5 Планирование на основе справедливого раздела

Вышеописанный алгоритм планирования не видит никакой разницы между пользователями различных классов (категорий). Другими словами, невозможно выделить определенной совокупности процессов, например, половину сеанса работы с ЦП. Тем не менее, такая возможность имеет важное значение для организации работы в условиях вычислительного центра, где группа пользователей может пожелать купить только половину машинного времени на гарантированной основе и с гарантированным уровнем реакции. Здесь мы рассмотрим схему, именуемую "Планированием на основе справедливого раздела" (Fair Share Scheduler) и реализованную на вычислительном центре Indian Hill фирмы AT&T Bell Laboratories [Henry 84].
Принцип "планирования на основе справедливого раздела" состоит в делении совокупности пользователей на группы, являющиеся объектами ограничений, накладываемых обычным планировщиком на обработку процессов из каждой группы. При этом система выделяет время ЦП пропорционально числу групп, вне зависимости от того, сколько процессов выполняется в группе. Пусть, например, в системе имеются четыре планируемые группы, каждая из которых загружает ЦП на 25 % и содержит, соответственно, 1, 2, 3 и 4 процесса, реализующих счетные задачи, которые никогда по своей воле не уступят ЦП. При условии, что в системе больше нет никаких других процессов, каждый процесс при использовании традиционного алгоритма планирования получил бы 10 % времени ЦП (поскольку всего процессов 10 и между ними не делается никаких различий). При использовании алгоритма планирования на основе справедливого раздела процесс из первой группы получит в два раза больше времени ЦП по сравнению с каждым процессом из второй группы, в 3 раза больше по сравнению с каждым процессом из третьей группы и в 4 раза больше по сравнению с каждым процессом из четвертой. В этом примере всем процессам в группе выделяется равное время, поскольку продолжительность цикла, реализуемого каждым процессом, заранее не установлена.
Реализация этой схемы довольно проста, что и делает ее привлекательной. В формуле расчета приоритета процесса появляется еще один термин — "приоритет группы справедливого раздела". В пространстве процесса также появляется новое поле, описывающее продолжительность ИЦП на основе справедливого раздела, общую для всех процессов из группы. Программа обработки прерываний по таймеру увеличивает значение этого поля для текущего процесса и ежесекундно пересчитывает значения соответствующих полей для всех процессов в системе. Новая компонента формулы вычисления приоритета процесса представляет собой нормализованное значение ИЦП для каждой группы. Чем больше процессорного времени выделяется процессам группы, тем выше значение этого показателя и ниже приоритет.
В качестве примера рассмотрим две группы процессов (Рисунок 8.6), в одной из которых один процесс (A), в другой — два (B и C). Предположим, что ядро первым запустило на выполнение процесс A, в течение секунды увеличивая соответствующие этому процессу значения полей, описывающих индивидуальное и групповое ИЦП. В результате пересчета приоритетов по истечении секунды процессы B и C будут иметь наивысшие приоритеты. Допустим, что ядро выбирает на выполнение процесс B. В течение следующей секунды значение поля ИЦП для процесса B поднимается до 60, точно такое же значение принимает поле группового ИЦП для процессов B и C. Таким образом, по истечении второй секунды процесс C получит приоритет, равный 75 (сравните с Рисунком 8.4), и ядро запустит на выполнение процесс A с приоритетом 74. Дальнейшие действия можно проследить на рисунке: ядро по очереди запускает процессы A, B, A, C, A, B и т. д.

8.1.6 Работа в режиме реального времени

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

8.2 СИСТЕМНЫЕ ОПЕРАЦИИ, СВЯЗАННЫЕ СО ВРЕМЕНЕМ

Существует несколько системных функций, имеющих отношение к времени протекания процесса: stime, time, times и alarm. Первые две имеют дело с глобальным системным временем, последние две — с временем выполнения отдельных процессов.
Функция stime дает суперпользователю возможность заносить в глобальную переменную значение глобальной переменной. Выбирается время из этой переменной с помощью функции time:
time(tloc);
где tloc — указатель на переменную, принадлежащую процессу, в которую заносится возвращаемое функцией значение. Функция возвращает это значение и из самой себя, например, команде date, которая вызывает эту функцию, чтобы определить текущее время.
Функция times возвращает суммарное время выполнения процесса и всех его потомков, прекративших существование, в режимах ядра и задачи. Синтаксис вызова функции:
times(tbuffer)
struct tms *tbuffer;
где tms — имя структуры, в которую помещаются возвращаемые значения и которая описывается следующим образом:
struct tms {
/* time_t — имя структуры данных, в которой хранится время */
 time_t tms_utime; /* время выполнения процесса в режиме задачи */
 time_t tms_stime; /* время выполнения процесса в режиме ядра */
 time_t tms_cutime; /* время выполнения потомков в режиме задачи */
 time_t tms_cstime; /* время выполнения потомков в режиме ядра */
};
Функция times возвращает время, прошедшее "с некоторого произвольного момента в прошлом", как правило, с момента загрузки системы.
  #include ‹sys/types.h›
  #include ‹sys/times.h›
  extern long times();
  main() {
   int i;
   /* tms — имя структуры данных, состоящей из 4 элементов */
   struct tms pb1, pb2;
   long pt1, pt2;
   pt1 = times(&pb1);
   for (i = 0; i ‹ 10; i++) if (fork() == 0) child(i);
   for (i = 0; i ‹ 10; i++) wait((int*) 0);
   pt2 = times(&pb2);
   printf("процесс-родитель: реальное время %u в режиме задачи %u в режиме ядра %u потомки: в режиме задачи %u в режиме ядра %u\n",
          pt2 - pt1, pb2.tms_utime - pb1.tms_utime, pb2.tms_stime - pb1.tms_stime, pb2.tms_cutime - pb1.tms_cutime, pb2.tms_cstime - pb1.tms_cstime);
  }
  child(n)
  int n;
  {
   int i;
   struct tms cb1, cb2;
   long t1, t2;
   t1 = times(&cb1);
   for (i = 0; i ‹ 10000; i++);
   t2 = times(&cb2);
   printf("потомок %d: реальное время %u в режиме задачи %u в режиме ядра %u\n",
            n, t2 - t1, cb2.tms_utime - cb1.tms_utime, cb2.tms_stime - cb1.tms_stime);
   exit();
  }
  Рисунок 8.7. Пример программы, использующей функцию times
На Рисунке 8.7 приведена программа, в которой процесс-родитель создает 10 потомков, каждый из которых 10000 раз выполняет пустой цикл. Процесс-родитель обращается к функции times перед созданием потомков и после их завершения, в свою очередь потомки вызывают эту функцию перед началом цикла и после его завершения. Кто-то по наивности может подумать, что время выполнения потомков процесса в режимах задачи и ядра равно сумме соответствующих слагаемых каждого потомка, а реальное время процесса-родителя является суммой реального времени его потомков. Однако, время выполнения потомков не включает в себя время, затраченное на исполнение системных функций fork и exit, кроме того оно может быть искажено за счет обработки прерываний и переключений контекста.
С помощью системной функции alarm пользовательские процессы могут инициировать посылку сигналов тревоги ("будильника") через кратные промежутки времени. Например, программа на Рисунке 8.8 каждую минуту проверяет время доступа к файлу и, если к файлу было произведено обращение, выводит соответствующее сообщение. Для этого в цикле, с помощью функции stat, устанавливается момент последнего обращения к файлу и, если оно имело место в течение последней минуты, выводится сообщение. Затем процесс с помощью функции signal делает распоряжение принимать сигналы тревоги, с помощью функции alarm задает интервал между сигналами в 60 секунд и с помощью функции pause приостанавливает свое выполнение до момента получения сигнала. Через 60 секунд сигнал поступает, ядро подготавливает стек задачи к вызову функции обработки сигнала wakeup, функция возвращает управление на оператор, следующий за вызовом функции pause, и процесс исполняет цикл вновь.
Все перечисленные функции работы с временем протекания процесса объединяет то, что они опираются на показания системных часов (таймера). Обрабатывая прерывания по таймеру, ядро обращается к различным таймерным счетчикам и инициирует соответствующее действие.

8.3 ТАЙМЕР

В функции программы обработки прерываний по таймеру входит:
• перезапуск часов,
• вызов на исполнение функций ядра, использующих встроенные часы,
• поддержка возможности профилирования выполнения процессов в режимах ядра и задачи;
• сбор статистики о системе и протекающих в ней процессах,
• слежение за временем,
• посылка процессам сигналов "будильника" по запросу,
• периодическое возобновление процесса подкачки (см. следующую главу),
• управление диспетчеризацией процессов.
Некоторые из функций реализуются при каждом прерывании по таймеру, другие — по прошествии нескольких таймерных тиков. Программа обработки прерываний по таймеру запускается с высоким приоритетом обращения к процессору, не допуская во время работы возникновения других внешних событий (таких как прерывания от периферийных устройств). Поэтому программа обработки прерываний по таймеру работает очень быстро, за максимально-короткое время пробегая свои критические отрезки, которые должны выполняться без прерываний со стороны других процессов. Алгоритм обработки прерываний по таймеру приведен на Рисунке 8.9.
  #include ‹sys/types.h›
  #include ‹sys/stat.h›
  #include ‹sys/signal.h›
  main(argc, argv)
  int argc;
  char *argv[];
  {
   extern unsigned alarm();
   extern wakeup();
   struct stat statbuf;
   time_t axtime;
   if (argc != 2) {
    printf("только 1 аргумент\n");
    exit();
   }
   axtime = (time_t) 0;
   for (;;) {
     /* получение значения времени доступа к файлу */
    if (stat(argv[1], &statbuf) == -1) {
     printf("файла с именем %s нет\n", argv[1]);
     exit();
    }
    if (axtime != statbuf.st_atime) {
     printf("к файлу %s было обращение\n", argv[1]);
     axtime = statbuf.st_atime;
    }
    signal(SIGALRM, wakeup); /* подготовка к приему сигнала */
    alarm(60);
    pause(); /* приостанов до получения сигнала */
   }
  }
   wakeup() {}
  Рисунок 8.8. Программа, использующая системную функцию alarm
  алгоритм clock
  входная информация: отсутствует
  выходная информация: отсутствует
  {
   перезапустить часы; /* чтобы они снова посылали прерывания */
   if (таблица ответных сигналов не пуста)   {
    установить время для ответных сигналов;
    запустить функцию callout, если время истекло;
   }
   if (профилируется выполнение в режиме ядра)
    запомнить значение счетчика команд в момент прерывания;
   if (профилируется выполнение в режиме задачи)
    запомнить значение счетчика команд в момент прерывания;
   собрать статистику о самой системе;
   собрать статистику о протекающих в системе процессах;
   выверить значение продолжительности ИЦП процессом;
   if (прошла 1 секунда или более и исполняется отрезок, не являющийся критическим)   {
    for (всех процессов в системе)   {
     установить "будильник", если он активен;
     выверить значение продолжительности ИЦП;
     if (процесс будет исполняться в режиме задачи) выверить приоритет процесса;
    }
    возобновить в случае необходимости выполнение процесса подкачки;
   }
  }
  Рисунок 8.9. Алгоритм обработки прерываний по таймеру

8.3.1 Перезапуск часов

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

8.3.2 Внутренние системные тайм-ауты

Некоторым из процедур ядра, в частности драйверам устройств и сетевым протоколам, требуется вызов функций ядра в режиме реального времени. Например, процесс может перевести терминал в режим ввода без обработки символов, при котором ядро выполняет запросы пользователя на чтение с терминала через фиксированные промежутки времени, не дожидаясь, когда пользователь нажмет клавишу "возврата каретки" (см.раздел 10.3.3). Ядро хранит всю необходимую информацию в таблице ответных сигналов (Рисунок 8.9), в том числе имя функции, запускаемой по истечении интервала времени, параметр, передаваемый этой функции, а также продолжительность интервала (в таймерных тиках) до момента запуска функции.
Пользователь не имеет возможности напрямую контролировать записи в таблице ответных сигналов; для работы с ними существуют различные системные алгоритмы. Ядро сортирует записи в этой таблице в соответствии с величиной интервала до момента запуска функций. В связи с этим для каждой записи таблицы запоминается не общая продолжительность интервала, а только промежуток времени между моментами запуска данной и предыдущей функций. Общая продолжительность интервала до момента запуска функции складывается из промежутков времени между моментами запуска всех функций, начиная с первой и вплоть до текущей.
  Рисунок 8.10. Включение новой записи в таблицу ответных сигналов
На Рисунке 8.10 приведен пример добавления новой записи в таблицу ответных сигналов. (К отрицательному значению поля "время до запуска" для функции a мы вернемся несколько позже). Создавая новую запись, ядро отводит для нее надлежащее место и соответствующим образом переустанавливает значение поля "время до запуска" в записи, следующей за добавляемой. Судя по рисунку, ядро собирается запустить функцию f через 5 таймерных тиков: оно отводит место для нее в таблице сразу после функции b и заносит в поле "время до запуска" значение, равное 2 (тогда сумма значений этих полей для функций b и f составит 5), и меняет "время до запуска" функции c на 8 (при этом функция c все равно запускается через 13 таймерных тиков). В одних версиях ядро пользуется связным списком указателей на записи таблицы ответных сигналов, в других меняет положение записей при корректировке таблицы. Последний способ требует значительно меньших издержек при условии, что ядро не будет слишком часто обращаться к таблице.
При каждом поступлении прерывания по таймеру программа обработки прерывания проверяет наличие записей в таблице ответных сигналов и в случае их обнаружения уменьшает значение поля "время до запуска" в первой записи. Способ хранения продолжительности интервалов до момента запуска каждой функции, выбранный ядром, позволяет, уменьшив значение поля "время до запуска" в одной только первой записи, соответственно уменьшить продолжительность интервала до момента запуска функций, описанных во всех записях таблицы. Если в указанном поле первой записи хранится отрицательное или нулевое значение, соответствующую функцию следует запустить. Программа обработки прерываний по таймеру не запускает функцию немедленно, таким образом она не блокирует возникновение последующих прерываний данного типа. Текущий приоритет работы процессора вроде бы не позволяет таким прерываниям вмешиваться в выполнение процесса, однако ядро не имеет представления о том, сколько времени потребуется на исполнение функции. Казалось бы, если функция выполняется дольше одного таймерного тика, все последующие прерывания должны быть заблокированы. Вместо этого, программа обработки прерываний в типичной ситуации оформляет вызов функции как "программное прерывание", порождаемое выполнением отдельной машинной команды. Поскольку среди всех прерываний программные прерывания имеют самый низкий приоритет, они блокируются, пока ядро не закончит обработку всех остальных прерываний. С момента завершения подготовки к запуску функции и до момента возникновения вызываемого запуском функции программного прерывания может произойти множество прерываний, в том числе и программных, в таком случае в поле "время до запуска", принадлежащее первой записи таблицы, будет занесено отрицательное значение. Когда же наконец программное прерывание происходит, программа обработки прерываний убирает из таблицы все записи с истекшими значениями полей "время до запуска" и вызывает соответствующую функцию.
Поскольку в указанном поле в начальных записях таблицы может храниться отрицательное или нулевое значение, программа обработки прерываний должна найти в таблице первую запись с положительным значением поля и уменьшить его. Пусть, например, функции a соответствует "время до запуска", равное -2 (Рисунок 8.10), то есть перед тем, как функция a была выбрана на выполнение, система получила 2 прерывания по таймеру. При условии, что функция b 2 тика назад уже была в таблице, ядро пропускает запись, соответствующую функции a, и уменьшает значение поля "время до запуска" для функции b.

8.3.3 Построение профиля

Построение профиля ядра включает в себя измерение продолжительности выполнения системы в режиме задачи против режима ядра, а также продолжительности выполнения отдельных процедур ядра. Драйвер параметров ядра следит за относительной эффективностью работы модулей ядра, замеряя параметры работы системы в момент прерывания по таймеру. Драйвер параметров имеет список адресов ядра (главным образом, функций ядра); эти адреса ранее были загружены процессом путем обращения к драйверу параметров. Если построение профиля ядра возможно, программа обработки прерывания по таймеру запускает подпрограмму обработки прерываний, принадлежащую драйверу параметров, которая определяет, в каком из режимов — ядра или задачи — работал процессор в момент прерывания. Если процессор работал в режиме задачи, система построения профиля увеличивает значение параметра, описывающего продолжительность выполнения в режиме задачи, если же процессор работал в режиме ядра, система увеличивает значение внутреннего счетчика, соответствующего счетчику команд. Пользовательские процессы могут обращаться к драйверу параметров, чтобы получить значения параметров ядра и различную статистическую информацию.
  Рисунок 8.11. Адреса некоторых алгоритмов ядра
На Рисунке 8.11 приведены гипотетические адреса некоторых процедур ядра. Пусть в результате 10 измерений, проведенных в моменты поступления прерываний по таймеру, были получены следующие значения счетчика команд: 110, 330, 145, адрес в пространстве задачи, 125, 440, 130, 320, адрес в пространстве задачи и 104. Ядро сохранит при этом те значения, которые показаны на рисунке. Анализ этих значений показывает, что система провела 20 % своего времени в режиме задачи (user) и 50 % времени потратила на выполнение алгоритма bread в режиме ядра.
Если измерение параметров ядра выполняется в течение длительного периода времени, результаты измерений приближаются к истинной картине использования системных ресурсов. Тем не менее, описываемый механизм не учитывает время, потраченное на обработку прерываний по таймеру и выполнение процедур, блокирующих поступление прерываний данного типа, поскольку таймер не может прерывать выполнение критических отрезков программ и, таким образом, не может в это время обращаться к подпрограмме обработки прерываний драйвера параметров. В этом недостаток описываемого механизма, ибо критические отрезки программ ядра чаще всего наиболее важны для измерений. Следовательно, результаты измерения параметров ядра содержат определенную долю приблизительности. Уайнбергер [Weinberger 84] описал механизм включения счетчиков в главных блоках программы, таких как "if-then" и "else", с целью повышения точности измерения частоты их выполнения. Однако, данный механизм увеличивает время счета программ на 50-200 %, поэтому его использование в качестве постоянного механизма измерения параметров ядра нельзя признать рациональным.
На пользовательском уровне для измерения параметров выполнения процессов можно использовать системную функцию profil:
profil(buff, bufsize, offset, scale);
где buff — адрес массива в пространстве задачи, bufsize — размер массива, offset — виртуальный адрес подпрограммы пользователя (обычно, первой по счету), scale — способ отображения виртуальных адресов задачи на адрес массива. Ядро трактует аргумент "scale" как двоичную дробь с фиксированной точкой слева. Так, например, значение аргумента в шестнадцатиричной системе счисления, равное 0xffff, соответствует однозначному отображению счетчика команд на адреса массива, значение, равное 0x7fff, соответствует размещению в одном слове массива buff двух адресов программы, 0x3fff — четырех адресов программы и т. д. Ядро хранит параметры, передаваемые при вызове системной функции, в пространстве процесса. Если таймер прерывает выполнение процесса тогда, когда он находится в режиме задачи, программа обработки прерываний проверяет значение счетчика команд в момент прерывания, сравнивает его со значением аргумента offset и увеличивает содержимое ячейки памяти, адрес которой является функцией от bufsize и scale.
Рассмотрим в качестве примера программу, приведенную на Рисунке 8.12, измеряющую продолжительность выполнения функций f и g. Сначала процесс, используя системную функцию signal, делает указание при получении сигнала о прерывании вызывать функцию theend, затем он вычисляет диапазон адресов программы, в пределах которых будет производиться измерение продолжительности (начиная с адреса функции main и кончая адресом функции theend), и, наконец, запускает функцию profil, сообщая ядру о том, что он собирается начать измерение. В результате выполнения программы в течение 10 секунд на несильно загруженной машине AT&T 3B20 были получены данные, представленные на Рисунке 8.13. Адрес функции f превышает адрес начала профилирования на 204 байта; поскольку текст функции f имеет размер 12 байт, а размер целого числа в машине AT&T 3B20 равен 4 байтам, адреса функции f отображаются на элементы массива buf с номерами 51, 52 и 53. По такому же принципу адреса функции g отображаются на элементы buf c номерами 54, 55 и 56. Элементы buf с номерами 46, 48 и 49 предназначены для адресов, принадлежащих циклу функции main. В обычном случае диапазон адресов, в пределах которого выполняется измерение параметров, определяется в результате обращения к таблице идентификаторов для данной программы, где указываются адреса программных секций. Пользователи сторонятся функции profil из-за того, что она кажется им слишком сложной; вместо нее они используют при компиляции программ на языке Си параметр, сообщающий компилятору о необходимости сгенерировать код, следящий за ходом выполнения процессов.
  #include ‹signal.h›
  int buffer[4096];
  main() {
   int offset, endof, scale, eff, gee, text;
   extern theend(), f(), g();
   signal(SIGINT, theend);
   endof = (int) theend;
   offset = (int) main; /* вычисляется количество слов в тексте программы */
   text = (endof - offset + sizeof(int) - 1) / sizeof(int);
   scale = Oxffff;
   printf("смещение до начала %d до конца %d длина текста %d\n", offset, endof, text);
   eff = (int) f;
   gee = (int) g;
   printf("f %d g %d fdiff %d gdiff %d\n", eff ,gee, eff - offset, gee - offset);
   profil(buffer, sizeof(int) * text, offset, scale);
   for (;;) {
    f(); g();
   }
  }
  f() {}
  g() {}
  theend() {
   int i; 
   for (i = 0; i ‹ 4096; i++) if (buffer[i]) printf("buf[%d] = %d\n", i, buffer[i]);
   exit();
  }
  Рисунок 8.12. Программа, использующая системную функцию profil
смещение до начала 212 до конца 440 длина текста 57
f 416 g 428 fdiff 204 gdiff 216
buf[46] = 50
buf[48] = 8585216
buf[49] = 151
buf[51] = 12189799
buf[53] = 65
buf[54] = 10682455
buf[56] = 67
  Рисунок 8.13. Пример результатов выполнения программы, использующей системную функцию profil

8.3.4 Учет и статистика

В момент поступления прерывания по таймеру система может выполняться в режиме ядра или задачи, а также находиться в состоянии простоя (бездействия). Состояние простоя означает, что все процессы приостановлены в ожидании наступления события. Для каждого состояния процессора ядро имеет внутренние счетчики, устанавливаемые при каждом прерывании по таймеру. Позже пользовательские процессы могут проанализировать накопленную ядром статистическую информацию.
В пространстве каждого процесса имеются два поля для записи продолжительности времени, проведенного процессом в режиме ядра и задачи. В ходе обработки прерываний по таймеру ядро корректирует значение поля, соответствующего текущему режиму выполнения процесса. Процессы-родители собирают статистику о своих потомках при выполнении функции wait, беря за основу информацию, поступающую от завершающих свое выполнение потомков.
В пространстве каждого процесса имеется также одно поле для ведения учета использования памяти. В ходе обработки прерывания по таймеру ядро вычисляет общий объем памяти, занимаемый текущим процессом, исходя из размера частных областей процесса и его долевого участия в использовании разделяемых областей памяти. Если, например, процесс использует области данных и стека размером 25 и 40 Кбайт, соответственно, и разделяет с четырьмя другими процессами одну область команд размером 50 Кбайт, ядро назначает процессу 75 Кбайт памяти (50К/5 + 25К + 40К). В системе с замещением страниц ядро вычисляет объем используемой памяти путем подсчета числа используемых в каждой области страниц. Таким образом, если прерываемый процесс имеет две частные области и еще одну область разделяет с другим процессом, ядро назначает ему столько страниц памяти, сколько содержится в этих частных областях, плюс половину страниц, принадлежащих разделяемой области. Вся указанная информация отражается в учетной записи при завершении процесса и может быть использована для расчетов с заказчиками машинного времени.

8.3.5 Поддержание времени в системе

Ядро увеличивает показание системных часов при каждом прерывании по таймеру, измеряя время в таймерных тиках от момента загрузки системы. Это значение возвращается процессу через системную функцию time и дает возможность определять общее время выполнения процесса. Время первоначального запуска процесса сохраняется ядром в адресном пространстве процесса при исполнении системной функции fork, в момент завершения процесса это значение вычитается из текущего времени, результат вычитания и составляет реальное время выполнения процесса. В другой переменной таймера, устанавливаемой с помощью системной функции stime и корректируемой раз в секунду, хранится календарное время.

8.4 ВЫВОДЫ

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

8.5 УПРАЖНЕНИЯ

1. При переводе процессов в состояние приостанова ядро назначает процессу, ожидающему снятия блокировки с индекса, более высокий приоритет по сравнению с процессом, ожидающим освобождения буфера. Точно так же, процессы, ожидающие ввода с терминала, получают более высокий приоритет по сравнению с процессами, ожидающими возможности производить вывод на терминал. Объясните причины такого поведения ядра.
*2.  В алгоритме обработки прерываний по таймеру предусмотрен пересчет приоритетов и перезапуск процессов на выполнение с интервалом в 1 секунду. Придумайте алгоритм, в котором интервал перезапуска динамически меняется в зависимости от степени загрузки системы. Перевесит ли выигрыш усилия по усложнению алгоритма?
3. В шестой редакции системы UNIX для расчета продолжительности ИЦП текущим процессом используется следующая формула:
decay(ИЦП) = max (пороговый приоритет, ИЦП-10);
а в седьмой редакции:
decay(ИЦП) =.8 * ИЦП;
Приоритет процесса в обеих редакциях вычисляется по формуле:
приоритет = ИЦП/16 + (базовый уровень приоритета);
Повторите пример на Рисунке 8.4, используя приведенные формулы.
4.Проделайте еще раз пример на Рисунке 8.4 с семью процессами вместо трех, а затем измените частоту прерываний по таймеру с 60 на 100 прерываний в секунду. Прокомментируйте результат.
5.Разработайте схему, в которой система накладывает ограничение на продолжительность выполнения процесса, при превышении которого процесс завершается. Каким образом пользователь должен отличать такой процесс от процессов, для которых не должны существовать подобные ограничения? Каким образом должна работать схема, если единственным условием является ее запуск из shell'а?
6. Когда процесс выполняет системную функцию wait и обнаруживает прекратившего существование потомка, ядро приплюсовывает к его ИЦП значение поля ИЦП потомка. Чем объясняется такое "наказание" процесса-родителя?
7. Команда nice запускает последующую команду с передачей ей указанного значения, например:
nice 6 nroff -mm big_memo › output
Напишите на языке Си программу, реализующую команду nice.
8. Проследите на примере Рисунка 8.4, каким образом будет осуществляться диспетчеризация процессов в том случае, если значение, передаваемое функцией nice для процесса A, равно 5 или -5.
9. Проведите эксперимент с системной функцией renice x y, где x — код идентификации процесса (активного), а y — новое значение nice для указанного процесса.
10. Вернемся к примеру, приведенному на Рисунке 8.6. Предположим, что группе, в которую входит процесс A, выделяется 33 % процессорного времени, а группе, в которую входит процесс B, — 66 % процессорного времени. В какой последовательности будут исполняться процессы? Обобщите алгоритм вычисления приоритетов таким образом, чтобы значение группового ИЦП усреднялось.
11. Выполните команду date. Команда без аргументов выводит текущую дату:
указав аргумент, например:
date mmddhhmmyy
(супер)пользователь может установить текущую дату в системе (соответственно, месяц, число, часы, минуты и год). Так,
date 0911205084
устанавливает в качестве текущего времени 11 сентября 1984 года 8:50 пополудни.
12. В программах можно использовать функцию пользовательского уровня sleep:
sleep(seconds);
с помощью которой выполнение программы приостанавливается на указанное число секунд. Разработайте ее алгоритм, в котором используйте системные функции alarm и pause. Что произойдет, если процесс вызовет функцию alarm раньше функции sleep? Рассмотрите две возможности: 1) действие ранее вызванной функции alarm истекает в то время, когда процесс находится в состоянии приостанова, 2) действие ранее вызванной функции alarm истекает после завершения функции sleep.
*13. Обратимся еще раз к последней проблеме. Ядро может выполнить переключение контекста во время исполнения функции sleep между вызовами alarm и pause. Тогда есть опасность, что процесс получит сигнал alarm до того, как вызовет функцию pause. Что произойдет в этом случае? Как вовремя распознать эту ситуацию?

0

16

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
ГЛАВА 9. АЛГОРИТМЫ УПРАВЛЕНИЯ ПАМЯТЬЮ

Алгоритм планирования использования процессорного времени, рассмотренный в предыдущей главе, в сильной степени зависит от выбранной стратегии управления памятью. Процесс может выполняться, если он хотя бы частично присутствует в основной памяти; ЦП не может исполнять процесс, полностью выгруженный во внешнюю память. Тем не менее, основная память — чересчур дефицитный ресурс, который зачастую не может вместить все активные процессы в системе. Если, например, в системе имеется основная память объемом 8 Мбайт, то девять процессов размером по 1 Мбайту каждый уже не смогут в ней одновременно помещаться. Какие процессы в таком случае следует размещать в памяти (хотя бы частично), а какие нет, решает подсистема управления памятью, она же управляет участками виртуального адресного пространства процесса, не резидентными в памяти. Она следит за объемом доступного пространства основной памяти и имеет право периодически переписывать процессы на устройство внешней памяти, именуемое устройством выгрузки, освобождая в основной памяти дополнительное место. Позднее ядро может вновь поместить данные с устройства выгрузки в основную память.
В ранних версиях системы UNIX процессы переносились между основной памятью и устройством выгрузки целиком и, за исключением разделяемой области команд, отдельные независимые части процесса не могли быть объектами перемещения. Такая стратегия управления памятью называется свопингом (подкачкой). Такую стратегию имело смысл реализовывать на машине типа PDP-11, где максимальный размер процесса составлял 64 Кбайта. При использовании этой стратегии размер процесса ограничивается объемом физической памяти, доступной в системе. Система BSD (версия 4.0) явилась главным полигоном для применения другой стратегии, стратегии "подкачки по обращению" (demand paging), в соответствии с которой основная память обменивается с внешней не процессами, а страницами памяти; эта стратегия поддерживается и в последних редакциях версии V системы UNIX. Держать в основной памяти весь выполняемый процесс нет необходимости, и ядро загружает в память только отдельные страницы по запросу выполняющегося процесса, ссылающегося на них. Преимущество стратегии подкачки по обращению состоит в том, что благодаря ей отображение виртуального адресного пространства процесса на физическую память машины становится более гибким: допускается превышение размером процесса объема доступной физической памяти и одновременное размещение в основной памяти большего числа процессов. Преимущество стратегии свопинга состоит в простоте реализации и облегчении "надстроечной" части системы. Обе стратегии управления памятью рассматриваются в настоящей главе.

9.1 СВОПИНГ

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

9.1.1 Управление пространством на устройстве выгрузки

Устройство выгрузки является устройством блочного типа, которое представляет собой конфигурируемый раздел диска. Тогда как обычно ядро выделяет место для файлов по одному блоку за одну операцию, на устройстве выгрузки пространство выделяется группами смежных блоков. Пространство, выделяемое для файлов, используется статическим образом; поскольку схема назначения пространства под файлы действует в течение длительного периода времени, ее гибкость понимается в смысле сокращения числа случаев фрагментации и, следовательно, объемов неиспользуемого пространства в файловой системе. Выделение пространства на устройстве выгрузки, напротив, является временным, в сильной степени зависящим от механизма диспетчеризации процессов. Процесс, размещаемый на устройстве выгрузки, в конечном итоге вернется в основную память, освобождая место на внешнем устройстве. Поскольку время является решающим фактором и с учетом того, что ввод-вывод данных за одну мультиблочную операцию происходит быстрее, чем за несколько одноблочных операций, ядро выделяет на устройстве выгрузки непрерывное пространство, не беря во внимание возможную фрагментацию.
Так как схема выделения пространства на устройстве выгрузки отличается от схемы, используемой для файловых систем, структуры данных, регистрирующие свободное пространство, должны также отличаться. Пространство, свободное в файловых системах, описывается с помощью связного списка свободных блоков, доступ к которому осуществляется через суперблок файловой системы, информация о свободном пространстве на устройстве выгрузки собирается в таблицу, именуемую "карта памяти устройства". Карты памяти, помимо устройства выгрузки, используются и другими системными ресурсами (например, драйверами некоторых устройств), они дают возможность распределять память устройства (в виде смежных блоков) по методу первого подходящего.
Каждая строка в карте памяти состоит из адреса распределяемого ресурса и количества доступных единиц ресурса; ядро интерпретирует элементы строки в соответствии с типом карты. В самом начале карта памяти состоит из одной строки, содержащей адрес и общее количество ресурсов. Если карта описывает распределение памяти на устройстве выгрузки, ядро трактует каждую единицу ресурса как группу дисковых блоков, а адрес — как смещение в блоках от начала области выгрузки. Первоначальный вид карты памяти для устройства выгрузки, состоящего из 10000 блоков с начальным адресом, равным 1, показан на Рисунке 9.1. Выделяя и освобождая ресурсы, ядро корректирует карту памяти, заботясь о том, чтобы в ней постоянно содержалась точная информация о свободных ресурсах в системе.
На Рисунке 9.2 представлен алгоритм выделения пространства с помощью карт памяти (malloc). Ядро просматривает карту в поисках первой строки, содержащей количество единиц ресурса, достаточное для удовлетворения запроса. Если запрос покрывает все количество единиц, содержащееся в строке, ядро удаляет строку и уплотняет карту (то есть в карте становится на одну строку меньше). В противном случае ядро переустанавливает адрес и число оставшихся единиц в строке в соответствии с числом единиц, выделенных по запросу. На Рисунке 9.3 показано, как меняется вид карты памяти для устройства выгрузки после выделения 100, 50 и вновь 100 единиц ресурса. В конечном итоге карта памяти принимает вид, показывающий, что первые 250 единиц ресурса выделены по запросам, и что теперь остались свободными 9750 единиц, начиная с адреса 251.
  Рисунок 9.1. Первоначальный вид карты памяти для устройства выгрузки
  алгоритм malloc /* алгоритм выделения пространства с использованием карты памяти */
  входная информация:
   (1) адрес /* указывает на тип используемой карты */
   (2) требуемое число единиц ресурса 
  выходная информация:
   адрес — в случае успешного завершения
   0 — в противном случае 
  {
   for (каждой строки карты)   {
    if (требуемое число единиц ресурса располагается в строке карты)   {
     if (требуемое число == числу единиц в строке)
      удалить строку из карты;
     else   отрегулировать стартовый адрес в строке;
     return (первоначальный адрес строки);
    }
   }
   return (0);
  }
  Рисунок 9.2. Алгоритм выделения пространства с помощью карт памяти
Освобождая ресурсы, ядро ищет для них соответствующее место в карте по адресу. При этом возможны три случая:
1. Освободившиеся ресурсы полностью закрывают пробел в карте памяти. Другими словами, они имеют смежные адреса с адресами ресурсов из строк, непосредственно предшествующей и следующей за данной. В этом случае ядро объединяет вновь освободившиеся ресурсы с ресурсами из указанных строк в одну строку карты памяти.
2. Освободившиеся ресурсы частично закрывают пробел в карте памяти. Если они имеют адрес, смежный с адресом ресурсов из строки, непосредственно предшествующей или непосредственно следующей за данной (но не с адресами из обеих строк), ядро переустанавливает значение адреса и числа ресурсов в соответствующей строке с учетом вновь освободившихся ресурсов. Число строк в карте памяти остается неизменным.
3. Освободившиеся ресурсы частично закрывают пробел в карте памяти, но их адреса не соприкасаются с адресами каких-либо других ресурсов карты. Ядро создает новую строку и вставляет ее в соответствующее место в карте.
  Рисунок 9.3. Выделение пространства на устройстве выгрузки
Возвращаясь к предыдущему примеру, отметим, что если ядро освобождает 50 единиц ресурса, начиная с адреса 101, в карте памяти появится новая строка, поскольку освободившиеся ресурсы имеют адреса, не соприкасающиеся с адресами существующих строк карты. Если же затем ядро освободит 100 единиц ресурса, начиная с адреса 1, первая строка карты будет расширена, поскольку освободившиеся ресурсы имеют адрес, смежный с адресом первой строки. Эволюция состояний карты памяти для данного случая показана на Рисунке 9.4.
Предположим, что ядру был сделан запрос на выделение 200 единиц (блоков) пространства устройства выгрузки. Поскольку первая строка карты содержит информацию только о 150 единицах, ядро привлекает для удовлетворения запроса информацию из второй строки (см. Рисунок 9.5). Наконец, предположим, что ядро освобождает 350 единиц пространства, начиная с адреса 151. Несмотря на то, что эти 350 единиц были выделены ядром в разное время, не существует причины, по которой ядро не могло бы освободить их все сразу. Ядро узнает о том, что освободившиеся ресурсы полностью закрывают разрыв между первой и второй строками карты, и вместо прежних двух создает одну строку, в которую включает и освободившиеся ресурсы.
  Рисунок 9.4. Освобождение пространства на устройстве выгрузки
  Рисунок 9.5. Выделение пространства на устройстве выгрузки, описанного во второй строке карты памяти
В традиционной реализации системы UNIX используется одно устройство выгрузки, однако в последних редакциях версии V допускается уже наличие множества устройств выгрузки. Ядро выбирает устройство выгрузки по схеме "кольцевого списка" при условии, что на устройстве имеется достаточный объем непрерывного адресного пространства. Администраторы могут динамически создавать и удалять из системы устройства выгрузки. Если устройство выгрузки удаляется из системы, ядро не выгружает данные на него; если же данные подкачиваются с удаляемого устройства, сначала оно опорожняется и только после освобождения принадлежащего устройству пространства устройство может быть удалено из системы.

9.1.2 Выгрузка процессов

Ядро выгружает процесс, если испытывает потребность в свободной памяти, которая может возникнуть в следующих случаях:
1. Произведено обращение к системной функции fork, которая должна выделить место в памяти для процесса-потомка.
2. Произведено обращение к системной функции brk, увеличивающей размер процесса.
3. Размер процесса увеличился в результате естественного увеличения стека процесса.
4. Ядру нужно освободить в памяти место для подкачки ранее выгруженных процессов.
Обращение к системной функции fork выделено в особую ситуацию, поскольку это единственный случай, когда пространство памяти, ранее занятое процессом (родителем), не освобождается.
Когда ядро принимает решение о том, что процесс будет выгружен из основной памяти, оно уменьшает значение счетчика ссылок, ассоциированного с каждой областью процесса, и выгружает те области, у которых счетчик ссылок стал равным 0. Ядро выделяет место на устройстве выгрузки и блокирует процесс в памяти (в случаях 1–3), запрещая его выгрузку (см. упражнение 9.12) до тех пор, пока не закончится текущая операция выгрузки. Адрес места выгрузки областей ядро сохраняет в соответствующих записях таблицы областей.
За одну операцию ввода-вывода, в которой участвуют устройство выгрузки и адресное пространство задачи и которая осуществляется через буферный кеш, ядро выгружает максимально-возможное количество данных. Если аппаратура не в состоянии передать за одну операцию содержимое нескольких страниц памяти, перед программами ядра встает задача осуществить передачу содержимого памяти за несколько шагов по одной странице за каждую операцию. Таким образом, точная скорость и механизм передачи данных определяются, помимо всего прочего, возможностями дискового контроллера и стратегией распределения памяти. Например, если используется страничная организация памяти, существует вероятность, что выгружаемые данные занимают несмежные участки физической памяти. Ядро обязано собирать информацию об адресах страниц с выгружаемыми данными, которую впоследствии использует дисковый драйвер, осуществляющий управление процессом ввода-вывода. Перед тем, как выгрузить следующую порцию данных, программа подкачки (выгрузки) ждет завершения предыдущей операции ввода-вывода.
При этом перед ядром не встает задача переписать на устройство выгрузки содержимое виртуального адресного пространства процесса полностью. Вместо этого ядро копирует на устройство выгрузки содержимое физической памяти, отведенной процессу, игнорируя неиспользуемые виртуальные адреса. Когда ядро подкачивает процесс обратно в память, оно имеет у себя карту виртуальных адресов процесса и может переназначить процессу новые адреса. Ядро считывает копию процесса из буферного кеша в физическую память, в те ячейки, для которых установлено соответствие с виртуальными адресами процесса.
На Рисунке 9.6 приведен пример отображения образа процесса в памяти на адресное пространство устройства выгрузки [25] . Процесс располагает тремя областями: команд, данных и стека. Область команд заканчивается на виртуальном адресе 2К, а область данных начинается с адреса 64К, таким образом в виртуальном адресном пространстве образовался пропуск в 62 Кбайта. Когда ядро выгружает процесс, оно выгружает содержимое страниц памяти с адресами 0, 1К, 64К, 65К, 66К и 128К; на устройстве выгрузки не будет отведено место под пропуск в 62 Кбайта между областями команд и данных, как и под пропуск в 61 Кбайт между областями данных и стека, ибо пространство на устройстве выгрузки заполняется непрерывно. Когда ядро загружает процесс обратно в память, оно уже знает из карты памяти процесса о том, что процесс имеет в своем пространстве неиспользуемый участок размером 62К, и с учетом этого соответственно выделяет физическую память. Этот случай проиллюстрирован с помощью Рисунка 9.7. Сравнение Рисунков 9.6 и 9.7 показывает, что физические адреса, занимаемые процессом до и после выгрузки, не совпадают между собой; однако, на пользовательском уровне процесс не обращает на это никакого внимания, поскольку содержимое его виртуального пространства осталось тем же самым.
Теоретически все пространство памяти, занятое процессом, в том числе его личное адресное пространство и стек ядра, может быть выгружено, хотя ядро и может временно заблокировать область в памяти на время выполнения критической операции. Однако практически, ядро не выгружает содержимое адресного пространства процесса, если в нем находятся таблицы преобразования адресов (адресные таблицы) процесса. Практическими соображениями так же диктуются условия, при которых процесс может выгрузить самого себя или потребовать своей выгрузки другим процессом (см. упражнение 9.4).
  Рисунок 9.6. Отображение пространства процесса на устройство выгрузки
  Рисунок 9.7. Загрузка процесса в память

9.1.2.1 Выгрузка при выполнении системной функции fork

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

9.1.2.2 Выгрузка с расширением

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

9.1.3 Загрузка (подкачка) процессов

Нулевой процесс (процесс подкачки) является единственным процессом, загружающим другие процессы в память с устройств выгрузки. Процесс подкачки начинает работу по выполнению этой своей единственной функции по окончании инициализации системы (как уже говорилось в разделе 7.9). Он загружает процессы в память и, если ему не хватает места в памяти, выгружает оттуда некоторые из процессов, находящихся там. Если у процесса подкачки нет работы (например, отсутствуют процессы, ожидающие загрузки в память) или же он не в состоянии выполнить свою работу (ни один из процессов не может быть выгружен), процесс подкачки приостанавливается; ядро периодически возобновляет его выполнение. Ядро планирует запуск процесса подкачки точно так же, как делает это в отношении других процессов, ориентируясь на более высокий приоритет, при этом процесс подкачки выполняется только в режиме ядра. Процесс подкачки не обращается к функциям операционной системы, а использует в своей работе только внутренние функции ядра; он является архетипом всех процессов ядра.
Как уже вкратце говорилось в главе 8, программа обработки прерываний по таймеру измеряет время нахождения каждого процесса в памяти или в состоянии выгрузки. Когда процесс подкачки возобновляет свою работу по загрузке процессов в память, он просматривает все процессы, находящиеся в состоянии "готовности к выполнению, будучи выгруженными", и выбирает из них один, который находится в этом состоянии дольше остальных (см. Рисунок 9.9). Если имеется достаточно свободной памяти, процесс подкачки загружает выбранный процесс, выполняя операции в последовательности, обратной выгрузке процесса. Сначала выделяется физическая память, затем с устройства выгрузки считывается нужный процесс и освобождается место на устройстве.
Если процесс подкачки выполнил процедуру загрузки успешно, он вновь просматривает совокупность выгруженных, но готовых к выполнению процессов в поисках следующего процесса, который предполагается загрузить в память, и повторяет указанную последовательность действий. В конечном итоге возникает одна из следующих ситуаций:
• На устройстве выгрузки больше нет ни одного процесса, готового к выполнению. Процесс подкачки приостанавливает свою работу до тех пор, пока не возобновится процесс на устройстве выгрузки или пока ядро не выгрузит процесс, готовый к выполнению. (Вспомним диаграмму состояний на Рисунке 6.1).
• Процесс подкачки обнаружил процесс, готовый к загрузке, но в системе недостаточно памяти для его размещения. Процесс подкачки пытается загрузить другой процесс и в случае успеха перезапускает алгоритм подкачки, продолжая поиск загружаемых процессов.
Если процессу подкачки нужно выгрузить процесс, он просматривает все процессы в памяти. Прекратившие свое существование процессы не подходят для выгрузки, поскольку они не занимают физическую память; также не могут быть выгружены процессы, заблокированные в памяти, например, выполняющие операции над областями. Ядро предпочитает выгружать приостановленные процессы, поскольку процессы, готовые к выполнению, имеют больше шансов быть вскоре выбранными на выполнение. Решение о выгрузке процесса принимается ядром на основании его приоритета и продолжительности его пребывания в памяти. Если в памяти нет ни одного приостановленного процесса, решение о том, какой из процессов, готовых к выполнению, следует выгрузить, зависит от значения, присвоенного процессу функцией nice, а также от продолжительности пребывания процесса в памяти.
Процесс, готовый к выполнению, должен быть резидентным в памяти в течение по меньшей мере 2 секунд до того, как уйти из нее, а процесс, загружаемый в память, должен по меньшей мере 2 секунды пробыть на устройстве выгрузки. Если процесс подкачки не может найти ни одного процесса, подходящего для выгрузки, или ни одного процесса, подходящего для загрузки, или ни одного процесса, перед выгрузкой не менее 2 секунд [26] находившегося в памяти, он приостанавливает свою работу по причине того, что ему нужно загрузить процесс в память, а в памяти нет места для его размещения. В этой ситуации таймер возобновляет выполнение процесса подкачки через каждую секунду. Ядро также возобновляет работу процесса подкачки в том случае, когда один из процессов переходит в состояние приостанова, так как последний может оказаться более подходящим для выгрузки процессом по сравнению с ранее рассмотренными. Если процесс подкачки расчистил место в памяти или если он был приостановлен по причине невозможности сделать это, он возобновляет свою работу с перезапуска алгоритма подкачки (с самого его начала), вновь предпринимая попытку загрузить ожидающие выполнения процессы.
  алгоритм swapper /* загрузка выгруженных процессов, выгрузка других процессов с целью расчистки места в памяти */
  входная информация: отсутствует
  выходная информация: отсутствует
  {
  loop:
   for (всех выгруженных процессов, готовых к выполнению)
    выбрать процесс, находящийся в состоянии выгруженности дольше остальных;
   if (таких процессов нет)   {
    приостановиться (до момента, когда возникнет необходимость в загрузке процессов);
    goto loop;
   }
   if (в основной памяти достаточно места для размещения процесса)   {
    загрузить процесс;
    goto loop;
   }
   /* loop2: сюда вставляются исправления, внесенные в алгоритм */
   for (всех процессов, загруженных в основную память, кроме прекративших существование и заблокированных в памяти)   {
    if (есть хотя бы один приостановленный процесс)
     выбрать процесс, у которого сумма приоритета и продолжительности нахождения в памяти наибольшая;
    else    /* нет ни одного приостановленного процесса */
     выбрать процесс, у которого сумма продолжительности нахождения в памяти и значения nice наибольшая;
   }
   if (выбранный процесс не является приостановленным или не соблюдены условия резидентности)
    приостановиться (до момента, когда появится возможность загрузить процесс);
   else   выгрузить процесс;
   goto loop; /* на loop2 в исправленном алгоритме */
  }
  Рисунок 9.9. Алгоритм подкачки
На Рисунке 9.10 показана динамика выполнения пяти процессов с указанием моментов их участия в реализации алгоритма подкачки. Положим для простоты, что все процессы интенсивно используют ресурсы центрального процессора и что они не производят обращений к системным функциям; следовательно, переключение контекста происходит только в результате возникновения прерываний по таймеру с интервалом в 1 секунду. Процесс подкачки исполняется с наивысшим приоритетом планирования, поэтому он всегда укладывается в секундный интервал, когда ему есть что делать. Предположим далее, что процессы имеют одинаковый размер и что в основной памяти могут одновременно поместиться только два процесса. Сначала в памяти находятся процессы A и B, остальные процессы выгружены. Процесс подкачки не может стронуть с места ни один процесс в течение первых двух секунд, поскольку этого требует условие нахождения перемещаемого процесса в течение этого интервала на одном месте (в памяти или на устройстве выгрузки), однако по истечении 2 секунд процесс подкачки выгружает процессы A и B и загружает на их место процессы C и D. Он пытается также загрузить и процесс E, но терпит неудачу, поскольку в основной памяти недостаточно места для этого. На 3-секундной отметке процесс E все еще годен для загрузки, поскольку он находился все 3 секунды на устройстве выгрузки, но процесс подкачки не может выгрузить из памяти ни один из процессов, ибо они находятся в памяти менее 2 секунд. На 4-секундной отметке процесс подкачки выгружает процессы C и D и загружает вместо них процессы E и A.
Процесс подкачки выбирает процессы для загрузки, основываясь на продолжительности их пребывания на устройстве выгрузки. В качестве другого критерия может применяться более высокий приоритет загружаемого процесса по сравнению с остальными, готовыми к выполнению процессами, поскольку такой процесс более предпочтителен для запуска. Практика показала, что такой подход "несколько" повышает пропускную способность системы в условиях сильной загруженности (см. [Peachey 84]).
Алгоритм выбора процесса для выгрузки из памяти с целью освобождения места требуемого объема имеет, однако, более серьезные изъяны. Во-первых, процесс подкачки производит выгрузку на основании приоритета, продолжительности нахождения в памяти и значения nice. Несмотря на то, что он производит выгрузку процесса с единственной целью — освободить в памяти место для загружаемого процесса, он может выгрузить и процесс, который не освобождает место требуемого размера. Например, если процесс подкачки пытается загрузить в память процесс размером 1 Мбайт, а в системе отсутствует свободная память, будет далеко не достаточно выгрузить процесс, занимающий только 2 Кбайта памяти. В качестве альтернативы может быть предложена стратегия выгрузки групп процессов при условии, что они освобождают место, достаточное для размещения загружаемых процессов. Эксперименты с использованием машины PDP 11/23 показали, что в условиях сильной загруженности такая стратегия может увеличить производительность системы почти на 10 процентов (см. [Peachey 84]).
Во-вторых, если процесс подкачки приостановил свою работу изза того, что в памяти не хватило места для загрузки процесса, после возобновления он вновь выбирает процесс для загрузки в память, несмотря на то, что ранее им уже был сделан выбор. Причина такого поведения заключается в том, что за прошедшее время в состояние готовности к выполнению могли перейти другие выгруженные процессы, более подходящие для загрузки в память по сравнению с ранее выбранным процессом. Однако от этого мало утешения для ранее выбранного процесса, все еще пытающегося загрузиться в память. В некоторых реализациях процесс подкачки стремится к тому, чтобы перед загрузкой в память одного крупного процесса выгрузить большое количество процессов маленького размера, это изменение в базовом алгоритме подкачки отражено в комментариях к алгоритму (Рисунок 9.9).
В-третьих, если процесс подкачки выбирает для выгрузки процесс, находящийся в состоянии "готовности к выполнению", не исключена возможность того, что этот процесс после загрузки в память ни разу не был запущен на исполнение. Этот случай показан на Рисунке 9.11, из которого видно, что ядро загружает процесс D на 2-секундной отметке, запускает процесс C, а затем на 3-секундной отметке процесс D выгружается в пользу процесса E (уступая последнему в значении nice), несмотря на то, что процессу D так и не был предоставлен ЦП. Понятно, что такая ситуация является нежелательной.
Следует упомянуть еще об одной опасности. Если при попытке выгрузить процесс на устройстве выгрузки не будет найдено свободное место, в системе может возникнуть тупиковая ситуация, при которой: все процессы в основной памяти находятся в состоянии приостанова, все готовые к выполнению процессы выгружены, для новых процессов на устройстве выгрузки уже нет места, нет свободного места и в основной памяти. Эта ситуация разбирается в упражнении 9.5. Интерес к проблемам, связанным с подкачкой процессов, в последние годы спал в связи с реализацией алгоритмов подкачки страниц памяти.
  Рисунок 9.10. Последовательность операций, выполняемых процессом подкачки
  Рисунок 9.11. Загрузка процессов в случае разбивки временных интервалов на части

9.2 ПОДКАЧКА ПО ЗАПРОСУ

Алгоритм подкачки страниц памяти поддерживается на машинах со страничной организацией памяти и с ЦП, имеющим прерываемые команды [27] . В системах с подкачкой страниц отсутствуют ограничения на размер процесса, связанные с объемом доступной физической памяти. Например, в машинах с объемом физической памяти 1 и 2 Мбайта могут исполняться процессы размером 4 или 5 Мбайт. Ограничение на виртуальный размер процесса, связанное с объемом адресуемой виртуальной памяти, остается в силе и здесь. Поскольку процесс может не поместиться в физической памяти, ядру приходится динамически загружать в память отдельные его части и исполнять их, несмотря на отсутствие остальных частей. В механизме подкачки страниц все открыто для пользовательских программ, за исключением разрешенного процессу виртуального размера.
Процессы стремятся исполнять команды небольшими порциями, которые именуются программными циклами или подпрограммами, используемые ими указатели группируются в небольшие поднаборы, располагаемые в информационном пространстве процесса. В этом состоит суть так называемого принципа "локальности". Деннингом [Denning 68] было сформулировано понятие рабочего множества процесса как совокупности страниц, использованных процессом в последних n ссылках на адресное пространство памяти; число n называется окном рабочего множества. Поскольку рабочее множество процесса является частью от целого, в основной памяти может поместиться больше процессов по сравнению с теми системами, где управление памятью базируется на подкачке процессов, что в конечном итоге приводит к увеличению производительности системы. Когда процесс обращается к странице, отсутствующей в его рабочем множестве, возникает ошибка, при обработке которой ядро корректирует рабочее множество процесса, в случае необходимости подкачивая страницы с внешнего устройства.
На Рисунке 9.12 приведена последовательность используемых процессом указателей страниц, описывающих рабочие множества с окнами различных размеров при условии соблюдения алгоритма замещения "стариков" (замещения страниц путем откачки тех, к которым наиболее долго не было обращений). По мере выполнения процесса его рабочее множество видоизменяется в соответствии с используемыми процессом указателями страниц; увеличение размера окна влечет за собой увеличение рабочего множества и, с другой стороны, сокращение числа ошибок в выполнении процесса. Использование неизменного рабочего множества не практикуется, поскольку запоминание очередности следования указателей страниц потребовало бы слишком больших затрат. Приблизительное соответствие между изменяемым рабочим множеством и пространством процесса достигается путем установки бита упоминания (reference bit) при обращении к странице памяти, а также периодическим опросом указателей страниц. Если на страницу была сделана ссылка, эта страница включается в рабочее множество; в противном случае она "дозревает" в памяти в ожидании своей очереди.
В случае возникновения ошибки из-за обращения к странице, отсутствующей в рабочем множестве, ядро приостанавливает выполнение процесса до тех пор, пока страница не будет считана в память и не станет доступной процессу. Когда страница будет загружена, процесс перезапустит ту команду, на которой выполнение процесса было приостановлено из-за ошибки. Таким образом, работа подсистемы замещения страниц распадается на две части: откачка редко используемых страниц на устройство выгрузки и обработка ошибок из-за отсутствия нужной страницы. Такое общее толкование механизма замещения страниц, конечно же, выходит за пределы одной конкретной системы. Оставшуюся часть главы мы посвятим более детальному рассмотрению особенностей реализации этого механизма в версии V системы UNIX.

9.2.1 Структуры данных, используемые подсистемой замещения страниц

Для поддержки функций управления памятью на машинном (низком) уровне и для реализации механизма замещения страниц ядро использует 4 основные структуры данных: записи таблицы страниц, дескрипторы дисковых блоков, таблицу содержимого страничных блоков (page frame data table — сокращенно: pfdata) и таблицу использования области подкачки. Место для таблицы pfdata выделяется один раз на все время жизни системы, для других же структур страницы памяти выделяются динамически.
Из главы 6 нам известно, что каждая область располагает своими таблицами страниц, с помощью которых осуществляется доступ к физической памяти. Каждая запись таблицы страниц (Рисунок 9.13) состоит из физического адреса страницы, кода защиты, в разрядах которого описываются права доступа процесса к странице (на чтение, запись и исполнение), а также следующих двоичных полей, используемых механизмом замещения страниц:
• бит доступности
• бит упоминания
• бит модификации
• бит копирования при записи
• "возраст" страницы
Установка бита доступности свидетельствует о правильности содержимого страницы памяти, однако из того, что бит доступности выключен, не следует с необходимостью то, что ссылка на страницу недопустима, в чем мы убедимся позже. Бит упоминания устанавливается в том случае, если процесс делает ссылку на страницу, а бит модификации — в том случае, если процесс скорректировал содержимое страницы. Установка бита копирования при записи, производимая во время выполнения системной функции fork, свидетельствует о том, что ядру в случае, когда процесс корректирует содержимое страницы, следует создавать ее новую копию. Наконец, "возраст" страницы говорит о продолжительности ее пребывания в составе рабочего множества процесса. Биты доступности, копирования при записи и "возраст" страницы устанавливаются ядром, биты упоминания и модификации — аппаратным путем; в разделе 9.2.4 рассматриваются конфигурации, в которых эти возможности не поддерживаются аппаратурой.
Рисунок 9.12. Рабочее множество процесса
  Рисунок 9.13. Записи таблицы страниц и дескрипторы дисковых блоков
Каждая запись таблицы страниц связана с дескриптором дискового блока, описывающим дисковую копию виртуальной страницы (Рисунок 9.13). Поэтому процессы, использующие разделяемую область, обращаются к общим записям таблицы страниц и к одним и тем же дескрипторам дисковых блоков. Содержимое виртуальной страницы располагается либо в отдельном блоке на устройстве выгрузки, либо в исполняемом файле, либо вообще отсутствует на устройстве выгрузки. Если страница находится на устройстве выгрузки, в дескрипторе дискового блока содержится логический номер устройства и номер блока, по которым можно отыскать содержимое страницы. Если страница содержится в исполняемом файле, в дескрипторе дискового блока располагается номер логического блока в файле с содержимым страницы; ядро может быстро преобразовать этот номер в адрес на диске. В дескрипторе дискового блока также имеется информация о двух устанавливаемых функцией exec особых условиях: страница при обращении к ней заполняется ("demand fill") или обнуляется ("demand zero"). Разъяснения по этому поводу даются в разделе 9.2.1.2.
В таблице pfdata описывается каждая страница физической памяти. Записи таблицы проиндексированы по номеру страницы и состоят из следующих полей:
• Статус страницы, указывающий на то, что страница располагается на устройстве выгрузки или в исполняемом файле, что к странице произведено обращение по прямому доступу в память (путем считывания информации с устройства выгрузки), или на то, что страница может быть переназначена.
• Количество процессов, ссылающихся на страницу. Счетчик ссылок хранит число записей в таблице страниц, имеющих ссылку на текущую страницу. Это значение может отличаться от количества процессов, использующих разделяемую область с данной страницей, в чем мы убедимся чуть позже, когда будем снова обращаться к алгоритму функции fork.
• Логический номер устройства (устройства выгрузки или файловой системы) и номер блока, указывающие расположение содержимого страницы.
• Указатели на другие записи таблицы pfdata в соответствии со списком свободных страниц или с хеш-очередью страниц.
По аналогии с буферным кэшем ядро связывает записи таблицы pfdata в список свободных страниц и хеш-очередь. Список свободных страниц представляет собой буфер, который содержит страницы, доступные для переназначения, однако процесс, обратившийся к этим страницам, может столкнуться с ошибкой адресации, так и не получив соответствующую страницу из списка. Этот список дает ядру возможность сократить число операций чтения с устройства выгрузки. Ядро выделяет страницы из этого списка по вышеназванному принципу замещения "стариков". Ядро выстраивает записи таблицы в хеш-очередь в соответствии с номером устройства (выгрузки) и номером блока. Используя эти номера, ядро может быстро отыскать страницу, если она находится в памяти. Передавая физическую страницу области, ядро выбирает соответствующую запись из списка свободных страниц, исправляет указанные в ней номера устройства и блока и помещает ее в соответствующее место хеш-очереди.
Каждая запись таблицы использования области подкачки соответствует странице, находящейся на устройстве выгрузки. Запись содержит счетчик ссылок, показывающий количество записей таблицы страниц, в которых имеется ссылка на текущую страницу.
На Рисунке 9.14 показана взаимосвязь между записями таблицы страниц, дескрипторами дисковых блоков, записями таблицы pfdata и таблицы использования области подкачки. Виртуальный адрес 1493К отображается на запись таблицы страниц, соответствующую странице с физическим номером 794; дескриптор дискового блока, связанный с этой записью, свидетельствует о том, что содержимое страницы располагается на устройстве выгрузки с номером 1 в дисковом блоке с номером 2743. Запись таблицы pfdata, помимо того, что указывает на те же номера устройства и блока, сообщает, что счетчик ссылок на физическую страницу имеет значение, равное 1. Счетчик ссылок на виртуальную страницу (в записи таблицы использования области подкачки) свидетельствует о том, что на копию страницы на устройстве выгрузки ссылается только одна запись таблицы страниц.

0

17

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
9.2.1.1 Функция fork в системе с замещением страниц

Как уже говорилось в разделе 7.1, во время выполнения функции fork ядро создает копию каждой области родительского процесса и присоединяет ее к процессу-потомку. В системе с замещением страниц ядро по традиции создает физическую копию адресного пространства процесса-родителя, что в общем случае является довольно расточительной операцией, поскольку процесс часто после выполнения функции fork обращается к функции exec и незамедлительно освобождает только что скопированное пространство. Если область разделяемая, в версии V вместо копирования страницы ядро просто увеличивает значение счетчика ссылок на область (в таблице областей, в таблице страниц и в таблице pfdata). Тем не менее, для частных областей, таких как область данных и стека, ядро отводит в таблице областей и таблице страниц новую запись, после чего просматривает в таблице страниц все записи процесса-родителя: если запись верна, ядро увеличивает значение счетчика ссылок в таблице pfdata, отражающее количество процессов, использующих страницу через разные области (в отличие от тех процессов, которые используют данную страницу через разделяемую область). Если страница располагается на устройстве выгрузки, ядро увеличивает значение счетчика ссылок в таблице использования области подкачки.
  Рисунок 9.14. Взаимосвязь между структурами данных, участвующими в реализации механизма замещения страниц по обращению
Теперь на страницу могут ссылаться обе области, использующие эту страницу совместно, пока процесс не ведет на нее запись. Как только страница понадобится процессу для записи, ядро создаст ее копию, с тем, чтобы у каждой области была своя личная версия страницы. Для этого при выполнении функции fork в каждой записи таблицы страниц, соответствующей частным областям родителя и потомка, ядро устанавливает бит "копирования при записи". Если один из процессов попытается что-то записать на страницу, он получит отказ системы защиты, после чего для него будет создана новая копия содержимого страницы. Таким образом, физическое копирование страницы откладывается до того момента, когда в этом возникнет реальная потребность.
В качестве примера рассмотрим Рисунок 9.15. Процессы разделяют доступ к таблице страниц совместно используемой области команд T, поэтому значение счетчика ссылок на область равно 2, а на страницы области единице (в таблице pfdata). Ядро назначает процессу-потомку новую область данных C1, являющуюся копией области P1 процесса-родителя. Обе области используют одни и те же записи таблицы страниц, это видно на примере страницы с виртуальным адресом 97К. Этой странице в таблице pfdata соответствует запись с номером 613, счетчик ссылок в которой равен 2, ибо на страницу ссылаются две области.
  Рисунок 9.15. Адресация страниц, участвующих в процессе выполнения функции fork
В ходе выполнения функции fork в системе BSD создается физическая копия страниц родительского процесса. Однако, учитывая наличие ситуаций, в которых создание физической копии не является обязательным, в системе BSD существует также функция vfork, которая используется в том случае, если процесс сразу по завершении функции fork собирается запустить функцию exec. Функция vfork не копирует таблицы страниц, поэтому она работает быстрее, чем функция fork в версии V системы UNIX. Однако процесс-потомок при этом исполняется в тех же самых физических адресах, что и его родитель, и может поэтому затереть данные и стек родительского процесса. Если программист использует функцию vfork неверно, может возникнуть опасная ситуация, поэтому вся ответственность за ее использование возлагается на программиста. Различие в подходах к рассматриваемому вопросу в системах UNIX и BSD имеет философский характер, они дают разный ответ на один и тот же вопрос: следует ли ядру скрывать особенности реализации своих функций, превращая их в тайну для пользователей, или же стоит дать опытным пользователям возможность повысить эффективность выполнения системных операций?
  int global;
  main() {
   int local;
   local = 1;
   if (vfork() == 0) {   /* потомок */
    global = 2; /* запись в область данных родителя */
    local = 3; /* запись в стек родителя */
    _exit();
   }
   printf("global %d local %d\n",global,local);
  }
  Рисунок 9.16. Функция vfork и искажение информации процесса
В качестве примера рассмотрим программу, приведенную на Рисунке 9.16. После выполнения функции vfork процесс-потомок не запускает функцию exec, а переустанавливает значения переменных global и local и завершается [28] . Система гарантирует, что процесс-родитель приостанавливается до того момента, когда потомок исполнит функции exec или exit. Возобновив в конечном итоге свое выполнение, процесс-родитель обнаружит, что значения двух его переменных не совпадают с теми значениями, которые были у них до обращения к функции vfork! Еще больший эффект может произвести возвращение процесса-потомка из функции, вызвавшей функцию vfork (см. упражнение 9.8).

9.2.1.2 Функция exec в системе с замещением страниц

Как уже говорилось в главе 7, когда процесс обращается к системной функции exec, ядро считывает из файловой системы в память указанный исполняемый файл. Однако в системе с замещением страниц по запросу исполняемый файл, имеющий большой размер, может не уместиться в доступном пространстве основной памяти. Поэтому ядро не назначает ему сразу все пространство, а отводит место в памяти по мере надобности. Сначала ядро назначает файлу таблицы страниц и дескрипторы дисковых блоков, помечая страницы в записях таблиц как "заполняемые при обращении" (для всех данных, кроме имеющих тип bss) или "обнуляемые при обращении" (для данных типа bss). Считывая в память каждую страницу файла по алгоритму read, процесс получает ошибку из-за отсутствия (недоступности) данных. Подпрограмма обработки ошибок проверяет, является ли страница "заполняемой при обращении" (тогда ее содержимое будет немедленно затираться содержимым исполняемого файла и поэтому ее не надо очищать) или "обнуляемой при обращении" (тогда ее следует очистить). В разделе 9.2.3 мы увидим, как это происходит. Если процесс не может поместиться в памяти, "сборщик" страниц освобождает для него место, периодически откачивая из памяти неиспользуемые страницы.
В этой схеме видны явные недостатки. Во-первых, при чтении каждой страницы исполняемого файла процесс сталкивается с ошибкой из-за обращения к отсутствующей странице, пусть даже процесс никогда и не обращался к ней. Во-вторых, если после того, как "сборщик" страниц откачал часть страниц из памяти, была запущена функция exec, каждая только что выгруженная и вновь понадобившаяся страница потребует дополнительную операцию по ее загрузке. Чтобы повысить эффективность функции exec, ядро может востребовать страницу непосредственно из исполняемого файла, если данные в файле соответствующим образом настроены, что определяется значением т. н. "магического числа". Однако, использование стандартных алгоритмов доступа к файлу (например, bmap) потребовало бы при обращении к странице, состоящей из блоков косвенной адресации, больших затрат, связанных с многократным использованием буферного кэша для чтения каждого блока. Кроме того, функция bmap не является реентерабельной, отсюда возникает опасность нарушения целостности данных. Во время выполнения системной функции read ядро устанавливает в пространстве процесса значения различных параметров ввода-вывода. Если при попытке скопировать данные в пространство пользователя процесс столкнется с отсутствием нужной страницы, он, считывая страницу из файловой системы, может затереть содержащие эти параметры поля. Поэтому ядро не может прибегать к использованию обычных алгоритмов обработки ошибок данного рода. Конечно же алгоритмы должны быть в обычных случаях реентерабельными, поскольку у каждого процесса свое отдельное адресное пространство и процесс не может одновременно исполнять несколько системных функций.
Для того, чтобы считывать страницы непосредственно из исполняемого файла, ядро во время исполнения функции exec составляет список номеров дисковых блоков файла и присоединяет этот список к индексу файла. Работая с таблицами страниц такого файла, ядро находит дескриптор дискового блока, содержащего страницу, и запоминает номер блока внутри файла; этот номер позже используется при загрузке страницы из файла. На Рисунке 9.17 показан пример, в котором страница имеет адрес расположения в логическом блоке с номером 84 от начала файла. В области имеется указатель на индекс, в котором содержится номер соответствующего физического блока на диске (279).
  Рисунок 9.17. Отображение файла на область

9.2.2 "Сборщик" страниц

"Сборщик" страниц (page stealer) является процессом, принадлежащим ядру операционной системы и выполняющим выгрузку из памяти тех страниц, которые больше не входят в состав рабочего множества пользовательского процесса. Этот процесс создается ядром во время инициализации системы и запускается в любой момент, когда в нем возникает необходимость. Он просматривает все активные незаблокированные области и увеличивает значение "возраста" каждой принадлежащей им страницы (заблокированные области пропускаются, но впоследствии, по снятии блокировки, тоже будут учтены). Когда процесс при работе со страницей, принадлежащей области, получает ошибку, ядро блокирует область, чтобы "сборщик" не смог выгрузить страницу до тех пор, пока ошибка не будет обработана.
Страница в памяти может находиться в двух состояниях: либо "дозревать", не будучи еще готовой к выгрузке, либо быть готовой к выгрузке и доступной для привязки к другим виртуальным страницам. Первое состояние означает, что процесс обратился к странице и поэтому страница включена в его рабочее множество. При обращении к странице в некоторых машинах аппаратно устанавливается бит упоминания, если же эта операция не выполняется, соответственно, и программные методы скорее всего используются другие (раздел 9.2.4). Если страница находится в первом состоянии, "сборщик" сбрасывает бит упоминания в ноль, но запоминает количество просмотров множества страниц, выполненных с момента последнего обращения к странице со стороны пользовательского процесса. Таким образом, первое состояние распадается на несколько подсостояний в соответствии с тем, сколько раз "сборщик" страниц обратился к странице до того, как страница стала готовой для выгрузки (см. Рисунок 9.18). Когда это число превышает некоторое пороговое значение, ядро переводит страницу во второе состояние — состояние готовности к выгрузке. Максимальная продолжительность пребывания страницы в первом состоянии зависит от условий конкретной реализации и ограничивается числом отведенных для этого поля разрядов в записи таблицы страниц.
На Рисунке 9.19 показано взаимодействие между процессами, работающими со страницей, и "сборщиком" страниц. Цифры обозначают номер обращения "сборщика" к странице с того момента, как страница была загружена в память. Процесс, обратившийся к странице после второго просмотра страниц "сборщиком", сбросил ее "возраст" в 0. После каждого просмотра пользовательский процесс обращался к странице вновь, но в конце концов "сборщик" страниц осуществил три просмотра страницы с момента последнего обращения к ней со стороны пользовательского процесса и выгрузил ее из памяти.
  Рисунок 9.18. Диаграмма состояний страницы
Если область используется совместно не менее, чем двумя процессами, все они работают с битами упоминания в одном и том же наборе записей таблицы страниц. Таким образом, страницы могут включаться в рабочие множества нескольких процессов, но для "сборщика" страниц это не имеет никакого значения. Если страница включена в рабочее множество хотя бы одного из процессов, она остается в памяти; в противном случае она может быть выгружена. Ничего, что одна область, к примеру, имеет в памяти страниц больше, чем имеют другие: "сборщик" страниц не пытается выгрузить равное количество страниц из всех активных областей.
Ядро возобновляет работу "сборщика" страниц, когда доступная в системе свободная память имеет размер, не дотягивающий до нижней допустимой отметки, и тогда "сборщик" производит откачку страниц до тех пор, пока объем свободной памяти не превысит верхнюю отметку. При использовании двух отметок количество производимых операций сокращается, ибо если ядро использует только одно пороговое значение, оно будет выгружать достаточное число страниц для освобождения памяти свыше порогового значения, но в результате возвращения ошибочно выгруженных страниц в память размер свободного пространства вскоре вновь опустится ниже этого порога. Объем свободной памяти при этом постоянно бы поддерживался около пороговой отметки. Выгрузка страниц с освобождением памяти в объеме, превышающем верхнюю отметку, откладывает момент, когда объем свободной памяти в системе станет меньше нижней отметки, поэтому "сборщику" страниц не приходится уже так часто выполнять свою работу. Оптимальный выбор уровней верхней и нижней отметок администратором повышает производительность системы.
  Рисунок 9.19. Пример "созревания" страницы
Когда "сборщик" страниц принимает решение выгрузить страницу из памяти, он проверяет возможность нахождения копии этой страницы на устройстве выгрузки. При этом могут иметь место три случая:
1. Если на устройстве выгрузки есть копия страницы, ядро "планирует" выгрузку страницы: "сборщик" страниц помещает ее в список выгруженных страниц и переходит дальше; выгрузка считается логически завершившейся. Когда число страниц в списке превысит ограничение (определяемое возможностями дискового контроллера), ядро переписывает страницы на устройство выгрузки.
2. Если на устройстве выгрузки уже есть копия страницы и ее содержимое ничем не отличается от содержимого страницы в памяти (бит модификации в записи таблицы страниц не установлен), ядро сбрасывает в ноль бит доступности (в той же записи таблицы), уменьшает значение счетчика ссылок в таблице pfdata и помещает запись в список свободных страниц для будущего переназначения.
3. Если на устройстве выгрузки есть копия страницы, но процесс изменил содержимое ее оригинала в памяти, ядро планирует выгрузку страницы и освобождает занимаемое ее копией место на устройстве выгрузки.
"Сборщик" страниц копирует страницу на устройство выгрузки, если имеют место случаи 1 и 3.
Чтобы проиллюстрировать различия между последними двумя случаями, предположим, что страница находится на устройстве выгрузки и загружается в основную память после того, как процесс столкнулся с отсутствием необходимых данных. Допустим, ядро не стало автоматически удалять копию страницы на диске. В конце концов, "сборщик" страниц вновь примет решение выгрузить страницу. Если с момента загрузки в память в страницу не производилась запись данных, содержимое страницы в памяти идентично содержимому ее дисковой копии и в переписи страницы на устройство выгрузки необходимости не возникает. Однако, если процесс успел что-то записать на страницу, старый и новый ее варианты будут различаться, поэтому ядру следует переписать страницу на устройство выгрузки, освободив предварительно место, занимаемое на устройстве старым вариантом. Ядро не сразу использует освобожденное пространство на устройстве выгрузки, поэтому оно имеет возможность поддерживать непрерывное размещение занятых участков, что повышает эффективность использования области выгрузки.
"Сборщик" страниц заполняет список выгруженных страниц, которые в принципе могут принадлежать разным областям, и по заполнении списка откачивает их на устройство выгрузки. Нет необходимости в том, чтобы все страницы одного процесса непременно выгружались: к примеру, некоторые из страниц, возможно, недостаточно "созрели" для этого. В этом видится различие со стратегией выгрузки процессов, согласно которой из памяти выгружаются все страницы одного процесса, вместе с тем метод переписи данных на устройство выгрузки идентичен тому методу, который описан для системы с замещением процессов в разделе 9.1.2. Если на устройстве выгрузки недостаточно непрерывного пространства, ядро выгружает страницы по отдельности (по одной странице за операцию), что в конечном итоге обходится недешево. В системе с замещением страниц фрагментация на устройстве выгрузки выше, чем в системе с замещением процессов, поскольку ядро выгружает блоки страниц, но загружает в память каждую страницу в отдельности.
Когда ядро переписывает страницу на устройство выгрузки, оно сбрасывает бит доступности в соответствующей записи таблицы страниц и уменьшает значение счетчика ссылок в соответствующей записи таблицы pfdata. Если значение счетчика становится равным 0, запись таблицы pfdata помещается в конец списка свободных страниц и запоминается для последующего переназначения. Если значение счетчика отлично от 0, это означает, что страница (в результате выполнения функции fork) используется совместно несколькими процессами, но ядро все равно выгружает ее. Наконец, ядро выделяет пространство на устройстве выгрузки, сохраняет его адрес в дескрипторе дискового блока и увеличивает значение счетчика ссылок на страницу в таблице использования области подкачки. Если в то время, пока страница находится в списке свободных страниц, процесс обнаружил ее отсутствие, получив соответствующую ошибку, ядро может восстановить ее в памяти, не обращаясь к устройству выгрузки. Однако, страница все равно будет считаться выгруженной, если она попала в список "сборщика" страниц.
Предположим, к примеру, что "сборщик" страниц выгружает 30, 40, 50 и 20 страниц из процессов A, B, C и D, соответственно, и что за одну операцию выгрузки на дисковое устройство откачиваются 64 страницы. На Рисунке 9.20 показана последовательность имеющих при этом место операций выгрузки при условии, что "сборщик" страниц осуществляет просмотр страниц процессов в очередности: A, B, C, D. "Сборщик" выделяет на устройстве выгрузки место для 64 страниц и выгружает 30 страниц процесса A и 34 страницы процесса B. Затем он выделяет место для следующих 64 страниц и выгружает оставшиеся 6 страниц процесса B, 50 страниц процесса C и 8 страниц процесса D. Выделенные для размещения страниц за две операции участки области выгрузки могут быть и несмежными. "Сборщик" сохраняет оставшиеся 12 страниц процесса D в списке выгружаемых страниц, но не выгружает их до тех пор, пока список не будет заполнен до конца. Как только у процессов возникает потребность в подкачке страниц с устройства выгрузки или если страницы больше не нужны использующим их процессам (процессы завершились), в области выгрузки освобождается место.
Чтобы подвести итог, выделим в процессе откачки страницы из памяти две фазы. На первой фазе "сборщик" страниц ищет страницы, подходящие для выгрузки, и помещает их номера в список выгружаемых страниц. На второй фазе ядро копирует страницу на устройство выгрузки (если на нем имеется место), сбрасывает в ноль бит допустимости в соответствующей записи таблицы страниц, уменьшает значение счетчика ссылок в соответствующей записи таблицы pfdata и если оно становится равным 0, помещает эту запись в конец списка свободных страниц. Содержимое физической страницы в памяти не изменяется до тех пор, пока страница не будет переназначена другому процессу.
  Рисунок 9.20. Выделение пространства на устройстве выгрузки в системе с замещением страниц

9.2.3 Отказы при обращениях к страницам

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

9.2.3.1 Обработка прерываний по отказу из-за недоступности данных

Если процесс пытается обратиться к странице, бит доступности для которой не установлен, он получает отказ из-за отсутствия (недоступности) данных и ядро запускает программу обработки прерываний по отказу данного типа (Рисунок 9.21). Бит доступности не устанавливается ни для тех страниц, которые располагаются за пределами виртуального адресного пространства процесса, ни для тех, которые входят в состав этого пространства, но не имеют в настоящий момент физического аналога в памяти. Фатальная ошибка памяти произошла в результате обращения ядра по виртуальному адресу страницы, поэтому ядро выходит на соответствующую этой странице запись в таблице страниц и дескриптор дискового блока. Чтобы предотвратить взаимную блокировку, которая может произойти, если "сборщик" попытается выгрузить страницу из памяти, ядро фиксирует в памяти область с соответствующей записью таблицы страниц. Если в дескрипторе дискового блока отсутствует информация о странице, сделанная ссылка на страницу является недопустимой и ядро посылает процессу-нарушителю сигнал о "нарушении сегментации" (см. Рисунок 7.25). Такой порядок действий совпадает с тем порядком, которого придерживается ядро, когда процесс обратился по неверному адресу, если не принимать во внимание то обстоятельство, что ядро узнает об ошибке немедленно, так как все "доступные" страницы являются резидентными в памяти. Если ссылка на страницу сделана правильно, ядро выделяет физическую страницу в памяти и считывает в нее содержимое виртуальной страницы с устройства выгрузки или из исполняемого файла.
Страница, вызвавшая отказ, находится в одном из пяти состояний:
1. На устройстве выгрузки вне памяти.
2. В списке свободных страниц в памяти.
3. В исполняемом файле.
4. С пометкой "обнуляемая при обращении".
5. С пометкой "заполняемая при обращении".
Рассмотрим каждый случай в подробностях.
Если страница находится на устройстве выгрузки, вне памяти (случай 1), это означает, что она когда-то располагалась в памяти, но была выгружена оттуда "сборщиком" страниц. Обратившись к дескриптору дискового блока, ядро узнает из него номера устройства выгрузки и блока, где расположена страница, и проверяет, не осталась ли страница в кэше. Ядро корректирует запись таблицы страниц так, чтобы она указывала на страницу, которую предполагается считать в память, включает соответствующую запись таблицы pfdata в хеш-очередь (облегчая последующую обработку отказа) и считывает страницу с устройства выгрузки. Допустивший ошибку процесс приостанавливается до момента завершения ввода-вывода; вместе с ним будут возобновлены все процессы, ожидавшие загрузки содержимого страницы.
Обратимся к Рисунку 9.22 и в качестве примера рассмотрим запись таблицы страниц, связанную с виртуальным адресом 66К. Если при обращении к странице процесс получает отказ из-за недоступности данных, программа обработки отказа обращается к дескриптору дискового блока и обнаруживает то, что страница находится на устройстве выгрузки в блоке с номером 847 (если предположить, что в системе только одно устройство выгрузки): следовательно, виртуальный адрес указан верно. Затем программа обработки отказа обращается к кэшу, но не находит информации о дисковом блоке с номером 847. Таким образом, копия виртуальной страницы в памяти отсутствует и программа обработки отказа должна загрузить ее с устройства выгрузки. Ядро отводит физическую страницу с номером 1776 (Рисунок 9.23), считывает в нее с устройства выгрузки содержимое виртуальной страницы и перенастраивает запись таблицы страниц на страницу с номером 1776. В завершение ядро корректирует дескриптор дискового блока, делая указание о том, что страница загружена, а также запись таблицы pfdata, отмечая, что на устройстве выгрузки в блоке с номером 847 содержится дубликат виртуальной страницы.
  алгоритм vfault /* обработка отказа из-за отсутствия (недоступности) данных */
  входная информация: адрес, по которому получен отказ
  выходная информация: отсутствует 
  {
   найти область, запись в таблице страниц, дескриптор дискового блока, связанные с адресом, по которому получен отказ, заблокировать область;
   if (адрес не принадлежит виртуальному адресному пространству процесса)   {
    послать сигнал (SIGSEGV: нарушение сегментации) процессу;
    goto out;
   }
   if (адрес указан неверно) goto out;/* возможно, процесс находился в состоянии приостанова */
   if (страница имеется в кэше)   {
    убрать страницу из кэша;
    поправить запись в таблице страниц;
    do   while (содержимое страницы не станет доступным) /* другой процесс получил такой же отказ, но раньше */
     sleep;
   } 
   else { /* страница отсутствует в кэше */ 
    назначить области новую страницу;
    поместить новую страницу в кэш, откорректировать запись в таблице pfdata;
    if (страница ранее не загружалась в память и имеет пометку "обнуляемая при обращении")
     очистить содержимое страницы;
    else   {
     считать виртуальную страницу с устройства выгрузки или из исполняемого файла;
     sleep (до завершения ввода-вывода);
    }
    возобновить процессы (ожидающие загрузки содержимого страницы);
   }
   установить бит доступности страницы;
   сбросить бит модификации и "возраст" страницы;
   пересчитать приоритет процесса;
  out:
   снять блокировку с области;
  }
  Рисунок 9.21. Алгоритм обработки отказа из-за отсутствия (недоступности) данных
При обработке отказов из-за недоступности данных ядро не всегда прибегает к выполнению операции ввода-вывода, даже когда из дескриптора дискового блока видно, что страница загружена (в случае 2). Может случиться так, что ядро после выгрузки содержимого физической страницы так и не переприсвоило ее или же какой-то иной процесс в результате отказа загрузил содержимое виртуальной страницы в другую физическую страницу. В любом случае программа обработки отказа обнаруживает страницу в кэше, в качестве ключа используя номер блока в дескрипторе дискового блока. Она перенастраивает соответствующую запись в таблице страниц на только что найденную страницу, увеличивает значение счетчика ссылок на страницу и в случае необходимости убирает страницу из списка свободных страниц. Предположим, к примеру, что процесс получил отказ при обращении к виртуальному адресу 64К (Рисунок 9.22). Просматривая кэш, ядро устанавливает, что страничный блок с номером 1861 связан с дисковым блоком 1206. Ядро перенастраивает запись таблицы страниц с виртуальным адресом 64К на страницу с номером 1861, устанавливает бит доступности и передает управление программе обработки отказа. Таким образом, номер дискового блока связывает вместе записи таблицы страниц и таблицы pfdata, чем и объясняется его запоминание в обеих таблицах.
  Рисунок 9.22. Иллюстрация к отказу из-за недоступности данных
Как и ядру, программе обработки отказа не нужно считывать страницу в память, если какой-то иной процесс уже получил отказ по той же самой странице, но еще не полностью загрузил ее. Программа находит область с записью таблицы страниц, которую она уже ранее заблокировала. Она дожидается, пока будет закончен цикл обработки предыдущего отказа, после чего обнаруживает, что страница стала доступной, и завершает свою работу. Эта процедура прослеживается на Рисунке 9.24.
  Рисунок 9.23. Результат загрузки страницы в память
  Рисунок 9.24. Два отказа на одной странице
Если копия страницы находится не на устройстве выгрузки, а в исполняемом файле (случай 3), ядро загружает страницу из файла. Программа обработки отказа обращается к дескриптору дискового блока, ищет соответствующий номер логического блока внутри файла, содержащего страницу, и индекс, ассоциированный с записью таблицы областей. Номер логического блока используется программой в качестве смещения внутри списка номеров дисковых блоков, присоединенного к индексу во время выполнения функции exec. По номеру блока на диске программа считывает страницу в память. Так, например, дескриптор дискового блока, связанный с виртуальным адресом 1К, показывает, что содержимое страницы располагается в исполняемом файле, внутри логического блока с номером 3 (см. Рисунок 9.22).
Если процесс получил отказ при обращении к странице, имеющей пометку "заполняемая при обращении" или "обнуляемая при обращении" (случаи 4 и 5), ядро выделяет свободную страницу в памяти и корректирует соответствующую запись таблицы страниц. Если страница "обнуляемая при обращении", ядро также очищает ее содержимое. В завершение обработки флаги "заполняемая при обращении" и "обнуляемая при обращении" сбрасываются. Теперь страница находится в памяти, доступна процессам и ее содержимое не имеет аналогов ни на устройстве выгрузки, ни в файловой системе. Так происходит, если процесс обращается к страницам с виртуальными адресами 3К и 65К (см. Рисунок 9.22): ни один из процессов не обращался к этим страницам с тех пор, как файл был запущен на выполнение функцией exec.
В завершение своей работы программа обработки отказов из-за отсутствия (недоступности) данных устанавливает бит доступности страницы и сбрасывает бит модификации. Приоритет процесса при этом пересчитывается, ибо во время выполнения программы процесс мог приостановить свое выполнение на уровне ядра, получая тем самым по возвращении в режим задачи незаслуженное преимущество перед другими процессами. И, наконец, возвращаясь в режим задачи, программа проверяет, не было ли за время обработки отказа поступления каких-либо сигналов.

9.2.3.2 Обработка прерываний по отказу системы защиты

Вторым типом отказа, встречающегося при обращении к странице, является отказ системы защиты, который означает, что процесс обратился к существующей странице памяти, но судя по разрядам, описывающим права доступа к странице, доступ к ней со стороны текущего процесса не разрешен. (Вспомним пример, описывающий попытку процесса произвести запись данных в область команд; см. Рисунок 7.22). Отказ данного типа имеет место также тогда, когда процесс предпринимает попытку записать что-то на страницу, для которой во время выполнения системной функции fork был установлен бит копирования при записи. Ядро должно различать между собой ситуации, когда отказ произошел по причине того, что страница требует копирования при записи, и когда имело место действительно что-то недопустимое.
Программа обработки отказа системы защиты автоматически получает виртуальный адрес, по которому произошел отказ, и ведет поиск соответствующей области и записи таблицы страниц (Рисунок 9.25). Она блокирует область, чтобы "сборщик" страниц не мог выгрузить страницу, пока связанный с ней отказ не будет обработан. Если программа обработки отказа устанавливает, что причиной отказа послужила установка бита копирования при записи, и если страницу используют сразу несколько процессов, ядро выделяет в памяти новую страницу и копирует в нее содержимое старой страницы; ссылки других процессов на старую страницу сохраняют свое значение. После копирования и внесения в запись таблицы страниц нового номера страницы ядро уменьшает значение счетчика ссылок в записи таблицы pfdata, соответствующей старой странице. Вся процедура показана на Рисунке 9.26, где три процесса совместно используют физическую страницу с номером 828. Процесс B считывает страницу, но поскольку бит копирования при записи установлен, получает отказ системы защиты. Программа обработки отказа выделяет страницу с номером 786, копирует в нее содержимое страницы 828, уменьшает значение счетчика ссылок на скопированную страницу и перенастраивает соответствующую запись таблицы страниц на страницу с номером 786.
Если бит копирования при записи установлен, но страница используется только одним процессом, ядро дает процессу возможность воспользоваться физической страницей повторно. Оно отключает бит копирования при записи и разрывает связь страницы с ее копией на диске (если таковая существует), поскольку не исключена возможность того, что дисковой копией пользуются другие процессы. Затем ядро убирает запись таблицы pfdata из очереди страниц, ибо новая копия виртуальной страницы располагается не на устройстве выгрузки. Кроме того, ядро уменьшает значение счетчика ссылок на страницу в таблице использования области подкачки, и если это значение становится равным 0, освобождает место на устройстве (см. упражнение 9.11).
Если запись в таблице страниц указывает на то, что страница недоступна, и ее бит копирования при записи установлен, выступая поводом для отказа системы защиты, допустим, что система при обращении к странице сначала обрабатывает отказ из-за недоступности данных (обратная очередность рассматривается в упражнении 9.17). Несмотря на это, программа обработки отказа системы защиты все равно обязана убедиться в доступности страницы, поскольку при установке блокировки на область программа может приостановиться, а "сборщик" страниц тем временем может выгрузить страницу из памяти. Если страница недоступна (бит доступности сброшен), программа немедленно завершит работу и процесс получит отказ из-за недоступности данных. Ядро обработает этот отказ, но процесс вновь получит отказ системы защиты. Более чем вероятно, что заключительный отказ системы защиты будет обработан без каких-либо препятствий и помех, поскольку пройдет довольно значительный период времени, прежде чем страница достаточно "созреет" для выгрузки из памяти. Описанная последовательность событий показана на Рисунке 9.27.
  алгоритм pfault /* обработка отказа системы защиты */
  входная информация: адрес, по которому получен отказ
  выходная информация: отсутствует 
  {
   найти область, запись в таблице страниц, дескриптор дискового блока, связанные с адресом, по которому получен отказ, заблокировать область;
   if (страница недоступна в памяти)   goto out;
   if (бит копирования при записи не установлен)   goto out; /* программная ошибка — сигнал */
   if (счетчик ссылок на страничный блок › 1)   {
    выделить новую физическую страницу;
    скопировать в нее содержимое старой страницы;
    уменьшить значение счетчика ссылок на старый страничный блок;
    перенастроить запись таблицы страниц на новую физическую страницу;
   }
   else { /* убрать страницу, поскольку она никем больше не используется */ 
    if (копия страницы имеется на устройстве выгрузки) 
     освободить место на устройстве, разорвать связь со страницей;
    if (страница находится в хеш-очереди страниц)
     убрать страницу из хеш-очереди;
   }
   в записи таблицы страниц установить бит модификации, сбросить бит копирования при записи;
   пересчитать приоритет процесса;
   проверить, не поступали ли сигналы;
  out:
   снять блокировку с области;
  }
  Рисунок 9.25. Алгоритм обработки отказа системы защиты
  Рисунок 9.26. Отказ системы защиты из-за установки бита копирования при записи
Перед завершением программа обработки отказа системы защиты устанавливает биты модификации и защиты, но сбрасывает бит копирования при записи. Она пересчитывает приоритет процесса и проверяет, не поступали ли за время ее работы сигналы, предназначенные процессу, в точности повторяя то, что делается по завершении обработки отказа из-за недопустимости данных.
  Рисунок 9.27. Взаимодействие отказа системы защиты и отказа из-за недоступности данных

9.2.4 Замещение страниц на менее сложной технической базе

Наибольшая действенность алгоритмов замещения страниц по запросу (обращению) достигается в том случае, если биты упоминания и модификации устанавливаются аппаратным путем и тем же путем вызывается отказ системы защиты при попытке записи в страницу, имеющую признак "копирования при записи". Тем не менее, указанные алгоритмы вполне применимы даже тогда, когда аппаратура распознает только бит доступности и код защиты. Если бит доступности, устанавливаемый аппаратно, дублируется программно-устанавливаемым битом, показывающим, действительно ли страница доступна или нет, ядро могло бы отключить аппаратно-устанавливаемый бит и проимитировать установку остальных битов программным путем. Так, например, в машине VAX-11 бит упоминания отсутствует (см. [Levy 82]). Ядро может отключить аппаратно-устанавливаемый бит доступности для страницы и дальше работать по следующему плану. Если процесс ссылается на страницу, он получает отказ, поскольку бит доступности сброшен, и в игру вступает программа обработки отказа, исследующая страницу. Поскольку "программный" бит доступности установлен, ядро знает, что страница действительно доступна и находится в памяти; оно устанавливает "программный" бит упоминания и "аппаратный" бит доступности, но ему еще предстоит узнать о том, что на страницу была сделана ссылка. Последующие ссылки на страницу уже не встретят отказ, ибо "аппаратный" бит доступности установлен. Когда с ней будет работать "сборщик" страниц, он вновь сбросит "аппаратный" бит доступности, вызывая тем самым от казы на все последующие обращения к странице и возвращая систему к началу цикла. Этот случай показан на Рисунке 9.28.
  Рисунок 9.28. Имитация установки "аппаратного" бита модификации программными средствами

9.3 СИСТЕМА СМЕШАННОГО ТИПА СО СВОПИНГОМ И ПОДКАЧКОЙ ПО ЗАПРОСУ

Несмотря на то, что в системах с замещением страниц по запросу обращение с памятью отличается большей гибкостью по сравнению с системами подкачки процессов, возможно возникновение ситуаций, в которых "сборщик" страниц и программа обработки отказов из-за недоступности данных начинают мешать друг другу из-за нехватки памяти. Если сумма рабочих множеств всех процессов превышает объем физической памяти в машине, программа обработки отказов обычно приостанавливается, поскольку выделять процессам страницы памяти дальше становится невозможным. "Сборщик" страниц не сможет достаточно быстро освободить место в памяти, ибо все страницы принадлежат рабочему множеству. Производительность системы падает, поскольку ядро тратит слишком много времени на верхнем уровне, с безумной скоростью перестраивая память.
Ядро в версии V манипулирует алгоритмами подкачки процессов и замещения страниц так, что проблемы соперничества перестают быть неизбежными. Когда ядро не может выделить процессу страницы памяти, оно возобновляет работу процесса подкачки и переводит пользовательский процесс в состояние, эквивалентное состоянию "готовности к запуску, будучи зарезервированным". В этом состоянии одновременно могут находиться несколько процессов. Процесс подкачки выгружает один за другим целые процессы, пока объем доступной памяти в системе не превысит верхнюю отметку. На каждый выгруженный процесс приходится один процесс, загруженный в память из состояния "готовности к выполнению, будучи зарезервированным". Ядро загружает эти процессы не с помощью обычного алгоритма подкачки, а путем обработки отказов при обращении к соответствующим страницам. На последующих итерациях процесса подкачки при условии наличия в системе достаточного объема свободной памяти будут обработаны отказы, полученные другими пользовательскими процессами. Применение такого метода ведет к снижению частоты возникновения системных отказов и устранению соперничества: по идеологии он близок к методам, используемым в операционной системе VAX/VMS ([Levy 82]).

9.4 ВЫВОДЫ

Прочитанная глава была посвящена рассмотрению алгоритмов подкачки процессов и замещения страниц, используемых в версии V системы UNIX. Алгоритм подкачки процессов реализует перемещение процессов целиком между основной памятью и устройством выгрузки. Ядро выгружает процессы из памяти, если их размер поглощает всю свободную память в системе (в результате выполнения функций fork, exec и sbrk или в результате естественного увеличения стека), или в том случае, если требуется освободить память для загрузки процесса. Загрузку процессов выполняет специальный процесс подкачки (процесс 0), который запускается всякий раз, как на устройстве выгрузки появляются процессы, готовые к выполнению. Процесс подкачки не прекращает своей работы до тех пор, пока на устройстве выгрузки не останется ни одного такого процесса или пока в основной памяти не останется свободного места. В последнем случае процесс подкачки пытается выгрузить что-нибудь из основной памяти, но в его обязанности входит также слежение за соблюдением требования минимальной продолжительности пребывания выгружаемых процессов в памяти (в целях предотвращения холостой перекачки); по этой причине процесс подкачки не всегда достигает успеха в своей работе. Возобновление процесса подкачки в случае возникновения необходимости в нем производит с интервалом в одну секунду программа обработки прерываний по таймеру.
В системе с замещением страниц по запросу процессы могут исполняться, даже если их виртуальное адресное пространство загружено в память не полностью; поэтому виртуальный размер процесса может превышать объем доступной физической памяти в системе. Когда ядро испытывает потребность в свободных страницах, "сборщик" страниц просматривает все активные страницы в каждой области, помечая для выгрузки те из них, которые достаточно "созрели" для этого, и в конечном итоге откачивает их на устройство выгрузки. Когда процесс обращается к виртуальной странице, которая в настоящий момент выгружена из памяти, он получает отказ из-за недоступности данных. Ядро запускает программу обработки отказа, которая назначает области новую физическую страницу памяти и копирует в нее содержимое виртуальной страницы.
Повысить производительность системы при использовании алгоритма замещения страниц по запросу можно несколькими способами. Во-первых, если процесс вызывает функцию fork, ядро использует бит копирования при записи, тем самым в большинстве случаев снимая необходимость в физическом копировании страниц. Во-вторых, ядро может запросить содержимое страницы исполняемого файла прямо из файловой системы, устраняя потребность в вызове функции exec для незамедлительного считывания файла в память. Это способствует повышению производительности, поскольку не исключена возможность того, что подобные страницы так никогда и не потребуются процессу, и устраняет излишнюю холостую перекачку, имеющую место в том случае, если "сборщик" страниц выгружает эти страницы из памяти до того, как в них возникает потребность.

9.5 УПРАЖНЕНИЯ

1. Набросайте схему реализации алгоритма mfree, который освобождает пространство памяти и возвращает его таблице свободного пространства.
2. В разделе 9.1.2 утверждается, что система блокирует перемещаемый процесс, чтобы другие процессы не могли его трогать с места до момента окончания операции. Что произошло бы, если бы система не делала этого?
3. Предположим, что в адресном пространстве процесса располагаются таблицы используемых процессом сегментов и страниц. Каким образом ядро может выгрузить это пространство из памяти?
4. Если стек ядра находится внутри адресного пространства процесса, почему процесс не может выгружать себя сам? Какой на Ваш взгляд должна быть системная программа выгрузки процессов, как она должна запускаться?
5. *Предположим, что ядро пытается выгрузить процесс, чтобы освободить место в памяти для других процессов, загружаемых с устройства выгрузки. Если ни на одном из устройств выгрузки для данного процесса нет места, процесс подкачки приостанавливает свою работу до тех пор, пока место не появится. Возможна ли ситуация, при которой все процессы, находящиеся в памяти, приостановлены, а все готовые к выполнению процессы находятся на устройстве выгрузки? Что нужно предпринять ядру для того, чтобы исправить это положение?
6. Рассмотрите еще раз пример, приведенный на Рисунке 9.10, при условии, что в памяти есть место только для 1 процесса.
7. Обратимся к примеру, приведенному на Рисунке 9.11. Составьте подобный пример, в котором процессу постоянно требуется для работы центральный процессор. Существует ли какой-нибудь способ снятия подобной напряженности?
  main() {
   f();
   g();
  }
  f() {
   vfork();
  }
  g() {
   int blast[100], i;
   for (i = 0; i ‹ 100; i++)
   blast[i] = i;
  }
  Рисунок 9.29
8. Что произойдет в результате выполнения программы, приведенной на Рисунке 9.29, в системе BSD 4.2? Каким будет стек процесса-родителя?
9. Почему после выполнения функции fork процесса-потомка предпочтительнее запускать впереди процесса-родителя, если на разделяемых страницах биты копирования при записи установлены? Каким образом ядро может заставить потомка запуститься первым?
10. *Алгоритм обработки отказа из-за недоступности данных, изложенный в тексте, загружает страницы поодиночке. Его эффективность можно повысить, если подготовить к загрузке помимо страницы, вызвавшей отказ, и все соседние с ней страницы. Переработайте исходный алгоритм с учетом указанной операции.
11. В алгоритмах работы "сборщика" страниц и программы обработки отказов из-за недоступности данных предполагается, что размер страницы равен размеру дискового блока. Что нужно изменить в этих алгоритмах для того, чтобы они работали и в тех случаях, когда указанное равенство не соблюдается?
12. *Когда процесс вызывает функцию fork (ветвится), значение счетчика ссылок на каждую разделяемую страницу (в таблице pfdata) увеличивается. Предположим, что "сборщик" страниц выгружает разделяемую страницу на устройство выгрузки, и один из процессов (скажем, родитель) впоследствии получает отказ при обращении к ней. Содержимое виртуальной страницы теперь располагается на физической странице. Объясните, почему процесс-потомок всегда имеет возможность получить верную копию страницы, даже после того, как процесс-родитель что-то запишет на нее. Почему, когда процесс-родитель ведет запись на страницу, он должен немедленно порвать связь с ее дисковой копией?
13. Что следует предпринять программе обработки отказов в том случае, если в системе исчерпаны страницы памяти?
14. *Составьте алгоритм выгрузки редко используемых компонент ядра. Какие из компонент нельзя выгружать и как их в таком случае следует обозначить?
15. Придумайте алгоритм, отслеживающий выделение пространства на устройстве выгрузки, используя вместо карт памяти, описанных в настоящей главе, битовый массив. Сравните эффективность обоих методов.
16. Предположим, что в машине нет аппаратно-устанавливаемого бита доступности, но есть код защиты, устанавливающий права доступа на чтение, запись и "исполнение" содержимого страницы. Смоделируйте работу с помощью программно-устанавливаемого бита доступности.
17. В машине VAX-11 перед проверкой наличия отказов из-за недоступности данных выполняется аппаратная проверка наличия отказов системы защиты. Как это отражается на алгоритмах обработки отказов?
18. Системная функция plock дает суперпользователю возможность устанавливать и снимать блокировку (в памяти) на областях команд и данных вызывающего процесса. Процесс подкачки и "сборщик" страниц не могут выгружать заблокированные страницы из памяти. Процессам, использующим эту системную функцию, не приходится дожидаться загрузки страниц, поэтому им гарантирован более быстрый ответ по сравнению с другими процессами. Следует ли иметь также возможность блокировки в памяти и области стека? Что произойдет в том случае, если суммарный объем заблокированных областей превысит размер доступной памяти в машине?
19. Что делает программа, приведенная на Рисунке 9.30? Подумайте над альтернативной стратегией замещения страниц, в соответствии с которой в рабочее множество каждого процесса включается максимально-возможное число страниц.
  struct fourmeg {
   int page[512]; /* пусть int занимает 4 байта */
  } fourmeg[2048];
  main() {
   for (;;) {
    switch(fork()) {
    case -1: /* процесс-родитель не может выполнить fork — слишком много потомков */ 
    case 0: /* потомок */
     func();
    default:
     continue;
    }
   }
  }
  func() {
   int i;
   for (;;) {
    printf("процесс %d повторяет цикл\n", getpid());
    for (i = 0; i ‹ 2048; i++) fourmeg[i].page[0] = i;
   }
  }
  Рис. 9.30

0

18

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
ГЛАВА 10. ПОДСИСТЕМА УПРАВЛЕНИЯ ВВОДОМ-ВЫВОДОМ

Подсистема управления вводом-выводом позволяет процессам поддерживать связь с периферийными устройствами, такими как накопители на магнитных дисках и лентах, терминалы, принтеры и сети, с одной стороны, и с модулями ядра, которые управляют устройствами и именуются драйверами устройств, с другой. Между драйверами устройств и типами устройств обычно существует однозначное соответствие: в системе может быть один дисковый драйвер для управления всеми дисководами, один терминальный драйвер для управления всеми терминалами и один ленточный драйвер для управления всеми ленточными накопителями. Если в системе имеются однотипные устройства, полученные от разных изготовителей — например, две марки ленточных накопителей, — в этом случае можно трактовать однотипные устройства как устройства двух различных типов и иметь для них два отдельных драйвера, поскольку таким устройствам для выполнения одних и тех же операций могут потребоваться разные последовательности команд. Один драйвер управляет множеством физических устройств данного типа. Например, один терминальный драйвер может управлять всеми терминалами, подключенными к системе. Драйвер различает устройства, которыми управляет: выходные данные, предназначенные для одного терминала, не должны быть посланы на другой.
Система поддерживает "программные устройства", с каждым из которых не связано ни одно конкретное физическое устройство. Например, как устройство трактуется физическая память, чтобы позволить процессу обращаться к ней извне, пусть даже память не является периферийным устройством. Команда ps обращается к информационным структурам ядра в физической памяти, чтобы сообщить статистику процессов. Еще один пример: драйверы могут вести трассировку записей в удобном для отладки виде, а драйвер трассировки дает возможность пользователям читать эти записи. Наконец, профиль ядра, рассмотренный в главе 8, выполнен как драйвер: процесс записывает адреса программ ядра, обнаруженных в таблице идентификаторов ядра, и читает результаты профилирования.
В этой главе рассматривается взаимодействие между процессами и подсистемой управления вводом-выводом, а также между машиной и драйверами устройств. Исследуется общая структура и функционирование драйверов и в качестве примеров общего взаимодействия рассматриваются дисковые и терминальные драйверы. Завершает главу описание нового метода реализации драйверов потоковых устройств.

10.1 ВЗАИМОДЕЙСТВИЕ ДРАЙВЕРОВ С ПРОГРАММНОЙ И АППАРАТНОЙ СРЕДОЙ

В системе UNIX имеется два типа устройств — устройства ввода/вывода блоками и устройства неструктурированного или посимвольного ввода-вывода. Как уже говорилось в главе 2, устройства ввода-вывода блоками, такие как диски и ленты, для остальной части системы выглядят как запоминающие устройства с произвольной выборкой; к устройствам посимвольного ввода-вывода относятся все другие устройства, в том числе терминалы и сетевое оборудование. Устройства ввода-вывода блоками могут иметь интерфейс и с устройствами посимвольного ввода-вывода.
Пользователь взаимодействует с устройствами через посредничество файловой системы (см. Рисунок 2.1). Каждое устройство имеет имя, похожее на имя файла, и пользователь обращается к нему как к файлу. Специальный файл устройства имеет индекс и занимает место в иерархии каталогов файловой системы. Файл устройства отличается от других файлов типом файла, хранящимся в его индексе, либо "блочный", либо "символьный специальный", в зависимости от устройства, которое этот файл представляет. Если устройство имеет как блочный, так и символьный интерфейс, его представляют два файла: специальный файл устройства ввода-вывода блоками и специальный файл устройства посимвольного ввода-вывода. Системные функции для обычных файлов, такие как open, close, read и write, имеют то же значение и для устройств, в чем мы убедимся позже. Системная функция ioctl предоставляет процессам возможность управлять устройствами посимвольного ввода-вывода, но не применима в отношении к файлам обычного типа [29] . Тем не менее, драйверам устройств нет необходимости поддерживать полный набор системных функций. Например, вышеупомянутый драйвер трассировки дает процессам возможность читать записи, созданные другими драйверами, но не позволяет создавать их.

10.1.1 Конфигурация системы

Задание конфигурации системы это процедура указания администраторами значений параметров, с помощью которых производится настройка системы. Некоторые из параметров указывают размеры таблиц ядра, таких как таблица процессов, таблица индексов и таблица файлов, а также сколько буферов помещается в буферном пуле. С помощью других параметров указывается конфигурация устройств, то есть производятся конкретные указания ядру, какие устройства включаются в данную системную реализацию и их "адрес". Например, в конфигурации может быть указано, что терминальная плата вставлена в соответствующий разъем на аппаратной панели.
Существует три стадии, на которых может быть указана конфигурация устройств. Во-первых, администраторы могут кодировать информацию о конфигурации в файлах, которые транслируются и компонуются во время построения ядра. Информация о конфигурации обычно указывается в простом формате, и программа конфигурации преобразует ее в файл, готовый для трансляции. Во-вторых, администраторы могут указывать информацию о конфигурации после того, как система уже запущена; ядро динамически корректирует внутренние таблицы конфигурации. Наконец, самоидентифицирующиеся устройства дают ядру возможность узнать, какие из устройств включены. Ядро считывает аппаратные ключи для самонастройки. Подробности задания системной конфигурации выходят за пределы этой книги, однако во всех случаях результатом процедуры задания конфигурации является генерация или заполнение таблиц, составляющих основу программ ядра.
Интерфейс "ядро — драйвер" описывается в таблице ключей устройств ввода-вывода блоками и в таблице ключей устройств посимвольного ввода-вывода (Рисунок 10.1). Каждый тип устройства имеет в таблице точки входа, которые при выполнении системных функций адресуют ядро к соответствующему драйверу. Функции open и close, вызываемые файлом устройства, "пропускаются" через таблицы ключей устройств в соответствии с типом файла. Функции mount и umount так же вызывают выполнение процедур открытия и закрытия устройств, но для устройств ввода-вывода блоками. Функции read и write, вызываемые устройствами ввода-вывода блоками и файлами в смонтированных файловых системах, запускают алгоритмы работы с буферным кешем, инициирующие реализацию стратегической процедуры работы с устройствами. Некоторые из драйверов запускают эту процедуру изнутри из процедур чтения и записи. Более подробно взаимодействие с каждым драйвером рассматривается в следующем разделе.
Интерфейс "аппаратура — драйвер" состоит из машинно-зависимых управляющих регистров или команд ввода-вывода для управления устройствами и векторами прерываний: когда происходит прерывание от устройства, система идентифицирует устройство, вызвавшее прерывание, и запускает программу обработки соответствующего прерывания. Очевидно, что "программные устройства", такие как драйвер системы построения профиля ядра (глава 8) не имеют аппаратного интерфейса, однако программы обработки других прерываний могут обращаться к "обработчику программного прерывания" непосредственно. Например, программа обработки прерывания по таймеру обращается к программе обработки прерывания системы построения профиля ядра.
Администраторы устанавливают специальные файлы устройств командой mknod, в которой указывается тип файла (блочный или символьный), старший и младший номера устройства. Команда mknod запускает выполнение системной функции с тем же именем, создающей файл устройства. Например, в командной строке
mknod /dev/tty13 c 2 13
"/dev/tty13" — имя файла устройства, "c" указывает, что тип файла — "символьный специальный" ("b", соответственно, блочный), "2" — старший номер устройства, "13" — младший номер устройства. Старший номер устройства показывает его тип, которому соответствует точка входа в таблице ключей устройств, младший номер устройства — это порядковый номер единицы устройства данного типа. Если процесс открывает специальный блочный файл с именем "/dev/dsk1" и кодом 0, ядро запускает программу gdopen в точке 0 таблицы ключей устройств блочного ввода-вывода (Рисунок 10.2); если процесс читает специальный символьный файл с именем "/dev/mem" и кодом 3, ядро запускает программу mmread в точке 3 таблицы ключей устройств посимвольного ввода-вывода. Программа nulldev — это "пустая" программа, используемая в тех случаях, когда отсутствует необходимость в конкретной функции драйвера. С одним старшим номером устройства может быть связано множество периферийных устройств; младший номер устройства позволяет отличить их одно от другого. Не нужно создавать специальные файлы устройств при каждой загрузке системы; их только нужно корректировать, если изменилась конфигурация системы, например, если к установленной конфигурации были добавлены устройства.
  Рисунок 10.1. Точки входа для драйверов

10.1.2 Системные функции и взаимодействие с драйверами

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

10.1.2.1 Open

При открытии устройства ядро следует той же процедуре, что и при открытии файлов обычного типа (см. раздел 5.1), выделяя в памяти индексы, увеличивая значение счетчика ссылок и присваивая значение точки входа в таблицу файлов и пользовательского дескриптора файла. Наконец, ядро возвращает значение пользовательского дескриптора файла вызывающему процессу, так что открытие устройства выглядит так же, как и открытие файла обычного типа. Однако, перед тем, как вернуться в режим задачи, ядро запускает зависящую от устройства процедуру open (Рисунок 10.3). Для устройства ввода-вывода блоками запускается процедура open, закодированная в таблице ключей устройств ввода-вывода блоками, для устройств посимвольного ввода-вывода — процедура open, закодированная в соответствующей таблице. Если устройство имеет как блочный, так и символьный тип, ядро запускает процедуру open, соответствующую типу файла устройства, открытого пользователем: обе процедуры могут даже быть идентичны, в зависимости от конкретного драйвера.
  алгоритм open /* для драйверов устройств */
  входная информация:
   имя пути поиска
   режим открытия
  выходная информация:
   дескриптор файла 
  {
   преобразовать имя пути поиска в индекс, увеличить значение счетчика ссылок в индексе;
   выделить в таблице файлов место для пользовательского дескриптора файла, как при открытии обычного файла;
   выбрать из индекса старший и младший номера устройства;
   сохранить контекст (алгоритм setjmp) в случае передачи управления от драйвера;
   if (устройство блочного типа)   {
    использовать старший номер устройства в качестве указателя в таблице ключей устройств ввода-вывода блоками;
    вызвать процедуру открытия драйвера по данному индексу: передать младший номер устройства, режимы открытия;
   }
   else   {
    использовать старший номер устройства в качестве указателя в таблице ключей устройств посимвольного ввода-вывода;
    вызвать процедуру открытия драйвера по данному индексу: передать младший номер устройства, режимы открытия;
   }
   if (открытие в драйвере не выполнилось) 
    привести таблицу файлов к первоначальному виду, уменьшить значение счетчика в индексе;
  }
  Рисунок 10.3. Алгоритм открытия устройства
Зависящая от типа устройства процедура open устанавливает связь между вызывающим процессом и открываемым устройством и инициализирует информационные структуры драйвера. Например, процедура open для терминала может приостановить процесс до тех пор, пока в машину не поступит сигнал (аппаратный) о том, что пользователь предпринял попытку зарегистрироваться. После этого инициализируются информационные структуры драйвера в соответствии с принятыми установками терминала (например, скоростью передачи информации в бодах). Для "программных устройств", таких как память системы, процедура open может не включать в себя инициализацию.
Если во время открытия устройства процессу пришлось приостановиться по какой-либо из внешних причин, может так случиться, что событие, которое должно было бы вызвать возобновление выполнения процесса, так никогда и не произойдет. Например, если на данном терминале еще не зарегистрировался ни один из пользователей, процесс getty, "открывший" терминал (раздел 7.9), приостанавливается до тех пор, пока пользователем не будет предпринята попытка регистрации, при этом может пройти достаточно большой промежуток времени. Ядро должно иметь возможность возобновить выполнение процесса и отменить вызов функции open по получении сигнала: ему следует сбросить индекс, отменить точку входа в таблице файлов и пользовательский дескриптор файла, которые были выделены перед входом в драйвер, поскольку открытие не произошло. Ядро сохраняет контекст процесса, используя алгоритм setjmp (раздел 6.4.4), прежде чем запустить процедуру open; если процесс возобновляется по сигналу, ядро восстанавливает контекст процесса в том состоянии, которое он имел перед обращением к драйверу, используя алгоритм longjmp (раздел 6.4.4), и возвращает системе все выделенные процедуре open структуры данных. Точно так же и драйвер может уловить сигнал и очистить доступные ему структуры данных, если это необходимо. Ядро также переустанавливает структуры данных файловой системы, когда драйвер сталкивается с исключительными ситуациями, такими, как попытка пользователя обратиться к устройству, отсутствующему в данной конфигурации. В подобных случаях функция open не выполняется.
Процессы могут указывать значения различных параметров, характеризующие особенности выполнения процедуры открытия. Из них наиболее часто используется "no delay" (без задержки), означающее, что процесс не будет приостановлен во время выполнения процедуры open, если устройство не готово. Системная функция open возвращает управление немедленно и пользовательский процесс не узнает, произошло ли аппаратное соединение или нет. Открытие устройства с параметром "no delay", кроме всего прочего, затронет семантику вызова функции read, что мы увидим далее (раздел 10.3.4).
Если устройство открывается многократно, ядро обрабатывает пользовательские дескрипторы файлов, индекс и записи в таблице файлов так, как это описано в главе 5, запуская определяемую типом устройства процедуру open при каждом вызове системной функции open. Таким образом, драйвер устройства может подсчитать, сколько раз устройство было "открыто", и прервать выполнение функции open, если количество открытий приняло недопустимое значение. Например, имеет смысл разрешить процессам многократно "открывать" терминал на запись для того, чтобы пользователи могли обмениваться сообщениями. Но при этом не следует допускать многократного "открытия" печатающего устройства для одновременной записи, так как процессы могут затереть друг другу информацию. Эти различия имеют смысл скорее на практике, нежели на стадии разработки: разрешение одновременной записи на терминалы способствует установлению взаимодействия между пользователями; запрещение одновременной записи на принтеры служит повышению читабельности машинограмм [30] .

10.1.2.2 Closе

Процесс разрывает связь с открытым устройством, закрывая его. Однако, ядро запускает определяемую типом устройства процедуру close только в последнем вызове функции close для этого устройства, и то только если не осталось процессов, которым устройство необходимо открытым, поскольку процедура закрытия устройства завершается разрывом аппаратного соединения; отсюда ясно, что ядру следует подождать, пока не останется ни одного процесса, обращающегося к устройству. Поскольку ядро запускает процедуру открытия устройства при каждом вызове системной функции open, а процедуру закрытия только один раз, драйверу устройства неведомо, сколько процессов используют устройство в данный момент. Драйверы могут легко выйти из строя, если при их написании не соблюдалась осторожность: когда при выполнении процедуры close они приостанавливают свою работу и какой-нибудь процесс открывает устройство до того, как завершится процедура закрытия, устройство может стать недоступным для работы, если в результате комбинации вызовов open и close сложилась нераспознаваемая ситуация.
  алгоритм close /* для устройств */
  входная информация: дескриптор файла
  выходная информация: отсутствует
  {
   выполнить алгоритм стандартного закрытия (глава 5ххх);
   if (значение счетчика ссылок в таблице файлов не 0)   goto finish;
   if (существует еще один открытый файл, старший и младший номера которого совпадают с номерами закрываемого устройства)
    goto finish; /* не последнее закрытие */
   if (устройство символьного типа)   {
    использовать старший номер в качестве указателя в таблице ключей устройства посимвольного ввода-вывода;
    вызвать процедуру закрытия, определяемую типом драйвера и передать ей в качестве параметра младший номер устройства;
   }
   if (устройство блочного типа)   {
    if (устройство монтировано) goto finish;
    переписать блоки устройства из буферного кеша на устройство;
    использовать старший номер в качестве указателя в таблице ключей устройства ввода-вывода блоками;
    вызвать процедуру закрытия, определяемую типом драйвера и передать ей в качестве параметра младший номер устройства;
    сделать недействительными блоки устройства, оставшиеся в буферном кеше;
   }
  finish:
   освободить индекс;
  }
  Рисунок 10.4. Алгоритм закрытия устройства
Алгоритм закрытия устройства похож на алгоритм закрытия файла обычного типа (Рисунок 10.4). Однако, до того, как ядро освобождает индекс, в нем выполняются действия, специфичные для файлов устройств.
1. Просматривается таблица файлов для того, чтобы убедиться в том, что ни одному из процессов не требуется, чтобы устройство было открыто. Чтобы установить, что вызов функции close для устройства является последним, недостаточно положиться на значение счетчика ссылок в таблице файлов, поскольку несколько процессов могут обращаться к одному и тому же устройству, используя различные точки входа в таблице файлов. Так же недос таточно положиться на значение счетчика в таблице индексов, поскольку одному и тому же устройству могут соответствовать несколько файлов устройства. Например, команда ls -l покажет, что одному и тому же устройству символьного типа ("c" в начале строки) соответствуют два файла устройства, старший и младший номера у которых (9 и 1) совпадают. Значение счетчика связей для каждого файла, равное 1, говорит о том, что имеется два индекса.
crw-w-w- 1 root vis 9, 1 Aug 6 1984 /dev/tty01
crw-w-w- 1 root unix 9, 1 May 3 15:02 /dev/tty01
Если процессы открывают оба файла независимо один от другого, они обратятся к разным индексам одного и того же устройства.
2. Если устройство символьного типа, ядро запускает процедуру закрытия устройства и возвращает управление в режим задачи. Если устройство блочного типа, ядро просматривает таблицу результатов монтирования и проверяет, не располагается ли на устройстве смонтированная файловая система. Если такая система есть, ядро не сможет запустить процедуру закрытия устройства, поскольку не был сделан последний вызов функции close для устройства. Даже если на устройстве нет смонтированной файловой системы, в буферном кеше еще могут находиться блоки с данными, оставшиеся от смонтированной ранее файловой системы и не переписанные на устройство, поскольку имели пометку "отложенная запись". Поэтому ядро просматривает буферный кеш в поисках таких блоков и переписывает их на устройство перед запуском процедуры закрытия устройства. После закрытия устройства ядро вновь просматривает буферный кеш и делает недействительными все буферы, которые содержат блоки для только что закрытого устройства, в то же вре мя позволяя буферам с актуальной информацией остаться в кеше.
3. Ядро освобождает индекс файла устройства. Короче говоря, процедура закрытия устройства разрывает связь с устройством и инициализирует заново информационные структуры драйвера и аппаратную часть устройства с тем, чтобы ядро могло бы позднее открыть устройство вновь.

10.1.2.3 Read и Writе

Алгоритмы чтения и записи ядром на устройстве похожи на аналогичные алгоритмы для файлов обычного типа. Если процесс производит чтение или запись на устройстве посимвольного ввода-вывода, ядро запускает процедуры read или write, определяемые типом драйвера. Несмотря на часто встречающиеся ситуации, когда ядро осуществляет передачу данных непосредственно между адресным пространством задачи и устройством, драйверы устройств могут буферизовать информацию внутри себя. Например, терминальные драйверы для буферизации данных используют символьные списки (раздел 10.3.1). В таких случаях драйвер устройства выделяет "буфер", копирует данные из пространства задачи при выполнении процедуры write и выводит их из "буфера" на устройство. Процедура записи, управляемая драйвером, регулирует объем выводимой информации (т. н. управление потоком данных): если процессы генерируют информацию быстрее, чем устройство выводит ее, процедура записи приостанавливает выполнение процессов до тех пор, пока устройство не будет готово принять следующую порцию данных. При чтении драйвер устройства помещает данные, полученные от устройства, в буфер и копирует их из буфера в пользовательские адреса, указанные в вызове системной функции.
  Рисунок 10.5. Отображение в памяти ввода-вывода с использованием контроллера VAX DZ11
Конкретный метод взаимодействия драйвера с устройством определяется особенностями аппаратуры. Некоторые из машин обеспечивают отображение ввода-вывода в памяти, подразумевающее, что конкретные адреса в адресном пространстве ядра являются не номерами ячеек в физической памяти, а специальными регистрами, контролирующими соответствующие устройства. Записывая в указанные регистры управляющие параметры в соответствии со спецификациями аппаратных средств, драйвер осуществляет управление устройством. Например, контроллер ввода-вывода для машины VAX-11 содержит специальные регистры для записи информации о состоянии устройства (регистры контроля и состояния) и для передачи данных (буферные регистры), которые формируются по специальным адресам в физической памяти. В частности, терминальный контроллер VAX DZ11 управляет 8 асинхронными линиями терминальной связи (см. [Levy 80], где более подробно объясняется архитектура машин VAX). Пусть регистр контроля и состояния (CSR) для конкретного терминала DZ11 имеет адрес 160120, передающий буферный регистр (TDB) — адрес 120126, а принимающий буферный регистр (RDB) — адрес 160122 (Рисунок 10.5). Для того, чтобы передать символ на терминал "/dev/tty09", драйвер терминала записывает единицу (1 = 9 по модулю 8) в указанный двоичный разряд регистра контроля и состояния и затем записывает символ в передающий буферный регистр. Запись в передающий буферный регистр является передачей данных. Контроллер DZ11 выставляет бит "выполнено" в регистре контроля и состояния, когда готов принять следующую порцию данных. Дополнительно драйвер может выставить бит "возможно прерывание передачи" в регистре контроля и состояния, что заставляет контроллер DZ11 прерывать работу системы, когда он готов принять следующую порцию данных. Чтение данных из DZ11 производится аналогично.
На других машинах имеется программируемый ввод-вывод, подразумевающий, что в машине имеются инструкции по управлению устройствами. Драйверы управляют устройствами, выполняя соответствующие инструкции. Например, в машине IBM 370 имеется инструкция "Start I/O" (Начать ввод-вывод), которая инициирует операцию ввода-вывода, связанную с устройством. Способ связи драйвера с периферийными устройствами незаметен для пользователя.
Поскольку интерфейс между драйверами устройств и соответствующими аппаратными средствами является машинно-зависимым, на этом уровне не существует стандартных интерфейсов. Как в случае вводавывода с отображением в памяти, так и в случае программируемого ввода-вывода драйвер может посылать на устройство управляющие последовательности с целью установления режима прямого доступа в память (ПДП) для устройства. Система позволяет осуществлять массовую передачу данных между устройством и памятью в режиме ПДП параллельно с работой центрального процессора, при этом устройство прерывает работу системы по завершении передачи данных. Драйвер организует управление виртуальной памятью таким образом, чтобы ячейки памяти с их действительными номерами использовались для ПДП.
Быстродействующие устройства могут иногда передавать данные непосредственно в адресное пространство задачи, без вмешательства буфера ядра. В результате повышается скорость передачи данных, поскольку при этом производится на одну операцию копирования меньше, и, кроме того, объем данных, передаваемых за одну операцию, не ограничивается размером буферов ядра. Драйверы, осуществляющие такую передачу данных без "обработки", обычно используют блочный интерфейс для процедур посимвольного чтения и записи, если у них имеется двойник блочного типа.

10.1.2.4 Стратегический интерфейс

Ядро использует стратегический интерфейс для передачи данных между буферным кешем и устройством, хотя, как уже говорилось ранее, процедуры чтения и записи для устройств посимвольного вводавывода иногда пользуются процедурой strategy (их двойника блочного типа) для непосредственной передачи данных между устройством и адресным пространством задачи. Процедура strategy может управлять очередностью выполнения заданий на ввод-вывод, связанный с устройством, или выполнять более сложные действия по планированию выполнения подобных заданий. Драйверы в состоянии привязывать передачу данных к одному физическому адресу или ко многим. Ядро передает адрес заголовка буфера стратегической процедуре драйвера; в заголовке содержится список адресов (страниц памяти) и размеры данных, передаваемых на или с устройства. Аналогичное действие имеет место при работе механизма свопинга, описанного в главе 9. При работе с буферным кешем ядро передает данные с одного адреса; во время свопинга ядро передает данные, расположенные по нескольким адресам (страницы памяти). Если данные копируются из или в адресное пространство задачи, драйвер должен блокировать процесс (или по крайней мере, соответствующие страницы) в памяти до завершения передачи данных.
Например, после монтирования файловой системы ядро идентифицирует каждый файл в файловой системе по номеру устройства и номеру индекса. В номере устройства закодированы его старший и младший номера. Когда ядро обращается к блоку, который принадлежит файлу, оно копирует номер устройства и номер блока в заголовок буфера, как уже говорилось ранее в главе 3. Обращения к диску, использующие алгоритмы работы с буферным кешем (например, bread или bwrite), инициируют выполнение стратегической процедуры, определяемой старшим номером устройства. Стратегическая процедура использует значения полей младшего номера и номера блока из заголовка буфера для идентификации места расположения данных на устройстве, а адрес буфера — для идентификации места назначения передаваемых данных. Точно так же, когда процесс обращается к устройству ввода-вывода блоками непосредственно (например, открывая устройство и читая или записывая на него), он использует алгоритмы работы с буферным кешем, и интерфейс при этом функционирует вышеописанным образом.

10.1.2.5 Ioctl

Системная функция ioctl является обобщением специфичных для терминала функций stty (задать установки терминала) и gtty (получить установки терминала), имевшихся в ранних версиях системы UNIX. Она выступает в качестве общей точки входа для всех связанных с типом устройства команд и позволяет процессам задавать аппаратные параметры, ассоциированные с устройством, и программные параметры, ассоциированные с драйвером. Специальные действия, выполняемые функцией ioctl для разных устройств различны и определяются типом драйвера. Программы, использующие вызов ioctl, должны должны знать, с файлом какого типа они работают, так как они являются аппаратно-зависимыми. Исключение из общего правила сделано для системы, которая не видит различий между файлами разных типов. Более подробно использование функции ioctl для терминалов рассмотрено в разделе 10.3.3.
Синтаксис командной строки, содержащей вызов системной функции:
ioctl(fd, command, arg);
где fd — дескриптор файла, возвращаемый предварительно вызванной функцией open, command — действие (команда), которое необходимо выполнить драйверу, arg — параметр команды (может быть указателем на структуру). Команды специфичны для различных драйверов; следовательно, каждый драйвер интерпретирует команды в соответствии со своими внутренними спецификациями, от команды, в свою очередь, зависит формат структуры данных, описываемой передаваемым параметром. Драйверы могут считывать структуру данных arg из пространства задачи в соответствии с предопределенным форматом или записывать установки устройства в пространство задачи по адресу указанной структуры. Например, наличие интерфейса, предоставляемого функцией ioctl, дает возможность пользователям устанавливать для терминала скорость передачи информации в бодах, перематывать магнитную ленту, и, наконец, выполнять сетевые операции, задавая номера виртуальных каналов и сетевые адреса.

10.1.2.6 Другие функции, имеющие отношение к файловой системе

Такие функции работы с файловой системой, как stat и chmod, выполняются одинаково, как для обычных файлов, так и для устройств; они манипулируют с индексом, не обращаясь к драйверу. Даже системная функция lseek работает для устройств. Например, если процесс подводит головку на лентопротяжном устройстве к указанному адресу смещения в байтах с помощью функции lseek, ядро корректирует смещение в таблице файлов но не выполняет никаких действий, специфичных для данного типа драйвера. Когда позднее процесс выполняет чтение (read) или запись (write), ядро пересылает адрес смещения из таблицы файлов в адресное пространство задачи, подобно тому, как это имеет место при работе с файлами обычного типа, и устройство физически перемещает головку к соответствующему смещению, указанному в пространстве задачи. Этот случай иллюстрируется на примере в разделе 10.3.
  Рисунок 10.6. Прерывания от устройств

10.1.3 Программы обработки прерываний

Как уже говорилось выше (раздел 6.4.1), возникновение прерывания побуждает ядро запускать программу обработки прерываний, в основе алгоритма которой лежит соотношение между устройством, вызвавшим прерывание, и смещением в таблице векторов прерываний. Ядро запускает программу обработки прерываний для данного типа устройства, передавая ей номер устройства или другие параметры для того, чтобы идентифицировать единицу устройства, вызвавшую прерывание. Например, в таблице векторов прерываний на Рисунке 10.6 показаны две точки входа для обработки прерываний от терминалов ("ttyintr"), каждая из которых используется для обработки прерываний, поступивших от 8 терминалов. Если устройство tty09 прервало работу системы, система вызывает программу обработки прерывания, ассоциированную с местом аппаратного подключения устройства. Поскольку с одной записью в таблице векторов прерываний может быть связано множество физических устройств, драйвер должен уметь распознавать устройство, вызвавшее прерывание. На рисунке записи в таблице векторов прерываний, соответствующие прерываниям от терминалов, имеют метки 0 и 1, чтобы система различала их между собой при вызове программы обработки прерываний, используя к примеру этот номер в качестве передаваемого программе параметра. Программа обработки прерываний использует этот номер и другую информацию, переданную механизмом прерывания, для того, чтобы удостовериться, что именно устройство tty09, а не tty12, прервало работу системы. Этот пример в упрощенном виде показывает то, что имеет место в реальных системах, где на самом деле существует несколько уровней контроллеров и соответствующих программ обработки прерываний, но он иллюстрирует общие принципы.
Если подвести итог, можно сказать, что номер устройства, используемый программой обработки прерываний, идентифицирует единицу аппаратуры, а младший номер в файле устройства идентифицирует устройство для ядра. Драйвер устройства устанавливает соответствие между младшим номером устройства и номером единицы аппаратуры.

0

19

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
10.2 ДИСКОВЫЕ ДРАЙВЕРЫ

Так сложилось исторически, что дисковые устройства в системах UNIX разбивались на разделы, содержащие различные файловые системы, что означало "деление [дискового] пакета на несколько управляемых по-своему частей" (см. [System V 84b]). Например, если на диске располагаются четыре файловые системы, администратор может оставить одну из них несмонтированной, одну смонтировать только для чтения, а две других только для записи. Несмотря на то, что все файловые системы сосуществуют на одном физическом устройстве, пользователи не могут ни обращаться к файлам немонтированной файловой системы, используя методы доступа, описанные в главах 4 и 5, ни записывать файлы в файловые системы, смонтированные только для чтения. Более того, так как каждый раздел (и, следовательно, файловая система) занимает на диске смежные дорожки и цилиндры, скопировать всю файловую систему легче, чем в том случае, если бы раздел занимал участки, разбросанные по всему дисковому тому.
Дисковый драйвер транслирует адрес файловой системы, состоящий из логического номера устройства и номера блока, в точный номер дискового сектора. Драйвер получает адрес одним из следующих путей: либо стратегическая процедура использует буфер из буферного пула, заголовок которого содержит номера устройства и блока, либо процедуры чтения и записи передают логический (младший) номер устройства в качестве параметра; они преобразуют адрес смещения в байтах, хранящийся в пространстве задачи, в адрес соответствующего блока. Дисковый драйвер использует номер устройства для идентификации физического устройства и указания используемого раздела, обращаясь при этом к внутренним таблицам для поиска сектора, отмечающего начало раздела на диске. Наконец, он добавляет номер блока в файловой системе к номеру блока, с которого начинается каждый сектор, чтобы идентифицировать сектор, используемый для ввода-вывода.
  Рисунок 10.7. Разделы на диске RP07
Исторически сложилось так, что размеры дисковых разделов устанавливаются в зависимости от типа диска. Например, диск DEC RP07 разбит на разделы, характеристика которых приведена на Рисунке 10.7. Предположим, что файлы "/dev/dsk0", "/dev/dsk1", "/dev/dsk2" и "/dev/dsk3" соответствуют разделам диска RP07, имеющим номера от 0 до 3, и имеют аналогичные младшие номера. Пусть размер логического блока в файловой системе совпадает с размером дискового блока. Если ядро пытается обратиться к блоку с номером 940 в файловой системе, хранящейся в "/dev/dsk3", дисковый драйвер переадресует запрос к блоку с номером 336940 (раздел 3 начинается с блока, имеющего номер 336000; 336000 + 940 = 336940) на диске.
Размеры разделов на диске варьируются и администраторы располагают файловые системы в разделах соответствующего размера: большие файловые системы попадают в разделы большего размера и т. д. Разделы на диске могут перекрываться. Например, разделы 0 и 1 на диске RP07 не пересекаются, но вместе они занимают блоки с номерами от 0 до 1008000, то есть весь диск. Раздел 7 так же занимает весь диск. Перекрытие разделов не имеет значения, поскольку файловые системы, хранящиеся в разделах, размещаются таким образом, что между ними нет пересечений. Иметь один раздел, включающий в себя все дисковое пространство, выгодно, поскольку весь том можно быстро скопировать.
Использование разделов фиксированного состава и размера ограничивает гибкость дисковой конфигурации. Информацию о разделах в закодированном виде не следует включать в дисковый драйвер, но нужно поместить в таблицу содержимого дискового тома. Однако, найти общее место на всех дисках для размещения таблицы содержимого дискового тома и сохранить тем самым совместимость с предыдущими версиями системы довольно трудно. В существующих реализациях версии V предполагается, что блок начальной загрузки первой из файловых систем на диске занимает первый сектор тома, хотя по логике это, казалось бы, самое подходящее место для таблицы содержимого тома. И все же дисковый драйвер должен иметь закодированную информацию о месте расположения таблицы содержимого тома для каждого диска, не препятствуя существованию дисковых разделов переменного размера.
В связи с тем, что для системы UNIX является типичным высокий уровень дискового трафика, драйвер диска должен максимизировать передачу данных с тем, чтобы обеспечить наилучшую производительность всей системы. Новейшие дисковые контроллеры осуществляют планирование выполнения заданий, требующих обращения к диску, позиционируют головку диска и обеспечивают передачу данных между диском и центральным процессором; иначе это приходится делать дисковому драйверу.
Сервисные программы могут непосредственно обращаться к диску в обход стандартного метода доступа к файловой системе, рассмотренного в главах 4 и 5, как пользуясь блочным интерфейсом, так и не прибегая к структурированию данных. Непосредственно работают с диском две важные программы — mkfs и fsck. Программа mkfs форматирует раздел диска для файловой системы UNIX, создавая при этом суперблок, список индексов, список свободных дисковых блоков с указателями и корневой каталог новой файловой системы. Программа fsck проверяет целостность существующей файловой системы и исправляет ошибки, как показано в главе 5.
Рассмотрим программу, приведенную на Рисунке 10.8, в применении к файлам "/dev/dsk15" и "/dev/rdsk15", и предположим, что команда ls выдала следующую информацию:
ls -l /dev/dsk15 /dev/rdsk15
br-------- 2 root root 0,21 Feb 12 15:40 /dev/dsk15
crw-rw---- 2 root root 7,21 Mar 7 09:29 /dev/rdsk15
Отсюда видно, что файл "/dev/dsk15" соответствует устройству блочного типа, владельцем которого является пользователь под именем "root", и только пользователь "root" может читать с него непосредственно. Его старший номер 0, младший — 21. Файл "/dev/rdsk15" соответствует устройству посимвольного ввода-вывода, владельцем которого является пользователь "root", однако права доступа к которому на запись и чтение есть как у владельца, так и у группы. Его старший номер — 7, младший — 21. Процесс, открывающий файлы, получает доступ к устройству через таблицу ключей устройств ввода-вывода блоками и таблицу ключей устройств посимвольного ввода-вывода, соответственно, а младший номер устройства 21 информирует драйвер о том, к какому разделу диска производится обращение, например, дисковод 2, раздел 1. Поскольку младшие номера у файлов совпадают, они ссылаются на один и тот же раздел диска, если предположить, что это одно устройство [31] . Таким образом, процесс, выполняющий программу, открывает один и тот же драйвер дважды (используя различные интерфейсы), позиционирует головку к смещению с адресом 8192 и считывает данные с этого места. Результаты выполнения операций чтения должны быть идентичными при условии, что работает только одна файловая система.
  #include "fcntl.h"
  main() {
   char buf1[4096], buf2[4096];
   int fd1, fd2, i;
   if (((fd1 = open("/dev/dsk5/", O_RDONLY)) == -1) || ((fd2 = open("/dev/rdsk5", O_RDONLY)) == -1)) {
    printf("ошибка при открытии\n");
    exit();
   }
   lseek(fd1, 8192L, 0);
   lseek(fd2, 8192L, 0);
   if ((read(fd1, buf1, sizeof(buf1)) == -1) || (read(fd2,  buf2, sizeof(buf2)) == -1)) {
    printf("ошибка при чтении\n");
    exit();
   }
   for (i = 0; i ‹ sizeof(buf1); i++) if (buf1[i] != buf2[i]) {
    printf("различие в смещении %d\n", i);
    exit();
   }
   printf("данные совпадают\n");
  }
  Рисунок 10.8. Чтение данных с диска с использованием блочного интерфейса и без структурирования данных
Программы, осуществляющие чтение и запись на диск непосредственно, представляют опасность, поскольку манипулируют с чувствительной информацией, рискуя нарушить системную защиту. Администраторам следует защищать интерфейсы ввода-вывода путем установки прав доступа к файлам дисковых устройств. Например, дисковые файлы "/dev/dsk15" и "/dev/rdsk15" должны принадлежать пользователю с именем "root", и права доступа к ним должны быть определены таким образом, чтобы пользователю "root" было разрешено чтение, а всем остальным пользователям и чтение, и запись должны быть запрещены.
Программы, осуществляющие чтение и запись на диск непосредственно, могут также нарушить целостность данных в файловой системе. Алгоритмы файловой системы, рассмотренные в главах 3, 4 и 5, координируют выполнение операций ввода-вывода, связанных с диском, тем самым поддерживая целостность информационных структур на диске, в том числе списка свободных дисковых блоков и указателей из индексов на информационные блоки прямой и косвенной адресации. Процессы, обращающиеся к диску непосредственно, обходят эти алгоритмы. Пусть даже их программы написаны с большой осторожностью, проблема целостности все равно не исчезнет, если они выполняются параллельно с работой другой файловой системы. По этой причине программа fsck не должна выполняться при наличии активной файловой системы.
Два типа дискового интерфейса различаются между собой по использованию буферного кеша. При работе с блочным интерфейсом ядро пользуется тем же алгоритмом, что и для файлов обычного типа, исключение составляет тот момент, когда после преобразования адреса смещения логического байта в адрес смещения логического блока (см. алгоритм bmap в главе 4) оно трактует адрес смещения логического блока как физический номер блока в файловой системе. Затем, используя буферный кеш, ядро обращается к данным, и, в конечном итоге, к стратегическому интерфейсу драйвера. Однако, при обращении к диску через символьный интерфейс (без структурирования данных), ядро не превращает адрес смещения в адрес файла, а передает его немедленно драйверу, используя для передачи рабочее пространство задачи. Процедуры чтения и записи, входящие в состав драйвера, преобразуют смещение в байтах в смещение в блоках и копируют данные непосредственно в адресное пространство задачи, минуя буферы ядра.
Таким образом, если один процесс записывает на устройство блочного типа, а второй процесс затем считывает с устройства символьного типа по тому же адресу, второй процесс может не считать информацию, записанную первым процессом, так как информация может еще находиться в буферном кеше, а не на диске. Тем не менее, если второй процесс обратится к устройству блочного типа, он автоматически попадет на новые данные, находящиеся в буферном кеше.
При использовании символьного интерфейса можно столкнуться со странной ситуацией. Если процесс читает или пишет на устройство посимвольного ввода-вывода порциями меньшего размера, чем, к примеру, блок, результаты будут зависеть от драйвера. Например, если производить запись на ленту по 1 байту, каждый байт может попасть в любой из ленточных блоков.
Преимущество использования символьного интерфейса состоит в скорости, если не возникает необходимость в кешировании данных для дальнейшей работы. Процессы, обращающиеся к устройствам ввода — вывода блоками, передают информацию блоками, размер каждого из которых ограничивается размером логического блока в данной файловой системе. Например, если размер логического блока в файловой системе 1 Кбайт, за одну операцию ввода-вывода может быть передано не больше 1 Кбайта информации. При этом процессы, обращающиеся к диску с помощью символьного интерфейса, могут передавать за одну дисковую операцию множество дисковых блоков, в зависимости от возможностей дискового контроллера. С функциональной точки зрения, процесс получает тот же самый результат, но символьный интерфейс может работать гораздо быстрее. Если воспользоваться примером, приведенным на Рисунке 10.8, можно увидеть, что когда процесс считывает 4096 байт, используя блочный интерфейс для файловой системы с размером блока 1 Кбайт, ядро производит четыре внутренние итерации, на каждом шаге обращаясь к диску, прежде чем вызванная системная функция возвращает управление, но когда процесс использует символьный интерфейс, драйвер может закончить чтение за одну дисковую операцию. Более того, использование блочного интерфейса вызывает дополнительное копирование данных между адресным пространством задачи и буферами ядра, что отсутствует в символьном интерфейсе.

10.3 ТЕРМИНАЛЬНЫЕ ДРАЙВЕРЫ

Терминальные драйверы выполняют ту же функцию, что и остальные драйверы: управление передачей данных от и на терминалы. Однако, терминалы имеют одну особенность, связанную с тем, что они обеспечивают интерфейс пользователя с системой. Обеспечивая интерактивное использование системы UNIX, терминальные драйверы имеют свой внутренний интерфейс с модулями, интерпретирующими ввод и вывод строк. В каноническом режиме интерпретаторы строк преобразуют неструктурированные последовательности данных, введенные с клавиатуры, в каноническую форму (то есть в форму, соответствующую тому, что пользователь имел ввиду на самом деле) прежде, чем послать эти данные принимающему процессу; строковый интерфейс также преобразует неструктурированные последовательности выходных данных, созданных процессом, в формат, необходимый пользователю. В режиме без обработки строковый интерфейс передает данные между процессами и терминалом без каких-либо преобразований.
Программисты, например, работают на клавиатуре терминала довольно быстро, но с ошибками. На этот случай терминалы имеют клавишу стирания ("erase"; клавиша может быть обозначена таким образом), чтобы пользователь имел возможность стирать часть введенной строки и вводить коррективы. Терминалы пересылают машине всю введенную последовательность, включая и символы стирания [32] . В каноническом режиме строковый интерфейс буферизует информацию в строки (набор символов, заканчивающийся символом возврата каретки [33] ) и процессы стирают символы у себя, прежде чем переслать исправленную последовательность считывающему процессу.
В функции строкового интерфейса входят:
• построчный разбор введенных последовательностей;
• обработка символов стирания;
• обработка символов "удаления", отменяющих все остальные символы, введенные до того в текущей строке;
• отображение символов, полученных терминалом;
• расширение выходных данных, например, преобразование символов табуляции в последовательности пробелов;
• сигнализирование процессам о зависании терминалов и прерывании строк или в ответ на нажатие пользователем клавиши удаления;
• предоставление возможности не обрабатывать специальные символы, такие как символы стирания, удаления и возврата каретки.
Функционирование без обработки подразумевает использование асинхронного терминала, поскольку процессы могут считывать символы в том виде, в каком они были введены, вместо того, чтобы ждать, когда пользователь нажмет клавишу ввода или возврата каретки.
Ричи отметил, что первые строковые интерфейсы, используемые еще при разработке системы в начале 70-х годов, работали в составе программ командного процессора и редактора, но не в ядре (см. [Ritchie 84], стр.1580). Однако, поскольку в их функциях нуждается множество программ, их место в составе ядра. Несмотря на то, что строковый интерфейс выполняет такие функции, из которых логически вытекает его место между терминальным драйвером и остальной частью ядра, ядро не запускает строковый интерфейс иначе, чем через терминальный драйвер. На Рисунке 10.9 показаны поток данных, проходящий через терминальный драйвер и строковый интерфейс, и соответствующие ему управляющие воздействия, проходящие через терминальный драйвер. Пользователи могут указать, какой строковый интерфейс используется посредством вызова системной функции ioctl, но реализовать схему, по которой одно устройство использовало бы несколько строковых интерфейсов одновременно, при чем каждый интерфейсный модуль, в свою очередь, успешно вызывал бы следующий модуль для обработки данных, довольно трудно.
  Рисунок 10.9. Последовательность обращений и поток данных через строковый интерфейс

10.3.1 Символьные списки

Строковый интерфейс обрабатывает данные в символьных списках. Символьный список (clist) представляет собой переменной длины список символьных блоков с использованием указателей и с подсчетом количества символов в списке. Символьный блок (cblock) содержит указатель на следующий блок в списке, небольшой массив хранимой в символьном виде информации и адреса смещений, показывающие место расположения внутри блока корректной информации (Рисунок 10.10). Смещение до начала показывает первую позицию расположения корректной информации в массиве, смещение до конца показывает первую позицию расположения некорректной информации.
  Рисунок 10.10. Символьный блок
Ядро обеспечивает ведение списка свободных символьных блоков и выполняет над символьными списками и символьными блоками шесть операций.
1. Ядро назначает драйверу символьный блок из списка свободных символьных блоков.
2. Оно также возвращает символьный блок в список свободных символьных блоков.
3. Ядро может выбирать первый символ из символьного списка: оно удаляет первый символ из первого символьного блока в списке и устанавливает значения счетчика символов в списке и указателей в блоке таким образом, чтобы последующие операции не выбирали один и тот же символ. Если в результате операции выбран последний символ блока, ядро помещает в список свободных символьных блоков пустой блок и переустанавливает указатели в символьном списке. Если в символьном списке отсутствуют символы, ядро возвращает пустой символ.
4. Ядро может поместить символ в конец символьного списка путем поиска последнего символьного блока в списке, включения символа в него и переустановки адресов смещений. Если символьный блок заполнен, ядро выделяет новый символьный блок, включает его в конец символьного списка и помещает символ в новый блок.
5. Ядро может удалять от начала списка группу символов по одному блоку за одну операцию, что эквивалентно удалению всех символов в блоке за один раз.
6. Ядро может поместить блок с символами в конец символьного списка.
Символьные списки позволяют создать несложный механизм буферизации, полезный при небольшом объеме передаваемых данных, типичном для медленных устройств, таких как терминалы. Они дают возможность манипулировать с данными с каждым символом в отдельности и с группой символьных блоков. Например, Рисунок 10.11 иллюстрирует удаление символов из символьного списка; ядро удаляет по одному символу из первого блока в списке (Рисунок 10.11а-в) до тех пор, пока в блоке не останется ни одного символа (Рисунок 10.11 г); затем оно устанавливает указатель списка на следующий блок, который становится первым блоком в списке. Подобно этому на Рисунке 10.12 показано, как ядро включает символы в символьный список; при этом предполагается, что в одном блоке помещается до 8 символов и что ядро размещает новый блок в конце списка (Рисунок 10.12 г).
  Рисунок 10.11. Удаление символов из символьного списка
  Рисунок 10.12. Включение символов в символьный список

10.3.2 Терминальный драйвер в каноническом режиме

Структуры данных, с которыми работают терминальные драйверы, связаны с тремя символьными списками: списком для хранения данных, выводимых на терминал, списком для хранения неструктурированных вводных данных, поступивших в результате выполнения программы обработки прерывания от терминала, вызванного попыткой пользователя ввести данные с клавиатуры, и списком для хранения обработанных входных данных, поступивших в результате преобразования строковым интерфейсом специальных символов (таких как символы стирания и удаления) в неструктурированном списке.
Когда процесс ведет запись на терминал (Рисунок 10.13), терминальный драйвер запускает строковый интерфейс. Строковый интерфейс в цикле считывает символы из адресного пространства процесса и помещает их в символьный список для хранения выводных данных до тех пор, пока поток данных не будет исчерпан. Строковый интерфейс обрабатывает выводимые символы, например, заменяя символы табуляции на последовательности пробелов. Если количество символов в списке для хранения выводных данных превысит верхнюю отметку, строковый интерфейс вызывает процедуры драйвера, пересылающие данные из символьного списка на терминал и после этого приостанавливающие выполнение процесса, ведущего запись. Когда объем информации в списке для хранения выводных данных падает за нижнюю отметку, программа обработки прерываний возобновляет выполнение всех процессов, приостановленных до того момента, когда терминал сможет принять следующую порцию данных. Строковый интерфейс завершает цикл обработки, скопировав всю выводимую информацию из адресного пространства задачи в соответствующий символьный список, и вызывает выполнение процедур драйвера, пересылающих данные на терминал, о которых уже было сказано выше.
  алгоритм terminal_write 
  {
   do while(из пространства задачи еще поступают данные)   {
    if (на терминал поступает информация)   {
     приступить к выполнению операции записи данных из списка, хранящего выводные данные;
     приостановиться (до того момента, когда терминал будет готов принять следующую порцию данных);
     continue; /* возврат к началу цикла */
    }
    скопировать данные в объеме символьного блока из пространства задачи в список, хранящий выводные данные: строковый интерфейс преобразует символы табуляции и т. д.;
   }
   приступить к выполнению операции записи данных из списка, хранящего выводные данные;
  }
  Рисунок 10.13. Алгоритм переписи данных на терминал
'Если на терминал ведут запись несколько процессов, они независимо друг от друга следуют указанной процедуре. Выводимая информация может быть искажена; то есть на терминале данные, записываемые процессами, могут пересекаться. Это может произойти из-за того, что процессы ведут запись на терминал, используя несколько вызовов системной функции write. Ядро может переключать контекст, пока процесс выполняется в режиме задачи, между последовательными вызовами функции write, и вновь запущенные процессы могут вести запись на терминал, пока первый из процессов приостановлен. Выводимые данные могут быть также искажены и на терминале, поскольку процесс может приостановиться на середине выполнения системной функции write, ожидая завершения вывода на терминал из системы предыдущей порции данных. Ядро может запустить другие процессы, которые вели запись на терминал до того, как первый процесс был повторно запущен. По этой причине, ядро не гарантирует, что содержимое буфера данных, выводимое в результате вызова системной функции write, появится на экране терминала в непрерывном виде.
  char form[]="это пример вывода строки из порожденного процесса";
  main() {
   char output[128];
   int i;
   for (i = 0; i ‹ 18; i++) {
    switch (fork()) {
    case -1: /* ошибка — превышено максимальное число процессов */
     exit();
    default: /* родительский процесс */
     break;
    case 0: /* порожденный процесс */
     /* формат вывода строки в переменной output */
      sprintf(output, "%%d\n%s%d\n", form, i, form, i);
      for (;;) write(1, output, sizeof(output));
     }
   }
  }
  Рисунок 10.14. Передача данных через стандартный вывод
Рассмотрим программу, приведенную на Рисунке 10.14. Родительский процесс создает до 18 порожденных процессов; каждый из порожденных процессов записывает строку (с помощью библиотечной функции sprintf) в массив output, который включает сообщение и значение счетчика i в момент выполнения функции fork, и затем входит в цикл пошаговой переписи строки в файл стандартного вывода. Если стандартным выводом является терминал, терминальный драйвер регулирует поток поступающих данных. Выводимая строка имеет более 64 символов в длину, то есть слишком велика для того, чтобы поместиться в символьном блоке (длиной 64 байта) в версии V системы. Следовательно, терминальному драйверу требуется более одного символьного блока для каждого вызова функции write, иначе выводной поток может стать искаженным. Например, следующие строки были частью выводного потока, полученного в результате выполнения программы на машине AT&T 3B20:
this is a sample output string from child 1
this is a sample outthis is a sample output string from child 0
Чтение данных с терминала в каноническом режиме более сложная операция. В вызове системной функции read указывается количество байт, которые процесс хочет считать, но строковый интерфейс выполняет чтение по получении символа перевода каретки, даже если количество символов не указано. Это удобно с практической точки зрения, так как процесс не в состоянии предугадать, сколько символов пользователь введет с клавиатуры, и, с другой стороны, не имеет смысла ждать, когда пользователь введет большое число символов. Например, пользователи вводят командные строки для командного процессора shell и ожидают ответа shell'а на команду по получении символа возврата каретки. При этом нет никакой разницы, являются ли введенные строки простыми командами, такими как "date" или "who", или же это более сложные последовательности команд, подобные следующей:
pic file* | tbl | eqn | troff -mm -Taps | apsend
Терминальный драйвер и строковый интерфейс ничего не знают о синтаксисе командного процессора shell, и это правильно, поскольку другие программы, которые считывают информацию с терминалов (например, редакторы), имеют различный синтаксис команд. Поэтому строковый интерфейс выполняет чтение по получении символа возврата каретки.
На Рисунке 10.15 показан алгоритм чтения с терминала. Предположим, что терминал работает в каноническом режиме; в разделе 10.3.3 будет рассмотрена работа в режиме без обработки. Если в настоящий момент в любом из символьных списков для хранения вводной информации отсутствуют данные, процесс, выполняющий чтение, приостанавливается до поступления первой строки данных. Когда данные поступают, программа обработки прерывания от терминала запускает "программу обработки прерывания" строкового интерфейса, которая помещает данные в список для хранения неструктурированных вводных данных для передачи процессам, осуществляющим чтение, и в список для хранения выводных данных, передаваемых в качестве эхосопровождения на терминал. Если введенная строка содержит символ возврата каретки, программа обработки прерывания возобновляет выполнение всех приостановленных процессов чтения. Когда процесс, осуществляющий чтение, выполняется, драйвер выбирает символы из списка для хранения неструктурированных вводных данных, обрабатывает символы стирания и удаления и помещает символы в канонический символьный список. Затем он копирует строку символов в адресное пространство задачи до символа возврата каретки или до исчерпания числа символов, указанного в вызове системной функции read, что встретится раньше. Однако, процесс может обнаружить, что данных, ради которых он возобновил свое выполнение, больше не существует: другие процессы считали данные с терминала и удалили их из списка для неструктурированных вводных данных до того, как первый процесс был запущен вновь. Такая ситуация похожа на ту, которая имеет место, когда из канала считывают данные несколько процессов.
  алгоритм terminal_read 
  {
   if (в каноническом символьном списке отсутствуют данные)   {
    do while (в списке для неструктурированных вводных данных отсутствует информация)   {
     if (терминал открыт с параметром "no delay" (без задержки))   return;
     if (терминал в режиме без обработки с использованием таймера и таймер не активен)
      предпринять действия к активизации таймера (таблица ответных сигналов);
     sleep (до поступления данных с терминала);
    }
   /* в списке для неструктурированных вводных данных есть информация */
    if (терминал в режиме без обработки)
     скопировать все данные из списка для неструктурированных вводных данных в канонический список;
    else { /* терминал в каноническом режиме */
     do while (в списке для неструктурированных вводных данных есть символы)   {
      копировать по одному символу из списка для неструктурированных вводных данных в канонический список: выполнить обработку символов стирания и удаления;
      if (символ — "возврат каретки" или "конец файла") break  ; /* выход из цикла */
     }
    }
   }
   do while(в каноническом списке еще есть символы и не исчерпано количество символов, указанное в вызове функции read)
    копировать символы из символьных блоков канонического списка в адресное пространство задачи;
  }
  Рисунок 10.15. Алгоритм чтения с терминала
Обработка символов в направлении ввода и в направлении вывода асимметрична, что видно из наличия двух символьных списков для ввода и одного — для вывода. Строковый интерфейс выводит данные из пространства задачи, обрабатывает их и помещает их в список для хранения выводных данных. Для симметрии следовало бы иметь только один список для вводных данных. Однако, в таком случае потребовалось бы использование программы обработки прерываний для интерпретации символов стирания и удаления, что сделало бы процедуру более сложной и длительной и запретило бы возникновение других прерываний на все критическое время. Использование двух символьных списков для ввода подразумевает, что программа обработки прерываний может просто сбросить символы в список для неструктурированных вводных данных и возобновить выполнение процесса, осуществляющего чтение, который собственно и возьмет на себя работу по интерпретации вводных данных. При этом программа обработки прерываний немедленно помещает введенные символы в список для хранения выводных данных, так что пользователь испытывает лишь минимальную задержку при просмотре введенных символов на терминале.
  char input[256];
  main() {
   register int i;
   for (i = 0; i ‹ 18; i++) {
    switch (fork()) {
    case -1: /* ошибка */
     printf("операция fork не выполнена из-за ошибки\n");
     exit();
    default: /* родительский процесс */
     break;
    case 0: /* порожденный процесс */
     for (;;) {
      read(0, input, 256);   /* чтение строки */
      printf("%d чтение %s\n",i,input);
     }
    }
   }
  }
  Рисунок 10.16. Конкуренция за данные, вводимые с терминала
На Рисунке 10.16 приведена программа, в которой родительский процесс порождает несколько процессов, осуществляющих чтение из файла стандартного ввода, конкурируя за получение данных, вводимых с терминала. Ввод с терминала обычно осуществляется слишком медленно для того, чтобы удовлетворить все процессы, ведущие чтение, поэтому процессы большую часть времени находятся в приостановленном состоянии в соответствии с алгоритмом terminal_read, ожидая ввода данных. Когда пользователь вводит строку данных, программа обработки прерываний от терминала возобновляет выполнение всех процессов, ведущих чтение; поскольку они были приостановлены с одним и тем же уровнем приоритета, они выбираются для запуска с одинаковым уровнем приоритета. Пользователь не в состоянии предугадать, какой из процессов выполняется и считывает строку данных; успешно созданный процесс печатает значение переменной i в момент его создания. Все другие процессы в конце концов будут запущены, но вполне возможно, что они не обнаружат введенной информации в списках для хранения вводных данных и их выполнение снова будет приостановлено. Вся процедура повторяется для каждой введенной строки; нельзя дать гарантию, что ни один из процессов не захватит все введенные данные.
Одновременному чтению с терминала несколькими процессами присуща неоднозначность, но ядро справляется с ситуацией наилучшим образом. С другой стороны, ядро обязано позволять процессам одновременно считывать данные с терминала, иначе порожденные командным процессором shell процессы, читающие из стандартного ввода, никогда не будут работать, поскольку shell тоже обращается к стандартному вводу. Короче говоря, процессы должны синхронизировать свои обращения к терминалу на пользовательском уровне.
Когда пользователь вводит символ "конец файла" (Ctrl-d в ASCII), строковый интерфейс передает функции read введенную строку до символа конца файла, но не включая его. Он не передает данные (код возврата 0) функции read, если в символьном списке встретился только символ "конец файла"; вызывающий процесс сам распознает, что обнаружен конец файла и больше не следует считывать данные с терминала. Если еще раз обратиться к примерам программ по shell'у, приведенным в главе 7, можно отметить, что цикл работы shell'а завершается, когда пользователь нажимает ‹Ctrl-d›: функция read возвращает 0 и производится выход из shell'а.
В этом разделе рассмотрена работа терминалов ввода-вывода, которые передают данные на машину по одному символу за одну операцию, в точности как пользователь их вводит с клавиатуры. Интеллектуальные терминалы подготавливают свой вводной поток на внешнем устройстве, освобождая центральный процессор для другой работы. Структура драйверов для таких терминалов походит на структуру драйверов для терминалов ввода-вывода, несмотря на то, что функции строкового интерфейса различаются в зависимости от возможностей внешних устройств.

10.3.3 Терминальный драйвер в режиме без обработки символов

Пользователи устанавливают параметры терминала, такие как символы стирания и удаления, и извлекают значения текущих установок с помощью системной функции ioctl. Сходным образом они устанавливают необходимость эхо-сопровождения ввода данных с терминала, задают скорость передачи информации в бодах, заполняют очереди символов ввода и вывода или вручную запускают и останавливают выводной поток символов. В информационной структуре терминального драйвера хранятся различные управляющие установки (см. [SVID 85], стр.281), и строковый интерфейс получает параметры функции ioctl и устанавливает или считывает значения соответствующих полей структуры данных. Когда процесс устанавливает значения параметров терминала, он делает это для всех процессов, использующих терминал. Установки терминала не сбрасываются автоматически при выходе из процесса, сделавшего изменения в установках.
Процессы могут также перевести терминал в режим без обработки символов, в котором строковый интерфейс передает символы в точном соответствии с тем, как пользователь ввел их: обработка вводного потока полностью отсутствует. Однако, ядро должно знать, когда выполнить вызванную пользователем системную функцию read, поскольку символ возврата каретки трактуется как обычный введенный символ. Оно выполняет функцию read после того, как с терминала будет введено минимальное число символов или по прохождении фиксированного промежутка времени от момента получения с терминала любого набора символов. В последнем случае ядро хронометрирует ввод символов с терминала, помещая записи в таблицу ответных сигналов (глава 8). Оба критерия (минимальное число символов и фиксированный промежуток времени) задаются в вызове функции ioctl. Когда соответствующие критерии удовлетворены, программа обработки прерываний строкового интерфейса возобновляет выполнение всех приостановленных процессов. Драйвер пересылает все символы из списка для хранения неструктурированных вводных данных в канонический список и выполняет запрос процесса на чтение, следуя тому же самому алгоритму, что и в случае работы в каноническом режиме. Режим без обработки символов особенно важен в экранно-ориентированных приложениях, таких как экранный редактор vi, многие из команд которого не заканчиваются символом возврата каретки. Например, команда dw удаляет слово в текущей позиции курсора.
На Рисунке 10.17 приведена программа, использующая функцию ioctl для сохранения текущих установок терминала для файла с дескриптором 0, что соответствует значению дескриптора файла стандартного ввода. Функция ioctl с командой TCGETA приказывает драйверу извлечь установки и сохранить их в структуре с именем savetty в адресном пространстве задачи. Эта команда часто используется для того, чтобы определить, является ли файл терминалом или нет, поскольку она ничего не изменяет в системе: если она завершается неудачно, процессы предполагают, что файл не является терминалом. Здесь же, процесс вторично вызывает функцию ioctl для того, чтобы перевести терминал в режим без обработки: он отключает эхо-сопровождение ввода символов и готовится к выполнению операций чтения с терминала по получении с терминала 5 символов, как минимум, или по прохождении 10 секунд с момента ввода первой порции символов. Когда процесс получает сигнал о прерывании, он сбрасывает первоначальные параметры терминала и завершается.
  #include ‹signal.h›
  #include ‹termio.h›
  struct termio savetty;
  main() {
   extern sigcatch();
   struct termio newtty;
   int nrd;
   char buf[32];
   signal(SIGINT, sigcatch);
   if (ioctl(0, TCGETA, &savetty) == -1) {
    printf("ioctl завершилась неудачно: нет терминала\n");
    exit();
   }
   newtty = savetty;
   newtty.c_lflag &= ~ICANON; /* выход из канонического режима */
   newtty.c_lflag &= ~ECHO; /* отключение эхо-сопровождения*/
   newtty.c_cc[VMIN] = 5; /* минимум 5 символов */
   newtty.c_cc[VTIME] = 100; /* интервал 10 секунд */
   if (ioctl(0,TCSETAF, &newtty) == -1) {
    printf("не могу перевести тер-л в режим без обработки\n");
    exit();
   }
   for(;;) {
    nrd = read(0, buf, sizeof(buf));
    buf[nrd] = 0;
    printf("чтение %d символов %s'\n", nrd, buf);
   }
  }
  sigcatch() {
   ioctl(0, TCSETAF, &savetty);
   exit();
  }
  Рисунок 10.17. Режим без обработки — чтение 5-символьных блоков

0

20

https://forumupload.ru/uploads/001b/09/4d/2/t95668.jpg
10.3.4 Опрос терминала

Иногда удобно производить опрос устройства, то есть считывать с него данные, если они есть, или продолжать выполнять обычную работу — в противном случае. Программа на Рисунке 10.18 иллюстрирует этот случай: после открытия терминала с параметром "no delay" (без задержки) процессы, ведущие чтение с него, не приостановят свое выполнение в случае отсутствия данных, а вернут управление немедленно (см. алгоритм terminal_read, Рисунок 10.15). Этот метод работает также, если процесс следит за множеством устройств: он может открыть каждое устройство с параметром "no delay" и опросить всех из них, ожидая поступления информации с каждого. Однако, этот метод растрачивает вычислительные мощности системы.
  #include ‹fcntl.h›
  main() {
   register int i, n;
   int fd;
   char buf[256]  ;
   /* открытие терминала только для чтения с опцией "no delay" */ 
   if ((fd = open("/dev/tty", O_RDONLYO_NDELAY)) == -1) exit();
   n = 1;
   for(;;) {  /* всегда */ 
    for (i = 0; i ‹ n; i++);
    if (read(fd, buf, sizeof(buf)) › 0) {
     printf("чтение с номера %d\n", n); 
     n--;
    }
    else n++; /* ничего не прочитано; возврат вследствие "no delay" */
   }
  }
  Рисунок 10.18. Опрос терминала
В системе BSD есть системная функция select, позволяющая производить опрос устройства. Синтаксис вызова этой функции:
select(nfds, rfds, wfds, efds, timeout)
где nfds — количество выбираемых дескрипторов файлов, а rfds, wfds и efds указывают на двоичные маски, которыми "выбирают" дескрипторы открытых файлов. То есть, бит 1 ‹‹ fd (сдвиг на 1 разряд влево значения дескриптора файла) соответствует установке на тот случай, если пользователю нужно выбрать этот дескриптор файла. Параметр timeout (тайм-аут) указывает, на какое время следует приостановить выполнение функции select, ожидая поступления данных, например; если данные поступают для любых дескрипторов и тайм-аут не закончился, select возвращает управление, указывая в двоичных масках, какие дескрипторы были выбраны. Например, если пользователь пожелал приостановиться до момента получения данных по дескрипторам 0, 1 или 2, параметр rfds укажет на двоичную маску 7; когда select возвратит управление, двоичная маска будет заменена маской, указывающей, по каким из дескрипторов имеются готовые данные. Двоичная маска wfds выполняет похожую функцию в отношении записи дескрипторов, а двоичная маска efds указывает на существование исключительных условий, связанных с конкретными дескрипторами, что бывает полезно при работе в сети.

10.3.5 Назначение операторского терминала

Операторский терминал — это терминал, с которого пользователь регистрируется в системе, он управляет процессами, запущенными пользователем с терминала. Когда процесс открывает терминал, драйвер терминала открывает строковый интерфейс. Если процесс возглавляет группу процессов как результат выполнения системной функции setpgrp и если процесс не связан с одним из операторских терминалов, строковый интерфейс делает открываемый терминал операторским. Он сохраняет старший и младший номера устройства для файла терминала в адресном пространстве, выделенном процессу, а номер группы процессов, связанной с открываемым процессом, в структуре данных терминального драйвера. Открываемый процесс становится управляющим процессом, обычно входным (начальным) командным процессором, что мы увидим далее.
Операторский терминал играет важную роль в обработке сигналов. Когда пользователь нажимает клавиши "delete" (удаления), "break" (прерывания), стирания или выхода, программа обработки прерываний загружает строковый интерфейс, который посылает соответствующий сигнал всем процессам в группе. Подобно этому, когда пользователь "зависает", программа обработки прерываний от терминала получает информацию о "зависании" от аппаратуры, и строковый интерфейс посылает соответствующий сигнал всем процессам в группе. Таким образом, все процессы, запущенные с конкретного терминала, получают сигнал о "зависании"; реакцией по умолчанию для большинства процессов будет выход из программы по получении сигнала; это похоже на то, как при завершении работы пользователя с терминалом из системы удаляются побочные процессы. После посылки сигнала о "зависании" программа обработки прерываний от терминала разъединяет терминал с группой процессов, чтобы процессы из этой группы не могли больше получать сигналы, возникающие на терминале.

10.3.6 Драйвер косвенного терминала

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

10.3.7 Вход в систему

Как показано в главе 7, процесс начальной загрузки, имеющий номер 1, выполняет бесконечный цикл чтения из файла "/etc/inittab" инструкций о том, что нужно делать, если загружаемая система определена как "однопользовательская" или "многопользовательская". В многопользовательском режиме самой первой обязанностью процесса начальной загрузки является предоставление пользователям возможности регистрироваться в системе с терминалов (Рисунок 10.19). Он порождает процессы, именуемые getty-процессами (от "get tty" — получить терминал), и следит за тем, какой из процессов открывает какой терминал; каждый getty-процесс устанавливает свою группу процессов, используя вызов системной функции setpgrp, открывает отдельную терминальную линию и обычно приостанавливается во время выполнения функции open до тех пор, пока машина не получит аппаратную связь с терминалом. Когда функция open возвращает управление, getty-процесс исполняет программу login (регистрации в системе), которая требует от пользователей, чтобы они идентифицировали себя указанием регистрационного имени и пароля. Если пользователь зарегистрировался успешно, программа login наконец запускает командный процессор shell и пользователь приступает к работе. Этот вызов shell'а именуется "login shell" (регистрационный shell, регистрационный интерпретатор команд). Процесс, связанный с shell'ом, имеет тот же идентификатор, что и начальный getty-процесс, поэтому login shell является процессом, возглавляющим группу процессов. Если пользователь не смог успешно зарегистрироваться, программа регистрации завершается через определенный промежуток времени, закрывая открытую терминальную линию, а процесс начальной загрузки порождает для этой линии следующий getty-процесс. Процесс начальной загрузки делает паузу до получения сигнала об окончании порожденного ранее процесса. После возобновления работы он выясняет, был ли прекративший существование процесс регистрационным shell'ом и если это так, порождает еще один getty-процесс, открывающий терминал, вместо прекратившего существование.
  алгоритм login /* процедура регистрации */
  {
   исполняется getty-процесс: установить группу процессов (вызов функции setpgrp);
   открыть терминальную линию; /* приостанов до завершения открытия */
   if (открытие завершилось успешно)   {
    исполнить программу регистрации: запросить имя пользователя;
    отключить эхо-сопровождение, запросить пароль;
    if (регистрация прошла успешно) { /* найден соответствующий пароль в /etc/passwd */ 
     перевести терминал в канонический режим (ioctl);
     исполнить shell;
    }
    в противном случае считать количество попыток регистрации, пытаться зарегистрироваться снова до достижения определенной точки;
   }
  }
  Рисунок 10.19. Алгоритм регистрации

10.4 ПОТОКИ

Схема реализации драйверов устройств, хотя и отвечает заложенным требованиям, страдает некоторыми недостатками, которые с годами стали заметнее. Разные драйверы имеют тенденцию дублировать свои функции, в частности драйверы, которые реализуют сетевые протоколы и которые обычно включают в себя секцию управления устройством и секцию протокола. Несмотря на то, что секция протокола должна быть общей для всех сетевых устройств, на практике это не так, поскольку ядро не имеет адекватных механизмов для общего использования. Например, символьные списки могли бы быть полезными благодаря своим возможностям в буферизации, но они требуют больших затрат ресурсов на посимвольную обработку. Попытки обойти этот механизм, чтобы повысить производительность системы, привели к нарушению модульности подсистемы управления вводом-выводом. Отсутствие общности на уровне драйверов распространяется вплоть до уровня команд пользователя, на котором несколько команд могут выполнять общие логические функции, но различными средствами. Еще один недостаток построения драйверов заключается в том, что сетевые протоколы требуют использования средства, подобного строковому интерфейсу, в котором каждая дисциплина реализует одну из частей протокола и составные части соединяются гибким образом. Однако, соединить традиционные строковые интерфейсы довольно трудно.
Ричи недавно разработал схему, получившую название "потоки" (streams), для повышения модульности и гибкости подсистемы управления вводом-выводом. Нижеследующее описание основывается на его работе [Ritchie 84b], хотя реализация этой схемы в версии V слегка отличается. Поток представляет собой полнодуплексную связь между процессом и драйвером устройства. Он состоит из совокупности линейно связанных между собой пар очередей, каждая из которых (пара) включает одну очередь для ввода и другую — для вывода. Когда процесс записывает данные в поток, ядро посылает данные в очереди для вывода; когда драйвер устройства получает входные данные, он пересылает их в очереди для ввода к процессу, производящему чтение. Очереди обмениваются сообщениями с соседними очередями, используя четко определенный интерфейс. Каждая пара очередей связана с одним из модулей ядра, таким как драйвер, строковый интерфейс или протокол, и модули ядра работают с данными, прошедшими через соответствующие очереди.
Каждая очередь представляет собой структуру данных, состоящую из следующих элементов:
• процедуры открытия, вызываемой во время выполнения системной функции open
• процедуры закрытия, вызываемой во время выполнения системной функции close
• процедуры "вывода", вызываемой для передачи сообщения в очередь
• процедуры "обслуживания", вызываемой, когда очередь запланирована к исполнению
• указателя на следующую очередь в потоке
• указателя на список сообщений, ожидающих обслуживания
• указателя на внутреннюю структуру данных, с помощью которой поддерживается рабочее состояние очереди
• флагов, а также верхней и нижней отметок, используемых для управления потоками данных, диспетчеризации и поддержания рабочего состояния очереди.
Ядро выделяет пары очередей, соседствующие в памяти; следовательно, очередь легко может отыскать своего партнера по паре.
  Рисунок 10.20. Поток после открытия
Устройство с потоковым драйвером является устройством посимвольного ввода-вывода; оно имеет в таблице ключей устройств соответствующего типа специальное поле, которое указывает на структуру инициализации потока, содержащую адреса процедур, а также верхнюю и нижнюю отметки, упомянутые выше. Когда ядро выполняет системную функцию open и обнаруживает, что файл устройства имеет тип "специальный символьный", оно проверяет наличие нового поля в таблице ключей устройств посимвольного ввода-вывода. Если в таблице отсутствует соответствующая точка входа, то драйвер не является потоковым, и ядро выполняет процедуру, обычную для устройств посимвольного ввода-вывода. Однако, при первом же открытии потокового драйвера ядро выделяет две пары очередей одну для заголовка потока и другую для драйвера. У всех открытых потоков модуль заголовка имеет идентичную структуру: он содержит общую процедуру "вывода" и общую процедуру "обслуживания" и имеет интерфейс с модулями ядра более высокого уровня, выполняющими функции read, write и ioctl. Ядро инициализирует структуру очередей драйвера, назначая значения указателям каждой очереди и копируя адреса процедур драйвера из структуры инициализации драйвера, и запускает процедуру открытия. Процедура открытия драйвера выполняет обычную инициализацию, но при этом сохраняет информацию, необходимую для повторного обращения к ассоциированной с этой процедурой очереди. Наконец, ядро отводит специальный указатель в копии индекса в памяти для ссылки на заголовок потока (Рисунок 10.20). Когда еще один процесс открывает устройство, ядро обнаруживает назначенный ранее поток с помощью этого указателя и запускает процедуру открытия для всех модулей потока.
Модули поддерживают связь со своими соседями по потоку путем передачи сообщений. Сообщение состоит из списка заголовков блоков, содержащих информацию сообщения; каждый заголовок блока содержит ссылку на место расположения начала и конца информации блока. Существует два типа сообщений — управляющее и информационное, которые определяются указателями типа в заголовке сообщения. Управляющие сообщения могут быть результатом выполнения системной функции ioctl или результатом особых условий, таких как зависание терминала, а информационные сообщения могут возникать в результате выполнения системной функции write или в результате поступления данных от устройства.
  Рисунок 10.21. Сообщения в потоках
Когда процесс производит запись в поток, ядро копирует данные из адресного пространства задачи в блоки сообщения, которые выделяются модулем заголовка потока. Модуль заголовка потока запускает процедуру "вывода" для модуля следующей очереди, которая обрабатывает сообщение, незамедлительно передает его в следующую очередь или ставит в эту же очередь для последующей обработки. В последнем случае модуль связывает заголовки блоков сообщения в список с указателями, формируя двунаправленный список (Рисунок 10.21). Затем он устанавливает в структуре данных очереди флаг, показывая тем самым, что имеются данные для обработки, и планирует собственное обслуживание. Модуль включает очередь в список очередей, требующих обслуживания и запускает механизм диспетчеризации; планировщик (диспетчер) вызывает процедуры обслуживания для каждой очереди в списке. Ядро может планировать обслуживание модулей по программному прерыванию, подобно тому, как оно вызывает функции в таблице ответных сигналов (см. главу 8); обработчик программных прерываний вызывает индивидуальные процедуры обслуживания.
  Рисунок 10.22. Продвижение модуля к потоку
Процессы могут "продвигать" модули к открытому потоку, используя вызов системной функции ioctl. Ядро помещает выдвинутый модуль сразу под заголовком потока и связывает указатели очереди таким образом, чтобы сохранить двунаправленную структуру списка. Модули, расположенные в потоке ниже, не беспокоятся о том, связаны ли они с заголовком потока или же с выдвинутым модулем: интерфейсом выступает процедура "вывода" следующей очереди в потоке; а следующая очередь принадлежит только что выдвинутому модулю. Например, процесс может выдвинуть модуль строкового интерфейса в поток терминального драйвера с целью обработки символов стирания и удаления (Рисунок 10.22); модуль строкового интерфейса не имеет тех же составляющих, что и строковые интерфейсы, рассмотренные в разделе 10.3, но выполняет те же функции. Без модуля строкового интерфейса терминальный драйвер не обработает вводные символы и они поступят в заголовок потока в неизмененном виде. Сегмент программы, открывающий терминал и выдвигающий строковый интерфейс, может выглядеть следующим образом:
fd = open("/dev/ttyxy", O_RDWR);
ioctl(fd, PUSH, TTYLD);
где PUSH — имя команды, а TTYLD — число, идентифицирующее модуль строкового интерфейса. Не существует ограничения на количество модулей, могущих быть выдвинутыми в поток. Процесс может выталкивать модули из потока в порядке поступления, "первым пришел — первым вышел", используя еще один вызов системной функции ioctl
ioctl(fd, POP, 0);
При том, что модуль строкового интерфейса выполняет обычные функции по управлению терминалом, соответствующее ему устройство может быть средством сетевой связи вместо того, чтобы обеспечивать связь с одним-единственным терминалом. Модуль строкового интерфейса работает одинаково, независимо от того, какого типа модуль расположен ниже него. Этот пример наглядно демонстрирует повышение гибкости вследствие соединения модулей ядра.

10.4.1 Более детальное рассмотрение потоков

Пайк описывает реализацию мультиплексных виртуальных терминалов, использующую потоки (см. [Pike 84]). Пользователь видит несколько виртуальных терминалов, каждый из которых занимает отдельное окно на экране физического терминала. Хотя в статье Пайка рассматривается схема для интеллектуальных графических терминалов, она работала бы и для терминалов ввода-вывода тоже; каждое окно занимало бы целый экран и пользователь для переключения виртуальных окон набирал бы последовательность управляющих клавиш.
  Рисунок 10.23. Отображение виртуальных окон на экране физического терминала
На Рисунке 10.23 показана схема расположения процессов и модулей ядра. Пользователь вызывает процесс mpx, контролирующий работу физического терминала. Mpx читает данные из линии физического терминала и ждет объявления об управляющих событиях, таких как создание нового окна, переключение управления на другое окно, удаление окна и т. п.
Когда mpx получает уведомление о том, что пользователю нужно создать новое окно, он создает процесс, управляющий новым окном, и поддерживает связь с ним через псевдотерминал. Псевдотерминал — это программное устройство, работающее по принципу пары: выходные данные, направляемые к одной составляющей пары, посылаются на вход другой составляющей; входные данные посылаются тому модулю потока, который расположен выше по течению. Для того, чтобы открыть окно (Рисунок 10.24), mpx назначает псевдотерминальную пару и открывает одну из составляющих пары, направляя поток к ней (открытие драйвера служит гарантией того, что псевдотерминальная пара не была выбрана раньше). Mpx ветвится и новый процесс открывает другую составляющую псевдотерминальной пары. Mpx выдвигает модуль управления сообщениями в псевдотерминальный поток, чтобы преобразовывать управляющие сообщения в информационные (об этом в следующем параграфе), а порожденный процесс помещает в псевдотерминальный поток модуль строкового интерфейса перед запуском shell'а. Этот shell теперь выполняется на виртуальном терминале; для пользователя виртуальный терминал неотличим от физического.
  /* предположим, что дескрипторы файлов 0 и 1 уже относятся к физическому терминалу */
  for (;;) { /* цикл */ 
   выбрать(ввод);
   /* ждать ввода из какой-либо линии */
   прочитать данные, введенные из линии;
   переключить(линию с вводимыми данными)   {
  если выбран физический терминал: /* данные вводятся по линии физического терминала */
    if (считана управляющая команда) { /* например, создание нового окна */ 
     открыть свободный псевдотерминал;
     пойти по ветви нового процесса:
     if (процесс родительский)   {
      выдвинуть интерфейс сообщений в сторону mpx;
      continue; /* возврат в цикл "для" */
     }
     /* процесс-потомок */
     закрыть ненужные дескрипторы файлов;
     открыть другой псевдотерминал из пары, выбрать stdin, stdout, stderr;
     выдвинуть строковый интерфейс терминала;
     запустить shell; /* подобно виртуальному терминалу */
    }
    /* "обычные" данные, появившиеся через виртуальный терминал */
    демультиплексировать считывание данных с физического терминала, снять заголовки и вести запись на соответствующий псевдотерминал;
    continue; /* возврат в цикл "для" */
  если выбран логический терминал: /* виртуальный терминал связан с окном */
    закодировать заголовок, указывающий назначение информации окна;
    переписать заголовок и информацию на физический терминал;
    continue; /* возврат в цикл "для" */
   }
  }
  Рисунок 10.24. Псевдопрограмма мультиплексирования окон
Процесс mpx является мультиплексором, направляющим вывод данных с виртуальных терминалов на физический терминал и демультиплексирующим ввод данных с физического терминала на подходящий виртуальный. Mpx ждет поступления данных по любой из линий, используя системную функцию select. Когда данные поступают от физического терминала, mpx решает вопрос, являются ли поступившие данные управляющим сообщением, извещающим о необходимости создания нового окна или удаления старого, или же это информационное сообщение, которое необходимо разослать процессам, считывающим информацию с виртуального терминала. В последнем случае данные имеют заголовок, идентифицирующий тот виртуальный терминал, к которому они относятся; mpx стирает заголовок с сообщения и переписывает данные в соответствующий псевдотерминальный поток. Драйвер псевдотерминала отправляет данные через строковый интерфейс терминала процессам, осуществляющим чтение. Обратная процедура имеет место, когда процесс ведет запись на виртуальный терминал; mpx присоединяет заголовок к данным, информируя физический терминал, для вывода в какое из окон предназначены эти данные.
Если процесс вызывает функцию ioctl с виртуального терминала, строковый интерфейс терминала задает необходимые установки терминала для его виртуальной линии; для каждого из виртуальных терминалов установки могут быть различными. Однако, на физический терминал должна быть послана и кое-какая информация, зависящая от типа устройства. Модуль управления сообщениями преобразует управляющие сообщения, генерируемые функцией ioctl, в информационные сообщения, предназначенные для чтения и записи их процессом mpx, и эти сообщения передаются на физическое устройство.

10.4.2 Анализ потоков

Ричи упоминает о том, что им была предпринята попытка создания потоков только с процедурами "вывода" или только с процедурами обслуживания. Однако, процедура обслуживания необходима для управления потоками данных, так как модули должны иногда ставить данные в очередь, если соседние модули на время закрыты для приема данных. Процедура "вывода" так же необходима, поскольку данные должны иногда доставляться в соседние модули незамедлительно. Например, строковому интерфейсу терминала нужно вести эхо-сопровождение ввода данных на терминале в темпе с процессом. Системная функция write могла бы запускать процедуру "вывода" для следующей очереди непосредственно, та, в свою очередь, вызывала бы процедуру "вывода" для следующей очереди и так далее, не нуждаясь в механизме диспетчеризации. Процесс приостановился бы в случае переполнения очередей для вывода. Однако, со стороны ввода модули не могут приостанавливаться, поскольку их выполнение вызывается программой обработки прерываний, иначе был бы приостановлен совершенно безобидный процесс. Связь между модулями не должна быть симметричной в направлениях ввода и вывода, хотя это и делает схему менее изящной.
Также было бы желательно реализовать каждый модуль в виде отдельного процесса, но использование большого количества модулей привело бы к переполнению таблицы процессов. Модули наделяются специальным механизмом диспетчеризации — программным прерыванием, независимым от обычного планировщика процессов. По этой причине модули не могут приостанавливать свое выполнение, так как они приостанавливали бы тем самым произвольный процесс (тот, который прерван). Модули должны хранить внутри себя информацию о своем состоянии, что делает лежащие в их основе программы более громоздкими, чем если бы приостановка выполнения была разрешена.
В реализации потоков можно выделить несколько отклонений или несоответствий:
• Учет ресурсов процесса в потоках затрудняется, поскольку модулям необязательно выполняться в контексте процесса, использующего поток. Ошибочно предполагать, что все процессы одинаково используют модули потоков, поскольку одним процессам может потребоваться использование сложных сетевых протоколов, тогда как другие могут использовать простые строковые интерфейсы.
• Пользователи имеют возможность переводить терминальный драйвер в режим без обработки, в котором функция read возвращает управление через короткий промежуток времени в случае отсутствия данных (например, если newtty.c_cc[VMIN] = 0 на Рисунке 10.17). Эту особенность сложно реализовать в потоковой среде без подключения специальной программы на уровне заголовка потока.
• Потоки выступают средствами линейной связи и не могут позволить производить с легкостью мультиплексирование на уровне ядра. В примере использования окон, рассмотренном в предыдущем разделе, выполнялось мультиплексирование на уровне пользовательского процесса.
Несмотря на эти несоответствия, с потоками связываются большие надежды в совершенствовании разработки модулей драйвера.

10.5 ВЫВОДЫ

Данная глава представляет собой обзор драйверов устройств в системе UNIX. Устройства могут быть либо блочного, либо символьного типа; интерфейс между устройствами и остальной частью ядра определяется типом устройств. Интерфейсом для устройств блочного типа выступает таблица ключей устройств ввода-вывода блоками, состоящая из точек входа, соответствующих процедурам открытия и закрытия устройств и стратегической процедуре. Стратегическая процедура управляет передачей данных от и к устройству блочного типа. Интерфейсом для устройств символьного типа выступает таблица ключей устройств посимвольного ввода-вывода, которая состоит из точек входа, соответствующих процедурам открытия и закрытия устройства, чтения, записи и процедуре ioctl. Системная функция ioctl использует при обращении к устройствам символьного типа свой собственный интерфейс, который позволяет осуществлять передачу управляющей информации между процессами и устройствами. По получении прерывания от устройства ядро вызывает программу обработки соответствующего прерывания, опираясь на информацию, хранящуюся в таблице векторов прерываний, и на параметры, сообщенные устройством, от которого поступило прерывание.
Дисковые драйверы превращают номера логических блоков, используемые файловой системой, в физические адреса на диске. Блочный интерфейс дает возможность ядру буферизовать данные. Взаимодействие без обработки ускоряет ввод-вывод на диск, но игнорирует буферный кеш, увеличивая тем самым шансы разрушить файловую систему.
Терминальные драйверы осуществляют непосредственное взаимодействие с пользователями. Ядро связывает с каждым терминалом три символьных списка, один для неструктурированного ввода с клавиатуры, один для ввода с обработкой символов стирания, удаления и возврата каретки и один для вывода. Системная функция ioctl дает процессам возможность следить за тем, как ядро обрабатывает вводимые данные, переводя терминал в канонический режим или устанавливая значения различных параметров для режима без обработки символов. Getty-процесс открывает терминальные линии и ждет связи: он формирует группу процессов во главе с регистрационным shell'ом, инициализирует с помощью функции ioctl параметры терминала и обращается к пользователю с предложением зарегистрироваться. Установленный таким образом операторский терминал посылает процессам в группе сигналы в ответ на возникновение таких событий, как "зависание" пользователя или нажатие им клавиши прерывания.
Потоки выступают средством повышения модульности построения драйверов устройств и протоколов. Поток — это полнодуплексная связь между процессами и драйверами устройств, которая может включать в себя строковые интерфейсы и протоколы для промежуточной обработки данных. Модули потоков характеризуются четко определенным взаимодействием и гибкостью, позволяющей использовать их в сочетании с другими модулями. Эта гибкость имеет особое значение для сетевых протоколов и драйверов.

10.6 УПРАЖНЕНИЯ

1. * Предположим, что в системе имеются два файла устройств с одними и теми же старшим и младшим номерами, при том, что оба устройства — символьного типа. Если два процесса желают одновременно открыть физическое устройство, не будет никакой разницы, открывают ли они один и тот же файл устройства или же разные файлы. Что произойдет, когда они станут закрывать устройство?
2. * Вспомним из главы 5, что системной функции mknod требуется разрешение суперпользователя на создание нового специального файла устройства. Если доступ к устройству управляется правами доступа к файлу, почему функции mknod нужно разрешение суперпользователя?
3. Напишите программу, которая проверяет, что файловые системы на диске не перекрываются. Этой программе потребовались бы два аргумента: файл устройства, представляющий дисковый том, и дескриптор файла, откуда берутся номера секторов и их размер для диска данного типа. Для проверки отсутствия перекрытий этой программе понадобилась бы информация из суперблоков. Будет ли такая программа всегда правильной?
4. Программа mkfs инициализирует файловую систему на диске путем создания суперблока, выделения места для списка индексов, включения всех информационных блоков в связанный список и создания корневого каталога. Как бы вы написали программу mkfs? Как изменится эта программа при наличии таблицы содержимого тома? Каким образом следует инициализировать таблицу содержимого тома?
5. Программы mkfs и fsck (глава 5) являются программами пользовательского уровня, а не частью ядра. Прокомментируйте это.
6. Предположим, что программисту нужно разработать базу данных, работающую в среде ОС UNIX. Программы базы данных выполняются на пользовательском уровне, а не в составе ядра. Как система управления базой данных будет взаимодействовать с диском? Подумайте над следующими вопросами:
• Использование стандартного интерфейса файловой системы вместо непосредственной работы с неструктурированными данными на диске,
• Потребность в быстродействии,
• Необходимость знать, когда фактически данные располагаются на диске,
• Размер базы данных: должна ли она помещаться в одной файловой системе, занимать собой весь дисковый том или же располагаться на нескольких дисковых томах?
7. Ядро системы UNIX по умолчанию предполагает, что файловая система располагается на идеальных дисках. Однако, диски могут содержать ошибки, которые делают непригодными и выводят из строя определенные сектора, несмотря на то, что остальная часть диска осталась "пригодной". Как дисковому драйверу (или интеллектуальному контроллеру диска) следует учитывать небольшое количество плохих секторов. Как это отразилось бы на производительности системы?
8. При монтировании файловой системы ядро запускает процедуру открытия для данного драйвера, но позже освобождает индекс специального файла устройства по завершении выполнения вызова системной функции mount. При демонтировании файловой системы ядро обращается к индексу специального файла устройства, запускает процедуру закрытия для данного драйвера и вновь освобождает индекс. Сравните эту последовательность операций над индексом, а также обращений к процедурам открытия и закрытия драйвера, с последовательностью действий, совершаемых при открывании и закрывании устройства блочного типа. Прокомментируйте результаты сравнения.
9. Выполните программу, приведенную на Рисунке 10.14, но направьте вывод данных в файл. Сравните содержимое файла с содержимым выводного потока, когда вывод идет на терминал. Вам придется прервать процессы, чтобы остановить их; только прежде пусть они получат достаточно большое количество данных. Что произойдет, если вызов функции write в программе заменить на printf(output);
10. Что произойдет, если пользователь попытается выполнить редактирование текста на фоне программы:
ed file&
Обоснуйте ответ.
11. К файлам терминалов обычно устанавливаются следующие права доступа
crw-w-w- 2 mjb lus 33,11 Oct 25 20:27 tty61
при входе пользователя в систему. То есть, чтение и запись разрешаются пользователю с именем "mjb", а остальным пользователям разрешена только запись. Почему?
12. Предположим, что вам известно имя файла терминала вашего товарища. Напишите программу записи сообщений с вашего терминала на терминал вашего товарища. Какая еще информация вам нужна, чтобы закодировать приемлемое воспроизведение обычной команды write?
13. Выполните команду stty: если параметры не указаны, она выбирает значения установок терминала и сообщает их пользователю. В противном случае пользователь может в интерактивном режиме сделать различные установки сам.
14. Напишите элементарный строковый интерфейс, записывающий идентификатор машины в начале каждой строки выводного потока.
15. В каноническом режиме пользователь может на время приостановить вывод данных на терминал, нажав последовательность клавиш ‹Ctrl-s›, и продолжить вывод, нажав ‹Ctrl-q›. Как в стандартном строковом интерфейсе реализуется эта особенность?
16. *Процесс начальной загрузки порождает getty-процесс для каждой терминальной линии в системе. Что произошло бы, если бы для одного и того же терминала существовали бы одновременно два getty-процесса, ожидающие регистрации пользователя? Может ли ядро помешать этому?
17. Пусть командный процессор shell реализован таким образом, что он "игнорирует" конец файла и продолжает считывать данные из стандартного ввода. Что произошло бы, если бы пользователь (в регистрационном shell'е) угадал конец файла и продолжил ввод с клавиатуры?
18. *Предположим, что процесс считывает данные с операторского терминала, но игнорирует или улавливает сигналы о "зависании". Что произойдет, когда процесс продолжит считывать данные с операторского терминала после зависания?
19. Программа getty-процесса несет ответственность за открытие терминальной линии, а программа login — за проверку регистрационных имен и паролей. Какие преимущества в том, что эти функции выполняются отдельными программами?
20. Рассмотрим два метода реализации драйвера косвенного терминала ("/dev/tty"), описанные в разделе 10.3.6. Какие различия между ними чувствует пользователь? (Совет: подумайте о системных функциях stat и fstat).
21. Разработайте метод планирования выполнения модулей потока, в соответствии с которым ядро имеет в своем составе специальный процесс, выполняющий процедуры обслуживания модулей тогда, когда выполнение этих процедур запланировано.
22. * Разработайте схему построения виртуальных терминалов (окон) с использованием традиционных (не потоковых) драйверов.
23. * Разработайте метод реализации виртуальных терминалов с использованием потоков, в котором мультиплексированием ввода-вывода между виртуальным и физическим терминалами занимался бы один из модулей ядра, а не пользовательский процесс. Опишите механизм соединения потоков со сверткой и разверткой. Что лучше: включить модуль, осуществляющий мультиплексирование, в состав ядра или построить его как пользовательский процесс?
24. Команда ps сообщает интересную информацию об активности процессов в работающей системе. В традиционных реализациях ps считывает информацию из таблицы процессов, прямо из памяти ядра. Такой метод не совсем удобен в среде разработки, когда размер записей таблицы процессов меняется и команде ps становится нелегко обнаружить в таблице соответствующие поля. Разработайте драйвер, нечувствительный к изменениям среды.

0



Рейтинг форумов | Создать форум бесплатно