Когда промышленный контроллер обязан отреагировать на сигнал датчика строго в пределах ста микросекунд, обычное ядро Linux превращается в источник неопределённости. Планировщик CFS честно делит процессор между всеми желающими, обработчики прерываний отнимают время у критичных потоков, а спинлоки блокируют вытеснение на десятки миллисекунд. Патч PREEMPT_RT, который после почти двадцати лет разработки вошёл в основное ядро с выходом версии 6.12 осенью 2024 года, решает именно эту проблему: он превращает Linux в систему жёсткого реального времени, где верхняя граница задержки становится измеримой и гарантированной. Но сама по себе установка RT ядра мало что даёт. Реальная предсказуемость начинается только после грамотной настройки, и именно о ней пойдёт речь.

Что на самом деле меняет PREEMPT_RT внутри ядра

Главная идея патча звучит просто, но её реализация перекраивает половину подсистем ядра. В обычном ядре спинлоки запрещают вытеснение, и код внутри критической секции неприкосновенен для планировщика. PREEMPT_RT заменяет большинство спинлоков на rtmutex, мьютексы с наследованием приоритетов, которые позволяют вытеснять владельца блокировки. Обработчики прерываний уезжают в потоки ядра, так называемые threaded IRQ, и их можно приоритизировать и разносить по ядрам как обычные задачи. Таймеры высокого разрешения hrtimer обрабатываются в потоках, а не в жёстком контексте прерывания, если это не критично.

С точки зрения приложения меняется философия: поток с политикой SCHED_FIFO и приоритетом 80 действительно получает процессор раньше всего остального, и время реакции перестаёт зависеть от случайностей. На типичной x86 системе с выключенным энергосбережением патч удерживает максимальную задержку пробуждения в районе 20-50 микросекунд, тогда как стандартное ядро под нагрузкой легко показывает всплески в 5-20 миллисекунд. Разница на два-три порядка, и это не маркетинговая цифра, а результат измерений cyclictest на загруженной системе.

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

Установка и проверка того что ядро действительно realtime

Дистрибутивы давно поставляют RT ядро: в Ubuntu это пакет linux-image-realtime из репозитория Ubuntu Pro, в RHEL и его клонах репозиторий RT через subscription-manager, у SUSE отдельный продукт SLE RT. Если нужно самое свежее, ядро собирают из исходников с включённым CONFIG_PREEMPT_RT; с версии 6.12 отдельный патч накладывать не требуется, опция живёт прямо в меню конфигурации.

После загрузки обязательна проверка, иначе легко целый день настраивать систему, которая вовсе не RT. Команда uname -v покажет строку с пометкой PREEMPT_RT, а файл /sys/kernel/realtime должен содержать единицу. Полезно также убедиться через zcat /proc/config.gz, что CONFIG_PREEMPT_RT равен y и вытеснение включено полностью.

Конфигурация заслуживает ещё двух взглядов. Без таймеров высокого разрешения HIGH_RES_TIMERS гранулярность планирования остаётся на уровне jiffies, то есть миллисекунды, что сводит затею на нет. А вот CONFIG_NO_HZ_FULL и CONFIG_RCU_NOCB_CPU по умолчанию выключены, и именно они понадобятся для изоляции ядер, о которой речь ниже.

Изоляция процессорных ядер как фундамент низких задержек

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

Параметр загрузки isolcpus=domain,managed_irq,2-3 исключает ядра 2 и 3 из общего планирования и запрещает размещать на них управляемые прерывания. Параметр nohz_full=2-3 останавливает тик таймера на этих ядрах, пока на каждом выполняется не больше одной задачи, и это убирает периодические прерывания по 250-1000 раз в секунду, которые иначе врываются в критичный поток. Параметр rcu_nocbs=2-3 выносит обратные вызовы RCU с изолированных ядер, иначе подсистема RCU будет будить их даже в простое.

Затем прерывания вручную прибивают к обслуживающим ядрам через /proc/irq/N/smp_affinity_list. Например, прерывания сетевой карты и диска логично держать на ядрах 0-1, а 2-3 оставить чистыми для rt потоков. Финальный штрих это перемещение системных служб в CPUSet с ядрами 0-1 ещё на этапе загрузки, чтобы фоновая активность вроде journald никогда не касалась изолированных процессоров.

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

Многие замечали, что после такой изоляции цифры cyclictest стабилизируются почти в прямую линию. Причина банальна: конкуренция за процессор исчезает физически, и планировщику просто не из кого выбирать.

Приоритеты потоков и типичные ловушки SCHED_FIFO

Реальное время в Linux держится на двух политиках планирования. SCHED_FIFO работает до явного освобождения процессора, SCHED_RR добавляет квант времени для задач равного приоритета. Диапазон приоритетов от 1 до 99, и существует негласная договорённость: 90 и выше это обработчики прерываний, которые ни в коем случае нельзя заставлять ждать, 50-80 отдают прикладным rt потокам, а ниже сидят служебные потоки ядра вроде ksoftirqd.

