В дампе зависшего рантайма Nova обнаружилась строка, похожая на противоречие: fibers[5] = NULL и одновременно parked[5] = true. Пятое место в таблице выглядело пустым, но система считала, что на нём спит файбер. Эта деталь помогла найти гонку, после которой некоторые задачи ждали пробуждения бесконечно.

Разбор опубликовал разработчик под ником unitcraft на Хабре. Перед нами инженерный кейс, а не рецензированное исследование. Мы сверили его с двумя документами проекта Nova: внутренней методичкой по отладке гонок и планом с постмортемами. В методичке сказано, что она собирает уроки примерно 25 часов расследований. План датирован 27 мая 2026 года, в нём есть записи о ходе работ. Документы помогают проверить детали, но написаны внутри того же проекта, поэтому независимым подтверждением не служат.

Рантайм M:N распределяет множество файберов, лёгких задач со своими стеками, между меньшим числом потоков операционной системы. Когда задача ждёт таймер или другое событие, она паркуется и освобождает поток. Вернуть её в очередь выполнения позже должен отдельный поток-драйвер. Схема экономит ресурсы, но её состояние разбросано по таблицам, очередям и потокам. Если проверка и запись состояния идут не в согласованном порядке, отдельные счётчики могут выглядеть нормально, хотя конкретная задача уже потерялась.

В первом расследовании тест поднимал 99 файберов, которые засыпали на 10 секунд, а сотый через 30 миллисекунд отменял группу. Из 30 запусков успешно проходили от 3 до 5, остальные зависали. Счётчики говорили, что таймеры закрылись и пробуждение запрашивали 100 раз, но успешно срабатывало только от 89 до 97 пробуждений. По этим числам можно было лишь приблизительно понять, где искать проблему: какого именно файбера не разбудили, они не показывали.

На дамп, снятый по сигналу сторожевого таймера, попали сразу пустой слот и отметка о парковке. Причина была в том, как место в таблице использовали повторно. Спящий файбер A временно оставлял в таблице пустое значение, а код выделения слотов проверял только его. Место получал новый файбер B. Когда срабатывал таймер A, драйвер обращался к тому, кто сейчас занимал номер 5, то есть к B. Пробуждение чужой задачи отбрасывалось, а A продолжал спать.

Постмортем в плане проекта описывает итоговый фикс не как одно изменение, а как три части. Среди них пропуск слота, занятого спящим файбером, и прямое пробуждение исходной задачи, если слот уже принадлежал другой. В статье автор кратко формулирует принцип: слот не свободен, пока на нём кто-то спит, а таймер должен помнить идентичность файбера, а не только номер места. После исправления конкретный стресс-тест прошёл 30 раз из 30. Это проверка на заданном сценарии, а не доказательство отсутствия ошибки при любом расписании потоков.

Другой сбой проявлялся при отмене группы. Запрос отмены попадал в очередь потока-драйвера вместе с указателем на структуру группы, размещённую в стеке вызывающей функции. Главный поток мог завершить функцию и переиспользовать её стек прежде, чем драйвер обработает запрос. Драйвер мог прочитать память, где прежней структуры уже не было. На Windows минимальный тест падал в 29 запусках из 30, тогда как под WSL с Linux проходил в 30 запусках из 30.

Разработчик встроил в программу обработчик падений Windows, который выводил стек вызовов с символами. Первый запуск показал, что сбой происходит в обработчике отмены потока-драйвера. Исправление добавило счётчик запросов, находящихся в обработке: группа не завершалась, пока он не обнулялся. Автор сообщает о 30 успешных запусках минимального примера и 120 успешных тестах отмены. Эти результаты относятся к названным тестам; доступные материалы не содержат сырых журналов и независимого воспроизведения.

Третья гонка укладывалась в короткий промежуток между проверкой таймера и записью о парковке. Файбер проверял, что таймер ещё не сработал. Затем драйвер закрывал таймер, но ещё не видел файбер в списке спящих, поэтому никого не будил. Файбер после этого записывал, что он спит, и мог остаться ждать события, которое уже произошло.

На машине разработчика с восемью потоками стресс-тест проходил, а в описанных условиях непрерывной интеграции с двумя ядрами зависал. Ограничив число ядер до двух, автор смог воспроизвести сбой и разобрать дамп памяти. Исправление изменило порядок действий: файбер сначала регистрировал парковку, а затем повторно проверял таймер; драйвер сначала отмечал закрытие таймера, а затем проверял состояние сна. Автор сообщает, что до исправления зависание происходило примерно в одном из 15 запусков, а после него не возникло ни разу в 150 прогонах. Это обнадёживающая проверка редкого сбоя, но не гарантия для всех платформ и расписаний.

Почему обычного логирования не хватило? Счётчик отвечает на вопрос «сколько раз вызвали пробуждение», но не обязательно на вопрос «какой именно файбер получил его и в каком состоянии находилась система». Кроме того, печать в критическом участке может менять порядок работы потоков. В первом расследовании добавление fprintf сделало тест зелёным, а удаление снова приводило к зависанию. Это свидетельство маскировки, а не исправления. При этом объяснение автора о конкретном барьере памяти от печати в доступных материалах отдельно не проверено, поэтому надёжнее говорить о наблюдаемом изменении поведения, а не считать механизм доказанным.

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

Внутренняя методичка Nova предлагает сперва получить воспроизводимый сбой и подобрать средство диагностики по симптому: дамп состояния при зависании, разбор стека при падении. Она также предостерегает от широкого логирования на горячем пути. Это практические рекомендации команды проекта, а не универсально доказанный закон. В этом кейсе рекомендации подкреплены конкретными состояниями и последовательностью событий, но материал остаётся авторским, независимой проверки нет.

Источники: инженерный кейс unitcraft на Хабре; методичка по отладке гонок в репозитории Nova; план проекта 83.11 с постмортемами. Дата публикации статьи на Хабре в доступном тексте указана относительным образом, а не календарной датой.

Источники и литература

Концептуальная схема показывает три описанных в кейсе механизма гонок и то, как инструменты диагностики помогли установить их причины.

Фото: Daniil Komov / Pexels