Я пишу ядро операционной системы на Rust. Не потому что собираюсь заменить Linux, а потому что мне интересно, что получится, если закладывать безопасность в архитектуру с нуля, а не встраивать в готовое.

Довольно быстро выяснилась вещь, о которой в разговорах про Rust в системном программировании говорят реже, чем стоило бы. Язык закрывает один класс ошибок и оставляет другой нетронутым. И второй класс ловится куда хуже первого.

Расскажу про конкретный баг, который я ловил дольше всего.

Симптом

Процесс-родитель вызывает wait4 и ждёт завершения ребёнка. Ребёнок завершается. Родитель не просыпается. Никогда.

Ни паники, ни ошибки, ни двойного освобождения. С точки зрения Rust всё безупречно: границы соблюдены, владение корректно, компилятор доволен. Просто поток спит вечно.

Воспроизводимость такая, какой вы ожидаете от гонки: примерно никакая. При добавлении отладочной печати проблема смещалась или исчезала.

Где окно

Упрощённо wait4 делал так:

  1. взять лок списка детей, поискать зомби;

  2. зомби нет, отпустить лок;

  3. взять лок очереди ожидания, поставить себя в очередь, уснуть.

А exit на другом ядре в это же время делал так:

  1. выставить состояние Zombie;

  2. взять лок очереди ожидания, разбудить того, кто там есть.

Между шагом 2 и шагом 3 у родителя есть окно. Если ребёнок завершится ровно в нём, он выставит Zombie и вызовет пробуждение на пустой очереди. Будить некого. Затем родитель, ничего об этом не зная, встаёт в очередь и засыпает. Ребёнок уже завершился и больше никого не разбудит.

Классический lost wakeup. Проверка условия и постановка в очередь идут под разными локами, и между ними ничто не сериализует их относительно пробуждения.

Ещё раз: система типов Rust к этому не имеет отношения. Здесь нечего проверять на уровне типов, здесь неправильный порядок синхронизации.

Почему очевидное решение не работает

Первое, что приходит в голову: держать лок очереди ожидания поперёк всей проверки. Тогда пробуждение не сможет вклиниться.

Не выйдет, и вот почему.

Поиск зомби берёт локи в порядке: список детей, потом таблица процессов. Выход процесса берёт локи в порядке: таблица процессов, потом очередь ожидания.

Если я оберну поиск зомби в лок очереди ожидания, получится вложение «очередь ожидания содержит таблицу процессов». А у выхода уже есть «таблица процессов содержит очередь ожидания». Это замкнутый цикл в графе порядка блокировок, то есть готовый дедлок, который сработает при первом же неудачном совпадении.

Так что лок поперёк проверки отпадает. Нужно что-то другое.

Примитив

Решение, на котором я остановился, выглядит так.

Появился счётчик поколений: атомарный счётчик, который exit увеличивает после публикации состояния Zombie и до вызова пробуждения. И появился примитив thread_block_if(wq, predicate), который:

  1. берёт лок очереди ожидания (тот же самый, который берёт пробуждающая сторона);

  2. под этим локом перечитывает предикат;

  3. если условие уже выполнено, не встаёт в очередь вообще и возвращает управление;

  4. если нет, ставит поток в очередь и засыпает.

Ключевое здесь в том, что решение «вставать в очередь или нет» принимается атомарно относительно пробуждения, потому что оба держат один и тот же лок. Гонящийся 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

