На попытку закрыть первый баг ушло десять часов и пять гипотез. Ни одна не подтвердилась. На второй — тоже около десяти часов и семь попыток, и все пришлось откатить. А потом каждый из них закрылся за минуты: в первом случае хватило одной строчки в дампе состояния, во втором — первого же запуска с самодельным обработчиком падений. Дело было не в сложности багов, а в инструментах.

Nova — язык для бэкенда, похожий на Go и Rust. Он компилируется в C, а оттуда в исполняемый файл. Здесь речь о том, что под синтаксисом: о рантайме, в котором живут файберы программы, и о том, как я ловил в нём гонки. Будет три истории, в каждой сначала что сломалось, потом как это починил.

Как устроен рантайм

Программа на Nova может запустить тысячи файберов — лёгких задач, как горутины в Go. Каждый файбер — это кусок кода со своим стеком. Файберы выполняются на нескольких потоках ОС, обычно по числу ядер. Такая схема называется M:N: M файберов выполняются на N потоках, и M, как правило, намного больше N. То есть это обычная многопоточность: несколько потоков одновременно читают и пишут общую память, и без защиты это приводит к гонкам. В прикладном коде память защищают мьютексами, но в планировщике файберов и в драйвере блокировка на каждом переключении была бы слишком дорогой. Поэтому там в основном атомарные операции и барьеры памяти. В них и прятались гонки из историй ниже.

Файбер, которому нечего делать (ждёт таймер, сеть, другой файбер), паркуется: отдаёт поток другим и засыпает. Когда событие случилось, кто-то должен его разбудить и вернуть в очередь на выполнение. Таймерами и сетью заведует отдельный поток-драйвер. Внутри у него libuv — та же библиотека событий, на которой работает Node.js: она следит за таймерами, сокетами и файлами и сообщает, когда что-то произошло: пришли данные по сети, прочитался файл, сработал таймер. Сами файберы сделаны на minicoro — маленькой библиотеке, которая даёт каждому файберу собственный стек и умеет переключаться между ними.

Память чистит Boehm GC — сборщик мусора для C, которому не нужно знать, где в памяти лежат указатели: он консервативно считает указателем всё, что на него похоже. Вариант не идеальный: по спецификации Nova сборщик должен работать параллельно с программой, останавливать её меньше чем на миллисекунду и делить объекты на поколения, как ZGC и Shenandoah в Java. Boehm же на время сборки останавливает все потоки. В инкрементальном режиме, который Nova включает, где получается, остановки короче, но они есть. Конкретный алгоритм спецификация не фиксирует: подойдёт фреймворк MMTk, свой сборщик или доработка готового. Любой путь — большая отдельная работа, и на старте языка она не нужна. Зато Boehm развивают с конца 1980-х, и он не заброшен: у Nova версия 8.2.12 от февраля 2026 года, это последний выпуск, а коммиты в репозиторий идут и сейчас. На нём работают, например, Crystal и IL2CPP в Unity.

У Nova два компилятора: первый написан на Rust, второй, Carina, — на самой Nova. Код, который они генерируют, работает на одном и том же рантайме.

Рантайм Nova: файберы, потоки ОС с очередями готовых файберов, поток-драйвер на libuv, который будит спящие файберы, и сборщик мусора Boehm
Рантайм Nova: файберы, потоки ОС с очередями готовых файберов, поток-драйвер на libuv, который будит спящие файберы, и сборщик мусора Boehm

Ещё одно понятие пригодится ниже. Блок supervised { … } объединяет файберы в группу: внутри через spawn можно запустить сколько угодно файберов, и блок не завершится, пока не закончатся все. Группу можно отменить токеном: tok.cancel() будит всех спящих и просит их выйти.

Первые две истории случились на Windows. Там гонки проявлялись чаще, а привычного отладчика под рукой не было: cdb ставится отдельно, с Windows SDK. Ставить его я не стал и научил саму программу рассказывать о своём падении: такой инструмент потом работает где угодно, на CI или на чужой машине, без установки.

История 1. Место, которое выглядело свободным

