Очередная заметка нашего

гуру Игоря Облакова,

ведущего постоянную рубрику,

посвященную деталям

функционирования ОС Unix и

Unix-подобных систем,

приглашает к обсуждению

некоторых механизмов ядра

ОС Unix
Вы когда-нибудь учили иностранный язык с «погружением»? Нет? Я тоже. Предлагаю вам изучение Unix с погружением. Благодаря растущей распространенности Linux появилась уникальная возможность изучение основных механизмов работы Unix. Будем считать это примером «Ядреного слова», поскольку речь пойдет теперь не о командном языке и работе процессов «сверху», а именно о функционировании ядра.

Итак «Погружение»! Кода по колено. Бр-р, холодно. Что может быть приятнее десятка-другого килобайт кода ядра на ночь. Не увлекает? Меня тоже.

Как вы думаете, чем больше всего любит заниматься каждая нормальная операционная система? Вы, вероятно, полагаете, — выполнять ваше приложение. Заблуждаетесь, основное занятие всякой операционной системы - ожидание. Это может быть ожидание прерывания от операции ввода/вывода, ожидание запроса от вашего скучного приложения, или нервное поглядывание на системные часы, дабы не дать вашему приложению времени больше положенного.

Ясно, что различные процессы могут одновременно попытаться выполнять системные вызовы, а эти вызовы могут застревать по самым разнообразным причинам — кому-то надо просмотреть каталог, которого нет в памяти, кому-то синхронно сбросить блок, кому-то сдвинуть руку какого-либо агрегата. Поэтому при переключении между процессами надо запоминать не только то, какую команду выполнял пользовательский процесс, но и то, в какой точке «завис» системный вызов, какой в этот момент был системный стек для данного вызова и т.п. Фактически правильнее говорить о том, что каждый процесс может выполняться в двух режимах: «режим пользователя» для действий типа дважды два, «режим ядра» - например, для вывода оного результата на экран.

При работе в режиме ядра все процессы (впрочем, возможны фокусы диалектов) имеют доступ к любым данным системы. Думаю, каждому понятно, что одновременное обращение параллельных процессов к общим данным может привести к неприятным последствиям. Необходимо также учитывать процедуры обработки прерываний, которые могут прервать выполнение системного вызова и как-либо изменить структуры (типа «дай символ с терминала» и «приход символа с терминала») обрабатываемые в этом вызове. Естественно, что от этого нужно как-то защититься. Защитой от прерывания «критичного» кода системного вызова служит маскирование прерывания данного типа (иногда явное, но чаще изменением уровня разрешенного прерывания или общего флага разрешения прерываний без разбирательства с адресами и т.п.) перед началом критичного кода и восстановление состояния процессора после этого кода. В Linux это выполняется макросами (с ассемблерными вставками) наподобие save_flags/cli/sti/restore_flags, в других диалектах это может быть что-то начинающееся с spl... (spltty, spl6 и т.п., соответственно возврат состояния splx) и т.п.

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

Вот мы и добрались до любимого занятия операционной системы - ожидания. Нам надо попытаться захватить ресурс и, если он не готов для использования, «заснуть» - дать работать другим. Для этого имеется специальный вызов ядра: __sleep_on в Linux и sleep в других диалектах. Для читающих «с погружением»: код можно найти в файле /usr/src/linux/kernel/sched.c и соответствующем включаемом файле. Первый параметр вызова задает ожидаемое событие. Во многих диалектах этот параметр представляет собой абстрактный адрес, то есть данные по этому адресу не используются - это лишь средство получить гарантированно разные идентификаторы/очереди ожидания; так, в Linux это явный адрес очереди. Второй параметр указывает на возможность выполнения прерывания данного вызова каким-либо сигналом. В Linux это делается явно (sleep_on или interruptable_sleep_on; оба имеют только один параметр и указывают тип ожидания). В других диалектах второй параметр sleep сравнивается с некоторой константой, обычно именуемой PZERO; если в запросе задан приоритет больше (соответственно задающее его число меньше), чем эта константа, sleep считается прерываемым.

Непрерываемое ожидание служит для «короткоживущих» ожиданий, прерываемое для «долгоживущих», например, для ожидания символа с терминала. Что означает, что ожидание непрерываемое? Это значит, что даже команда «kill -9 pid» для процесса в таком режиме ожидания является не более, чем предложением «подохни, собака, будь так добра», которое он может полностью проигнорировать. Другими словами, во время ожидания такого типа процесс не обрабатывает никаких сигналов и продолжает ждать. Если для какой-либо операции с внешними устройствами используется такой вариант ожидания, то процесс может быть прерван (заметим, что реально он и так «прерван» и ничего не делает, лишь занимая память и отбирая другие ресурсы) только внешними причинами, сигналом сбоя/внимания/сброса по шине и т.п., которые отрабатываются соответствующими драйверами.

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

