Микроядро: реализация и сервисы

Особенности реализации ядра операционной системы и его основных сервисов

Общие сведения
Сервисы ядра
Потоки и процессы
Атрибуты потоков
Жизненный цикл потока
Планирование потоков
Выполнение планирования
Приоритеты потоков
Алгоритмы планирования
Алгоритм планирования FIFO
Циклический алгоритм планирования (Round-Robin)
Спорадический алгоритм планирования
Управление приоритетами и алгоритмами планирования
Проблемы межзадачного взаимодействия
Алгоритмические проблемы потоков
Примитивы синхронизации
Блокировки взаимного исключения (мьютексы)
Наследование приоритетов
Условные переменные
Барьеры
Ждущие блокировки
Блокировки по чтению/записи
Семафоры
FIFO-планирование
Синхронизация с помощью механизма обмена сообщениями
Синхронизация с помощью атомарных операций
Реализация служб синхронизации
Службы управления часами и таймерами
Корректировка времени
Таймеры и таймауты
Обработка прерываний
Задержка обработки прерывания
Задержка планирования
Вложенные прерывания
Способы работы с прерываниями

Общие сведения

Микроядро ЗОСРВ «Нейтрино» реализует основные интерфейсы стандарта POSIX, а также базовые механизмы обмена сообщениями. Части стандарта POSIX, которые не реализованы в ядре (например, файловый и device-специфичный ввод-вывод), реализованы в дополнительных процессах и разделяемых библиотеках.

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

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

2_1.png
Рисунок 1. Объекты, которыми управляет микроядро ЗОСРВ «Нейтрино»

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


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

Сервисы ядра

В микроядре ЗОСРВ «Нейтрино» существуют системные вызовы, для обслуживания:

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

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

Строгое следование этому правилу позволяет сохранять ядро отзывчивым и производительным.

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

2_2.png
Рисунок 2. Вытесняемость ядра при обработке системного вызова

Запрет прерываний или вытеснений осуществляется на очень короткие периоды времени (как правило, не более десятых долей микросекунд).

Потоки и процессы

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

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

В зависимости от природы разрабатываемого приложения, потоки процесса могут выполняться независимо друг от друга (такой сценарий воплощается не часто) или же для их выполнения требуется взаимодействие (IPC) и синхронизация. Для обеспечения комфортного функционирования многопоточных приложений микроядро ЗОСРВ «Нейтрино» предусматривает богатый набор механизмов взаимодействия и сервисов синхронизации.

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

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

POSIX-вызов Системный вызов Описание
pthread_create() ThreadCreate() Создать новый поток
pthread_exit() ThreadDestroy() Уничтожить поток
pthread_detach() ThreadDetach() Отсоединить поток, чтобы не ждать его завершения
pthread_join() ThreadJoin() Присоединить поток и ждать его кода завершения
pthread_cancel() ThreadCancel() Завершить поток в следующей точке завершения
отсутствует ThreadCtl() Изменить характеристики потока, специфичные для ЗОСРВ «Нейтрино»
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() Отправить сигнал потоку

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


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

Атрибуты потоков

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

Идентификатор потока (tread id, tid)
Уникальный в пределах процесса целочисленный идентификатор, начинающийся с 1 (для основного потока).
Приоритет (priority)
Приоритет потока, который определяет меру важности потока в концепции реального времени. Приоритет наследуется от родительского потока, но может быть изменен на постоянной основе самим потоком явно или алгоритмом планирования и временно при получении потоком сообщения.

Note: Процессы приоритет не имеют, только потоки.

Дисциплина планирования (scheduling policy)
Алгоритм определения очередности предоставления потоку ресурсов центрального процессора в пределах своего приоритета.
Имя (name)
В ЗОСРВ «Нейтрино» поток может иметь произвольное символьное имя (см. pthread_getname_np() и pthread_setname_np()). Такие утилиты как dumper и pidin поддерживают работу с именами потоков. Данный атрибут является ОС-специфичным расширением стандарта POSIX.
Набор регистров (register set)
Каждый поток имеет свой собственный контекст потока – совокупность состояний используемых регистров: указатель команд (Instruction Pointer, IP), указатель стека (Stack Pointer, SP), указатель потока (Thread Pointer, TP) и другие.
Стек (stack)
В адресном пространстве процесса каждый поток имеет свой собственный стек.
Маска сигналов (signal mask)
Каждый поток имеет свою собственную маску сигналов.
Локальное потоковое хранилище или локальная память потока (Thread Local Storage, TLS)
Это уникальный для каждого потока буфер. TLS используется для хранения информации, относящейся к каждому отдельному потоку (например, tid, pid, базовый адрес стека, errno, потоко-специфичные ключевые данные). Поток может адресовать пользовательские данные с помощью ключа, чье значение будет уникально в каждом потоке (см. pthread_key_create()).
Обработчик завершений (cancellation handler)
Содержит callback-функции, выполняемые при завершении потока.

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