Если увидите, что примитив выше содержит дыру, которую я не заметил, буду рад узнать об этом сейчас, а не потом.

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


  1. Siemargl
    07.08.2026 13:55

    Rust убирает целый класс ошибок, и это правда. Но он не убирает ошибки порядка синхронизации

    Убирает простые ошибки, оставляет сложные. Этого в рекламе не пишут.

    Вообще, я ссылочку не вспомню, но есть такая вещь, как "паттерны многопоточного программирования". Найди их и не изобретай велосипед, как в этом посте.


    1. sciofuturum Автор
      07.08.2026 13:55

      С первой частью согласен, про это и текст.

      Про велосипед: я и не говорю, что изобрёл примитив. Перепроверка условия под тем же локом, который держит будильщик, это обычный condvar, так же устроены и Convar, и pthread. Стоило это в статье оговорить.

      Интересным было другое: стандартную схему не получилось применить в лоб. Держать лок очереди поперёк проверки нельзя, потому что поиск зомби берёт локи в порядке "дети, потом таблица процессов", а выход процесса наоборот. Вложение замкнуло бы цикл, отсюда и счётчик поколений.

      Если знаете готовый паттерн для этого случая, скиньте, правда интересно.


      1. Siemargl
        07.08.2026 13:55

        Список паттернов на самом деле длинный.

        По-моему пример в посте это двухфазная блокировка.


        1. sciofuturum Автор
          07.08.2026 13:55

          Двухфазная блокировка это про транзакции в БД, там смысл в порядке захвата и освобождения ради сериализуемости. Здесь другое.

          По структуре ближе двойная проверка: проверить, взять лок, проверить ещё раз. Но её обычно применяют для ленивой инициализации, а тут перепроверка условия перед сном, то есть по сути condvar. Разница в том, что применить его в лоб мешал порядок блокировок, из-за этого и понадобился счётчик.


          1. Siemargl
            07.08.2026 13:55

            двухфазная блокировка - два ресурса (у тебя две очереди) - две блокировки

            изменения статуса == транзакция

            сначала ставятся блокировки, потом меняются данные, блокировки снимаются в обратном порядке


            1. sciofuturum Автор
              07.08.2026 13:55

              Схема правильная, но она требует единого порядка захвата. У меня два пути уже фиксируют противоположные: поиск зомби берёт children, потом ptable, а выход процесса берёт ptable, потом wait_queue. Обернуть проверку в лок очереди значит потребовать wait_queue в ptable, и получается цикл. Двухфазная блокировка циклы не разрешает, она предполагает, что их нет.

              И второе: между проверкой и снятием лока поток должен уснуть. Уснуть, держа лок очереди ожидания, значит заблокировать того, кто придёт будить.

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


            1. TheHost
              07.08.2026 13:55

              мне кажется вы беседуете с ЛЛМком)) будильщик, ё, двоеточее, какой-то несвязный набор слов со стилем явно иишным


      1. rukhi7
        07.08.2026 13:55

        Упрощённо wait4 делал так:

        1. взять лок списка детей, поискать зомби;

        2. зомби нет, отпустить лок;

        3. взять лок очереди ожидания, поставить себя в очередь, уснуть.

        А exit на другом ядре в это же время делал так:

        1. выставить состояние Zombie;

        2. взять лок очереди ожидания, разбудить того, кто там есть.

        штудировать список паттернов действительно странное предложение, когда есть конкретная задача.

        Самое простое решение, по моему, завести список зомби-детей (это же списки указателей?), вместо очереди ожидания, если я правильно понимаю задачу. Зомби должен добавлять себя в эту очередь, когда он становится зомби. Самое интересное что он должен получить ссылку (указатель?) на эту очередь от парента видимо при рождении (хотя возможны варианты, например ребенок может уже иметь указатель на структуру с данными парента и достать оттуда то что нужно в любой удобный момент).

        Честно говоря я не понял что за очередь ожидания у парента? он же один парент? кто еще может попасть в эту очередь? И при каких условиях?


        1. sciofuturum Автор
          07.08.2026 13:55

          Про очередь: там ждут потоки, а не процессы. У процесса может быть несколько потоков, и wait4 может вызвать любой из них. Плюс сама очередь это общий примитив ядра, не специальный под wait4. Стоило это в статье пояснить, спасибо.

          Про список зомби: он у меня и есть, поиск идёт как раз по списку детей, и ребёнок при выходе публикует своё состояние сам. Но гонку это не убирает. Разверните по шагам: родитель проверил список и увидел пусто, ребёнок добавился и пошёл будить, родитель уснул. Окно то же самое, просто информация теперь лежит в другом месте.

          Дело не в том, где хранится факт "есть зомби", а в том, что проверка факта и засыпание не атомарны относительно его публикации. Пока эти два действия не сериализованы с пробуждением, любая структура данных даст ту же дыру. Отсюда и перечитывание условия под локом будильщика.


          1. rukhi7
            07.08.2026 13:55

            Но гонку это не убирает. Разверните по шагам: родитель проверил список и увидел пусто, ребёнок добавился и пошёл будить, родитель уснул.

            это убирает гонку если лок привязан к этой очереди, то есть зомби лочит очередь и парент не может ее прочитать пока лок не снимет тот кто его взял. И наоборот, если парент залочил очередь на чтение, зомби будет ждать эту очередь чтобы сменить состояние и тут вот я тоже не вижу достаточно информации что должно быть после того как парент вызвал wait ... Он должен дождаться же окончания всех чайлдов???

            Если так то вообще все стандартно: чайлд удаляет себя из списка детей и добавляет себя в список зомбЕй с соответствующими локами, оба списка принадлежат паренту. Тогда wait должен висеть пока список детей не станет пустым! Но висеть он должен НЕ потребляя процесорного времени! Это просто специальный лок который лочит поток который вызвал такой wait до момента когда список стал пустым. Вот такой конкретный паттерн ищите, если самому не получится додуматься! Это не чайлд будит родителя! Это родитель заблокирован до тех пор пока список чайлдов не опустел! Пока все чайлды не закончились и не стерли себя из списка чайлдов. Что-то в этом роде должно быть, я почти уверен. Эти штуки всегда такие хитрые получаются!


            1. sciofuturum Автор
              07.08.2026 13:55

              Лок на очереди сериализует доступ к самой очереди. А гонка не там: условие проверяется под локом списка детей, а засыпание идёт под локом очереди ожидания. Это разные локи и разные структуры, и между двумя операциями ничего не удерживается. Удерживать нельзя: порядок захвата у find_zombie и у exit противоположный, вложение замкнёт цикл.

              Про «специальный лок, который держит поток, пока список не опустеет»: внутри он устроен как очередь ожидания плюс атомарная проверка условия. Это и есть то, что я описал в статье, только названо иначе. Вопрос был не в том, что нужно, а как это построить, не получив дедлок.

              И wait4 не ждёт завершения всех детей, он ждёт одного и возвращает его статус. Ждать опустошения списка это другая семантика.

              Пробуждать кто-то всё равно должен. Если родитель спит, разбудить его может только тот, кто изменил условие. Альтернатива только опрос в цикле.


              1. rukhi7
                07.08.2026 13:55

                И wait4 не ждёт завершения всех детей, он ждёт одного и возвращает его статус. Ждать опустошения списка это другая семантика.

                вот опять же у вас не понятно, речь идет про процессы или про потоки, которые принадлежат разным процессам. Как-то получается что вам это особо не важно, как будто это вам мешает! Но из-за этого и происходит путаница в том числе, по моему, вы как будто хотите сократить формулировку задачи, а этого никак нельзя делать, обрезанная задача это не та задача которую нужно решать, на сколько я знаю. Это надо перевести в разряд математической задачи, а в математике важны детали, даже самые мелкие, нельзя рассуждать про каких-то абстрактных детей и родителей, дети процесса - это и потоки, и другие процессы, нельзя от этого абстрагироваться.

                Завершения одного такого, какого типа детей ждет wait4? Проблема еще в том что процесс с точки зрения выполнения, и значит завершения, представлен своими потоками, то есть ждать все равно надо в конечном итоге поток или несколько потоков, все эти детали надо учитывать, нельзя решать какую-то упрощенную-обрезанную задачу, надо до конца сформулировать полную задачу и убедиться что она этой полнотой обладает, и понимать что надо не только искать "необходимое и достаточное" математическое решение, но и подтверждение полноты сформулированной задачи. Именно в этом и заключается сложность! Оказывается недостаточно только доказать сформулированную теорему (фактически, а вообще систему теорем обычно) надо еще доказать что эта теорема соответствует задаче предметной области и что полностью перекрывает все аспекты функционирования в этой предметной области.


                1. sciofuturum Автор
                  07.08.2026 13:55

                  Справедливо, в статье я это не развёл, и вопрос возник уже не первый раз. Точные определения по коду:

                  wait4 ждёт завершения дочернего процесса, не потока, и возвращает статус одного завершившегося. Это семантика POSIX, а не моё упрощение.

                  Процесс становится зомби, когда завершился последний его поток. Пока жив хоть один, процесс не зомби и родитель ждёт дальше. Так что «ждать в конечном итоге поток» верно на уровне реализации, но наружу это выходит как ожидание процесса.

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

                  То есть на разных концах разные сущности: спят потоки, ждут завершения процессов. Гонка, о которой статья, от этого не зависит, она между проверкой условия и засыпанием независимо от того, что считать условием.


                  1. rukhi7
                    07.08.2026 13:55

                    То есть на разных концах разные сущности: спят потоки, ждут завершения процессов. Гонка, о которой статья, от этого не зависит, она между проверкой условия и засыпанием независимо от того, что считать условием.

                    да как же не зависит-то? У вас поиск зомби процесса это длительная операция, в этом проблема. Надо ее сделать транзакцией (по отношению к переходу в Зомби состояние любого чайлд-процесса), для этого можно например залочить список чайлдов, чтобы во время этого поиска чайлды не могли пройти переход в зомби, потому что должны обратиться к этому списку у парента, ну либо сделать как я выше написал специалльный список Зомби процессов парента который тоже лочится из парента при/для добавления в список ожидания, и из любого чайлда при переходе в зомби состояние и чтобы добавиться в список Зомбиков.


        1. Siemargl
          07.08.2026 13:55

          штудировать список паттернов действительно странное предложение, когда есть конкретная задача.

          Человек учится, пишет ОС. Вот я и даю сразу инфу с заделом на будущее.

          А конкретное решение я сразу написал.


  1. Jijiki
    07.08.2026 13:55

    похоже на интрузивный счетчик ссылок потоков тоесть такая сущность шедулер, которая отслеживает использование памяти(или комбо: память+ссылки),в С++, мне это напомнило

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


    1. sciofuturum Автор
      07.08.2026 13:55

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


  1. rukhi7
    07.08.2026 13:55

    Ещё раз: система типов Rust к этому не имеет отношения. Здесь нечего проверять на уровне типов

    если задача предметной области сформулирована/понята/интерпретирована не правильно, никакая система типов не поможет найти нужное решение, просто потому что даже правильное решение неправильной задачи не будет иметь смысла.


    1. sciofuturum Автор
      07.08.2026 13:55

      Согласен, и это шире моего тезиса.

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


  1. kuraga333
    07.08.2026 13:55

    Так какой паттерн тут нужно было применить, товарищи комментаторы?


  1. say_TT_plz
    07.08.2026 13:55

    Я конечно не системный программист(только учусь), я но я всегда думал, что используется обратный кольцевой буфер для передачи события о завершении родителю. Ну и всякую магию шмагию LAPIC, прерывания то есть.


  1. si_12345
    07.08.2026 13:55

    Я заметил одну странную вещь, которая к ядрам не имеет отношения
    Я на расте сам не пишу, все пишет дипсик
    Я формализую математические алгоритмы, которые затем дипсик реализует на расте
    И получается так, что однопоточная версия одного и того же алгоритма работает как правило не медленнее многопоточной
    Дипсик использует rayon::prelude, std::sync::{Arc, Mutex}, std::thread, file.lock().unwrap() и т.д.


    1. sciofuturum Автор
      07.08.2026 13:55

      многопоточность не ускоряет автоматически, Mutex вокруг общих данных часто съедает весь выигрыш, а на мелких задачах накладные расходы на потоки больше самой работы.


    1. rukhi7
      07.08.2026 13:55

      Я формализую математические алгоритмы, которые затем дипсик реализует на растеИ получается так, что однопоточная версия одного и того же алгоритма работает как правило не медленнее многопоточной

      Вообще то алгоритм это предопределенная последовательность действий со строгим порядком этих действий изначально и обычно. Если вы пытаетесь распараллелить такой последовательный алгоритм, что это значит? распихать стадии этого алгоритма по разным потокам? ... не понятно на какое ускорение вы рассчитываете. Когда-то люди это понимали.