Budowanie systemu operacyjnego od zera brzmi jak zadanie dla hakerów jądra piszących w C. Ale można uruchomić uproszczoną symulację w Pythonie w jedno popołudnie i szybko odkryć, że logika zarządzania procesami jest równie bezlitosna w języku wysokiego poziomu. Nauczyłem się tego na własnej skórze. Usiadłem, aby napisać malutki symulator systemu operacyjnego. Cel był skromny: stworzyć kilka procesów, zaplanować ich działanie i oznaczyć jako zakończone, gdy wykonają swoją pracę. Kod był krótki. Logika wydawała się nie do przebicia. Potem go uruchomiłem i... nic nie chciało zakończyć działania.
Dlaczego warto budować mini system operacyjny w Pythonie?
Prawdziwy system operacyjny zarządza stronicowaniem pamięci, systemami plików, przerwaniami sprzętowymi i sterownikami urządzeń. Symulacja odrzuca to wszystko i pozwala skupić się na głównej idei: stanie (state). Definiujesz proces. Ma on PID, czas wykonania (burst time) i status cyklu życia: Gotowy (Ready), Uruchomiony (Running), Zakończony (Finished). Pętla planisty (scheduler) wybiera kolejnego kandydata, zmienia jego stan, symuluje kwant czasu i przechodzi do stanu zakończonego.
Python jest doskonałym narzędziem do tego typu eksperymentów, ponieważ pozwala zignorować arytmetykę wskaźników i wyrównywanie pamięci. Lista słowników staje się twoją tablicą procesów. Pętla while staje się planistą jądra. Możesz zaimplementować szeregowanie round-robin lub kolejki priorytetowe, używając jedynie narzędzi ze standardowej biblioteki. Wydaje się to przystępne, i właśnie dlatego błąd, który nastąpił później, był tak irytujący.
Konfiguracja
Moja symulacja używała listy o nazwie process_table. Każdy wpis był słownikiem o takim kształcie:
{
"pid": 1,
"burst_time": 3,
"status": "ready"
}
Planista działał w prostej pętli while. Przeszukiwał tablicę w poszukiwaniu pierwszego procesu, którego status nie był "finished". Gdy go znalazł, wywoływał funkcję pomocniczą execute_tick(p), aby uruchomić ten proces na jeden symulowany cykl. Wewnątrz execute_tick ustawiałem status procesu na "running", zmniejszałem czas wykonania (burst time) i sprawdzałem, czy pozostała praca spadła do zera. Jeśli tak, aktualizowałem status na "finished". Pętla zewnętrzna miała zakończyć działanie, gdy każdy proces osiągnie stan zakończony.
Na papierze przepływ był czysty. Znajdź gotowy proces. Uruchom go. Sprawdź, czy zakończył działanie. Powtarzaj, aż do skutku. Dodałem nawet instrukcje print, aby obserwować pracę planisty. Widziałem, jak procesy są wybierane. Pętla kręciła się w kółko. Mimo to procesy zdawały się wpadać w stan wiecznego teraźniejszego, wiecznie działając, nigdy nie kończąc.
Objaw
To najgorszy rodzaj awarii: cichy. Żaden ślad stosu (stack trace) nie pojawił się w terminalu. Żaden IndexError ani KeyError nie zostawił mi żadnego tropu. Interpreter był w pełni zadowolony. Program po prostu nie zachowywał się tak, jak powinien. Procesy startowały, ale nigdy się nie kończyły. Spędziłem godziny na analizowaniu przepływu.
Czy warunek pętli był błędny? Może popełniłem błąd typu off-by-one w obliczaniu czasu wykonania? Czy tablica procesów była nadpisywana lub kopiowana zamiast aktualizowana w miejscu? Czy warunek zakończenia sprawdzał niewłaściwy klucz? Dodałem więcej instrukcji print. Przejrza
Potęgujesz to błędem potwierdzenia. Wiesz, że wpisałeś przypisanie, bo tak zamierzałeś. Kiedy czytasz kod po raz piąty, Twój mózg autokoryguje symbol. Właśnie dlatego metoda gumowej kaczuszki działa. Zmusza Cię ona do artykułowania każdej linii na tyle powoli, aby luka między tym, co zostało napisane, a tym, co miałeś na myśli, stała się widoczna.
Małe błędy tego typu są trudniejsze do znalezienia niż spektakularne awarie. Segfault lub błąd składni ogłasza się natychmiast. Ciche „no-op” po prostu korumpuje stan i pozwala programowi pełzać naprzód. Awaria objawia się później w przepływie programu, a Twój instynkt podpowiada debugowanie objawu zamiast przyczyny.
Lepsza obrona
Nie możesz ufać samym oczom. Po tym incydencie zmieniłem kilka nawyków, które pozwoliłyby wyłapać błąd wcześniej.
Po pierwsze, jeśli przechowujesz stan w słowniku, rozważ użycie dataclass lub enum.Enum dla stanów procesu. Zdefiniuj swoje statusy jako stałe lub elementy enumów:
from enum import Enum
class ProcessState(Enum):
READY = "ready"
RUNNING = "running"
FINISHED = "finished"
Dzięki jawnie zdefiniowanym typom, narzędzia takie jak mypy mogą flagować podejrzane porównania podczas analizy statycznej. Przypadkowe porównanie w miejscu, gdzie powinno znajdować się przypisanie, staje się znacznie łatwiejsze do zauważenia, gdy typy nie zgadzają się z oczekiwaniami.
Po drugie, napisz testy jednostkowe dla przejść stanów, zanim napiszesz logikę schedulera. Prosty test, który tworzy proces z jednym cyklem pracy, uruchamia scheduler i sprawdza, czy stan końcowy to FINISHED, zawiódłby natychmiast. Ta porażka zawęziłaby poszukiwania do logiki aktualizacji stanu, zamiast pozwalać mi błądzić po całej pętli.