2_3.png
Рисунок 3. Матрица мапирования потоко-специфичных значений для пары tid/ключ

Функции для создания и управления TLS данных:

Функция Описание
pthread_key_create() Создание ключа данных с функцией-деструктором
pthread_key_delete() Уничтожение ключа данных
pthread_setspecific() Связывание значения с ключом
pthread_getspecific() Получение значения, связанное с указанным ключом

Жизненный цикл потока


Note: Хотя потоки создаются и уничтожаются динамически и их количество внутри процесса может изменяться, в системах реального времени эта практика не приветствуется.

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

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

2_4.png
Рисунок 4. Граф состояний потока


Caution: Диаграмма выше содержит одно значимое упрощение: поток может перейти из любого состояния (кроме DEAD) в состояние READY.

Расшифровка состояний потока:

CONDVAR
Поток заблокирован на условной переменной (например, при вызове функции pthread_cond_wait()).
DEAD
Поток завершен и ожидает считывания статус кода родительским потоком.
INTERRUPT
Поток заблокирован и ожидает прерывания (поток вызвал функцию InterruptWait()).
JOIN
Поток заблокирован и ожидает завершения другого потока (например, при вызове функции pthread_join()).
MUTEX
Поток заблокирован на мьютексе (например, при вызове функции pthread_mutex_lock()).
NANOSLEEP
Поток находится в ожидания в течение короткого периода времени (например, при вызове функции nanosleep()).
NET_REPLY
Поток ожидает ответ на сообщение от другого узла сети (поток вызвал функцию группы MsgReply()).
NET_SEND
Поток ожидает получения импульса или сигнала от другого узла сети (поток вызвал функцию MsgSendPulse(), MsgDeliverEvent() или SignalKill()).
READY
Поток готов к выполнению, но пока процессор занят выполнением другого потока равного или более высокого приоритета.
RECEIVE
Серверный поток заблокирован в ожидании получения сообщения или импульса (например, при вызове функции MsgReceive()).
REPLY
Клиентские поток заблокирован при отправке сообщения (при вызове функции MsgSend() и после начала обработки полученного сообщения сервером).
RUNNING
Поток выполняется процессором. Ядро использует массив с одной записью на процессор для отслеживания запущенных потоков.
SEM
Поток заблокирован на семафоре (поток вызвал функцию SyncSemWait()).
SEND
Клиентский поток заблокирован при отправке сообщения (при вызвове функции MsgSend() и до начала обработки сообщения сервером).
SIGSUSPEND
Поток заблокирован и ожидает сигнал (поток вызвал функцию sigsuspend()).
SIGWAITINFO
Поток заблокирован и ожидает сигнал (поток вызвал функцию sigwaitinfo()).
STACK
Поток ожидает выделения виртуальной памяти для своего стека (родительский поток использовал системный вызов ThreadCreate()).
STOPPED
Поток заблокирован и ожидает сигнал SIGCONT.
WAITCTX
Поток ожидает доступности нецелочисленного контекста (например, контекста с плавающей запятой).
WAITPAGE
Поток ожидает выделения физической памяти для виртуальной страницы памяти (ожидание завершения операций с MMU).
WAITTHREAD
Поток ожидает завершения создания дочернего потока (родительский поток использовал системный вызов ThreadCreate()).

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

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

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

Если нет оснований для передачи управления в другой поток, выполнение приостановленного потока возобновляется. Планировщик потоков выполняет переключение контекстов с одного потока на другой, если текущий поток:

Блокировка потока

Активный поток блокируется, если он должен дождаться внешнего события – например, ответа на отправленное сообщение или освобождения мьютекса. Блокированный поток удаляется из очереди готовых потоков на данном приоритете (ready queue), после чего запускается первый готовый к исполнению поток с наивысшим приоритетом. Разблокированный поток помещается в конец очереди готовых потков на своем уровне приоритета.


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