Следующим интересным моментом является процесс пробуждения очереди. Все процессы, которые ждут требуемого события или один ресурс (семафор, блок и т.п.) просыпаются одновременно и попадают в очередь готовых на выполнение. Соответственно, если sleep был использован для разделения ресурсов, то проснувшиеся процессы должны вновь сделать попытку захвата ресурса. Пример подобного рода вы можете найти в /usr/src/linux/fs/fifo.c (первые же interruptible_sleep_on находятся внутри цикла запроса).

Итак, Unix-процесс при помощи sleep всегда ждет только одного события. Вот напасть-то. А как же обрабатывать много событий от различных источников одновременно? Представляете радость любителей NT, прочитавших эти строки: у вас в Unix все криво, мы знаем!.. Смею вас уверить - не представляете. С другой стороны, существуют же вызовы наподобие select, которые предполагают ожидание событий ввода/вывода на некотором множестве каналов. Они-то как работают? Фокус выполняется достаточно просто. Никто не требует, что бы мы ожидали ровно в одной очереди. Да, sleep добавляет нас к очереди ожидания конкретного события. Однако либо это событие формулируется в виде «что-то произошло с имеющимися в списке файлами», либо (как это сделано, например, в Linux) можно добавить процесс явно в несколько очередей при помощи какого-нибудь специального вызова типа select_wait, который должен быть выдан для каждого файла или устройства с очередью событий соответствующего типа: чтение/запись/исключения (см. например usr/src/linux/drivers/char/busmouse.c). В результате, когда, например, на одном из требуемых для нас устройств происходит событие нужного типа, ожидающие его процессы пробуждаются командой типа wake_up_interruptible. Пробудившийся процесс заново проверяет все заявленные источники событий.

Какие еще фокусы может породить такой механизм ожидания? Что произойдет, если какой-либо процесс попробует захватить какой-либо ресурс повторно (то есть захвативший его ранее как часть текущей операции)? Очевидно, что если никакой защиты от этого не предпринимается, то данный процесс и затем все процессы, которым необходим этот же ресурс, зависнут (до перезагрузки). Как это не странно, но стандартный пример зависания от Баха (Морис Бах - автор классической книги «Архитектура системы Unix» - прим. ред.) наподобие «link . ./a» срабатывает для некоторых диалектов Unix и файловых систем до сих пор (он основан именно на повторном захвате ресурсов, как вы полагаете, каких?). Заметим, что обычный пользователь не имеет возможности связывать каталоги и сотворить такое.

Как мы видим, создать «бессмертный» процесс можно либо из-за ошибок драйверов и специфики поведения внешних устройств, либо из-за ошибок в программировании конкретных операций. Если ли еще другие формы «бессмертных»? Да, это «привидения» — зомби. Наиболее простой способ породить зомби следующий:

(sleep 10 & exec sleep 30000) &

В результате подобной операции мы сначала получим деревце процессов в виде sh -> sleep 30000 -> sleep 30, а через 30 секунд sh -> sleep 30000 -> зомби (проверьте, как ps отображает их в вашей системе). В Unix для процессов действует принцип «никто не забыт, ничто не забыто». Процесс не может исчезнуть до тех пор, пока его родитель не узнает код завершения процесса. Если же он погиб, заботу о сыне берет «сиротский дом» - процесс init. Для получения кода возврата родитель должен выдать специальный системный вызов. До тех пор, пока он этого не сделал, сын присутствует в системе. Сын не имеет никаких атрибутов процесса (кроме идентификатора) и не потребляет никаких ресурсов, кроме места в таблице процессов. Как вы, вероятно, догадываетесь, sleep даже в страшном сне не может представить, что у него есть сын. Соответственно, пока родитель не умрет (а если не вмешаться это произойдет часов через 8 с гаком), сын будет присутствовать в системе. Все это время сын попросту игнорирует любые операции наподобие «kill -9»: что с ним, с трупом сделается. Как в кино: чтобы стать бессмертным, надо один раз умереть.

Продолжение темы

