Я пишу ядро операционной системы на Rust. Не потому что собираюсь заменить Linux, а потому что мне интересно, что получится, если закладывать безопасность в архитектуру с нуля, а не встраивать в готовое.
Довольно быстро выяснилась вещь, о которой в разговорах про Rust в системном программировании говорят реже, чем стоило бы. Язык закрывает один класс ошибок и оставляет другой нетронутым. И второй класс ловится куда хуже первого.
Расскажу про конкретный баг, который я ловил дольше всего.
Симптом
Процесс-родитель вызывает wait4 и ждёт завершения ребёнка. Ребёнок завершается. Родитель не просыпается. Никогда.
Ни паники, ни ошибки, ни двойного освобождения. С точки зрения Rust всё безупречно: границы соблюдены, владение корректно, компилятор доволен. Просто поток спит вечно.
Воспроизводимость такая, какой вы ожидаете от гонки: примерно никакая. При добавлении отладочной печати проблема смещалась или исчезала.
Где окно
Упрощённо wait4 делал так:
-
взять лок списка детей, поискать зомби;
-
зомби нет, отпустить лок;
-
взять лок очереди ожидания, поставить себя в очередь, уснуть.
А exit на другом ядре в это же время делал так:
-
выставить состояние Zombie;
-
взять лок очереди ожидания, разбудить того, кто там есть.
Между шагом 2 и шагом 3 у родителя есть окно. Если ребёнок завершится ровно в нём, он выставит Zombie и вызовет пробуждение на пустой очереди. Будить некого. Затем родитель, ничего об этом не зная, встаёт в очередь и засыпает. Ребёнок уже завершился и больше никого не разбудит.
Классический lost wakeup. Проверка условия и постановка в очередь идут под разными локами, и между ними ничто не сериализует их относительно пробуждения.
Ещё раз: система типов Rust к этому не имеет отношения. Здесь нечего проверять на уровне типов, здесь неправильный порядок синхронизации.
Почему очевидное решение не работает
Первое, что приходит в голову: держать лок очереди ожидания поперёк всей проверки. Тогда пробуждение не сможет вклиниться.
Не выйдет, и вот почему.
Поиск зомби берёт локи в порядке: список детей, потом таблица процессов. Выход процесса берёт локи в порядке: таблица процессов, потом очередь ожидания.
Если я оберну поиск зомби в лок очереди ожидания, получится вложение «очередь ожидания содержит таблицу процессов». А у выхода уже есть «таблица процессов содержит очередь ожидания». Это замкнутый цикл в графе порядка блокировок, то есть готовый дедлок, который сработает при первом же неудачном совпадении.
Так что лок поперёк проверки отпадает. Нужно что-то другое.
Примитив
Решение, на котором я остановился, выглядит так.
Появился счётчик поколений: атомарный счётчик, который exit увеличивает после публикации состояния Zombie и до вызова пробуждения. И появился примитив thread_block_if(wq, predicate), который:
-
берёт лок очереди ожидания (тот же самый, который берёт пробуждающая сторона);
-
под этим локом перечитывает предикат;
-
если условие уже выполнено, не встаёт в очередь вообще и возвращает управление;
-
если нет, ставит поток в очередь и засыпает.
Ключевое здесь в том, что решение «вставать в очередь или нет» принимается атомарно относительно пробуждения, потому что оба держат один и тот же лок. Гонящийся exit теперь попадает в один из двух исходов, и оба правильные: либо он успел увеличить счётчик до перечитывания, и тогда родитель просто не заснёт, либо он приходит позже и застаёт поток уже в очереди, и тогда пробуждение сработает.
Никакой лок при этом не удерживается поперёк засыпания, так что цикл в порядке блокировок не появляется.
Почему это интереснее, чем один починенный баг
Когда я разобрался с wait4, стало видно, что та же форма встречается ещё в трёх местах: чтение из канала, запись в канал, чтение с клавиатуры. Везде одно и то же: проверить, есть ли данные, потом заснуть. Везде то же окно.
Тем же примитивом закрылись все три.
Вот это, на мой взгляд, единственный способ разумно бороться с такими ошибками. Чинить по одному месту бессмысленно: пока вы правите одно, пишется четвёртое с той же конструкцией. Особенно очевидно это становится, когда думаешь про сокеты: блокирующий recv это буквально «проверь, есть ли данные, потом заблокируйся», то есть та же конструкция ещё раз. Если не закрыть форму целиком, вы просто заведёте себе новое место для того же бага.
Про тесты, которые ничего не проверяют
Отдельная история, которая изменила мой подход к тестированию сильнее, чем сам баг.
В ядре лежал модуль для сохранения состояния FPU и SSE. Написан, оттестирован, лежит. Проблема была в том, что его никто не вызывал: переключение контекста сохраняло шесть целочисленных регистров и на этом заканчивалось. То есть любой пользовательский код, использующий SSE, тихо портил бы свои регистры при постороннем переключении.
Когда я это починил, я написал тест: процесс кладёт в регистры метки, делает fork, ребёнок затирает регистры своими значениями, после ожидания родитель проверяет, что его метки на месте.
Тест прошёл. И вот тут я сделал то, чего раньше не делал: выключил починку и прогнал тест снова. Он упал. Значит, тест действительно проверяет то, что должен, а не просто зеленеет за компанию.
С тех пор я считаю, что тест, который не падает при удалении проверяемого механизма, ничего не доказывает. Звучит банально, но пока не проверишь, не узнаешь. У меня сейчас 347 тестов логики, которые гоняются на хосте без железа, и 38 регрессий на реальной загрузке в QEMU, и часть из них я проверял именно так.
Про 768 unsafe
Раз уж речь про безопасность, назову неудобное число сам, пока его не назвали в комментариях.
В ядре 768 блоков unsafe на примерно 24 600 строк. Ядро в принципе не может быть полностью safe, оно работает с железом напрямую, но списывать всё на железо было бы нечестно.
Около 40% блоков действительно на аппаратной границе: MMIO, порты, ассемблерные вставки, работа с регистрами и MSR. Эта часть скучная и по смыслу тривиальная.
Основной массив другой. Самый крупный кластер, 201 блок, это подсистема процессов: сырые указатели на процессы и потоки. Вот там и живёт настоящий риск, потому что время жизни этих объектов гарантирую я, а не компилятор.
Что с этим сделано: 714 письменных обоснований безопасности на блоки, #![deny(unsafe_op_in_unsafe_fn)], чтобы каждая небезопасная операция размечалась явно даже внутри небезопасной функции, и разделение, при котором разбор данных и вся чистая логика (арифметика аллокатора, кодирование таблиц страниц, конечные автоматы) остаются без unsafe и тестируются на хосте.
Утверждать, что этого достаточно, я не буду. Утверждаю только, что небезопасность локализована и задокументирована, а не размазана ровным слоем.
Что не работает
Многоядерное планирование у меня сейчас отключено: потоки принудительно привязаны к одному ядру.
Когда я привязку снял, система начала зависать. Без паники, без ошибки, просто тишина. Точка зависания смещалась между запусками при добавлении инструментации, что довольно однозначно указывает на гонку, а не на фиксированный дедлок.
Что удалось выяснить: триггер это миграция потока через work stealing. Поток блокируется на одном ядре, будится, возобновляется на другом, и дальше зависает на том, что делает с глобальными структурами под конкуренцией, которую привязка фактически делала однопоточной. Есть трассировка и список подозреваемых, отранжированный по силе улик. Отдельно неприятный пункт: спин-ожидание завершения инвалидации TLB, которое, судя по всему, никогда и не работало при настоящем многоядерном исполнении, просто до сих пор не было случая это заметить.
Я решил не чинить это наугад. Спекулятивная правка многофронтовой гонки один раз пройдёт, а потом вернётся при другом тайминге, и я останусь без диагноза и с ложным ощущением, что всё в порядке. Привязка вернулась на место, разбор лежит в репозитории.
Так что честный статус: диагноз есть, починки нет.
Итого
Rust убирает целый класс ошибок, и это правда. Но он не убирает ошибки порядка синхронизации, а в ядре именно они дают самые неприятные отказы: без падения, без сообщения, с правильным на вид поведением и зависанием через раз.
Единственное, что у меня работает против них, это не язык, а две привычки. Первая: закрывать форму ошибки целиком, а не отдельные её проявления. Вторая: проверять, что тест падает, когда починку убираешь.
Код открыт, писал один, стадия ранняя, к применению где бы то ни было не готов: github.com/ScioFuturum/rumicos
Если увидите, что примитив выше содержит дыру, которую я не заметил, буду рад узнать об этом сейчас, а не потом.
ссылка на оригинал статьи https://habr.com/ru/articles/1067986/