Вытеснение потока

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


Note: В SMP-системах разблокирование высокоприоритетного потока не является единственным достаточным условием для вытеснения текущего потока, так как они могут иметь основания для параллельного исполнения.

Добровольная передача управления потоком

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


Note: Передача управления не связана с блокировкой текущего потока.

Приоритеты потоков

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

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

2_5.png
Рисунок 5. Очереди готовых к исполнению потоков

В ЗОСРВ «Нейтрино» поддерживается до 256 уровней приоритетов, однако непривилегированный пользователь (не root) может использовать лишь приоритеты от 1 до 63, независимо от дисциплины планирования. Пользователь root (потоки, чей эффективный uid равен 0) может назначать потокам приоритеты от 64 до 255. Специальные системные потоки микроядра с именем idle имеют приоритет 0. Их число соответствует числу ядер центрального процессора, они всегда готовы к выполнению (не блокируются) и назначаются на исполнение лишь в том случае, если нет потоков с полезной нагрузкой.


Note: По умолчанию потоки наследует приоритет родительского потока в момент создания. Приоритет потока может быть изменен в момент создания (см. pthread_attr_setschedparam()), либо уже в процессе исполнения созданным потоком (см. pthread_setschedparam() и pthread_setschedprio()).

Опция -P процесса микроядра procnto позволяет изменить граничное значение приоритетов для непривилегированного процесса (задать ее можно в Файле построения загрузочного образа) на значение аргумента приоритет:

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

Диапазоны приоритетов:

Уровень приоритета Владелец
0 поток idle
от 1 до приоритет - 1 поток произвольного пользователя
от приоритет до 255 поток пользователя root

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

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

Большую часть времени потоки размещаются в очередях в порядке своего поступления (FIFO), но есть исключения:

Алгоритмы планирования

В микроядре ЗОСРВ «Нейтрино» реализованы следующие дисциплины планирования (алгоритмы диспетчеризации):

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

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

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

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

2_6.png
Рисунок 6. Блокировка потока А приводит к исполнению потока B

Алгоритм планирования FIFO

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

2_7.png
Рисунок 7. FIFO-планирование

Циклический алгоритм планирования (Round-Robin)

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

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

2_8.png
Рисунок 8. Циклическое планирование

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


Note: Циклическое планирование отличается от FIFO только наличием дополнительного условия вытеснения потока – исчерпания кванта времени.

Спорадический алгоритм планирования

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

Условия продолжения исполнения для потоков в данном алгоритме аналогичны FIFO. Однако, как и в адаптивном планировании, поток имеет второй (пониженный) приоритет – в процессе выполнения приоритет потока динамически переключается ядром между нормальным и пониженным уровням. Данная процедура опирается на понятие бюджета, включая следующие параметры:

Начальный бюджет потока (C)
Количество времени в пределах периода пополнения, в пределах которого поток может выполняться с нормальным приоритетом (N), перед переходом на пониженный приоритет (L);
Пониженный приоритет (L)
Уровень, до которого понижается приоритет потока при исчерпании бюджета. Для простоты можно считать, что приоритет L соответствует фоновому исполнению потока, а N нормальному.
Период пополнения (T)
Период времени, по истечении которого происходит пополнение бюджета. В течение этого времени поток может расходовать свой бюджет на выполнение с приоритетом N. При планировании операций пополнения бюджета POSIX-реализация также использует это значение в качестве смещения от момента перехода потока в состояние READY.
Максимальное число ожидающих пополнений
Это значение устанавливает ограничение на количество однократно выполняемых операций пополнения, тем самым ограничивая объем выделяемых потоку системных ресурсов.

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

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

2_9.png
Рисунок 9. Периодическое (T) пополнение бюджета выполнения потока (C)

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

Рассмотрим для наглядности систему, в которой поток не блокируется и не вытесняется:

2_10.png
Рисунок 10. Приоритет потока снижается до следующего пополнения бюджета

Поток переходит в фоновый режим исполнения, существенно снижая влияние на потоки, чей приоритет находится в диапазоне от L до N.

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

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

2_11.png
Рисунок 11. Колебания приоритета потока между нормальным и фоновым

