Книга: Введение в QNX/Neutrino 2. Руководство по программированию приложений реального времени в QNX Realtime Platform

Структура администратора ресурсов

Структура администратора ресурсов

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

Мы рассмотрим:

• Функцию resmgr_attach() и ее параметры;

• Подстановку своих собственных функций;

• Общую схему работы администратора ресурсов;

• Сообщения, которые должны бы быть сообщениями установления соединения, но таковыми не являются;

• Составные сообщения.

Функция resmgr_attach() и ее параметры

Как вы уже видели в приведенном выше примере с администратором /dev/null, первое, что вы должны сделать — это зарегистрировать у администратора процессов свою точку монтирования. Это делается с помощью функции resmgr_attach(), которая имеет следующий прототип:

int resmgr_attach(void *dpp, resmgr_attr_t *resmgr_attr,
 const char *path, enum _file_type file_type,
 unsigned flags,
 const resmgr_connect_funcs_t *connect_funcs,
 const resmgr_io_funcs_t *io_funcs,
 RESMGR_HANDLE_T *handle);

Давайте исследуем по порядку ее аргументы и посмотрим, как они применяются.

dpp Дескриптор диспетчера (dispatch handle). Обеспечивает интерфейсу диспетчеризации возможность управлять приемом сообщений для вашего администратора ресурсов.
resmgr_attr Управляет характеристиками администратора ресурсов, как обсуждалось ранее.
path Точка монтирования, которую вы регистрируете. Если вы регистрируете дискретную точку монтирования (как, например, в случае с /dev/null или /dev/ser1), клиент должен указывать ее точно, без каких бы то ни было дополнительных компонентов имени пути в ее конце. Если вы регистрируете каталоговую точку монтирования (как было бы, например, в случае с сетевой файловой системой, монтируемой как /nfs), то соответствие тоже должно быть точным, но с той оговоркой, что в этом случае продолжение имени пути допускается; то, что идет после точки монтирования, будет передано функции установления соединения (например, имя пути /nfs/etc/passwd даст совпадение с точкой монтирования сетевой файловой системой, а «остатком» будет etc/passwd). (Эта особенность, кстати, может пригодиться и там, где на первый взгляд логичнее было бы регистрировать дискретную точку монтирования — см. параграф «Регистрация префикса» раздела «Взгляд со стороны администратора ресурсов» — прим. ред.)
file_type Класс администратора ресурсов. См. ниже.
flags Дополнительные флаги, управляющие поведением вашего администратора ресурсов. Эти флаги выбираются из множества _RESMGR_FLAG_BEFORE, _RESMGR_FLAG_AFTER, _RESMGR_FLAG_DIR и константы 0. Флаги «BEFORE» (букв, «перед») и «AFTER» (букв, «после») указывают на то, что ваш администратор ресурсов хочет зарегистрироваться на данной точке монтирования перед или, соответственно, после других. Эти два флага могут быть полезны, если надо реализовать объединенные файловые системы. Мы вскоре вернемся к взаимосвязи этих флагов. Флаг «DIR.» («каталог») указывает на то, что ваш администратор ресурса хочет обслуживать указанную точку монтирования и все, что находится ниже ее — этот стиль характерен для администратора файловой системы, в противоположность администратору ресурсов, регистрирующему дискретную точку монтирования.
connect_funcs и io_funcs Эти параметры являются просто списком функций установления соединения и функций ввода/вывода, которые вы хотите привязать к точке монтирования.
handle Это «расширяемая» структура (также известная как «атрибутная запись»), описывающая монтируемый ресурс. Например, в случае последовательного порта вы могли бы расширить стандартную атрибутную запись POSIX-уровня информацией о базовом адресе последовательного порта, скорости обмена в бодах, и т.д.

Вы можете вызывать функцию resmgr_attach() столько раз, сколько вам захочется зарегистрировать различных точек монтирования. Вы также можете вызывать функцию resmgr_attach() из тела функций установления соединения или ввода/вывода — эта аккуратная особенность позволяет вам «создавать» устройства «на лету».