Некоторые читатели заметки о возможности восстановления Linux при помощи загрузочного компакт-диска (см. «Мелочи жизни» в январском выпуске «Открытых систем» — Прим. ред.) обратили внимание, что автор не упомянул о владельцах дисков SCSI. Это, конечно, непростительное упущение, которое я попытаюсь исправить.

Как получить доступ к диску SCSI после загрузки с CD-ROM? Сложность лишь в одном: где взять модуль, который отвечает за работу с адаптером конкретного типа? Особо смелые могут добраться до запроса Install/ Upgrade и выбрать Upgrade. Следующим будет вопрос о типе SCSI-адаптера. Получив ответ, программа попытается проверить состояние пакетов на диске. Монтирование будет выполнено при любом состоянии диска без fsck. Если у вас серьезные проблемы, дополнительная активность не желательна. Как быть?

В момент запуска с CDROM все модули находятся в файле /modules/modules.cgz. Окончание cgz означает, что это архив cpio, который был сжат программой gzip. Gzip/ gunzip доступен сразу, но где взять cpio? На самом CD-ROM, надо только знать место.

Итак, как получить доступ к диску? Доходим до выбора Install/Upgrade, нажимаем ALT-F2 и начинаем работать с bash :

mkdir /tmp/modules

cd /tmp/modules

Получаем требуемый модуль, вариант для «зануд» (попробуйте запомнить):

PATH=$PATH:/tmp/rhimage/misc/

src/trees/rescue/bin

gunzip

Вариант для «волшебников», требуется следующее заклинание:

ln -s /usr/bin/install2 uncpio

./uncpio

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

Продолжим:

insmod blablabla.o

rm *.o

mknod sda1 8 1

mkdir /a

fsck /tmp/sda1

mount /tmp/sda1 /a

Теперь диск можно корректировать.

Присылайте свои вопросы и пожелания по адресу oblakov@bigfoot.com

Почем пирожок?

Зададимся вопросом о том, сколько места занимает какой-либо файл в системе. Например, сколько места в системе занимает файл размером в 1 Гбайт. Этот вопрос показался вам странным? Вы уверены, что килограмм должен весить килограмм, а не 1024 грамма. Однако тут есть несколько моментов. Например, производители памяти думают, что 1 Гбайт это 1024x1024x1024 байт, производители дисков в этом не уверены и 1 Мбайт часто воспринимают как 1000x1024 и, соответственно, 1 Гбайт понимают как 1000x1000x1024. Оставим это на их совести и обратимся к «нормальному» гигабайту. Система несет определенные накладные расходы и где-то должна хранить адреса тех блоков, которые входят в файл. Предположим, что файловая система 32-разрядная. Соответственно номер блока занимает не более 4 байт; как правило, это адрес физического блока и, обычно, 1-4 разряда как-либо используются, что ограничивает размер файловой системы в пределах 128-2048 Гбайт.

При логическом размере блока 1 Кбайт (для других размеров попробуйте посчитать сами), нам потребуется 4 Мбайт на индекс первого уровня. Как организован описатель файла в таблице inode? Обычно первые несколько ссылок (например, заглянув в файл /usr/src/linux/include/ext2_fs.h, можно обнаружить, что для ext2, файловой системы Linux, это число равно 12) явно адресуют блоки. Это позволяет не заводить лишние индексы и, соответственно, не читать их и добиваться лучшей производительности. Далее идут ссылки на одно-, двух-, трехуровневые и т.п. индексы. Очевидно, нам необходимо 4 килоссылки индекса второго уровня для доступа к блокам индекса первого уровня, это требует 16 Кбайт или 16 блоков второго уровня индекса.

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

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

Вернемся вновь к блочной системе. Предположим, размер файла равен 1073741824 байт (наш любимый 1 Гбайт), а размер логического блока 1 Кбайт. Действительно ли файл в этом случае обязательно занимает тот размер, что мы подсчитали? Оказывается, не обязательно.

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

dd if=/etc/passwd seek=10000000

of=tmp.tmp.tmp

ls -l tmp.tmp.tmp

df

rm tmp.tmp.tmp

df

Можно обнаружить, что хотя файл tmp.tmp.tmp имеет достаточно большой размер (сотни мегабайт), df выдают совсем маленькую разницу. В каком виде хранится такой файл? Хранится записанный блок с данными и по одному блоку (если мы не попали в границу) для индексов требуемых уровней. Все промежуточные ссылки за исключением последних равны нулю и это воспринимается системой как указатели на блоки заполненные нулями. Команда du может выдавать вполне корректный результат порядка 4-30 Кбайт в зависимости от диалекта Unix и размера блока в вашей файловой системе.

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