На схеме видно, что в течение каждого 40-мс периода T бюджет выполнения потока C составляет 22 мс:

  1. Поток блокируется через 4 мс, поэтому 3-мс операция пополнения будет запланирована к выполнению через 40 мс – когда первый период пополнения будет завершен.

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

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

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

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

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

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

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

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

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

Функция Системный вызов Описание
pthread_getschedparam() SchedGet() Считывание параметров планирования (включая приоритет).
pthread_setschedparam() SchedSet() Установка параметров планирования (включая приоритет).
pthread_setschedprio() SchedSet() Установка приоритета.
sched_getparam() SchedGet() Считывание параметров планирования (включая приоритет).
sched_setparam() SchedSet() Установка параметров планирования (включая приоритет).
sched_getscheduler() SchedGet() Считывание дисциплины планирования.
sched_setscheduler() SchedSet() Установка дисциплины планирования.

Проблемы межзадачного взаимодействия

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

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

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

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

Алгоритмические проблемы потоков

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

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

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

Примитивы синхронизации

Потоки одного процесса имеют совместный доступ к его адресному пространству. В ЗОСРВ «Нейтрино» некоторые примитивы синхронизации могут применяться для потоков в разных процессах (разделение между несколькими процессами). К штатным примитивам синхронизации относятся, как минимум, следующие объекты:

Примитив синхронизации Совместное использование процессами Работа по сети
Мьютексы Да Нет
Условные переменные (condvars) Да Нет
Барьеры (barriers) Нет Нет
Ждущие блокировки (sleepon locks) Нет Нет
Блокировки чтения/записи (rw locks) Да Нет
Семафоры Да Да (только для именованных)
FIFO-планирование Да Нет
Сообщения Да Да
Атомарные операции Да Нет

Приведенные выше объекты синхронизации реализуются непосредственно ядром, за исключением:


Caution: Выделять мьютексы, условные переменные, барьеры, блокировки чтения/записи и семафоры, а также атомарные объекты следует только в памяти с обычным отображением (с включенным кэшированием). В некоторых системах (например, с архитектурой PowerPC) атомарные операции и некоторые библиотечные вызовы (например, pthread_mutex_lock()), вызовут исключение процессора, если объект размещен в некэшированной памяти.

Блокировки взаимного исключения (мьютексы)

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

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

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

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

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

Наследование приоритетов

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

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

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


Note: Приоритета потока не меняется при использовании функции pthread_mutex_trylock(), поскольку он не является блокирующим.

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

Условные переменные

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

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


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

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

pthread_mutex_lock( &mutex );
...
while ( !arbitrary_condition )
{
pthread_cond_wait( &condvar, &mutex );
}
...
pthread_mutex_unlock( &mutex );

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

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

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

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


Caution: Не следует использовать рекурсивные мьютексы (использующие атрибут 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 );

Эта функция создает барьер с атрибутами, определяемыми аргументом attr. Параметр count определяет максимальное число потоков, которые будут блокироваться вызовом pthread_barrier_wait():

#include <pthread.h>
int pthread_barrier_wait( pthread_barrier_t *barrier );

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

Пример:

/* barrier1.c */
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <time.h>
#include <pthread.h>
#include <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 );
/* Запуск двух потоков */
pthread_create( NULL, NULL, thread1, NULL );
pthread_create( NULL, NULL, thread2, NULL );
/* Оба поток созданы и исполняются, ожидаем их завершения */
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);
}

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

Функции работы с барьерами:

Функция Описание
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()) значение счетчика на семафоре для управления блокировкой потоков.

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

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


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

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

Полезной разновидностью семафоров являются именованные семафоры. Они позволяют применять семафоры между процессами, выполняемыми на разных узлах сети Qnet.


Note: Стоит иметь в виду, что именованные семафоры гораздо медленнее, чем не именованные.

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

while ( sem_wait( &s ) && errno == EINTR )
{
do_nothing();
}
do_critical_region(); /* Значение семафора уменьшилось. */

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

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

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

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


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

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


Синхронизация с помощью механизма обмена сообщениями

Механизмы обмена сообщениями (Send / Receive / Reply), регламентируемые микроядром, осуществляют неявную синхронизацию посредством блокировок клиентского и серверного потоков. Во многих случаях они могут самостоятельно заменить собой другие примитивы синхронизации. Кроме того, обмен сообщениями — наряду с именованными семафорами, практически единственный примитив синхронизации и IPC, который может работать прозрачно по сети Qnet.