Когда вы определились с точкой монтирования и хотите ее зарегистрировать, вы должны сообщить администратору процессов, хочет ли ваш администратор ресурсов обрабатывать запросы от кого попало или только от клиентуры, которая помечает свои сообщения установления соединения специальными метками. Например, рассмотрим драйвер очередей сообщений POSIX (mqueue). Ему совершенно ни к чему «обычные» вызовы open() от старых добрых клиентов — он просто не будет знать, что с ними делать. Он примет сообщения только от тех клиентов, которые используют POSIX-вызовы mq_open(), mq_receive(), и т.п. Чтобы не позволять администратору процессов даже перенаправлять «обычные» запросы администратору очередей mqueue, у этого администратора в параметре параметр file_type задается значение _TYPE_MQUEUE. Это означает, что когда клиент пытается с помощью администратора процессов выполнить разрешение имени, при этом явно не определив, что хочет поговорить с администратором ресурсов, зарегистрированным как _FTYPE_MQUEUE, администратор процессов не будет даже рассматривать администратор mqueue как возможный вариант.

Если только вы не делаете что-либо уж очень специфичное, вам лучше всего подойдет значение file_type, равное _FTYPE_ANY, означающее, что ваш администратор ресурсов готов обработать запрос от любого клиента. Полный список именованных констант _FTYPE_* приведен в файле <sys/ftype.h>.

Что касательно флагов «BEFORE» и «AFTER», тут все становится интереснее. Вы можете задать либо один из этих флагов, либо константу 0.

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

Администратор Флаг Очередность
1 _RESMGR_BEFORE 1
2 _RESMGR_AFTER 1, 2
3 0 1, 3 ,2
4 _RESMGR_BEFORE 1, 4, 3, 2
5 _RESMGR_AFTER 1, 4, 3, 5, 2
6 0 1, 4, 6, 3, 5, 2

Из таблицы видно, что первый администратор ресурса, явно определивший флаг, далее не сдвигается со своей позиции.(См. таблицу: администратор ресурсов № 1 был первым определившим флаг «BEFORE»; кто бы теперь ни зарегистрировался, он так и останется первым в списке. Аналогично, администратор ресурсов № 2 был первым определившим флаг «AFTER» — и снова, независимо от того, кто еще будет регистрироваться после него, он всегда остается в списке последним.) Если не определен никакой флаг, это действует как флаг «между». Когда стартует администратор ресурсов № 3 (указав нулевой флаг), он помещается в середину очереди. Как и в случае с флагами «BEFORE» и «AFTER», здесь имеет место упорядочивание, в результате чего все вновь регистрирующиеся «средние» администраторы ресурсов располагаются перед уже зарегистрированными «средними».

Однако, в действительности, только в очень редких случаях вам придется регистрировать более одного, и в еще меньшем числе случаев — более двух администраторов ресурсов при той же самой точке монтирования. Полезный совет: обеспечьте возможность установки флагов непосредственно в командной строке администратора ресурса, чтобы конечный пользователь вашего администратора ресурса мог сам задать, например, флаг «BEFORE» опцией -b, флаг «AFTER» — опцией , а нулевой флаг («между») был бы, скажем, установкой по умолчанию.

Имейте в виду, что данное обсуждение применимо только для администраторов ресурсов, регистрируемых при одной и той же точке монтирования. Монтирование «/nfs» с флагом «BEFORE» и «/disk2» с флагом «AFTER» не будет иметь никакого взаимного влияния. Однако, если вы затем будете монтировать еще одну «/nfs» или «/disk2», вот тогда эти флаги и проявят себя.

И наконец, функция resmgr_attach() в случае успешного завершения возвращает дескриптор (handle) в виде небольшого целого числа (или -1 при неудаче). Этот дескриптор можно затем применить для того, чтобы убрать данное имя пути из внутренней таблицы имен путей администратора процессов.

Подстановка своих собственных функций

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

Чтобы все работало как надо, рекомендуемым подходом здесь является использование функции-инициализатора iofunc_func_init() из уровня POSIX, чтобы заполнить таблицы функций установления соединения и функций ввода/вывода заданными по умолчанию функциями POSIX-уровня. Это значит; что вы можете фактически написать каркас вашего администратора ресурсов, как мы уже делали выше, с помощью всего нескольких вызовов.