Главная ловушка кроется в инверсии приоритетов. Если rt поток ждёт мьютекс, который держит обычный поток с низким приоритетом, а между ними вклинивается поток со средним приоритетом, критичная задача простаивает. PREEMPT_RT решает это наследованием приоритетов в rtmutex, но только если приложение использует pthread_mutex с протоколом PTHREAD_PRIO_INHERIT. Мьютекс по умолчанию наследование не включает, и это одна из самых частых ошибок портирования.

Вторая ловушка касается памяти. Один единственный page fault внутри rt потока способен добавить сотни микросекунд задержки, потому что ядро уйдёт читать страницу с диска. Поэтому канонический пролог критичного приложения выглядит так: сначала mlockall с флагами MCL_CURRENT и MCL_FUTURE, затем предварительное касание каждой страницы стека циклом записи, и только потом переход в рабочий цикл.

Бюджет rt задач тоже умеет пакостить. По умолчанию ядро ограничивает долю процессора для rt задач величиной 0.95 через /proc/sys/kernel/sched_rt_runtime_us, и если один поток завис в бесконечном цикле, остальные 5 процентов времени выделяются обычным задачам, чтобы система не умерла. Для контроллера, который обязан работать всегда, этот параметр выставляют в минус единицу, понимая ответственность.

Измерение задержек инструментами cyclictest и rtla

Доверять реальному времени без измерений нельзя, и эталонным инструментом десятилетиями остаётся cyclictest из пакета rt tests. Канонический запуск для проверки настроенной системы: cyclictest с блокировкой памяти, приоритетом 99, привязкой к изолированным ядрам, интервалом 1000 микросекунд и длительностью не менее нескольких часов. Важно нагрузить систему параллельно через hackbench или stress-ng, потому что пустая машина покажет красивые цифры, которые не имеют ничего общего с боевыми.

На настроенном сервере x86 типичная картина такова: средняя задержка 5-10 микросекунд, максимальная 25-40 микросекунд за час нагрузочного теста. Если максимальное значение улетает за сотню микросекунд, ищут виновника, и современное ядро предлагает для этого два мощных механизма.

Первый это timerlat из набора rtla. Он измеряет задержку от срабатывания таймера до начала обработки прерывания и отдельно задержку потока, разделяя аппаратную часть и часть планировщика. Второй инструмент, rtla osnoise, ловит все источники шума на ядре: потоки, прерывания, softirq, и раскладывает их по вкладу в микросекундах. Если на изолированном ядре osnoise показывает всплески от irq, значит smp_affinity настроен небрежно.

Практический порядок измерений строят примерно так:

  1. Прогон cyclictest минимум один час под нагрузкой hackbench с фиксацией максимальной задержки;
  2. Параллельный запуск rtla osnoise на каждом изолированном ядре для поиска шумящих irq и softirq;
  3. Запуск rtla timerlat с трассировкой при превышении порога, чтобы увидеть источник задержки;
  4. Отключение найденных источников шума и повторный прогон для подтверждения эффекта.

Управление питанием и частотой процессора

Подсистема энергосбережения это скрытый враг номер один. Глубокие состояния сна C states требуют от десятков до сотен микросекунд на пробуждение ядра, и пока процессор вылезает из C6, rt поток уже давно мог бы отработать. Параметры загрузки intel_idle.max_cstate=1 или processor.max_cstate=1 ограничивают глубину сна, хотя и заставляют систему потреблять заметно больше энергии.

Понижение частоты тоже вредит, потому что задача, рассчитанная на 3 ГГц, на частоте 1.2 ГГц выполняется в два с половиной раза дольше и нарушает дедлайн. Свойство scaling_governor выставляют в performance для всех ядер, а если доступен intel_pstate, иногда его отключают, чтобы вернуть управление классическому acpi cpufreq и получить предсказуемые частоты. Шину PCIe с её ASPM при жёстких требованиях выключают параметром pcie_aspm=off, ведь выход линка из состояния L1 тоже стоит микросекунды, которые никому не обещали.

Скрытый источник аномалий это системные прерывании управления SMI, которые генерирует прошивка. Ядро их не видит и остановить не может, поэтому аппаратный детектор hwlatdetect из того же пакета rt tests гоняют перед всеми остальными тестами: он крутится в цикле чтения временного штемпеля и фиксирует дыры, где процессор молчал дольше порога. Если hwlatdetect показывает задержки в десятки микросекунд на пустой системе, виновата прошивка, и никакая настройка Linux это не вылечит.

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

Оптимизация приложений под реальное время

Ядро создаёт условия, но приложение способно всё испортить само. Главные правила давно превратились в канон: rt поток не делает системных вызовов, способных блокироваться, внутри критичного цикла, не выделяет память через malloc после входа в rt фазу, не пишет логи напрямую, а передаёт их не rt потоку через lockfree кольцевой буфер. Каждый printf внутри цикла управления это потенциальный пропущенный дедлайн.

Ввод и вывод оформляют через заранее открытые файловые дескрипторы и отображённые в память буферы, а сетевой обмен надёжнее всего строить на raw сокетах с привязкой к изолированному ядру и настроенным приоритетом обработчика прерываний сетевого контроллера. Для межпроцессного обмена пригодны области разделяемой памяти с futex, хотя нередко выбирают eventfd и собственную передачу через буферы в shared memory.

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