Синхронизация с помощью атомарных операций

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

Типичные атомарные операции:

Данные атомарные операции можно задействовать, подключив заголовочный файл <atomic.h>.

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

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

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

Реализация служб синхронизации

В таблице ниже приведены различные системные вызовы и соответствующие 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) используются для отсчета системного времени, которое необходимо ядру для обеспечения работы интервальных таймеров, реализуемых посредством соответствующих системных вызовов.


Note: В ЗОСРВ «Нейтрино» значение даты допустимо в диапазоне от января 1970 года до января 2554 года.

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

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

Функция ClockCycles() возвращает текущее значение автономно работающего 64-битного счетчика циклов процессора. Он реализует на всех процессорах высокопроизводительный механизм измерения коротких интервалов времени. Например, в процессорах с архитектурой x86 такой счетчик реализуется через ассемблерную инструкцию, считывающую процессорный счетчик циклов. На других процессорных архитектурах существуют аналогичные инструкции.

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

В любом случае поле системной страницы

SYSPAGE_ENTRY( qtime )->cycles_per_sec

характеризует приращение счетчика ClockCycles() за одну секунду.

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

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


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

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


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

Системный вызов POSIX функция Описание
ClockTime() clock_gettime(), clock_settime() Получение и установка времени и даты (используя 64-битное значение в наносекундах в диапазоне от 1970 до 2554)
ClockAdjust() отсутствует Тонкая корректировка времени для синхронизации часов
ClockCycles() отсутствует Чтение значения 64-битного высокоточного автономного счетчика (имейте в виду, что аппаратный счетчик может иметь меньшую разрядность)
ClockPeriod() clock_getres() Получение и установка разрешения системного таймера
ClockId() clock_getcpuclockid(), pthread_getcpuclockid() Получение целого значения, переданного функции ClockTime() как clockid_t

Корректировка времени

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

Таймеры и таймауты

ЗОСРВ «Нейтрино» обеспечивает полный набор логических таймеров стандарта POSIX, опирающихся на единственный аппаратный системный таймер. Они являются очень удобным инструментом ядра, поскольку их можно быстро создать и ими легко управлять.

Модель POSIX таймеров имеет весьма широкие возможности. Срок действия таймера может определяться следующими параметрами:

Циклический режим имеет очень большое значение, так как наиболее часто таймер используется в качестве периодического источника событий — он помогает "пробудить" поток, чтобы он выполнил периодические вычисления и затем снова заблокировался до возникновения следующего события. Если бы потоку пришлось каждый раз перенастраивать таймер, пришлось бы ориентироваться на абсолютную дату, так как проконтролировать время исполнения самого потока достаточно сложно. Еще худшим вариантом является вытеснение такого потока более приоритетным и пропуск следующего срабатывания вовсе без взведения таймера (и обнаружение того обстоятельства, что интервал следующего срабатывания таймера давно истек). Циклический режим таймера позволяет обойти эти проблемы, позволяя потоку установить таймер один раз, а затем просто реагировать на события.

Так как таймеры являются лишь разновидностью источников событий, они также используют общесистемную процедуру доставки событий. Это дает возможность потоку получать по таймеру любое событие, реализуемое в ЗОСРВ «Нейтрино».

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

alarm( ... );
...
... ← Таймаут произошел в этот момент
...
блокирующий_вызов();

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

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

TimerTimeout( ... );
...
...
...
блокирующий_вызов();
... ← Таймаут автоматически взводится микроядром при обслуживании системного вызова

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

Системный вызов POSIX функции Описание
TimerAlarm() alarm() Установка для процесса "будильника"
TimerCreate() timer_create() Создание интервального таймера
TimerDestroy() timer_delete() Уничтожение интервального таймера
TimerInfo() timer_gettime() Получение остатка времени в интервальном таймере
TimerInfo() timer_getoverrun() Получение количества переполнений интервального таймера
TimerSettime() timer_settime() Запуск интервального таймера
TimerTimeout() sleep(), nanosleep(), sigtimedwait(), pthread_cond_timedwait(), pthread_mutex_trylock(), intr_timed_wait() Включение таймаута ядра для некоторого состояния блокировки потока

