2. Микроядро QNX Neutrino


Введение

Микроядро ОС QNX Neutrino реализует основные функции стандартов POSIX, используемые во встраиваемых системах реального времени, а также базовые службы обмена сообщениями. Те POSIX-функции, которые не реализованы в микроядре (например, функции файлового или аппаратного ввода/вывода), выполняются дополнительными процессами или разделяемыми библиотеками.


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

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

На самых нижних уровнях микроядра расположено всего несколько базовых объектов, а также четко отрегулированные процедуры для управления ими. Это и есть та основа, на которой строится ОС QNX Neutrino (рис. 2.1).


Рис. 2.1. Микроядро ОС QNX Neutrino

Некоторые разработчики считают, что компактность и высокая производительность микроядра ОС QNX Neutrino достигается благодаря его реализации в виде ассемблерного кода. В действительности микроядро ОС QNX Neutrino реализовано преимущественно на C, а компактность и высокая производительность достигаются с помощью четко отлаженных алгоритмов и структур данных, а не посредством оптимизации на уровне ассемблерного кода.
Реализация ОС QNX Neutrino
Исторически складывалось так, что операционные системы QNX испытывали на себе "прикладное давление" одновременно с двух сторон спектра вычислительных моделей. С одной стороны, это встраиваемые системы с ограниченными ресурсами памяти, а с другой — высокотехнологичные машины симметричной многопроцессорной обработки с гигабайтами физической памяти. Именно поэтому при разработке ОС QNX Neutrino в качестве проектных целей были приняты оба эти на первый взгляд исключающие друг друга подхода. Их выбор связан со стремлением значительно расширить функциональный диапазон операционных систем QNX за пределы возможностей других ОС.
Потоки и функции реального времени в POSIX
Поскольку в ОС QNX Neutrino большинство служб по обеспечению работы в реальном масштабе времени и по планированию потоков реализуется прямо в микроядре, эти службы могут работать даже без дополнительных модулей ОС.

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

Нужно отметить, что многие исполнительные модули и ядра, предназначенные для работы в реальном масштабе времени, не обеспечивают изоляции между потоками и не имеют модели на основе процессов. Однако без модели на основе процессов невозможно достичь полного соответствия стандартам POSIX.
Системные службы
В микроядре ОС QNX Neutrino существуют системные вызовы (kernel calls), которые служат для управления следующими объектами: ОС QNX Neutrino целиком построена на основе таких вызовов, причем ядро ОС полностью вытесняемо (preemptable), в том числе и во время обмена сообщениями между процессами (обмен сообщениями продолжается с той точки, на которой был прерван перед вытеснением).

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

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

На рис. 2.2 показан механизм вытеснения в ядре (для процессоров с архитектурой x86) без поддержки симметричной многопроцессорной обработки (SMP).

Рис. 2.2. Механизм вытеснения в ОС QNX Neutrino

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

Поток можно рассматривать как минимальную "единицу выполнения", т. е. единицу планирования (scheduling) и выполнения в микроядре. Процесс, в свою очередь, можно рассматривать как объект, который содержит в себе эти потоки и который определяет для их выполнения свое "адресное пространство". Процесс всегда содержит по крайней мере один поток.

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

В следующих далее вызовах из библиотек pthread_* (POSIX Threads) не используются вызовы микроядра по управлению потоками:

pthread_attr_destroy()

pthread_attr_getdetachstate()

pthread_attr_getinheritsched()

pthread_attr_getschedparam()

pthread_attr_getschedpolicy()

pthread_attr_getscope()

pthread_attr_getstackaddr()

pthread_attr_getstacksize()

pthread_attr_init()

pthread_attr_setdetachstate()

pthread_attr_setinheritsched()

pthread_attr_setschedparam()

pthread_attr_setschedpolicy()

pthread_attr_setscope()

pthread_attr_setstackaddr()

pthread_attr_setstacksize()

pthread_cleanup_pop()

pthread_cleanup_push()

pthread_equal()

pthread_getspecic()

pthread_setspecic()

pthread_key_create()

pthread_key_delete()

pthread_self()

В табл. 2.1 приведен список вызовов по управлению потоками POSIX и соответствующие вызовы микроядра.

Таблица 2.1. Вызовы по управлению потоками POSIX и соответствующие вызовы микроядра

POSIX-вызов

Вызов микроядра

Описание

pthread_create()

ThreadCreate()

Создать новый поток

pthread_exit()

ThreadDestroy()

Уничтожить поток

pthread_detach()

ThreadDetach()

Отсоединить поток, чтобы не ждать его завершения

pthread_join()

ThreadJoin()

Присоединить поток и ждать его кода завершения

pthread_cancel()

ThreadCancel()

Завершить поток в следующей точке завершения

отсутствует

ThreadCtl()

Изменить характеристики потока, специфичные для ОС QNX Neutrino

pthread_mutex_init()

SyncTypeCreate()

Создать мутекс

pthread_mutex_destroy()

SyncDestroy()

Уничтожить мутекс

pthread_mutex_lock()

SyncMutexLock()

Блокировать мутекс

pthread_mutex_trylock()

SyncMutexLock()

Условно блокировать мутекс

pthread_mutex_unlock()

SyncMutexUnlock()

Снять блокировку мутекса

pthread_cond_init()

SyncTypeCreate()

Создать условную переменную

pthread_cond_destroy()

SyncDestroy()

Уничтожить условную переменную

pthread_cond_wait()

SyncCondvarWait()

Ожидать условную переменную

pthread_cond_signal()

SyncCondvarSignal()

Разблокировать один из потоков, блокированных на условной переменной

pthread_cond_broadcast()

SyncCondvarSignal()

Разблокировать все потоки, блокированные на условной переменной

