В настоящее время планировщик работы с ядрами ЦП, действующий в ядре Linux, предусматривает несколько режимов вытеснения. В этих режимах предлагается целый ряд компромиссов между временем отклика и пропускной способностью системы. Ещё в сентябре 2023 года развернулась дискуссия о работе планировщиков, в результате которой была выработана концепция «ленивого вытеснения». Данная концепция упрощает планирование задач в ядре, при этом улучшая результаты. Какое-то время эта работа протекала тихо, но затем ленивое вытеснение было заново реализовано Питером Зайлстрой в виде этой серии патчей. Притом, что сама концепция с виду работает хорошо, здесь ещё немало требуется доделывать.
❯ Небольшой обзор
В современных ядрах предусмотрено четыре разных режима работы, при которых регулируется, каким образом одна задача может вытесняться в пользу другой. В простейшем режиме PREEMPT_NONE
вытеснение допускается лишь в том случае, когда текущая задача исчерпала отведённое ей окно времени. При режиме PREEMPT_VOLUNTARY
внутри ядра также расставляется множество точек, в каждой из которых при необходимости можно выполнить вытеснение. В режиме PREEMPT_FULL
вытеснение возможно практически в любой точке кроме тех, где ядро это явно запрещает, например, там, где удерживается спинлок. Наконец, в режиме PREEMPT_RT
вытеснению отдаётся приоритет почти над любыми прочими операциями. Вытеснению поддаётся даже большая часть того кода, который удерживается через спинлоки.
Чем выше уровень вытеснения, тем быстрее система успевает реагировать на события, будь то движение компьютерной мыши или сигнал от ядерного реактора, что мы на пороге расплавления активной зоны. Чем быстрее отклик — тем больше от него пользы. Но, если в системе задан сравнительно высокий уровень вытеснения, это может пагубно сказаться на общей пропускной способности системы. Если приходится иметь дело с долгоиграющими и ресурсоёмкими задачами, то лучше как можно реже вмешиваться в работу машины. Кроме того, частое вытеснение может приводить к обострению конфликтов при захвате блокировки. Именно поэтому и существуют различные режимы вытеснения. Оптимальный режим вытеснения будет отличаться от нагрузки к нагрузке.
В большинстве дистрибутивов предоставляются ядра, собираемые в псевдо-режиме PREEMPT_DYNAMIC
, который позволяет во время загрузки выбрать любой из первых трёх режимов, причём, PREEMPT_VOLUNTARY
задаётся по умолчанию. В системах со смонтированным debugfs текущий режим можно прочитать из /sys/kernel/debug/sched/preempt
.
В режимах PREEMPT_NONE
и PREEMPT_VOLUNTARY
не допускается произвольное вытеснение кода, выполняемого в ядре. Бывает, что такой подход приводит к чрезмерным задержкам, даже в тех системах, где минимальная задержка не в приоритете. Эта проблема отражается на тех областях ядра, где может выполняться значительная часть работы. Если пустить эту работу на самотёк, то из-за неё может нарушиться планирование в пределах всей системы. Чтобы такого не происходило, долгоиграющие циклы сдабриваются вызовами cond_resched(), каждый из которых служит дополнительной точкой произвольного вытеснения, остающейся активной даже в режиме PREEMPT_NONE
. В ядре насчитываются сотни таких вызовов.
С этим подходом возникают и некоторые проблемы. В cond_resched()
заключена своеобразная эвристика, и она работает только в тех точках, куда разработчик целенаправленно её поместил. Без некоторых вызовов определённо можно обойтись, тогда как в ядре обязательно найдутся другие места, в которых были бы полезны вызовы cond_resched()
, а на деле они там отсутствуют. Используя cond_resched()
, мы, в сущности, принимаем решение, которое должно распространяться только на код планировщика — а мы распространяем его по всему ядру. В общем, это небольшой костыль, который, как правило, работает, но вообще эту задачу можно решить лучше.
❯ Как сделать лучше
Если попробуете отслеживать, может ли конкретная задача быть вытеснена в любой конкретный момент времени, сразу увидите, насколько это сложно. Занимаясь таким делом, нужно учитывать сразу несколько переменных — подробнее данные проблемы раскрыты в этой и этой статье. Одна из этих переменных — просто флаг TIF_NEED_RESCHED
, сигнализирующий, есть ли более высокоприоритетная задача, ожидающая доступа к ядру ЦП. Если, например, нужно разбудить такую задачу, то при данном событии такой флаг может быть установлен в рамках любой задачи, какая бы сейчас ни выполнялась. При отсутствии такого флага ядру нет никакой необходимости взвешивать, стоит ли вытеснить текущую задачу.
Есть различные точки, в которых ядро может заметить этот флаг и вытеснить ту задачу, которая в данный момент выполняется. Пример такой задачи — отсчёт таймера. Другой пример — возвращение задачи в пользовательское пространство после системного вызова. Третий пример — завершение работы обработчика прерываний. Однако третья ситуация с проверкой на такое завершение возможна лишь в конфигурациях, где включены прерывания, и только в ядрах с действующей опцией PREEMPT_FULL
. При вызове cond_resched()
также будет проверен этот флаг и, если он установлен, то планировщику поступит вызов с требованием уступить ядро ЦП другой задаче.
В сущности, патчи для ленивого вытеснения устроены просто. При работе с ними добавляется ещё один флаг, TIF_NEED_RESCHED_LAZY
, сигнализирующий о необходимости перепланировать задачи, но не обязательно прямо сейчас. В режиме ленивого вытеснения (PREEMPT_LAZY)
при большинстве событий будет устанавливаться новый флаг, а не TIF_NEED_RESCHED
. При таких операциях как возвращение в пользовательское пространство из ядра любой из этих флагов приведёт к вызову планировщика. Но в точках для произвольного вытеснения и на пути возврата после прерывания проверяется только флаг TIF_NEED_RESCHED
.
Данное изменение приводит к тому, что в режиме ленивого вытеснения большинство событий в ядре не приводят к вытеснению текущей задачи. Но рано или поздно эта задача должна быть вытеснена. Чтобы так и произошло, обработчик таймера ядра проверит, установлен ли флаг TIF_NEED_RESCHED_LAZY
; если так, то будет установлен и TIF_NEED_RESCHED
, что может привести к вытеснению текущей задачи. Как правило, задачи расходуют на работу выделенное им временное окно полностью или почти полностью, после чего без принуждения освобождают ядро ЦП. В таком случае удаётся добиться хорошей пропускной способности.
С учётом всех этих изменений в режиме ленивого вытеснения можно, как и в режиме PREEMPT_FULL
, (почти) не выключать функцию вытеснения. Вытеснение может произойти в любой момент, когда соответствующий счётчик сочтёт, что задача должна быть вытеснена. В таком случае долгоиграющий код можно вытеснять в моменты, когда никакие другие условия этому не препятствуют. Кроме того, вытеснение можно очень быстро организовать в тех случаях, когда это действительно нужно. Например, как только задача реального времени становится готова к выполнению — скажем, в результате обработки прерывания — устанавливается флаг TIF_NEED_RESCHED
, тогда вытеснение произойдёт почти немедленно. В таких случаях не придётся дожидаться, пока в таймере пройдёт нужная часть отсчёта.
Но вытеснение не произойдёт, если установлен лишь флаг TIF_NEED_RESCHED_LAZY
, а именно так в большинстве случаев и будет. Поэтому ядро в режиме PREEMPT_LAZY
вытесняет действующую задачу с гораздо меньшей вероятностью, чем ядро в режиме PREEMPT_FULL
.
❯ Наконец, избавляемся от cond_resched()
В результате этой работы мы стремимся получить планировщик, в котором, кроме режима выполнения в реальном времени, предусматривалось бы ещё всего два режима: PREEMPT_LAZY
и PREEMPT_FULL
. Ленивый режим является промежуточным между PREEMPT_NONE
и PREEMPT_VOLUNTARY
, поэтому заменяет их оба. Но в нём не потребуется предусматривать точки добровольного вытеснения, которые добавлялись в тех двух режимах, которые он заменяет. Поскольку теперь вытеснение может происходить почти где угодно, нет необходимости активировать его в конкретных точках.
Но вызовы cond_resched()
пока остаются. Так или иначе, они нужны хотя бы с учётом того, что режимы PREEMPT_NONE
и PREEMPT_VOLUNTARY
существуют. Также такие вызовы помогают гарантировать, что не возникнет новых проблем после того, как стабилизируется режим ленивого вытеснения.
Когда данный патч установлен, вызов cond_resched()
проверяет только TIF_NEED_RESCHED
. Поэтому зачастую вытеснение будет откладываться именно в тех ситуациях, где в режимах PREEMPT_VOLUNTARY
или PREEMPT_NONE
оно немедленно происходило бы под действием cond_resched()
. Стив Роштедт усомнился, так ли необходимо это изменение, и не следует ли сохранить за cond_resched() его старую семантику — как минимум, в случае с PREEMPT_VOLUNTARY
. Пусть PREEMPT_VOLUNTARY
уже и стоит в очереди на удаление, было бы лучше сохранить старое поведение — это упростило бы переход.
Томас Глейкснер ответил, что в данном случае правильно выставлять только TIF_NEED_RESCHED
, поскольку это поможет при окончательном избавлении от вызовов cond_resched()
:
В таком случае мы должны присматриваться к ним и решать, следует ли их расширить так, чтобы в них включался и ленивый функционал. Те, в которых он не нужен, можно устранить при действующем LAZY, поскольку тогда вытеснение произойдёт в следующей предназначенной для этой точке, как только код перейдёт в область «неленивого» выполнения.
Также он добавил, что, по его мнению «менее чем при 5%» вызовах cond_resched()
придётся выставлять TIF_NEED_RESCHED_LAZY
и, следовательно, эта функция должна будет остаться даже после того, как полностью завершится переход на PREEMPT_LAZY
.
А до тех пор сохранятся сотни вызовов cond_resched()
, которые потребуется проверить, и большинство из них — удалить. Также придётся справиться со многими другими деталями; некоторых из них касается этот набор патчей от Анкура Ароры. Кроме того, требуется всеобъемлющее тестирование производительности. Майк Гэлбрайт одним из первых приступил к этой работе, продемонстрировав, что при использовании ленивого вытеснения пропускная способность лишь немного не дотягивает до варианта с PREEMPT_VOLUNTARY
.
Таким образом, накапливается немало работы, но, когда она будет завершена, должно получиться очень компактное и простое ядро, задержки в котором предсказуемы, и по всему коду не приходится расставлять вызовы, касающиеся работы планировщика. Это определённо очень привлекательное решение, но до его достижения ещё далеко.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