Добавлен: 29.10.2018
Просмотров: 47971
Скачиваний: 190
12.3. Реализация
1081
странства. При этом его размер можно заранее не указывать. Для потоков ядра размер
стека должен быть указан заранее, так как стек занимает часть виртуального адресного
пространства ядра, кроме того, стеков может быть несколько. Вопрос заключается
в следующем: сколько памяти следует выделить для каждого стека? Преимущества
и недостатки различных подходов здесь примерно такие же, как и в случае с таблицей
процессов. Придать подобную динамичность основным структурам данных, конечно,
можно, но очень сложно.
Проблема выбора между статическим или динамическим выделением памяти воз-
никает также для планирования процессов. В некоторых системах, особенно системах
реального времени, планирование может быть выполнено заранее статически. На-
пример, авиалинии знают, в котором часу их самолеты будут взлетать, за несколько
недель до их фактического отправления. Подобно этому, мультимедийным системам
известно заранее, когда запускать те или иные видео-, аудио- и другие процессы. Для
универсальных систем общего назначения эти соображения не работают, и планиро-
вание должно быть динамическим.
Вопрос выбора между статическим или динамическим выделением памяти возникает
и при проектировании структур ядра. Значительно проще, если ядро построено как
единая двоичная программа, загружаемая в память для работы. Следствием такого под-
хода, однако, является то, что для установки каждого нового устройства ввода-вывода
необходима перекомпоновка ядра вместе с драйвером нового устройства. Подобным
образом работали ранние версии операционной системы UNIX, что всех устраивало,
так как новые устройства ввода-вывода добавлялись к мини-компьютерам довольно
редко. Сегодня большинство операционных систем позволяют динамически добавлять
программы в ядро, со всеми дополнительными сложностями, которые такой подход
влечет за собой.
12.3.7. Реализация системы сверху вниз и снизу вверх
Лучше всего проектировать систему сверху вниз, однако теоретически реализация си-
стемы может выполняться как сверху вниз, так и снизу вверх . При реализации сверху
вниз конструкторы начинают с обработчиков системных вызовов, а затем смотрят,
какие механизмы и структуры данных требуются для их поддержки. Затем пишутся
эти процедуры, при этом весь процесс повторяется до тех пор, пока не будет достигнут
аппаратный уровень.
Недостаток этого подхода заключается в том, что пока в наличии имеются только про-
цедуры верхнего уровня, трудно что-либо протестировать. По этой причине многие
разработчики считают более практичным построение системы снизу вверх. При таком
подходе сначала пишется программа, скрывающая аппаратуру нижнего уровня. Об-
работка прерываний и драйвер часов также оказываются нужны уже на раннем этапе
конструирования.
Затем можно реализовать многозадачность вместе с простым планировщиком (на-
пример, запускающим процессы в порядке циклической очереди). Уже в этот момент
систему можно протестировать, чтобы проверить, правильно ли она управляет не-
сколькими процессами. Если все работает нормально, можно приступить к детальной
разработке различных таблиц и структур данных, необходимых системе, особенно тех,
которые управляют процессами и потоками, а также памятью. Ввод-вывод и файловую
систему можно отложить на потом, реализовав поначалу лишь примитивный ввод
1082
Глава 12. Разработка операционных систем
с клавиатуры и вывод на экран для тестирования и отладки. В некоторых случаях
следует защитить ключевые низкоуровневые структуры данных, разрешив доступ
к ним только с помощью специальных процедур доступа, — в результате мы получаем
объектно-ориентированное программирование независимо от того, какой язык про-
граммирования применяется в действительности. Когда нижние уровни созданы, они
могут быть тщательно протестированы. Таким образом, система создается снизу вверх,
подобно тому как строятся высокие здания.
Если над проектом работает большая команда программистов, то альтернативный
подход заключается в том, чтобы сначала создать детальный проект всей системы,
после чего распределить задачи по написанию отдельных модулей среди различных
групп программистов. Каждая группа независимо тестирует собственную работу.
Когда все модули готовы, их объединяют и тестируют совместно. Недостаток такого
подхода заключается в том, что если изначально ничто не работает, то очень труд-
но определить, какой модуль создан правильно, а какой содержит ошибки и какая
группа программистов неверно поняла, чего ей следует ожидать от других модулей.
Тем не менее такой метод часто применяется при наличии большого количества
программистов, чтобы максимально использовать распараллеливание работ по кон-
струированию системы.
12.3.8 Сравнение синхронного и асинхронного
обмена данными
Еще один вопрос, часто возникающий в разговоре разработчиков операционных систем,
касается того, каким должно быть взаимодействие между системными компонентами,
синхронным или асинхронным (и соответственно что лучше: потоки или события).
Вопрос часто приводит к оживленной дискуссии между представителями двух лаге-
рей, хотя и ведется она без пены у рта, как это обычно бывает при принятии решений
по действительно важным вопросам, например какой редактор лучше, vi или emacs.
Термин «синхронный» используется нами в упрощенном смысле, изложенном в раз-
деле 8.2, для обозначения вызовов, блокирующихся до их завершения. И наоборот, при
асинхронных вызовах вызывающая сторона продолжает работу. У каждой модели есть
свои достоинства и недостатки.
Для некоторых систем, подобных Amoeba, фактически принята синхронная модель
и обмен данными между процессами реализован в виде блокирующихся клиент-сер-
верных вызовов. Концептуально полностью синхронный обмен данными весьма прост.
Процесс отправляет запрос и блокируется, ожидая прихода ответа, — что может быть
проще? Когда появляется множество клиентов, каждый из которых добивается вни-
мания сервера, все несколько усложняется. Каждый отдельный запрос может блоки-
роваться на длительный срок, ожидая завершения других запросов. Проблема может
быть решена, если сделать сервер многопоточным, чтобы каждый поток мог работать
с одним клиентом. Эта модель была испробована и протестирована во многих рабочих
реализациях, в операционных системах, а также приложениях.
Ситуация усложняется еще больше, если потоки часто считывают и записывают
данные в совместно используемые структуры данных. В таком случае блокировка не-
избежна. К сожалению, правильное решение с блокировкой дается нелегко. Наипро-
стейшее решение заключается в выдаче одной большой блокировки на всю совместно
используемую структуру данных (что похоже на большую блокировку ядра). Когда
12.3. Реализация
1083
потоку нужно будет получить доступ к общей структуре данных, он должен захватить
блокировку первым. С точки зрения производительности одну большую блокировку
признать удачной идеей нельзя, поскольку потоки вынуждены будут ждать один дру-
гого все время, даже если они вообще не конфликтуют друг с другом. Другим экстре-
мальным решением является использование множества микроблокировок для частей
отдельных структур данных, при этом скорость существенно возрастает, но возникает
конфликт с нашим ведущим принципом № 1 — простотой.
Другие операционные системы ведут обмен данными между процессами, используя
асинхронные примитивы. В некотором смысле асинхронный обмен данными даже про-
ще своего синхронного собрата. Клиентский процесс отправляет сообщение серверу,
но вместо того чтобы ожидать доставки сообщения или ответа, он просто продолжает
свою работу. Разумеется, это означает, что ответ он получает также асинхронно и при
его приходе должен помнить, какой именно запрос ему соответствует. Сервер обычно
обрабатывает запросы (события) как единый поток в круговороте событий.
Когда запрос для дальнейшей обработки требует от сервера контакта с другими сер-
верами, этот сервер отправляет собственное асинхронное сообщение и вместо блоки-
рования продолжает работу со следующим запросом. Здесь не требуется множество
потоков. При наличии всего одного потока, обрабатывающего события, проблемы
доступа нескольких потоков к общей структуре данных не возникает. В то же время
долго работающий обработчик события замедляет ответ однопоточного сервера.
Вопрос, какая из моделей лучше, потоков или событий, давно уже является спорным,
заставляющим ломать копья приверженцев с обеих сторон с того времени, как вы-
шла классическая статья Джона Oустерхаута (John Ousterhout) «Why threads are
a bad idea (for most purposes)» (1996) («Почему потоки для большинства случаев
являются плохой затеей»). Оустерхаут утверждает, что потоки неоправданно всё
слишком усложняют: блокировку, отладку, обратные вызовы, достижение высокой
производительность и многое другое. Разумеется, если бы все с этим согласились,
то никаких споров бы не было. Через несколько лет после выхода статьи Оустер-
хаута вышла статья Von Behren et al. (2003) под названием «Why events are a bad
idea (for highconcurrency servers)» («Почему события являются плохой затеей для
хорошо распараллеленных серверов»). Стало быть, принятие решения о том, какая
из моделей программирования правильная, дается нелегко, но имеет для системных
программистов весьма большое значение. Абсолютный победитель здесь так и не
выявлен. Разработчики таких веб-серверов, как apache, твердо придерживаются
синхронного обмена данными и потоков, но разработчики других веб-серверов, на-
пример lighttpd, выстраивают свою работу на парадигме управления событиями
(event-driven paradigm). Обе модели пользуются широкой популярностью. По наше-
му мнению, событийную модель зачастую проще понять и отладить, чем потоковую.
Пока не ставится вопрос о необходимости поядерной параллельной работы, выбрать,
наверное, будет лучше именно ее.
12.3.9. Полезные методы
Итак, мы только что обсудили некоторые абстрактные идеи, применяющиеся при про-
ектировании и конструировании операционных систем. Теперь рассмотрим несколько
конкретных методов, полезных при реализации систем. Разумеется, существует также
множество других методов, но объем книги не позволяет нам рассмотреть их все.
1084
Глава 12. Разработка операционных систем
Сокрытие аппаратуры
Большое количество аппаратуры весьма неудобно в использовании. Его следует скры-
вать на ранней стадии реализации системы (если только оно не предоставляет особых
возможностей). Некоторые детали самого нижнего уровня могут быть скрыты уровнем
вроде HAL. Однако есть детали, которые таким способом скрыть невозможно.
На ранней стадии конструирования системы следует решить вопрос с обработкой
прерываний. Наличие прерываний делает программирование неприятным делом, но
операционные системы должны работать с прерываниями. Один из подходов состоит
в том, чтобы немедленно преобразовать их во что-либо иное. Например, каждое пре-
рывание можно превратить в появляющийся новый поток. При этом более высокие
уровни будут иметь дело уже не с прерываниями, а с потоками.
Второй метод заключается в том, чтобы преобразовать каждое прерывание в операцию
unlock на мьютексе, которого ожидает соответствующий драйвер. Тогда единственный
эффект от прерывания будет заключаться в том, что один из потоков перейдет в со-
стояние готовности.
Третий подход состоит в немедленном преобразовании прерывания в сообщение како-
му-либо потоку. Программа низкого уровня просто формирует сообщение, в котором
содержатся сведения о том, откуда прибыло прерывание, ставит его в очередь и вызы-
вает планировщик, который, возможно, запустит процесс, вероятно, ожидающий этого
сообщения. Все эти методы, а также подобные им пытаются преобразовать прерывания
в операции синхронизации потоков. Проще управлять обработкой каждого прерывания
соответствующим потоком в соответствующем контексте, чем создать обработчик пре-
рываний, работающий в произвольном контексте, то есть в том, в котором случилось
прерывание. Разумеется, обработка прерываний должна быть эффективной, но глубоко
внутри операционной системы эффективным должно быть всё.
Большинство операционных систем спроектировано так, чтобы работать на различных
платформах. Эти платформы могут различаться центральными процессорами, блоками
MMU, длиной машинного слова, объемом оперативной памяти и другими параметра-
ми, которые трудно замаскировать уровнем HAL или его эквивалентом. Тем не менее
желательно иметь единый набор исходных файлов, используемых для формирования
всех версий. В противном случае каждую обнаруженную ошибку придется исправлять
много раз во многих исходных файлах. При этом возникает опасность того, что исход-
ные файлы станут отличаться друг от друга все больше и больше.
С некоторыми различиями аппаратуры, такими как объем ОЗУ, можно бороться,
оформив этот параметр в виде переменной и определяя его значение во время загруз-
ки системы. Например, переменная, содержащая объем оперативной памяти, может
использоваться программой, предоставляющей память процессам, а также для опре-
деления размера блока кэша, таблиц страниц и т. д. Даже размер статических таблиц,
таких как таблица процессов, может задаваться при загрузке в зависимости от общего
объема оперативной памяти.
Однако другие различия, такие как различия в центральных процессорах, не могут
быть скрыты при помощи единого двоичного кода, определяющего при загрузке тип
процессора. Один из способов решения данной проблемы состоит в использовании
условной трансляции единого исходного кода. В исходных файлах для различных
конфигураций используются определенные флаги компиляции, позволяющие по-
лучать из единого исходного текста различные двоичные файлы в зависимости от
12.3. Реализация
1085
центрального процессора, длины слова, блока MMU и т. д. Представьте себе опера-
ционную систему, которая должна работать на компьютерах с процессорами линейки
IA32 микропроцессоров x86 (иногда называемой x86-32) и UltraSPARC, требующих
различных программ инициализации. Процедура init может быть написана так, как
показано в листинге 12.3, а. В зависимости от значения константы CPU, определяемой
в заголовочном файле
config.h
, будет выполняться либо один тип инициализации, либо
другой. Поскольку в двоичном файле содержится только один вариант программы,
потери эффективности не происходит.
Листинг 12.3. Условная компиляция, зависящая: а — от типа центрального
процессора; б — от длины слова
#include "config.h" #include "config.h"
init( ) #if (WORD_LENGTH == 32)
{ typedef int Register;
#if (CPU == IA32) #endif
/* Здесь инициализация для IA32 */
#endif #if (WORD_LENGTH == 64)
typedef long Register;
#if (CPU == ULTRASPARC) #endif
/* Здесь инициализация для UltraSPARC */
#endif Register R0, R1, R2, R3;
}
a
б
В качестве второго примера предположим, что нам требуется тип данных Regis-ter,
который должен состоять из 32 бит на компьютере с процессором IA32 и 64 бит на
компьютере с процессором UltraSPARC. Этого можно добиться при помощи условно-
го кода, приведенного в листинге 12.3, б (при условии, что компилятор воспринимает
тип int как 32-разрядное целое, а тип long — как 64-разрядное). Как только это опреде-
ление дано (возможно, в заголовочном файле, включаемом во все остальные исходные
файлы), программист может просто объявить переменные типа Register и быть уверен-
ным, что эти переменные имеют правильный размер.
Разумеется, заголовочный файл
config.h
должен быть определен корректно. Для про-
цессора IA32 он может выглядеть примерно так:
#defi ne CPU IA32
#define WORD_LENGTH 32
Чтобы откомпилировать операционную систему для процессора UltraSPARC, нужно
использовать другой файл
config.h
, содержащий правильные значения для процессора
UltraSPARC, возможно, что-то вроде
#defi ne CPU ULTRASPARC
#define WORD_LENGTH 64
Некоторые читатели могут удивиться, почему переменные CPU и WORD_LENGTH
управляются различными макросами. В определении константы Register можно сделать
ветвление программы, устанавливая ее значение в зависимости от значения константы
CPU, то есть устанавливая значение константы Register равной 32 битам для процессо-
ра IA32 и 64 битам для процессора UltraSPARC. Однако эта идея не слишком удачна.
Что произойдет, если позднее мы соберемся переносить систему на 32-разрядный