pthread_getschedparam()

SchedGet()

Получить параметры планирования и дисциплину потока

pthread_setschedparam() pthread_setschedprio()

SchedSet()

Установить параметры планирования и дисциплину потока

pthread_sigmask()

SignalProcMask()

Проверить или вывести маску сигналов потока

pthread_kill()

SignalKill()

Отправить сигнал потоку


ОС QNX Neutrino можно конфигурировать определенным образом для реализации некоторого набора потоков и процессов (в соответствии со стандартами POSIX). Все процессы отделены друг от друга с помощью блока управления памятью (Memory Management Unit, MMU), и каждый процесс может содержать один или несколько потоков, использующих адресное пространство процесса.

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

Примечание

Хотя термин "межзадачное взаимодействие" (InterProcess Communication, IPC) обычно относят к процессам, он используется для обозначения взаимодействия между потоками (внутри одного процесса или внутри разных процессов).

Информацию о процессах и потоках с точки зрения программирования можно найти в главе «Процессы и потоки» книги Р. Кртена «Введение в QNX Neutrino».
Атрибуты потока
Хотя потоки внутри процесса и используют совместно одно общее адресное пространство этого процесса, каждый из этих потоков имеет некоторые "собственные" данные. В некоторых случаях эти данные защищаются внутри ядра (например, идентификатор потока tid), в то время как другие данные остаются незащищенными в адресном пространстве процесса (например, каждый поток имеет свой собственный стек). Перечислим некоторые из наиболее важных ресурсов, относящихся к потоку: Данные, относящие к потоку, реализуются в библиотеке pthread и хранятся в локальной памяти потока. Они обеспечивают механизм, предназначенный для связывания глобального целочисленного ключа процесса (process global integer key) с уникальным значением данных для каждого потока. Для того чтобы использовать данные потока, сначала создается новый ключ, а затем с этим ключом связывается уникальное значение данных (по каждому потоку). Значение данных может, например, представлять собой целое число или являться указателем на динамическую структуру данных (dynamically allocated data structure). После этого ключ может по каждому потоку возвращать то значение данных, с которым он связан.

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

Рис. 2.3. Разреженная матрица (tid, key) отображения значений

Для создания и управления этими данными используются следующие функции — табл. 2.2.

Таблица 2.2. Функции для управления и создания данных потока

Функция

Описание

Pthread_key_create()

Создать ключ данных с функцией-деструктором

Pthread_key_delete()

Уничтожить ключ данных

Pthread_setspecific()

Связать значение данных с ключом данных

Pthread_getspecific()

Получить значение данных, связанное с ключом данных

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

Завершение потока (pthread_exit(), pthread_cancel()) включает в себя останов потока и освобождение ресурсов потока. Говоря в целом, когда поток запущен, он может находиться в одном из двух состояний: "готов" (ready) или "блокирован" (blocked). Если же говорить более детально, то поток может иметь одно из следующих состояний (рис. 2.4):


Рис. 2.4. Возможные состояния потока. Замечание: в дополнение к показанным выше переходам, поток может перейти из любого состояния (кроме DEAD) в состояние READY.

Планирование потоков

Выполнение операций планирования

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

Как правило, выполнение приостановленного потока через некоторое время возобновляется. При этом планировщик (scheduler) выполняет переключение контекстов с одного потока на другой всякий раз, когда активный поток:
Когда поток блокируется
Активный поток блокируется, если он должен ожидать какое-либо событие (например, ответ на запрос механизма обмена сообщениями, освобождение мутекса и т. д.). Блокированный поток удаляется из очереди готовности (ready queue), после чего запускается поток с наивысшим приоритетом. Когда блокированный поток разблокируется, он помещается в конец очереди готовности на соответствующий приоритетный уровень.
Когда поток вытесняется
Активный поток вытесняется, когда поток с более высоким приоритетом помещается в очередь готовности (т. е. он переходит в состояние готовности (READY) в результате снятия условия блокировки). Прерванный поток остается на соответствующем приоритетном уровне в начале очереди готовности, а поток с более высоким приоритетом начинает выполняться.
Когда поток отдает управление
Активный поток самостоятельно освобождает процессор (sched_yield()) и помещается в конец очереди готовности на данном уровне приоритета. После этого запускается поток с наивысшим приоритетом (в том числе им может быть поток, который только что отдал управление).
Планирование и приоритеты
Каждому потоку назначается свой приоритет. Планировщик выбирает поток для выполнения в соответствии с приоритетом каждого потока, находящегося в состоянии готовности (READY), т. е. способного использовать процессор. Таким образом, выбирается поток с наивысшим приоритетом.

Следующая схема показывает очередь из пяти потоков (B-F) находящихся в состоянии готовности.

Поток A в настоящий момент является активным. Остальные потоки (G-Z) блокированы (BLOCKED). Потоки A, B и C имеют наивысший приоритет, поэтому они совместно используют процессор в соответствии с алгоритмом планирования активного потока.
 

Рис. 2.5. Очередь готовности

Всего в ОС QNX Neutrino поддерживается до 256 уровней приоритетов. Приоритет выполнения каждого непривилегированного (nonroot) потока может изменяться в пределах от 1 до 63 (наивысший приоритет), независимо от его дисциплины планирования (scheduling policy). Только привилегированные (root) потоки (т. е. потоки, действующий uid которых равен 0) могут иметь приоритет выше 63. Специальный поток с именем idle в администраторе процессов, имеет приоритет 0 и всегда готов к выполнению. По умолчанию поток наследует приоритет своего родительского потока.

Команда procnto -p позволяет изменить диапазон допустимых приоритетов для непривилегированного процесса:

procnto -p приоритет


В табл. 2.3. приводится список всех диапазонов приоритетов.

