Создание операционной системы с нуля звучит как задача для хакеров ядра, пишущих на C. Но за один вечер можно запустить упрощенную симуляцию на Python, и вы быстро обнаружите, что логика управления процессами столь же беспощадна и в высокоуровневом языке. Я усвоил это на собственном горьком опыте. Я сел писать крошечный симулятор ОС. Цель была скромной: создать несколько процессов, запланировать их выполнение и пометить как завершенные, когда работа будет выполнена. Код был коротким. Логика казалась безупречной. Затем я запустил его, и ни один процесс не завершался.

Зачем создавать мини-ОС на Python?

Настоящая операционная система жонглирует подкачкой памяти, файловыми системами, аппаратными прерываниями и драйверами устройств. Симуляция отсекает всё это и позволяет сосредоточиться на основной идее: состоянии. Вы определяете процесс. У него есть PID, время выполнения (burst time) и статус жизненного цикла: Ready (Готов), Running (Выполняется), Finished (Завершен). Цикл планировщика выбирает следующего кандидата, изменяет его состояние, имитирует квант времени и переводит его в статус завершенного.

Python — отличный инструмент для подобного эксперимента, так как он позволяет игнорировать арифметику указателей и выравнивание памяти. Список словарей становится вашей таблицей процессов. Цикл while становится планировщиком ядра. Вы можете реализовать алгоритм round-robin или очереди с приоритетами, используя лишь инструменты стандартной библиотеки. Это кажется доступным, и именно поэтому последовавшая за этим ошибка была такой бесячей.

Подготовка

В моей симуляции использовался список под названием process_table. Каждая запись представляла собой словарь следующего вида:

{
    "pid": 1,
    "burst_time": 3,
    "status": "ready"
}

Планировщик работал в простом цикле while. Он сканировал таблицу в поисках первого процесса, чей статус не был "finished". Найдя такой процесс, он вызывал вспомогательную функцию execute_tick(p), чтобы выполнить этот процесс в течение одного симулированного цикла. Внутри execute_tick я устанавливал статус процесса на "running", уменьшал время выполнения и проверял, не достигло ли оставшееся время нуля. Если достигало, я обновлял статус на "finished". Внешний цикл должен был завершиться, как только каждый процесс перейдет в состояние "finished".

На бумаге алгоритм был чистым: найти готовый процесс, запустить его, проверить завершение, повторить до конца. Я даже добавил операторы print, чтобы наблюдать за работой планировщика. Я видел, как выбираются процессы. Цикл продолжал вращаться. Тем не менее, процессы словно вошли в состояние вечного настоящего: они бесконечно работали, но никогда не завершались.

Симптом

Это худший вид ошибки: «тихий» сбой. Никакой стек вызовов не вывалился в терминал. Ни IndexError, ни KeyError не оставили зацепок. Интерпретатор был совершенно доволен. Программа просто вела себя не так, как ожидалось. Процессы запускались, но никогда не заканчивались. Я часами отслеживал ход выполнения.

Было ли условие цикла неверным? Может, я допустил ошибку на единицу при расчете времени выполнения? Не заменялась ли таблица процессов копией вместо обновления на месте? Не проверял ли мой критерий завершения не тот ключ? Я добавлял больше print. Я проверял каждое логическое выражение. Я подвергал сомнению всё, кроме той единственной строки, которая на самом деле имела значение.

Виновник

И тут я это увидел. Внутри execute_tick я написал:

p["status"] == "running"

Два знака равенства. Сравнение вместо присваивания. Исправление было всего в одном символе:

p["status"] = "running"

В Python выражение p["status"] == "running" является абсолютно корректным. Оно вычисляется как True или False, а затем интерпретатор отбрасывает результат, так как я ничему его не присвоил. Эта строка не делает абсолютно ничего полезного. Запись в словаре оставалась нетронутой, сохраняя тот статус, который был у неё до этого, и процесс так и не продвигался по своему жизненному циклу.

Я заменил его на одиночный знак равенства. Снова запустил скрипт. Симуляция ожила. Процессы проходили через стадии ready, running и finished именно так, как планировалось. Один лишний символ стоил мне нескольких часов.

Почему такие ошибки прячутся

Причина, по которой это так сильно задевает, заключается в том, что Python не помечает выражение как ошибку, если оно не является грубой синтаксической ошибкой. Это была семантическая опечатка. Программа сравнивала статус, получала логическое значение и выбрасывала его. Поскольку само сравнение могло вернуть False, процесс застревал в своем предыдущем состоянии, и у внешнего цикла не было причин для завершения.

Все это усугубляется предвзятостью подтверждения. Вы уверены, что написали присваивание, потому что именно это и намеревались сделать. Когда вы читаете код в пятый раз, мозг сам «исправляет» символ. Именно поэтому работает метод «утенка». Он заставляет вас проговаривать каждую строку достаточно медленно, чтобы разрыв между тем, что написано, и тем, что вы имели в виду, стал очевидным.

Такие мелкие баги найти сложнее, чем фатальные сбои. Segfault или синтаксическая ошибка заявляют о себе немедленно. Бесшумная бесполезная операция (no-op) просто искажает состояние, позволяя программе продолжать работу в некорректном режиме. Ошибка проявляется на более поздних этапах, и ваш инстинкт заставляет вас отлаживать симптом, а не причину.

Более эффективная защита

Нельзя полагаться только на свои глаза. После этого случая я изменил несколько привычек, которые помогли бы обнаружить ошибку раньше.

Во-первых, если вы храните состояние в словаре, рассмотрите возможность использования dataclass или enum.Enum для состояний процесса. Определяйте свои статусы как константы или члены перечисления:

from enum import Enum

class ProcessState(Enum):
    READY = "ready"
    RUNNING = "running"
    FINISHED = "finished"

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

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