Что сломалось. Тест запускает в группе 99 файберов, каждый засыпает на 10 секунд, а сотый через 30 мс отменяет группу. Все 99 должны проснуться и выйти, тест — закончиться за полсекунды. На деле из 30 прогонов проходили 3–5. Остальные висели навсегда.

Как искал. Сначала поставил счётчики: сколько таймеров закрылось, сколько раз вызвали «разбудить», сколько пробуждений сработало. Таймеры закрылись все 100, «разбудить» вызвали 100 раз, а сработало 89–97. Часть пробуждений терялась.

Дальше пошли гипотезы. Будить без проверки — файбер просыпался дважды на двух потоках и всё падало. Счётчик поколений по образцу futex в Linux — 1 из 30. Строгий порядок памяти на всех операциях — от 1 до 19 из 30, в зависимости от нагрузки. Переписать состояние файбера по образцу Tokio — падение уже на первом запуске. Ни одна не дала 30 из 30, и каждую я откатывал.

Ловушка: fprintf, который всё чинит. В какой-то момент одна отладочная печать в обработчике таймера сделала тест зелёным. Убрал печать — снова висит. Печать в C берёт блокировку, а блокировка — это барьер памяти: потоки начинают видеть изменения друг друга в другом порядке. Гонка никуда не делась, её просто спрятали. Если отладочная печать «чинит» гонку, это маскировка, и коммитить так нельзя.

Была и ещё одна ловушка. Один из счётчиков показывал, что семь файберов «утекли». Два часа я искал эту утечку, пока не понял, что счётчик увеличивался отдельно от операции, которую считал. Его показаниям нельзя было верить.

Что нашло баг. После десяти часов я перестал придумывать гипотезы и написал дамп: функцию, которая по таймеру-сторожу снимает снимок всех потоков, всех групп и всех спящих файберов и печатает его. Около 80 строк на C. Первый же дамп зависшего процесса показал строчку:

scope fibers[5] = NULL   parked[5] = true

У каждой группы есть таблица мест: fibers[i] — какой файбер сидит на месте i, parked[i] — спит ли он. Пустое место, на котором кто-то спит, — так быть не должно. Это и было ответом.

Суть бага. Пока файбер спит, его место в таблице должно оставаться за ним. Но код, который выдаёт места новым файберам, проверял только одно: пусто ли место. А у спящего файбера A место на время сна выглядело пустым. Новый файбер B получал это место. Потом у A срабатывал таймер и шёл будить «того, кто на месте 5». Там сидел уже B, а будить чужой файбер нельзя, поэтому пробуждение отбрасывалось. A спал вечно, и группа ждала его вечно.

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

История 1: место 5 пустое, но помечено как занятое спящим файбером; новый файбер садится на него, и таймер будит не тот
История 1: место 5 пустое, но помечено как занятое спящим файбером; новый файбер садится на него, и таймер будит не тот

Фикс. Место не считается свободным, пока на нём кто-то спит. А таймер теперь запоминает, кого будить: указатель на сам файбер, а не номер места. Номер места может перейти к другому, указатель — нет. 30 из 30.

История 2. Указатель в стек, которого уже нет

Что сломалось. Через несколько дней после большой переделки, когда таймеры и отмена переехали в отдельный поток-драйвер, упал тест отмены. Минимальный пример выглядел совсем невинно:

ro tok = CancelToken.new()
supervised(cancel: tok) {
    spawn { tok.cancel() }    // один файбер, который отменяет свою же группу
}

На Windows — 29 падений из 30. В Linux под WSL — 30 из 30 зелёных.

Как искал. Семь попыток за десять часов. Все крутились вокруг сборщика мусора и стеков файберов: держать токен корнем GC, отключать GC на время отмены, сканировать стеки файберов иначе. Лучшие давали 25 из 30, одна сломала 726 других тестов. Бисект показал коммит, после которого всё началось, но в нём было 225 строк, и где именно ошибка, оставалось загадкой. Хуже всего, что символизатор подписывал место падения как memcpy, и все семь попыток строились на ложной картине.