Таблица 2.3. Диапазоны приоритетов

Уровень приоритета

Владелец

0

поток idle

от 1 до приоритет – 1

непривилегированный или привилегированный поток

от приоритет до 255

привилегированный поток


Следует обратить внимание на то, что для предотвращения инверсии приоритетов (priority inversion) ядро может временно повышать приоритет потока. Дополнительную информацию см. в «Наследование приоритетов и мутексы» далее в этой главе и «Наследование приоритетов и сообщения» в главе Механизм межзадачного взаимодействия (IPC). Начальный приоритет потоков ядра равен 255, они сразу же блокикуются MsgReceive(), поэтому они работают с приоритетом потоков, которые передают им сообщения.

Потоки, находящиеся в очереди, упорядочиваются по приоритету. В действительности очередь готовых потоков состоит из 256 отдельных очередей — по одной на каждый приоритет. Потоки выстраиваются в очереди по порядку поступления (FIFO) и в соответствии с их приоритетом. Для выполнения выбирается первый поток с наивысшим приоритетом.

Большую часть времени потоки выстраиваются в очереди по порядку поступления (FIFO) и в соответствии с их приоритетом, но есть исключения:
Алгоритмы планирования
Для работы с различными приложениями в ОС QNX Neutrino используются следующие алгоритмы планирования: Каждый поток в системе может выполняться по любому из методов. Методы применяются для каждого отдельного потока, а не для всех потоков или процессов одновременно.

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

На рис. 2.6 схематически показаны три потока с одинаковым приоритетом, находящиеся в состоянии готовности. Если поток A будет блокирован, то запустится выполнение потока B.


Рис. 2.6. После блокировки потока А запускается выполнение потока B

Хотя поток наследует алгоритм планирования от своего родительского процесса, он может сделать запрос на его изменение.
FIFO-планирование
В алгоритме FIFO-планирования (рис. 2.7) поток продолжает выполняться до тех пор, пока он:

Рис. 2.7. FIFO-планирование

Циклическое планирование
В алгоритме планирования циклического типа поток продолжает выполняться до тех пор, пока он:


Рис. 2.8. Циклическое планирование

Как показано на рис. 2.8, поток A выполняется до тех пор, пока он не израсходует свой квант времени, после чего начинается выполнение следующего потока, находящегося в состоянии готовности (поток B).

Квант времени — это единица времени, выделяемого каждому процессу. После того как поток расходует свой квант времени, он прерывается, и управление получает следующий поток, который находится в состоянии готовности и на том же приоритетном уровне. Квант времени определяется как 4 × тактовый интервал (clock period). (Более подробное определение см. в статье ClockPeriod() в "Справочнике по библиотекам языка Си" (Library Reference).)

Замечание


Алгоритм циклического планирования отличается от алгоритма FIFO-планирования только использованием временного квантования.
Спорадическое планирование
Алгоритм спорадического планирования обычно используется для задания верхнего лимита на время выполнения потока в пределах заданного периода времени. Этот метод необходим при выполнении монотонного частотного анализа (Rate Monotonic Analysis) системы, обслуживающей как периодические, так и апериодические события. По сути, данный алгоритм позволяет потоку обслуживать апериодические события, не препятствуя своевременному выполнению других потоков или процессов в системе.

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


При спорадическом планировании, приоритет потока может динамически изменяться между
приоритетом переднего плана (foreground) (или нормальным приоритетом) и фоновым (background) (или пониженным) приоритетом. Для управления этим спорадическим переходом используются следующие параметры:

Замечание


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


Как видно из рис. 2.9, алгоритм спорадического планирования устанавливает начальный бюджет выполнения потока (C), который расходуется потоком в процессе его выполнения и пополняется с периодичностью, определенной параметром T. Когда поток блокируется, израсходованная часть бюджета выполнения потока (R) пополняется через какое-то установленное время (например, через 40 мс), отсчитываемое от момента, когда поток перешел в состояние готовности.

 

Рис. 2.9. Пополнение периода выполнения потока происходит периодически

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

Представим, например, систему, в которой поток никогда не блокируется и не прерывается — рис. 2.10.

Рис. 2.10. Приоритет потока снижается до того момента, пока его бюджет выполнения не пополнится

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

Как только происходит пополнение, приоритет потока повышается до начального уровня. Таким образом, в правильно настроенной системе поток выполняется каждый период времени Т в течение максимального времени С. Это обеспечивает такой порядок, при котором каждый поток, выполняемый с приоритетом N, будет использовать только C/T процентов системных ресурсов.

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

Рис. 2.11. Приоритет потока изменяется между повышенным и пониженным

На схеме рис. 2.11 видно, что в течение каждого 40-мс периода пополнения (T) бюджет выполнения потока (C) составляет 10 мс.
  1. Поток блокируется через 3 мс, поэтому 3-мс операция пополнения будет запланирована к выполнению через 40 мс, т. е. на тот момент завершения первого периода пополнения.

  2. Выполнение потока возобновляется на 6-й миллисекунде, и этот момент становится началом следующего периода пополнения (T). В бюджете выполнения потока еще остается запас в 7 мс.

  3. Поток выполняется без блокировки в течение 7 мс, в результате чего бюджет выполнения потока исчерпывается, и приоритет потока снижается до уровня L, на котором он сможет или не сможет получить управление. Пополнение в объеме 7 мс запланировано произойти на 46-й миллисекунде (40 + 6), т. е. по истечении периода T.

  4. На 40-й миллисекунде бюджет потока пополняется на 3 мс (см. шаг 1 на схеме), в результате чего приоритет потока поднимается до нормального.

  5. Поток расходует 3 мс своего бюджета и затем снова переходит на пониженный приоритет.

  6. На 46-й миллисекунде бюджет потока пополняется на 7 мс (см. шаг 3), и поток снова получает нормальный приоритет.

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