Обработка прерываний

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

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


Note: Фактическая величина той или иной задержки зависит от множества факторов и в первую очередь от производительности центрального процессора.

Задержка обработки прерывания

Задержка обработки прерываний (interrupt latency) — это время, прошедшее от момента возникновения аппаратного прерывания на входе контроллера прерываний до выполнения первой команды обработчиком прерывания (англ. Interrupt Service Routine, ISR) в драйвере устройства. Микроядро ЗОСРВ «Нейтрино» почти всегда оставляет прерывания разрешенными (если для них запущен соответствующий драйвер), поэтому задержка обработки прерывания обычно незначительная. Однако, некоторые критические секции программного кода требуют временного запрета обработки прерываний на уровне контроллера прерываний или центрального процессора. В общем случае максимальная длительность такого запрета определяет наихудший размер задержки обработки прерываний.

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

2_12.png
Рисунок 12. Завершение обработчика прерывания без возвращения события

Условные обозначения:

Интервал T₁ обозначает минимальную задержку обработки прерывания — соответствует случаю, когда прерывания были разрешены в момент возникновения прерывания. Наихудшая задержка обработки прерывания определяется как сумма T₁ и наибольшего времени на которое микроядро (или любой поток) запрещает аппаратные прерывания.

Задержка планирования

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

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

2_13.png
Рисунок 13. Завершение обработчика прерывания с возвращением события потоку

Условные обозначения:

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

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

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

На диаграмме ниже показан выполняемый поток А. Прерывание IRQ₁ запускает обработчик прерываний ISR₁, который вытесняется прерыванием IRQ₂ и его обработчиком ISR₂. Обработчик ISR₂ возвращает событие, которое разблокирует поток B, а обработчик прерывания IRQ₁ возвращает событие, которое разблокирует поток C:

2_14.png
Рисунок 14. Вложенные прерывания и их влияние на задержки

Способы работы с прерываниями

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

Функция Описание
InterruptAttach() Присоединение обработчика прерываний к вектору прерываний
InterruptAttachEvent() Присоединение события, посылаемого микроядром при поступлении прерывания. Обработчик прерывания, предоставляемый пользователем в этом случае не предполагается (используется минималистичный системный обработчик). Данный способ обработки прерываний является наиболее предпочтительным, как обладающий наименьшей вероятностью негативных общесистемных последствий.
InterruptDetach() Отсоединение от прерывания обработчика или события, используя идентификатор, возвращенный функцией InterruptAttach() или InterruptAttachEvent()
InterruptWait() Ожидание уведомления о прерывании
InterruptEnable() Включение аппаратных прерываний на уровне центрального процессора
InterruptDisable() Выключение аппаратных прерываний на уровне центрального процессора
InterruptMask() МаскИрование аппаратного прерывания на уровне контроллера прерываний
InterruptUnmask() Размаскирование аппаратного прерывания на уровне контроллера прерываний
InterruptLock() Защита критической секции кода между обработчиком прерывания и потоком. Для того чтобы обеспечить возможность применения этого кода в SMP-системах, используется блокировка с активным ожиданием (spinlock). Эта функция является расширением функции InterruptDisable() и должна использоваться вместо нее.
InterruptUnlock() Снятие защиты с критической секции кода

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


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

  • Более подробную информацию см. в описании функций InterruptLock() и InterruptUnlock().

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

Представленный далее код демонстрирует присоединение ISR к таймерному прерыванию (оно же используется микроядром для контроля даты и времени).


Warning: Ни в коем случае нельзя маскировать таймерное прерывание или нарушать логику работы его обработчика. В системах реального времени это действие является фатальным.

Поскольку системный обработчик (в микроядре) таймерного прерывания очищает источник прерывания, данный 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);
}

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

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

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

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

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

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

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

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

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

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


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

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


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

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

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

В таблице ниже приведены соответствующие системные вызовы.

Системный вызов Описание
InterruptHookIdle() Когда микроядро не обнаруживает готовый к исполнению поток, оно запускает поток idle, который может вызвать внешний пользовательский обработчик. Этот обработчик может, например, переводить оборудование в энергосберегающий режим.
InterruptHookTrace() Данная функция присоединяет псевдообработчик прерываний, который может принимать трассировочные события от инструментальной (диагностической) версии ядра.




Предыдущий раздел: перейти