Обычный планировщик Linux справедлив: он старается раздать процессорное время всем поровну, чтобы никто не голодал. Для большинства задач это идеально. Но есть работа, которой справедливость противопоказана. Поток, снимающий данные с датчика строго по таймеру, обработчик звука, который обязан выдать порцию точно к сроку, иначе раздастся щелчок, управление станком, где опоздание на миллисекунду означает брак. Таким задачам нужна не справедливая доля, а безусловное право бежать первыми, отодвигая всё остальное. Это право даёт политика планирования с жёстким приоритетом.
Политика первый пришёл, первый обслужен, она же планирование со статическим приоритетом, переворачивает обычную логику. Поток с такой политикой и заданным приоритетом получает процессор и держит его, пока сам не отпустит. Соблазнительно мощный инструмент, но именно его мощь делает его опасным: неосторожное применение способно подвесить всю систему наглухо. Разобраться, как он работает и где у него острые грани, значит научиться им пользоваться, не отрезав себе руку.
Чем поведение жёсткого приоритета отличается от обычного справедливого планирования
Суть политики жёсткого приоритета в её бескомпромиссности. Когда задача с этой политикой начинает выполняться, она продолжает работать, пока не наступит одно из трёх: её вытеснит поток с ещё более высоким приоритетом, она сама заблокируется на операции ввода-вывода, или она добровольно уступит процессор особым вызовом. Никакие задачи с меньшим приоритетом не получат процессор, пока высокоприоритетная его не освободит. Это и есть обещание реального времени: пока ты главный, тебя никто не подвинет.
Диапазон приоритетов реального времени идёт от единицы как низшего до девяноста девяти как высшего, и эти значения живут в отдельном пространстве над обычными политиками. Любая задача реального времени всегда обходит любую обычную задачу, как бы важной та ни казалась справедливому планировщику. Установить политику и приоритет можно системным вызовом смены политики планирования. Перед началом критичной работы поток объявляет себя задачей реального времени:
#include <sched.h>
#include <sys/mman.h>
struct sched_param param;
param.sched_priority = 80; /* приоритет в диапазоне 1..99 */
if (sched_setscheduler(0, SCHED_FIFO, ¶m) != 0) {
perror("sched_setscheduler"); /* обычно нужны повышенные права */
}
mlockall(MCL_CURRENT | MCL_FUTURE); /* запираем память от вытеснения */
Есть близкая родственная политика, круговая, отличающаяся одним: задачи равного приоритета она прокручивает по кругу, нарезая каждой квант времени, тогда как при жёсткой политике две задачи равного приоритета не вытесняют друг друга вовсе, и первая захватившая процессор держит его до упора. Если задача в своём приоритете одна, обе политики ведут себя одинаково.
Выбор между этими двумя политиками определяется тем, сколько задач делят один уровень приоритета. Когда на уровне ровно одна критичная задача, жёсткая политика идеальна: задача бежит без всяких квантов и переключений, пока ей нужно. Но если на одном уровне приоритета окажется несколько задач, жёсткая политика рискует заморозить остальных, ведь захватившая процессор не уступит его равным добровольно. Круговая политика тут страхует, честно прокручивая равных по очереди, и потому её берут там, где несколько потоков должны делить уровень на равных. Для обычных же фоновых задач остаётся справедливый планировщик по умолчанию, который к приоритетам реального времени отношения не имеет и распределяет время поровну между всеми.
Стоит подчеркнуть и то, что приоритет реального времени статичен. В отличие от обычного планировщика, который динамически подкручивает приоритеты ради справедливости, поднимая засидевшихся и притормаживая прожорливых, политика реального времени держит заданный приоритет неизменным. Задача с приоритетом восемьдесят всегда обходит задачу с приоритетом семьдесят, сколько бы та ни ждала, и никакого автоматического выравнивания не происходит. Это и делает поведение предсказуемым, и одновременно перекладывает на разработчика всю ответственность за то, чтобы расстановка приоритетов между задачами была продуманной, а не случайной.
Почему запирание памяти так же важно, как сам приоритет
В примере выше не случайно стоит вызов запирания памяти, и пропустить его значит подорвать всю затею с реальным временем. Дело в том, что обычная память процесса может быть вытеснена ядром на диск, а при первом обращении к вытесненной странице происходит страничный сбой: процесс замирает, пока ядро не подтянет страницу обратно. Для обычной задачи это незаметная заминка. Для задачи реального времени это катастрофа: тщательно выверенный по времени поток вдруг застывает на непредсказуемый срок, ожидая диск, и весь смысл жёсткого приоритета испаряется.
Запирание всей памяти процесса намертво прибивает её к оперативной, запрещая вытеснение. После этого обращения к памяти происходят без страничных сбоев и без внезапных пауз, и поведение потока становится предсказуемым по времени, как и требуется. Это одна из тех деталей, которую новички упускают, а потом мучительно ловят случайные подвисания критичного кода. Запирание памяти и жёсткий приоритет работают в паре: приоритет гарантирует, что тебя не подвинут, а запирание гарантирует, что тебя не затормозит собственная вытесненная память.
Запиранием дело не ограничивается, есть и смежные приёмы предотвращения внезапных пауз. Память стоит не только запереть, но и прогреть заранее, коснувшись всех нужных страниц до начала критичной работы, чтобы первое касание не пришлось на горячий участок. Динамическое выделение памяти посреди критичного цикла тоже опасно, потому что выделение способно уйти в ядро за новыми страницами и застрять там на непредсказуемый срок, поэтому всю нужную память выделяют и трогают заранее, на этапе подготовки. Та же логика касается любых системных вызовов с неопределённой задержкой: их выносят из горячего пути, оставляя в критичной секции только то, что выполняется за предсказуемое время. Реальное время это во многом искусство убрать из горячего пути всё, что способно внезапно затормозить.
Чем грозит зацикленная задача с высоким приоритетом
Теперь о самой страшной грани. Задача жёсткого приоритета держит процессор, пока сама не отпустит. А что, если она не отпускает? Если высокоприоритетный поток впадает в плотный цикл опроса, который не блокируется на вводе-выводе, он намертво занимает ядро, и ничто с меньшим приоритетом на этом ядре не выполнится вовсе. Это называется голоданием потоков: задачи на очереди ядра ждут дольше всякого разумного срока и не двигаются.
Последствия бывают тяжёлыми. Поток-пожиратель процессора с приоритетом выше, чем у обработчиков прерываний, способен не дать выполниться самим обработчикам прерываний. Тогда программы, ждущие данных от этих прерываний, начинают голодать и отказывают. По сути единственный неосторожный цикл в высокоприоритетной задаче подвешивает работу на затронутом ядре, включая важные системные потоки. На однопроцессорной машине это означало бы зависание всей системы целиком, без возможности даже вмешаться.
Спецификация, определяющая эти политики, честно признаёт: в ситуации, когда высокоприоритетная задача не уступает процессор, никакого механизма дать время низкоприоритетным потокам не предусмотрено. Реальное время это про предсказуемость и приоритет, а не про справедливость, и ответственность за то, чтобы высокоприоритетная задача вовремя уступала процессор, лежит целиком на разработчике. Критичный поток обязан блокироваться на ожидании события или добровольно уступать, а не крутиться в бесконечном цикле, иначе он съест систему.
Особенно коварна тут разработка с ошибкой в логике выхода из цикла. Обычный фоновый процесс, угодивший в бесконечный цикл, лишь зря греет одно ядро, а система остаётся отзывчивой и его можно спокойно убить. Тот же баг в задаче жёсткого приоритета превращается в катастрофу: взбесившийся поток не отдаёт ядро никому ниже себя, и даже команда на его завершение может не пройти, если у неё приоритет ниже. Поэтому отладку кода реального времени ведут с особой осторожностью, нередко на отдельной машине или с заранее подготовленным способом аварийно вернуть контроль, чтобы единственная ошибка в цикле не потребовала жёсткой перезагрузки. Разумной страховкой служит и запуск критичного кода не на максимально возможном приоритете, а чуть ниже него, оставляя сверху запас для аварийных управляющих средств, способных перехватить контроль у вышедшей из-под управления задачи.
Как ядро страхует от зависания и почему эта страховка спорна
Понимая опасность, ядро Linux завело предохранитель под названием ограничение времени реального времени. Идея в том, чтобы зарезервировать малую долю процессорного времени для не реального времени, не дав задачам реального времени выесть все сто процентов. Каждое ядро по умолчанию отдаёт около девяноста пяти процентов времени задачам реального времени, оставляя пять процентов на обычные. Управляется это двумя параметрами ядра: один задаёт длину расчётного периода, по умолчанию около секунды, другой задаёт, сколько из этого периода отводится задачам реального времени, по умолчанию чуть меньше периода.
Благодаря этому даже зацикленная высокоприоритетная задача периодически принудительно притормаживается, и система сохраняет крошечную щёлочку, чтобы вмешаться, например убить взбесившийся процесс. Это спасает машину от полного зависания при ошибке в коде реального времени.
Но у этой страховки есть оборотная сторона, и она серьёзна. Для настоящих систем реального времени ограничение времени само становится источником бед: оно создаёт драматические сценарии инверсии приоритетов, когда тщательно спланированная высокоприоритетная задача вдруг притормаживается ядром ровно тогда, когда обязана работать. Поэтому в строгих системах реального времени это ограничение наоборот рекомендуют отключать, записав особое значение в управляющий параметр, чтобы убрать непредсказуемые притормаживания. Получается развилка: на обычной машине ограничение спасает от зависания при ошибке, а на выделенной системе жёсткого реального времени оно же вредит и его снимают. Выбор зависит от того, что строится, защищённая от ошибок система общего назначения или выделенный узел реального времени, где за корректность кода отвечают полностью.
Стоит знать и тонкую деталь самого планировщика, связанную со спецификацией. Если у задачи жёсткого приоритета сменить приоритет на то же самое значение, её положение в очереди равных по приоритету меняться не должно, потому что спецификация предписывает не трогать позицию потока при неизменном приоритете. Эта мелочь важна для корректной работы и была отдельно учтена в ядре, иначе бессмысленная смена приоритета на прежний сбивала бы порядок выполнения равноприоритетных задач.
Сведём всё воедино. Политика жёсткого приоритета даёт критичной по времени задаче безусловное право бежать первой, отодвигая всё с меньшим приоритетом, и это незаменимо для датчиков, звука, управления оборудованием. Но мощь оборачивается опасностью: зацикленная высокоприоритетная задача способна заморить голодом всё на своём ядре, вплоть до обработчиков прерываний. Поэтому критичный поток обязан вовремя уступать процессор, запирать свою память от вытеснения ради предсказуемости и осознанно решать судьбу ограничения времени реального времени: оставить как страховку от ошибок или снять ради чистой предсказуемости на выделенной системе. Реальное время вознаграждает дисциплину и жестоко наказывает беспечность, и пользоваться жёстким приоритетом надо с полным пониманием того, что справедливости от него ждать не приходится.