В дополнение к приоритету можно также изменять алгоритм планирования, применяемый ядром для потока. Хотя библиотеки предоставляют множество различных путей получения и установки параметров алгоритма планирования, лучшим вариантом будут pthread_getschedparam(), pthread_setschedparam() и pthread_setschedprio(). Дополнительную информацию по другим вариантам смотрите в “Scheduling policies” в главе «Programming Overview» электронного документа “QNX Neutrino Realtime Operation System. Programmers Guide”.

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

Таблица 2.4. POSIX-вызовы для управления потоком и вызовы микроядра

POSIX-вызов

Вызов микроядра

Описание

sched_getparam()

SchedGet()

Получить приоритет.

sched_setparam()

SchedSet()

Установить приоритет.

sched_getscheduler()

SchedGet()

Получить дисциплину планирования.

sched_setscheduler()

SchedSet()

Установить дисциплину планирования.


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

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

Мутексы, семафоры и условные переменные — примеры инструментов синхронизации, которые предназначены для решения этой проблемы. Описание этих инструментов будет дано далее в этом разделе.

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

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

Изначально потоки возникли в UNIX-системах как "легковесный" механизм управления параллелизмом, предназначенный для решения проблемы слишком медленного переключения контекстов между "тяжеловесными" процессами. Конечно, сам по себе такой механизм весьма полезен, но возникает очевидный вопрос: почему переключения контекстов между процессами происходят так медленно?

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

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

Кроме того, потоки могут дать такие возможности параллельной обработки, которыми не обладает модель на основе процессов. Например, серверный процесс файловой системы (filesystem server process), предназначенный для обработки запросов, посылаемых множеством клиентов (при этом каждый запрос требует значительного времени для завершения), стал бы значительно более эффективным, если бы состоял из множества потоков. Если один клиентский процесс запрашивает блок данных с диска, а другой клиент запрашивает блок данных, который уже находится в кэше, процесс файловой системы может использовать группу потоков для одновременного обслуживания клиентских запросов вместо того, чтобы оставаться "занятым" во время считывания блока данных по первому запросу.

По мере поступления запросов каждый поток может отвечать на них, обращаясь напрямую к кэшу, или блокировать их и ждать ввода/вывода данных с диска, не увеличивая время реакции (response latency), как это происходит в других клиентских процессах. Сервер файловой системы может заранее создавать ("precreate") группу потоков, готовых последовательно реагировать на клиентские запросы по мере их поступления. Хотя такая модель усложняет архитектуру администратора файловой системы, она значительно увеличивает возможности параллельной обработки.
Службы синхронизации
В OC QNX Neutrino используются POSIX-примитивы для синхронизации на уровне потоков. Некоторые из этих примитивов могут применяться для потоков в разных процессах. К службам синхронизации относятся, по крайней мере, следующие объекты — табл. 2.5.

Таблица 2.5. Службы синхронизации

Служба синхронизации

Межзадачная поддержка

Сетевая поддержка

Мутекс

Да

Нет

Условная переменная

Да

Нет

Барьер

Нет

Нет

Ждущая блокировка

Нет

Нет

Блокировка чтения/записи

Да

Нет

Семафор

Да

Да (только для именованных)

FIFO-планирование

Да

Нет

Отправка/получение/ответ

Да

Да

Атомарная операция

Да

Нет


        Замечание


Приведенные ранее примитивы синхронизации реализуются непосредственно ядром, за исключением:
Блокировки взаимного исключения (мутексы)
Наиболее простыми из служб синхронизации являются мутексы. Мутекс (от англ. mutex (mutual exclusion lock)) служит для обеспечения монопольного доступа к данным, которые совместно используются несколькими потоками. Операциями захвата мутекса (с помощью функции pthread_mutex_lock()) и освобождения мутекса (с помощью функции pthread_mutex_unlock()) обычно обрамляются участки кода, который обращается к совместно используемым данным (обычно это критическая секция кода).

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

В большинстве процессоров захват мутекса не требует обращения к ядру. Это достигается благодаря операции "сравнить и переставить" (compare-and-swap opcode) в семействе x86, а также посредством условных инструкций "загрузить/сохранить" на большинстве процессоров семейства RISC.

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

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

Для обеспечения такого поведения, функция pthread_mutexattr_init() устанавливает атрибут PTHREAD_PRIO_INHERIT. Для изменения этого значения может использоваться функция pthread_mutexattr_setprotocol(). Значение приоритета потока при вызове функции pthread_mutex_trylock() не изменяется, поскольку данный вызов является неблокирующим.

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

Замечание


Рекурсивные мутексы не входят в стандарты POSIX и не работают с условными переменными.
Условные переменные
Условная переменная (condvar – сокр. от condition variable) используется для блокировки потока по какому-либо условию во время выполнения критической секции кода. Условие может быть сколь угодно сложным и не зависит от условной переменной. Однако условная переменная всегда должна использоваться совместно с мутексом для проверки условия.

Условные переменные поддерживают следующие функции:

Замечание


Следует иметь в виду, что единичная разблокировка потока, обозначаемая термином "signal", никак не связана с понятием сигнала в стандартах POSIX.

Приведем пример типичного использования условной переменной:

pthread mutex lock( &m );

. . .

while (!arbitrary condition) {

pthread cond wait( &cv, &m );

}

. . .

pthread mutex unlock( &m );


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

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

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

Другой вид операции ожидания условной переменной (pthread_cond_timedwait()) позволяет установить таймаут. По окончании этого периода ожидающий поток может быть разблокирован.

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

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

Барьер создается с помощью функции pthread_barrier_init():