Что нашло баг. Нормального отладчика не было, поэтому я встроил его кусок в саму программу. В Windows есть векторные обработчики исключений (VEH): функция, которую система вызывает при падении до всего остального. А библиотека dbghelp.dll, которая есть в любой Windows, умеет раскрутить стек и найти по PDB имя функции, файл и строку. Обработчик получился около 180 строк, включается переменной окружения NOVA_DIAG_SEGV=1, а его суть — несколько вызовов:

AddVectoredExceptionHandler(1, _nova_segv_veh);   // при старте, если NOVA_DIAG_SEGV
// в обработчике:
SymInitialize(proc, NULL, TRUE);
while (StackWalk64(IMAGE_FILE_MACHINE_AMD64, proc, thread, &sf, &ctx, ...))
    if (SymFromAddr(...) && SymGetLineFromAddr64(...))
        fprintf(stderr, "  #%02d %s (%s:%lu)\n", frame, sym->Name, file, line);

Первый же запуск напечатал стек, и во второй строке стояло то, что семь попыток не могли найти: падение внутри обработчика отмены в потоке-драйвере. Драйвер читал структуру группы, и поля в ней были мусором: счётчик файберов 372 вместо одного, отрицательная блокировка, указатель в область только для чтения. Сам адрес структуры при этом был правильный — адрес на стеке главного потока.

Суть бага. Структура группы лежит на стеке функции, которая её запустила. Отмена из файбера не выполняется сразу: она кладёт заявку в очередь драйвера и уходит. Файбер завершается, главный поток видит «все файберы кончились», выходит из блока, функция возвращается, и её стек тут же занимает следующая функция. А заявка всё ещё лежит в очереди с адресом старой структуры. Драйвер берёт её и читает чужие данные.

Вы сдали костюм в химчистку, оставили адрес гостиничного номера и выехали. Через день курьер привозит костюм в этот номер, а там уже живут другие люди.

Фикс. У группы появился счётчик заявок, которые ещё в пути. Отмена увеличивает его до отправки заявки, драйвер уменьшает, когда закончил. Группа не выходит, пока счётчик не обнулится. 30 из 30 на минимальном примере, 120 из 120 на полном наборе тестов отмены. От запуска обработчика до проверенного фикса — около 30 минут.

Сколько ушло времени: около 10 часов гипотез в каждой истории и минуты, когда появился дамп или обработчик падений
Сколько ушло времени: около 10 часов гипотез в каждой истории и минуты, когда появился дамп или обработчик падений

История 3. Звонок, пока вы шли к двери

Что сломалось. Стресс-тест отменял группу из 2000 спящих файберов. На моей машине с восемью потоками он проходил за 5 секунд. На CI под Linux иногда висел до таймаута в 300 секунд: вчера зелёный, сегодня красный.

Как искал. Первое — воспроизвести в тех же условиях. У раннера CI два ядра, поэтому тест гонял в WSL, прижав его к двум ядрам через taskset -c 0,1. Девять прогонов прошли, десятый завис. На восьми ядрах не зависал никогда. Живой отладчик к процессу подключить не давала система, поэтому зависший процесс убивал с дампом памяти и разбирал дамп в gdb скриптами.

Дамп снова показал невозможное состояние. Все таймеры отработали честно: за прогон их было 7181, и драйвер столько же получил и столько же закрыл. Все очереди всех потоков пусты. А группа ждёт ещё один файбер, которого нигде нет. Попутно нашёлся и закрылся другой настоящий баг, но зависание осталось.

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

Суть бага. Файбер собирается заснуть до срабатывания таймера. Он проверяет: таймер ещё не сработал? Нет — значит, надо спать. Между этой проверкой и моментом, когда файбер записал «я сплю здесь», драйвер закрывает таймер и смотрит, кого будить. Записи ещё нет, будить некого, и драйвер уходит. Файбер дописывает «я сплю» и засыпает навсегда.

Курьер позвонил в дверь, пока вы шли в прихожую, никто не открыл, и он ушёл. Вы подошли, повесили табличку «жду курьера» и сели ждать. Звонка больше не будет.