Какую функцию запрограммировать первой — это будет зависеть от того, какой администратор ресурсов вы пишете. Если это администратор файловой системы, отвечающий за точку монтирования и все, что под ней, то вам, скорее всего, лучше всего начать с функции io_open(). С другой стороны, если вы пишете администратор ресурса с дискретной точкой монтирования, который выполняет «традиционные» операции ввода/вывода (то есть вы будете общаться с ним преимущественно вызовами типа read() и write()), то лучшей стартовой позицией для вас были бы функции io_read() и/или io_write(). Если же вы пишете администратор ресурса с дискретной точкой монтирования, но вместо «традиционных» операций ввода/вывода основу его функциональности составляют вызовы типа devctl() или ioctl(), то правильнее было бы начать с io_devctl().

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

Это означает, что если вы захотите что-то дополнительно проконтролировать, просто добавьте дополнительный диагностический вызов printf(), чтобы он сказал что-то типа «Я тут!», а затем делайте «то, что надо сделать» — все очень просто.

Вот фрагмент администратора ресурсов, который перехватывает функцию io_open():

// Упреждающая декларация
int io_open(resmgr_context_t*, io_open_t*,
 RESMGR_HANDLE_T*, void*);
int main() {
 // Все как в примере /dev/null,
 // кроме следующего за этой строкой:
 iofunc_func_init(_RESMGR_CONNECT_NFUNCS, &cfuncs,
  _RESMGR_IO_NFUNCS, &ifuncs);
 // Добавьте это для перехвата управления:
 cfuncs.open = io_open;

Если вы описали функцию io_open() корректно, как в этом примере кода, то вы можете вызывать функцию, заданную по умолчанию, из вашей собственной!

int io_open(resmgr_context_t *ctp, io_open_t *msg,
 RESMGR_HANDLE_T *handle, void *extra) {
 printf("Мы в io_open!n");
 return (iofunc_open_default(ctp, msg, handle, extra));
}

Таким образом, вы по-прежнему применяете POSIX-обработчик по умолчанию iofunc_open_default(), но заодно перехватываете управление для вызова printf().

Очевидно, что вы могли бы выполнить аналогичные действия для функций io_read(), io_write(), io_devctl() и любых других, для которых есть обработчики POSIX-уровня по умолчанию. Идея, кстати, действительно отличная, потому что такой подход показывает вам, что клиент вызывает ваш администратор ресурса именно так, как вы предполагаете.

Общая схема работы администратора ресурсов

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

Это действительно ключевой момент, потому что функция io_open() выполняет для вашего администратора ресурсов функцию «швейцара». Если «швейцар» посмотрит на сообщение и отклонит запрос, вы не получите никаких запросов на ввод/вывод, потому что у клиента не будет корректного дескриптора файла. И наоборот, если «швейцар» пропустит сообщение, тогда клиент получит корректный дескриптор файла, и логично будет ожидать от него сообщений ввода/вывода.

Но на самом деле роль функции io_open() гораздо значительнее. Она отвечает не только за проверку, может клиент открыть ресурс или нет, но также за следующее:

• инициализацию внутренних параметров библиотеки;

• привязку к запросу контекстного блока;

• привязку к контекстному блоку атрибутной записи.

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

Будучи однажды вызвана, io_open() выпадает из рассмотрения. Клиент может либо прислать сообщение ввода/вывода, либо нет, но в любом случае должен будет однажды завершить «сеанс связи» с помощью сообщения, соответствующего функции close(). Заметьте, что если клиента вдруг постигает внезапная смерть (например, он получает SIGSEGV, или выходит из строя узел, на котором он работает), операционная система автоматически синтезирует сообщение close(), чтобы администратор ресурсов смог корректно завершить сессию. Поэтому вы гарантированно получите сообщение close()!

Сообщения, которые должны быть сообщениями установления соединения, но таковыми не являются

Тут есть один интересный момент, который вы, может быть, для себя уже отметили. Прототип клиентской функции chown() имеет вид:

int chown(const char *path, uid_t owner, gid_t group);

Вспомните: сообщение об установлении соединения всегда содержит имя пути и является либо однократным, либо устанавливает контекст для дальнейших сообщений ввода/ вывода.

Так почему же сообщение, соответствующее клиентской функции chown(), не является сообщением установления соединения? К чему здесь сообщение ввода/вывода, когда в прототипе даже дескриптора файла нет?!

Ответ простой — чтобы облегчить вам жизнь.

Представьте себе, что было бы, если бы функции типа chown(), chmod(), stat() и им подобные требовали от администратора ресурсов, чтобы он сначала анализировал имя пути, а затем уже выполнял нужные действия. (Именно так, кстати, все реализовано в QNX4.) Типичные проблемы этого подхода:

• Каждой функции приходится вызывать процедуру поиска.

• Для функций, у которых есть также версия, ориентированная на файловый дескриптор, драйвер должен обеспечить две отдельные точки входа: одну для версии с именем пути, и еще одну — версии с дескриптором файла.

В QNX/Neutrino же происходит следующее. Клиент создает составное сообщение — реально это одно сообщение, но оно включает в себя несколько сообщений администратору ресурсов. Без составных сообщений мы могли бы смоделировать функцию chown() чем-то таким:

int chown(const char *path, uid_t owner, gid_t group) {
 int fd, sts;
 if ((fd = open(path, O_RDWR)) == -1) {
  return (-1);
 }
 sts = fchown(fd, owner, group);
 close(fd);
 return (sts);
}

где функция fchown() — это версия функции chown(), ориентированная на файловые дескрипторы. Проблема здесь в том, что мы в этом случае используем три вызова функций (а значит, и три отдельных транзакции передачи сообщений) и привносим дополнительные накладные расходы применением функций open() и close() на стороне клиента.

При использовании составных сообщений в QNX/Neutrino непосредственно клиентским вызовом chown() создается одиночное сообщение, выглядящее примерно так:


Составное сообщение.

Сообщение состоит из двух частей. Первая часть посвящена установлению соединения (подобно сообщению, которое сгенерировала бы функция open()), вторая — вводу/выводу (эквивалент сообщения, генерируемого функцией fchown()). Никакого эквивалента функции close() здесь нет, поскольку мы выбрали сообщение типа _IO_CONNECT_COMBINE_CLOSE, которое гласит: «Открой указанное имя пути, используй полученный дескриптор файла для обработки остальной части сообщения, а когда закончишь дела или столкнешься с ошибкой, закрой дескриптор».

Написанный вами администратор ресурса даже не заметит, вызвал ли клиент функцию chown() или сначала сделал open(), а потом вызвал fchown() и далее close(). Все это скрыто базовым уровнем библиотеки.

Составные сообщения

Как выясняется, концепция составных сообщений полезна не только для экономии ресурсов вследствие уменьшения числа сообщений (как в случае с chown(), см. выше). Она также критически важна для обеспечения атомарности операций.

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

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

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

Давайте взглянем на библиотечный вызов readblock() (из <unistd.h>):

int readblock(int fd, size_t blksize, unsigned block,
 int numblks, void *buff);

(Функция writeblock() описывается аналогично.)

Вы можете вообразить для функции readblock() довольно «простенькую» реализацию:

int readblock(int fd, size_t blksize, unsigned block,
 int numblks, void *buff) {
 lseek(fd, blksize * block, SEEK_SET); // Идем к блоку
 read(fd, buff, blksize * numblks);
}

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

int readblock(int fd, size_t blksize, unsigned block,
 int numblks, void *buff) {
 pthread_mutex_lock(&block_mutex);
 lseek(fd, blksize * block, SEEK_SET); // Идем к блоку
 read(fd, buff, blksize * numblks);
 pthread_mutex_unlock(&block_mutex);
}

(Мы здесь предполагаем, что мутекс уже инициализирован.)

Этот код по-прежнему уязвим для «незащищенного» доступа — если некий поток вызовет lseek() на этом файловом дескрипторе без предварительной попытки захвата мутекса, вот у нас уже и ошибка.

Решение проблемы заключается в использовании составного сообщения, аналогично вышеописанному случаю с функцией chown(). В данном случае библиотечная реализация функции readblock() помещает обе операции — lseek() и read() — в единое сообщение и посылает это сообщение администратору ресурсов:


Составное сообщение для функции readblock().

Это работает, потому что передача сообщения является атомарной операцией. С точки зрения клиента, сообщение уходит либо целиком, либо не уходит вообще. Поэтому, вмешательство «незащищенной» функции lseek() становится несущественным — когда администратор ресурсов принимает сообщение с запросом readblock(), он делает это за один прием. (Очевидно, что в этом случае пострадает сама «незащищенная» lseek(), поскольку после отработки readblock() смещение на этом дескрипторе файла будет отличаться от того, которое она хотела установить.)

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

Оглавление книги


Генерация: 5.486. Запросов К БД/Cache: 3 / 0
поделиться
Вверх Вниз