#include <pthread.h>

int

pthread barrier init (pthread barrier t *barrier,

const pthread barrierattr t *attr,

unsigned int count);


В результате выполнения этого кода создается барьер по заданному адресу (указатель на барьер находится в аргументе barrier) и с атрибутами, установленными аргументом attr. Аргумент count задает количество потоков, которые должны вызвать функцию pthread_barrier_wait().

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

#include <pthread.h>

int pthread barrier wait (pthread barrier t *barrier);


Когда поток вызывает функцию pthread_barrier_wait(), он блокируется до тех пор, пока то число потоков, которое было задано функцией pthread_barrier_init(), не вызовет функцию pthread_barrier_wait() и, соответственно, не заблокируется. После того как заданное количество потоков вызовет функцию pthread_barrier_wait(), все они разблокируются одновременно.

Листинг 2.1

/*
* barrier1.c

*/

#include

#include

#include

#include

#include

#include

<stdio.h>

<unistd.h>

<stdlib.h>

<time.h>

<pthread.h>

<sys/neutrino.h>

pthread_barrier_t

barrier; // объект синхронизации типа "барьер"

void *

thread1 (void *not_used)

{

time_t now;

time (&now);

printf ("thread1 starting at %s", ctime (&now));

// выполнение вычислений

// пауза

sleep (20);

pthread_barrier_wait (&barrier);

// после этого момента все три потока завершены

time (&now);

printf ("barrier in thread1() done at %s", ctime (&now));

}

void *

thread2 (void *not_used)

{

time_t now;

time (&now);

printf ("thread2 starting at %s", ctime (&now));

// выполнение вычислений

// пауза

sleep (40);

pthread_barrier_wait (&barrier);

// после этого момента все три потока завершены

time (&now);

printf ("barrier in thread2() done at %s", ctime (&now));

}

int main () // игнорировать аргументы

{

time_t now;

// создать барьер со значением счетчика 3

pthread_barrier_init (&barrier, NULL, 3);

// стартовать два потока thread1 и thread2

pthread_create (NULL, NULL, thread1, NULL);

pthread_create (NULL, NULL, thread2, NULL);

// потоки thread1 и thread2 выполняются

// ожидание завершения

time (&now);

printf ("main() waiting for barrier at %s", ctime (&now));

pthread_barrier_wait (&barrier);

// после этого момента все три потока завершены

time (&now);

printf ("barrier in main() done at %s", ctime (&now));

pthread_exit( NULL );

return (EXIT_SUCCESS);

}

В примере из листинга 2.1 основной поток создает барьер, после запуска которого начинается подсчет количества потоков, заблокированных на барьере для синхронизации. В данном случае количество синхронизируемых потоков задается равным 3: поток main(), поток thread1() и поток thread2().

Запускаются потоки thread1() и thread2(). Для наглядности в потоке задается пауза, чтобы имитировать процесс вычислений. Для выполнения синхронизации основной поток блокируется на барьере и ожидает разблокировки, которая произойдет после того, как остальные два потока не присоединятся к нему на этом барьере.

В данную версию ОС QNX Neutrino включены следующие функции работы с барьерами — табл. 2.6.

Таблица 2.6. Функции работы с барьерами

Функция

Описание

pthread_barrierattr_getpshared()

Получить значение атрибута совместного использования для заданного барьера

pthread_barrierattr_destroy()

Уничтожить атрибутную запись барьера

pthread_barrierattr_init()

Инициализировать атрибуты объекта

pthread_barrierattr_setpshared()

Установить значение атрибута совместного использования для заданного барьера

pthread_barrier_destroy()

Уничтожить барьер

pthread_barrier_init()

Инициализировать барьер

pthread_barrier_wait()

Синхронизировать потоки на барьере

Ждущие блокировки
Ждущие блокировки (sleepon locks) работают аналогично условным переменным, за исключением некоторых деталей. Как и условные переменные, ждущие блокировки (pthread_sleepon_lock()) могут использоваться для блокировки потока до тех пор, пока условие не станет истинным (аналогично изменению значения ячейки памяти). Но в отличие от условных переменных (которые должны существовать для каждого проверяемого условия), ждущие блокировки применяются к одному мутексу и динамически создаваемой условной переменной независимо от количества проверяемых условий. Максимальное число условных переменных в конечном итоге равно максимальному числу блокированных потоков. Этот вид блокировок аналогичен тем, которые применяются в ядре UNIX.
Блокировки по чтению/записи
Блокировки по чтению/записи (reader/writer locks) (или более точное название "блокировки на множественное чтение и однократную запись") используются в тех случаях, когда доступ к структуре данных должен определяться по следующей схеме: чтение данных выполняет множество потоков, запись — не более одного потока. Хотя этот вид блокировок несет больше накладных расходов, чем мутексы, он позволяет организовывать описанную схему доступа к данным.

Блокировка по чтению/записи (pthread_rwlock_rdlock()) предоставляет доступ по чтению всем потокам, которые его запрашивают. Однако если поток запрашивает блокировку по записи (pthread_rwlock_wrlock()), запрос отклоняется до тех пор, пока все потоки, выполняющие чтение, не снимут свои блокировки по чтению (pthread_rwlock_unlock()).

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

Существуют специальные вызовы (pthread_rwlock_tryrdlock() и pthread_rwlock_trywrlock()), которые позволяют потоку тестировать возможность доступа к необходимой блокировке, оставаясь в активном состоянии. Эти вызовы возвращают код завершения, сообщающий о возможности или невозможности установки блокировки.

Реализация блокировок по чтению/записи происходит не в ядре, а посредством мутексов и условных переменных, предоставляемых ядром.
Семафоры
Еще одним средством синхронизации являются семафоры (semaphores), которые позволяют потокам увеличивать (с помощью функции sem_post()) или уменьшать (с помощью функции sem_wait()) значение счетчика на семафоре для управления блокировкой потока (операции "post" и "wait" соответственно).

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

