nos_
ENRU

Дедлок без паники: почему в nos нельзя освобождать память под спинлоком

TLB shootdown ждал CPU, который крутился на спинлоке отправителя. Как одно тихое зависание стало правилом, пулом кадров, гейтом и паникой через 10 секунд.

7 мин чтения
БезопасностьПамятьОтладкаСетьФаззингx86-64
Read in English

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 в запасе было ещё одно тихое зависание, на приёме, — его исправили на следующий день, и это отдельная история.