Когда ожидание не кончается
Task::Wait, через который проходит каждая остановка в nos, ждал вечно и молча. Теперь он сообщает, кто ждёт, какую задачу и где она сейчас.
Одно правило в CLAUDE.md проекта nos сформулировано жёстче остальных: «зависание без отчёта — худший из возможных исходов, и изменение, которое превращает такое зависание в отчёт, стоит сделать». Некоторые ожидания в ядре уже так устроены. На x86 TLB shootdown просит все остальные процессоры выбросить устаревшую трансляцию адресов, и если какой-то из них так и не ответил, через десять секунд ядро паникует и называет его (эта история). Спинлок, который держат десять секунд, тоже приводит к панике.
Но одно ожидание всё ещё оставалось немым, причём то самое, через которое проходит каждая остановка. До 22 октября Task::Wait ждал завершения другой задачи без срока и без единого слова. Ниже рассказ о том, как он научился говорить и как новая функция Task::Locate подсказывает ему, что сказать.
Join под каждой остановкой
Join — это когда одна задача ждёт завершения другой. В nos это Task::Wait, и через него проходит каждый путь остановки в ядре. TaskHandle на стороне Rust ждёт свою задачу, когда его уничтожают (task.rs). hv stop ждёт задачу гостя в finish(). Диск гостя, FileDisk, при удалении ждёт свою дисковую задачу. rmmod и выключение хоста тоже заканчиваются join'ом.
Вот старый join целиком:
void Task::Wait()
{
while (State.Get() != StateExited)
{
Sleep(1 * Const::NanoSecsInMs);
}
}
Раз в миллисекунду он проверяет, не завершилась ли задача. Если она так и не завершится, ждущий будет ждать вечно, и в лог ничего не попадёт. Дисковой задачи, застрявшей на чтении, хватало, чтобы повесить hv stop, выгрузку модуля hv и выключение машины, и всё это молча. На ревью гипервизора 10 октября эту дыру уже занесли в документацию (ddf53d3): «нужно ожидание, которое называет того, кто не ответил, как это делает TLB shootdown».
Почему он по-прежнему не сдаётся
Очевидное решение — таймаут, и оно было бы ошибкой. Задача, которая не завершилась, всё ещё пользуется тем, что ждущий собирается освободить. Join, который сдался бы, отдал бы вызывающему коду память, которой ещё пользуется другая задача, а это хуже зависания.
Поэтому join по-прежнему ждёт сколько потребуется, но уже не молча (9b1bcbc). Через десять секунд он пишет отчёт в лог ядра. Дальше он повторяет отчёт каждый раз, когда ожидание удвоилось: примерно на 20-й секунде, потом на 40-й, на 80-й, но не реже чем раз в пять минут:
void Task::Wait(ulong reportNs)
{
const ulong start = GetBootTime().GetValue();
ulong reportAt = reportNs;
while (State.Get() != StateExited)
{
Sleep(1 * Const::NanoSecsInMs);
ulong waited = GetBootTime().GetValue() - start;
if (waited < reportAt)
continue;
ReportJoin(waited);
reportAt = waited + Stdlib::Min(waited, JoinReportMaxNs);
}
}
По умолчанию reportNs равен десяти секундам, так что отчёт получают все вызывающие, и ни одного из них не пришлось менять. Вот как отчёт выглядит в документации планировщика:
join: shell (pid 12) has waited 10000 ms for hv/vm3/vda (pid 57) to exit, and it is blocked
join: [0] 0xFFFF80000105AF99 Kernel::TaskQueue::Schedule+0x449
join: [1] 0xFFFF80000105B594 Kernel::ScheduleCurrent+0x154
...
Первая строка говорит, кто ждёт (шелл, pid 12), какую задачу (hv/vm3/vda, дисковую задачу гостя vm3), сколько времени и в каком состоянии эта задача. Следующие строки показывают, где она находится, по кадру на строку, и каждый адрес возврата назван по таблице символов ядра.
Где задача: Task::Locate
Самое трудное в отчёте — сказать, где задача. Она может быть в одном из трёх мест, и Task::Locate в каждом случае действует по-своему (task.cpp).
Вне процессора. Задача, которая заблокирована, спит или готова к работе и ждёт своей очереди, нигде не выполняется. Последнее переключение контекста сохранило её регистры на её собственном стеке, в том числе указатель кадра (frame pointer). С него начинается цепочка, которая связывает каждый кадр стека с кадром вызывающей функции. Locate читает сохранённый указатель и проходит по цепочке, не выходя за границы стека задачи.
Подвох в том, что задача может начать выполняться прямо во время чтения: её ничто не держит. Поэтому чтение устроено как у seqlock: запомнить номер версии, прочитать и оставить прочитанное, только если номер не изменился. Номером версии здесь служит счётчик переключений контекста самой задачи:
long switches = ContextSwitches.Get();
ulong base = (ulong)StackPtr;
ulong limit = (ulong)&StackPtr->StackTop[0];
ulong sp = __atomic_load_n(&Rsp, __ATOMIC_RELAXED);
size_t count = StackTrace::CaptureFrom(Hal::TaskSavedFramePointer(sp, base, limit),
base, limit, where.Frames, WhereFrames);
Hal::SmpRmb();
if (State.Get() != StateWaiting || ContextSwitches.Get() != switches)
return false;
Если за время чтения задачу успели переключить, это видно по её состоянию или по счётчику, и тогда Locate выбрасывает результат и смотрит снова. Проигранная гонка может дать неправильные кадры, но к падению привести не может. Спрашивающий держит ссылку на задачу, поэтому её стек остаётся отображённым. А Hal::TaskSavedFramePointer теперь отвергает сохранённый кадр, который не лежит внутри стека, так что устаревший указатель стека не уведёт обход в неотображённую память.
На процессоре. Кадры работающей задачи всё время меняются, поэтому Locate не читает их, а спрашивает. Он отправляет межпроцессорное прерывание (IPI) каждому другому работающему процессору. Каждый обработчик проверяет, ту ли задачу он прервал, и если ту, записывает прерванную инструкцию и кадры над ней. Ответов Locate ждёт до секунды.
На процессоре, который не отвечает. Процессор с выключенными прерываниями IPI не примет, а застрявшая задача обычно именно там и находится: крутится с выключенными прерываниями. Поэтому через секунду Locate называет вместо задачи молчащие процессоры, в виде маски: «on a cpu that would not answer: cpus 0x20 took no interrupt in 1000 ms».
С последним случаем пришлось повозиться. Процессор, который примет IPI поздно, всё равно запишет ответ, когда до него дойдёт, и если бы ответ лежал на стеке спрашивающего, он писал бы в давно переиспользованный стек. Поэтому у каждого процессора есть свой слот для ответов: он создаётся, когда процессор спрашивают впервые, и остаётся навсегда. Если IPI в слоте ещё не обработан, повторно его не отправляют, а процессор просто снова называют молчащим. Слот, на который ответили поздно, используется заново.
bt видит то, чего не видел
Команда шелла bt <pid> печатает стек вызовов задачи и теперь тоже работает через Locate. Для задачи вне процессора старый bt проходил по сохранённому указателю кадра в границах стека самого шелла. Кадры задачи лежат на другом стеке, поэтому обход сразу останавливался, и bt ни разу не напечатал ни одного кадра заблокированной или спящей задачи. Для работающей задачи старый bt отправлял IPI процессору, в очереди которого стояла задача, и брал то, что этот процессор выполнял в момент прерывания. Теперь первая строка bt говорит, где задача, теми же словами, что и отчёт join'а: заблокирована, спит, готова и ждёт процессора, выполняется на таком-то процессоре в такой-то функции или стоит на процессоре, который за секунду не принял прерывание.
Заодно исправлена ещё одна мелочь. На x86 стек вызовов любой задачи заканчивался лишним кадром, прочитанным из шаблона 0x5A5A5A5A5A5A5A5A, которым заполняется каждый новый стек. Теперь Hal::BuildTaskFrame оставляет 0 там, где был бы адрес возврата входной функции задачи, и обход на нём останавливается.
Проверка при каждой загрузке
Отчёт, который появляется, только когда что-то уже застряло, легко сломать и не заметить. Поэтому самотест TestTaskLocate запускается один раз за загрузку на загрузочном процессоре, когда остальные уже работают. Задача, заблокированная на событии, должна быть прочитана с её стека, и среди кадров должна найтись её функция. Для задачи, которая держит другой процессор глухим, с выключенными прерываниями, Locate должен назвать этот процессор молчащим через ту десятую долю секунды, которую ждёт тест. Затем задача, которая крутится на том же процессоре, должна быть поймана там через IPI, и это доказывает, что слот, на который глухой процессор ответил поздно, можно использовать снова. И наконец, join длиной 50 мс при интервале отчёта 20 мс должен попасть в отчёт.
Если тест так и не скажет глухой задаче остановиться, через две секунды она сдастся сама и тест провалится, но загрузка не повиснет. Проваленная проверка приводит к панике с сообщением «Task locate test failed», а тест идёт при каждой загрузке на обеих архитектурах, так что его проверяет каждый smoke-тест. Кроме того, в коммите smoke-тесты прогнали на сборках UBSAN=1 RUSTUB=1, которые ловят неопределённое поведение во время работы, а C++-фаззер прошёл 935 000 входов без единой находки. Проверка коммита на неопределённое поведение называет одно ограничение: на arm64 в течение нескольких инструкций после переключения на задачу чтение может дать неправильные кадры, но падения при этом не бывает.
Зачем это всё
Отчёт join'а делает для задачи то же, что список не ответивших процессоров у TLB shootdown делает для процессора, и правило в CLAUDE.md теперь называет его рядом с shootdown'ом и watchdog'ами. Пункт из ревью в документации гипервизора зачёркнут. Застрявшая дисковая задача по-прежнему повесит hv stop, потому что освободить то, чем она пользуется, было бы хуже. Но теперь через десять секунд лог скажет, какая задача застряла, в каком госте и где.
Тем же слиянием, b235726, хост на AMD закрылся от BTI, Retbleed и SRSO со стороны своих гостей. Об этом рассказано в обновлении поста о спекулятивном исполнении гостей.