Существенным отличием семафоров от других примитивов синхронизации является то, что семафоры безопасны для применения в асинхронной среде ("async safe") и могут управляться обработчиками сигналов. Семафоры как раз подходят для тех случаев, когда требуется пробудить поток с помощью обработчика сигнала.

Замечание


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

Другим полезным свойством семафоров является то, что они были определены для работы между процессами. Хотя мутексы в QNX Neutrino тоже работают между процессами, стандарты POSIX рассматривают эту возможность как дополнительную функцию, которая может оказаться непереносимой между системами. Что касается синхронизации между потоками внутри одного процесса, то здесь мутексы более эффективны, чем семафоры.

Полезной разновидностью семафоров является служба именованных семафоров (named semaphore service). Эта служба использует администратор ресурсов и позволяет применять семафоры между процессами, выполняемыми на разных машинах внутри сети.

Замечание

Именованные семафоры работают медленнее, чем неименованные.

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

while (sem wait(&s) && errno == EINTR) { do nothing(); }

do critical region();/* Значение семафора уменьшилось. */

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

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

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


ВНИМАНИЕ!


Метод монопольного доступа неприменим в многопроцессорных системах, поскольку в таких системах несколько процессоров могут одновременно исполнить код, который в однопроцессорной машине был бы запланирован на последовательное исполнение.
Синхронизация с помощью механизма обмена сообщениями
Службы обмена сообщениями (Send/Receive/Reply), используемые в ОС QNX Neutrino (см. описание далее), осуществляют неявную синхронизацию посредством блокировок. Во многих случаях они могут заменить собой другие службы синхронизации. Кроме того, службы обмена сообщениями — единственные примитивы синхронизации и межзадачного взаимодействия (кроме именованных семафоров, основанных на механизме обмена сообщениями), которые могут работать в сети.
Синхронизация с помощью атомарных операций
В некоторых случаях необходимо выполнить какую-либо небольшую операцию (например, увеличить значение переменной) атомарно, т. е. с гарантией того, что она не будет вытеснена другим потоком или каким-либо обработчиком прерываний (Interrupt Service Routine, ISR).

В ОС QNX Neutrino атомарные операции применяются для:
Атомарные операции можно задействовать, подключив заголовочный файл <atomic.h>.

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

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

Таблица 2.7. Вызовы микроядра и соответствующие POSIX-вызовы

Вызов микроядра

POSIX-вызов

Описание

SyncTypeCreate()

pthread_mutex_init(), pthread_cond_init(), sem_init()

Создать объект для мутекса, условной переменной или семафора

SyncDestroy()

pthread_mutex_destroy(), pthread_cond_destroy(), sem_destroy

Уничтожить объект синхронизации

SyncCondvarWait()

pthread_cond_wait(), pthread_cond_timedwait()

Блокировать поток на условной переменной

SyncCondvarSignal()

pthread_cond_broadcast(), pthread_cond_signal()

Пробудить потоки, блокированные на условной переменной

SyncMutexLock()

pthread_mutex_lock(), pthread_mutex_trylock()

Захватить мутекс

SyncMutexUnlock()

pthread_mutex_unlock()

Освободить мутекс

SyncSemPost()

sem_post()

Увеличить значение счетчика на семафоре

SyncSemWait()

sem_wait(), sem_trywait()

Уменьшить значение счетчика на семафоре

Службы управления часами и таймерами
Службы управления часами (clock services) используются для отсчета системного времени, которое необходимо ядру для обеспечения работы интервальных таймеров, реализуемых посредством соответствующих системных вызовов.

Замечание


В ОС QNX Neutrino значение даты допустимо в диапазоне от января 1970 года до января 2554 года, хотя в соответствии со стандартами POSIX программный код не может содержать значения даты больше 2038 года. Внутреннее представление даты и времени не может превышать максимального значения 2554. Если система должна оперировать значениями больше 2554, но возможность провести модификацию системы отсутствует, следует проявлять особое внимание при работе с системными датами (при необходимости, пожалуйста, обратитесь по этому вопросу в компанию QNX Software Systems).

Вызов ядра ClockTime() позволяет установить системные часы с идентификатором ID (CLOCK_REALTIME) или получить их значение. После установки значение системного времени увеличивается на некоторое число наносекунд в зависимости от разрешения системных часов. Получить или установить значение этого разрешения можно с помощью вызова ClockPeriod().

Системная страница (system page), которая служит в качестве ОЗУ-резидентной структуры данных, содержит 64-битное поле (nsec), которое отображает количество наносекунд, прошедшее с момента начальной загрузки системы. Поле nsec всегда увеличивается монотонным образом и никогда не зависит от текущего времени, установленного функцией ClockTime() или ClockAdjust().

Функция ClockCycles() возвращает текущее значение автономно работающего 64-битного счетчика циклов. Это обеспечивает на любом процессоре высокопроизводительный механизм для отсчета коротких интервалов времени. Например, на процессорах Intel x86 выполняется операция чтения счетчика "тиков" времени. На процессорах Pentium такой счетчик увеличивает свое значение на каждом такте. Таким образом, на процессоре Pentium с частотой 100 МГц один цикл будет длиться 1/100 000 000 секунды (10 наносекунд). В других процессорных архитектурах используются аналогичные механизмы.

В некоторых процессорах (например, в 386-ом) данный механизм не реализуется аппаратными средствами, а эмулируется ядром. Это позволяет получить более высокое разрешение времени (838,095345 наносекунд в системе типа IBM PC), чем при использовании машинной команды.