Фикс. Решение классическое, по сути алгоритм Деккера: каждая сторона сначала пишет свой флаг, потом читает чужой, с полным барьером памяти между ними. Файбер сначала записывает «сплю здесь», потом ещё раз проверяет таймер. Драйвер сначала записывает «таймер закрыт», потом смотрит, кто спит. Хотя бы одна из сторон обязательно увидит другую. И будит драйвер только того, кого ждал: сравнивает указатель на файбер, а не номер места, как в первой истории.

История 3: было — файбер проверил таймер, драйвер никого не нашёл, файбер уснул навсегда; стало — каждая сторона сначала пишет свой флаг, потом читает чужой
История 3: было — файбер проверил таймер, драйвер никого не нашёл, файбер уснул навсегда; стало — каждая сторона сначала пишет свой флаг, потом читает чужой

До этого были две неудачные редакции. Первая будила файбер по его статусу, но память файбера могла уже достаться другому, и разбудили бы чужой. Вторая не будила вовсе, рассчитывая на перепроверку со стороны файбера. Номер 330 завис как раз на ней: у драйвера не было своей половины проверки.

После фикса — 0 зависаний на 150 прогонах на двух ядрах, до этого был примерно один на 15. Этот стресс-тест и дальше время от времени что-то находит: для того его и писали.

А как же гонки в коде на Nova

Эти истории — про ошибки внутри рантайма, в его коде на C. Для кода на самой Nova картина другая: гонку данных там ловит компилятор. Значение можно отдать нескольким файберам, только если его тип безопасен для этого: неизменяемый, принадлежит одному файберу или защищён синхронизацией (Mutex, атомики, каналы). Иначе ошибка компиляции, как с Send и Sync в Rust. Читатель прошлой статьи нашёл в этом правиле дыру, и я её закрою.

Nova сейчас в альфе, и часть правил о гонках пока существует только как принятый план, в компиляторе их ещё нет. Например, эффект Exclusive. Компилятор сам определит, что замыкание захватило данные, которые нельзя трогать из двух файберов одновременно (их тип не помечен как безопасный для общего доступа), и потребует записать это в типе функции. Без записи код не скомпилируется, так что забыть метку нельзя. Вписывать её будет не человек, а инструмент: форматтер или исправление по подсказке компилятора. Запуск файберов и параллельный цикл такие функции отвергнут, а библиотека, которая запускает чужой код параллельно, сможет запретить их в своей сигнатуре. Пока это не реализовано, и баги в рантайме тоже встречаются. Поэтому инструменты из этих историй никуда не делись.

А для самого рантайма к дампу и VEH добавился ThreadSanitizer: несколько найденных им гонок закрыли атомиками, и на девяти стресс-прогонах после этого он не нашёл ни одной.

Чек-лист

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

  2. Если отладочная печать чинит гонку, это маскировка. Барьер памяти внутри printf прячет гонку, но не убирает её.

  3. Счётчик должен меняться вместе с операцией, которую считает. Иначе он показывает утечки, которых нет.

  4. Помните, кого будите, по указателю, а не по месту. Номер слота может достаться другому.

  5. Ссылки из других потоков не должны пережить объект на стеке. Если ссылка уходит в очередь, кто-то должен дождаться её обработки.

  6. На Windows без отладчика есть VEH и dbghelp. Они есть в любой системе и дают стек с файлами и строками прямо из программы.

  7. Сначала докажите посылку. Семь попыток во второй истории чинили GC, потому что символизатор неправильно назвал функцию. В третьей вывод «теряется завершение» продержался час, пока дамп его не опроверг.

  8. Воспроизводите в условиях, где падает. Гонка, которая на восьми ядрах не видна, на двух прижатых ядрах ловится раз в десять прогонов.

Метод и первые две истории подробно разобраны в методичке по отладке гонок в репозитории Nova. Там же обработчик падений segv_diag.c, дамп nova_runtime_dump_state и скрипт scripts/tools/stress_bisect.sh для бисекта по стресс-прогонам.

А как вы ищете гонки в многопоточном коде: TSan, rr, дампы, или тоже приходилось писать свой инструмент?

Комментарии (0)