Дедлок без паники: почему в nos нельзя освобождать память под спинлоком
TLB shootdown ждал CPU, который крутился на спинлоке отправителя. Как одно тихое зависание стало правилом, пулом кадров, гейтом и паникой через 10 секунд.
EX44 перестал отвечать. Это один из двух серверов Hetzner, на которых работает nos, и единственная его консоль — сеть: UDP-шелл, чтобы вводить команды, и netconsole, чтобы читать лог. Команда ps в шелле осталась без ответа на третий раз, а в следующий — на седьмой. Ни паники, ни исключения: лог netconsole просто обрывался на полуслове, и машину ничто не перезагрузило — ничего не сломалось так, чтобы ядро умело это заметить.
Два стека
Воспроизвести удалось на гостевой машине QEMU с 20 CPU: ps по UDP-шеллу в цикле, и где-то между 60-й и 140-й итерацией всё вставало. GDB, подключённый к gdbstub в QEMU, показал всю историю в двух стеках — так их записал коммит с исправлением:
CPU#0 UdpShell::Run -> SendUdp -> SendRaw -> NetDevice::SubmitTx
-> RawSpinLock::LockIrqSave(TxQueueLock) spinning, IRQs off
CPU#1 softirq -> VirtioNet::DrainTx (holds TxQueueLock) -> CompleteTx
-> NetFrame::Put -> Mm::Free -> PageAllocator::Free
-> CpuTable::InvalidateTlbRange -> SendTlbIPI(mask 0xFFFFD)
-> WaitGroup::Wait waiting for CPU#0
CPU#1 завершал отправленные кадры, держа TX-лок устройства. Когда отпускалась последняя ссылка на кадр, кадр освобождался, а освобождение снимало отображение страницы. На x86 invlpg чистит только локальный TLB, поэтому CpuTable::InvalidateTlbRange рассылает всем остальным работающим CPU IPI с просьбой выбросить устаревшую трансляцию и ждёт, пока каждый подтвердит (страничная память). Маска 0xFFFFD — это все CPU, кроме отправителя, и CPU#0 в том числе. А CPU#0 крутился на том же TX-локе, который LockIrqSave берёт с выключенными прерываниями, — и IPI так и остался непринятым. CPU#1 ждал CPU#0, CPU#0 ждал лока, который держал CPU#1. Остальные восемнадцать процессоров ответили и ушли в idle.
Нет исключения — нет и паники. TCO watchdog не был взведён — значит, нет и перезагрузки. А задаче, которая сливает лог netconsole, нужен тот же TX-лок, чтобы отправить хоть одну строчку, — поэтому лог и обрывался на полуслове.
Обиднее всего, что опасность уже была описана — в том же самом драйвере. Его обработчик прерывания откладывал завершение TX в softirq именно потому, что «Mm::Free() can trigger a TLB shootdown IPI [and] if another CPU has IRQs disabled the IPI cannot be acknowledged, causing a deadlock». Отложить было необходимо, но недостаточно: softirq затем брал TX-лок с выключенными прерываниями и всё равно освобождал под ним. Состояние прерываний на освобождающем CPU проблемой не было никогда. Проблемой было состояние того, кто крутится на локе.
Собрать, отпустить, освободить
Форма исправления (eaff359) с тех пор повторялась в каждом следующем случае. Под локом переложить всё, что нужно освободить, в локальный список; отпустить лок; освободить.
void VirtioNet::DrainTx()
{
Stdlib::ListEntry done;
done.Init();
ulong flags = TxQueueLock.LockIrqSave();
CompleteTx(done);
FlushTx();
TxQueueLock.UnlockIrqRestore(flags);
// ...
while (!done.IsEmpty())
{
NetFrame* frame = CONTAINING_RECORD(done.RemoveHead(), NetFrame, Link);
frame->Put();
}
}
800 прогонов воспроизводящего сценария прошли, а в CLAUDE.md появилось правило — ведь ничего сетевого в нём нет: никогда не выделять и не освобождать память, держа спинлок. На arm64 именно этот цикл замкнуться не может: TLBI рассылается аппаратно, Hal::TlbShootdownNeedsIpi() отвечает false, и никто никого не ждёт. Но код у обеих архитектур общий, а все три реальные машины, на которых работает nos, — x86-64.
Не тот драйвер
Через шестнадцать минут пришла поправка (fc314e7). Сценарий гонялся на virtio-net — это то, что даёт QEMU. А у EX44 стоит RTL8125, и его Rust-драйвер, как и драйвер r8168, разбирал TX-кольцо прямо в flush_tx, который сетевой стек вызывал под тем же TX-локом, и прямо там делал drop каждому отправленному кадру. Тот же дедлок — на той самой машине, где он и случался. Мы утверждали, что остальные драйверы проверены. Grep, на котором держалось это утверждение, искал имя функции, которого в дереве нет, ничего не вернул — и это «ничего» приняли за «чисто».
Поэтому освобождение ушло из драйверов в слой, которому принадлежит лок, — туда, где драйвер ошибиться не может. Сегодня сетевой слой написан на Rust: flush_tx драйвера на время вызова получает взаймы TxQueue, и у готового кадра есть один задокументированный выход (device.rs):
/// A transmitted frame, for release once the lock is down. Never dropped
/// here instead: dropping frees, a free can reach the page allocator,
/// which shoots down the TLB on every other CPU and waits for each -- and
/// a CPU spinning on this lock has interrupts off and never answers.
pub fn done(&mut self, frame: Frame) {
self.done.push(frame);
}
Эту очередь слой опустошает, когда лок уже отпущен:
pub fn release_tx_done(&self) {
loop {
let frame = self.tx.lock().done.pop();
match frame {
/* Outside the lock, always: see `tx_done` */
Some(frame) => drop(frame),
None => return,
}
}
}
self.tx — это IrqSpinLock<Tx>, лок, который владеет тем, что защищает (sync.rs): до очереди вообще нельзя добраться иначе как через guard, и обеспечивает это тип, а не комментарий. Guard, который здесь возвращает lock(), — временный объект и умирает в конце let, так что к моменту drop(frame) лок уже отпущен. submit_tx добавляет ещё одну тонкость: освобождает только при включённых прерываниях. Вызывающий с выключенными прерываниями сам не смог бы ответить на shootdown, поэтому он поднимает TX softirq и оставляет освобождение ему.
Десять секунд — и отчёт
Правило в файле остановит следующий случай, только если его кто-то прочтёт, а мы только что показали, как легко такой случай пропустить. Поэтому тот же коммит сделал отказ громким. Ожидание подтверждений было циклом, который мог закончиться только ответом; теперь CpuTable::SendTlbIPI сдаётся через десять секунд — это далеко за пределами любого честного раунда IPI — и паникует, называя CPU, которые так и не ответили (cpu.cpp):
static const ulong AckTimeoutNs = 10 * Const::NanoSecsInSec;
// ...
for (ulong i = 0; i < MaxCpus; i++)
{
// ...
while (tasks[i].Completion.GetCounter() != 0)
{
if (GetBootTime() >= deadline)
{
silent |= (1UL << i);
break;
}
YieldToRunnable();
}
}
if (silent != 0)
Panic("TLB shootdown: cpus 0x%lX never acknowledged (asked 0x%lX)",
silent, cpuMask);
Позже в тот же день тот же урок выучил watchdog локов (22e49c2). Он следил только за SpinLock, а TxQueueLock был RawSpinLock, — поэтому dmesg 2048 Spinlock и возвращал пустоту на машине, где TX-лок держали минутами. Инструментирование переехало в RawSpinLock. Лок, удерживаемый десять секунд, стал паникой, а не очередной строкой трассировки. А путь паники научился отправлять отчёт без TX-лока: одна попытка взять, не вышло — дальше без него, иначе отчёту пришлось бы выходить через ту самую заклинившую дверь. Нарочное зависание — команда шелла, которая навсегда захватывает TX-лок с выключенными прерываниями, — дошло до коллектора так:
Spinlock 0xFFFF800040420330 is held too long 27055333
PANIC: Spinlock 0xFFFF800040420330 stuck, held 10003951083 ns
CPU: 3 Task: pid 3 name idle3
Backtrace: DoPanic <- Watchdog::Check <- GenericTimer::LocalTick <- ArmIrqEntry
Путь данных, который ничего не освобождает
Лучше, чем освобождать вне лока, — вообще не возвращать память аллокатору. В тот же день, 4 сентября (c3e1d8d) сетевые кадры переехали в пул, который строится один раз при загрузке. Кадры у него фиксированного размера, а их физические адреса вычисляются однажды. Раздаются они из per-CPU кэшей — с выключенными прерываниями и без атомиков, — а за кэшами стоит одно lock-free кольцо, ограниченная MPMC-очередь Вьюкова, из которого кэши пополняются и в которое сбрасывают излишки пачками по 32. Коммит формулирует приоритет прямо: путь данных, который никогда не зовёт аллокатор, этой ошибки иметь не может вовсе, и это ценнее скорости. На гостевой arm64-машине с 4 CPU 500 обменов с шеллом взяли из пула 506 кадров без единого промаха, а к кольцу обратились 31 раз. Теперь пул написан на Rust (frame.rs): 4096 кадров по умолчанию, netframes=, чтобы это изменить, и netpool, чтобы посмотреть.
А скорость пришла сама собой. На AX41 первый поток трафика после каждой загрузки терял кадры прямо на сетевой карте: 755 105 из 2,06 миллиона, все — за первые две секунды. Приёмное кольцо заполнялось через Mm::Alloc ещё до того, как поднимался пул, и освобождение каждого такого кадра означало TLB shootdown с ожиданием всех остальных CPU — там это около 4 мс на штуку (e02dfe3). Теперь пул поднимается раньше любого драйвера.
Правило как гейт
Фаззеры (тесты и гейты) следят за правилом механически. Аллокатор под сетевым и дисковым фаззерами проверяет каждое выделение и каждое освобождение, и сделанное с выключенными прерываниями — это находка прямо в той строке, где оно случилось, вместе с seed и номером итерации, которые её воспроизводят; паника объясняет почему: «a spin lock held: a free can wait on every other CPU, and one spinning on this lock never answers» (heap.rs). Цель heap в C++-фаззере требует того же от каждой страницы, которую берёт аллокатор: INVARIANT(Fuzz::HeldSpinLocks() == 0, "a page allocated with a spin lock held").
30 сентября он нашёл нарушение там, где мы стали бы искать в последнюю очередь, — в самой куче ядра (00631ab). Pool::Alloc, через который проходят мелкие выделения кучи, брал новую страницу у страничного аллокатора, держа собственный спинлок пула. Если отобразить эту страницу не удавалось, страничный аллокатор сбрасывал TLB на всех CPU и ждал — а CPU, крутящийся на локе пула, не ответил бы никогда. Исправление снова той же формы: выделять, отпустив лок, а если другой CPU тем временем уже положил страницу в список, вернуть лишнюю — тоже вне лока (pool.cpp):
if (block == nullptr)
{
Page* page = NewPage();
if (page == nullptr)
return nullptr;
Page* spare = nullptr;
{
Stdlib::AutoLock lock(Lock);
if (FreePageList.IsEmpty())
{
FreePageList.InsertHead(&page->Link);
EmptyPages++;
}
else
spare = page;
block = TakeLocked();
}
if (spare != nullptr)
PgAlloc->Free(spare);
}
Класс ошибок никуда не делся, и ядро не делает вид, будто его нет. Изменилось другое: он больше не проходит молча. Правило в CLAUDE.md его называет. На пути передачи сетевой слой даёт драйверу один способ вернуть кадр. Фаззеры ловят нарушение в той строке, где оно случилось. Пул кадров держит самый нагруженный путь подальше от аллокатора. А на реальной машине новый случай — это паника через десять секунд, пришедшая по netconsole с номерами CPU, которые не ответили, а не сервер, который просто встал. У EX44 в запасе было ещё одно тихое зависание, на приёме, — его исправили на следующий день, и это отдельная история.