Во всех случаях поле SYSPAGE_ENTRY(qtime)->cycles_per_sec отображает значение приращения счетчика ClockCycles() в одну секунду.

Функция ClockPeriod() позволяет потоку установить разрешающую способность системного таймера в какое-либо значение, кратное наносекундам. Ядро ОС QNX Neutrino использует все аппаратные возможности для достижения заданной точности.

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

В табл. 2.8 приведены функции, используемые при работе со временем.

Таблица 2.8. Вызовы микроядра и соответствующие POSIX-вызовы

Вызов микроядра

POSIX-вызов

Описание

ClockTime()

clock_gettime(),

clock_settime()

Получить или установить время и дату (используя 64-битное значение в наносекундах в диапазоне от 1970 до 2554)

ClockAdjust()

отсутствует

Применить тонкую корректировку времени для синхронизации часов

ClockCycles()

отсутствует

Прочитать значение 64-битного высокоточного автономного счетчика

ClockPeriod()

clock_getres()

Получить или установить размер периода часов

ClockId()

clock_getcpuclockid(),

pthread_getcpuclockid()

Получить целое значение, переданное функции ClockTime() как clockid_t

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

Модель таймеров стандарта POSIX имеет весьма широкие возможности. Срок действия таймера может определяться следующими параметрами: Циклический режим имеет очень большое значение, так как наиболее часто таймер используется в качестве периодического источника событий — он помогает "пробудить" поток, чтобы он выполнил какой-то цикл вычислений, и затем снова "усыпить" его до возникновения следующего события. Необходимость перепрограммировать таймер на каждое следующее событие могла бы привести к сбоям отсчета времени, за исключением случаев, когда программирование выполняется по абсолютному времени. Если бы запуску потока по таймеру могло бы препятствовать вытеснение данного потока потоком с более высоким приоритетом, то следующая дата, на которую установлен таймер, могла бы быть пропущена!

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

Так как таймеры являются разновидностью источников событий в ОС, они также используют систему передачи событий. Это дает возможность приложению потребовать, чтобы все события, реализуемые в QNX Neutrino, доставлялись при возникновении таймаута.

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

alarm(...);

.

. ← Подача сигнала.

.

blocking_call();


Для решения этой проблемы в ОС QNX Neutrino используется запрос таймаута, который является атомарным по отношению к самому запросу службы. Другим решением могло бы быть применение необязательного параметра таймаута в каждом запросе службы, однако это чрезмерно усложнило бы структуру запроса, в то время как параметр использовался бы далеко не всегда.

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

Поскольку в ОС QNX Neutrino предусмотрено только очень небольшое количество состояний блокировки, этот механизм работает очень быстро. По завершении либо запроса, либо таймаута, таймер будет отключен и управление отдано обратно приложению.

TimerTimeout(...);

.

.

.

blocking_call();

.

. ← Таймер атомарно включается в ядре.

.

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

Таблица 2.9. Вызовы микроядра и соответствующие POSIX-вызовы

Вызов микроядра

POSIX-вызов

Описание

TimerAlarm()

alarm()

Установить для процесса "будильник"

TimerCreate()

timer_create()

Создать интервальный таймер

TimerDestroy()

timer_delete()

Уничтожить интервальный таймер

TimerGettime()

TimerInfo()

timer_gettime()

Получить остаток времени в интервальном таймере

TimerGetoverrun()

TimerInfo()

timer_getoverrun()

Получить количество переполнений интервального таймера

TimerSettime()

timer_settime()

Запустить интервальный таймер

TimerTimeout()

sleep(), nanosleep(), sigtimedwait(), pthread_cond_timedwait(), pthread_mutex_trylock(), intr_timed_wait()

Включить таймаут ядра для какого-либо состояния блокировки


Для более подробной информации см. главу «Clocks, Timers, and Getting a Kick Every So Often» в электронном документе «QNX Neutrino Realtime Operation System. Getting Started with QNX Neutrino: A Guide for Realtime Programmers».

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

Наиболее важными формами задержки являются следующие: задержка обработки прерывания (interrupt latency) и задержка планирования (scheduling latency).

Замечание


Время задержки может быть очень различным в зависимости от производительности процессора и других факторов. Более подробную информацию можно найти на веб-сайте компании QNX Software Systems (www.qnx.com).
Задержка обработки прерывания
Задержка обработки прерывания(interrupt latency) — это время, прошедшее от момента возникновения аппаратного прерывания до выполнения первой команды обработчиком прерывания в драйвере устройства. В ОС QNX Neutrino прерывания почти всегда остаются разрешенными, поэтому задержка обработки прерывания обычно незначительная. Однако некоторые критические секции программного кода требуют временного запрета прерываний. Обычно максимальное время такого запрета и определяет наибольшую задержку обработки прерывания, и в QNX Neutrino оно составляет очень небольшое значение.

На рис. 2.12 показано, как происходит обработка аппаратного прерывания обработчиком прерываний. Обработчик прерываний либо просто возвращается, либо возвращается и запускает событие.



Til – задержка обработки прерываний
T
int – время обработки прерываний
Tiret – время завершения прерывания

Рис . 2.12. Простое завершение обработчика прерываний

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

Задержка планирования (рис. 2.13) — это время между последней командой обработчика прерываний и выполнением первой команды потока драйвера. Как правило, это означает время, за которое выполняется сохранение контекста текущего потока и загрузка контекста требуемого потока драйвера. Хотя задержка планирования происходит дольше, чем задержка обработки прерывания, в ОС QNX Neutrino этот период времени также имеет довольно небольшое значение.

Til – задержка обработки прерываний
Tint – время обработки прерываний
Tsl – задержка планирования

Рис. 2.13. Обработчик прерываний завершает работу, возвращая событие

Важно отметить, что большинство прерываний завершается без передачи события. Как правило, обработчик прерываний может самостоятельно решить все вопросы аппаратного уровня. Передача события для пробуждения потока драйвера верхнего уровня осуществляется только в том случае, когда происходит значительное событие. Например, во время взаимодействия с драйвером последовательных портов обработчик прерываний может передавать оборудованию один байт данных при получении каждого прерывания передачи данных и запускать поток более высокого уровня (т. е. поток модуля devc-ser*) только в том случае, когда выходной буфер почти пуст.
Вложенные прерывания
Этот механизм полностью поддерживается в ОС QNX Neutrino. В предыдущих сценариях, описывалась самая простая и наиболее распространенная ситуация, когда возникает только одно прерывание. Однако для получения максимального значения задержки в отсутствие немаскированных прерываний следует рассматривать время с учетом сразу всех текущих прерываний, так как немаскированное прерывание с более высоким приоритетом будет вытеснять текущее прерывание.

На рис. 2.14 показан выполняемый Поток А. Прерывание IRQx запускает обработчик прерываний Intx, который вытесняется прерыванием IRQy и обработчиком прерываний Inty. Обработчик прерываний Inty возвращает событие, которое запускает Поток B, а обработчик прерываний Intx возвращает событие, которое запускает Поток C.

Рис. 2.14. Пакет прерываний

Вызовы, связанные с прерываниями
Программный интерфейс обработки прерываний включает в себя следующие вызовы ядра — табл. 2.10.

Таблица 2.10. Функции вызовов ядра

Функция

Описание

InterruptAttach()

Присоединить локальную функцию к вектору прерываний

InterruptAttachEvent()

Генерировать при возникновении прерывания событие, которое переведет поток в активное состояние. Пользовательский обработчик прерываний при этом не запускается. Этот вызов является предпочтительным

InterruptDetach()

Отсоединиться от прерывания, используя идентификатор, возвращенный функцией InterruptAttach() или InterruptAttachEvent()

InterruptWait()

Ожидать прерывание

InterruptEnable()

Включить аппаратные прерывания

InterruptDisable()

Выключить аппаратные прерывания

InterruptMask()

Маскировать аппаратное прерывание

InterruptUnmask()

Снять маску с аппаратного прерывания

InterruptLock()

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

InterruptUnlock()

Снять защиту критической секции программного кода


Посредством этого программного интерфейса поток с соответствующими пользовательскими привилегиями может вызывать функцию InterruptAttach() или InterruptAttachEvent(), передавая номер аппаратного прерывания и адрес функции в адресном пространстве потока, которая должна быть вызвана при возникновении прерывания. ОС QNX Neutrino позволяет с каждым номером аппаратного прерывания связывать множество обработчиков прерываний (ISR). Немаскированные прерывания могут обрабатываться во время выполнения уже запущенных обработчиков прерываний.

Замечание

За маскирование всех источников прерывания отвечает startup-модуль, выполняющийся при инициализации системы. Ядро демаскирует источник прерывания при первом вызове InterruptAttach() или InterruptAttachEvent() для какого-либо вектора прерывания. Аналогично, при выполнении для данного вектора прерываний последнего вызова InterruptDetach() ядро снова маскирует данный уровень.

Более подробную информацию о функциях InterruptLock() и InterruptUnlock() можно найти в разделе "Критические секции программного кода" главы 4.

Использование в обработчиках прерываний операций с плавающей запятой запрещается.

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

#include <stdio.h>

#include <sys/neutrino.h>

#include <sys/syspage.h>

struct sigevent event;

volatile unsigned counter;

const struct sigevent *handler( void *area, int id ) {

// Пробуждение потока на каждом 100-ом прерывании.

if ( ++counter == 100 ) {

counter = 0;

return( &event );

}

else

return( NULL );

}

int main() {

int i;

int id;

// Запросить допуск ввода/вывода.

ThreadCtl( NTO TCTL IO, 0 );

// Инициализировать структуру событий.

event.sigev notify = SIGEV INTR;

// Подключить вектор обработчика прерываний.

id=InterruptAttach( SYSPAGE ENTRY(qtime)->intr, &handler,

NULL, 0, 0 );

for( i = 0; i < 10; ++i ) {

// Ожидать вектор обработчика прерываний для пробуждения.

InterruptWait( 0, NULL );

printf( "100 events\n" );

}

// Отключить обработчик прерываний.

InterruptDetach(id);

return 0;

}

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

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

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

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

Этот возврат из прерывания не обязательно должен приводить в контекст прерванного потока. Если событие, поставленное в очередь, заставило поток с более высоким приоритетом перейти в состояние готовности (READY), то микроядро выполнит возврат из прерывания в контекст потока, который является активным в текущий момент.

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

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

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

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

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

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

Замечание


Обе функции InterruptMask() и InterruptUnmask() являются счетными. Например, если функция InterruptMask() вызвана 10 раз, то InterruptUnmask() также должна быть вызвана 10 раз.

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

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

В таблице 2.11 приведены функции вызовов микроядра.

Таблица 2.11. Функции вызовов микроядра

Вызов микроядра

Описание

InterruptHookIdle()

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

InterruptHookTrace()

Данная функция присоединяет псевдообработчик прерываний, который может принимать трассировочные события от диагностической версии ядра


Более подробную информацию о прерываниях см. в главе «Interrupts» электронного документа «QNX Neutrino Realtime Operation System. Getting Started with QNX Neutrino: A Guide for Realtime Programmers» и в главе «Writing an Interrupt Handler» электронного документа «QNX Neutrino Realtime Operation System. Programmer’s Guide».

1 От англ. First In First Out (первым пришел — первым обслужен) — Прим. перев.