Costruire un sistema operativo da zero sembra un lavoro per kernel hacker che scrivono in C. Ma si può avviare una simulazione semplificata in Python in un pomeriggio, e si scoprirà presto che la logica della gestione dei processi è altrettanto implacabile in un linguaggio di alto livello. L'ho imparato a mie spese. Mi sono messo a scrivere un piccolo simulatore di OS. L'obiettivo era modesto: creare alcuni processi, pianificarli e segnarli come completati una volta terminato il lavoro. Il codice era breve. La logica sembrava infallibile. Poi l'ho eseguito, e nulla terminava.
Perché costruire un mini OS in Python?
Un vero sistema operativo gestisce paging della memoria, file system, interrupt hardware e driver di periferica. Una simulazione elimina tutto questo e ti permette di concentrarti sull'idea centrale: lo stato. Definisci un processo. Ha un PID, un burst time e uno stato del ciclo di vita. Ready. Running. Finished. Un ciclo dello scheduler sceglie il prossimo candidato, ne avanza lo stato, simula un time slice e lo fa passare a "done".
Python è un eccellente strumento per questo tipo di esperimento perché ti permette di ignorare l'aritmetica dei puntatori e l'allineamento della memoria. Una lista di dizionari diventa la tua tabella dei processi. Un ciclo while diventa il tuo kernel scheduler. Puoi implementare lo scheduling round-robin o code a priorità usando nient'altro che gli strumenti della libreria standard. Sembra accessibile, ed è esattamente per questo che il bug che ne è seguito è stato così frustrante.
Il Setup
La mia simulazione utilizzava una lista chiamata process_table. Ogni voce era un dizionario strutturato così:
{
"pid": 1,
"burst_time": 3,
"status": "ready"
}
Lo scheduler eseguiva un semplice ciclo while. Scansionava la tabella alla ricerca del primo processo il cui stato non fosse "finished". Quando ne trovava uno, chiamava una funzione helper, execute_tick(p), per eseguire quel processo per un ciclo simulato. All'interno di execute_tick, impostavo lo stato del processo su "running", decrementavo il burst time e controllavo se il lavoro rimanente fosse arrivato a zero. Se accadeva, aggiornavo lo stato in "finished". Il ciclo esterno doveva terminare una volta che ogni processo avesse raggiunto lo stato "finished".
Sulla carta, il flusso era pulito. Trova un processo pronto. Eseguilo. Controlla il completamento. Ripeti finché non hai finito. Ho persino aggiunto degli statement print per osservare lo scheduler al lavoro. Potevo vedere i processi essere selezionati. Il ciclo continuava a girare. Eppure, i processi sembravano entrare in un presente eterno, in esecuzione per sempre, senza mai procedere.
Il Sintomo
Questo è il peggior tipo di fallimento: quello silenzioso. Nessun trace dello stack è apparso nel terminale. Nessun IndexError o KeyError mi ha fornito una traccia da seguire. L'interprete era perfettamente soddisfatto. Il programma semplicemente non si comportava come previsto. I processi partivano, ma non finivano mai. Ho passato ore a ricostruire il flusso.
La condizione del ciclo era sbagliata? Forse avevo un errore "off-by-one" nel calcolo del burst time. La tabella dei processi veniva oscurata o copiata invece di essere aggiornata sul posto? La mia condizione di terminazione stava controllando la chiave sbagliata? Ho aggiunto altri print. Ho analizzato ogni espressione booleana. Ho messo in dubbio tutto, tranne l'unica riga che contava davvero.
Il Colpevole
Poi l'ho visto. All'interno di execute_tick, avevo scritto:
p["status"] == "running"
Due segni di uguale. Un confronto, non un'assegnazione. La soluzione era a un solo carattere di distanza:
p["status"] = "running"
In Python, p["status"] == "running" è un'espressione perfettamente valida. Valuta a True o False, e poi l'interprete scarta il risultato perché non l'ho mai assegnato a nulla. La riga non fa assolutamente nulla di utile. La voce nel dizionario rimaneva intatta, preservando qualsiasi stato avesse avuto in precedenza, e il processo non avanzava mai nel suo ciclo di vita.
L'ho cambiata con un singolo segno di uguale. Ho eseguito di nuovo lo script. La simulazione ha ripreso vita. I processi passavano attraverso gli stati ready, running e finished esattamente come previsto. Un singolo tasto premuto in più mi era costato ore.
Perché questi bug si nascondono
Il motivo per cui questo brucia così tanto è che Python non segnala un'istruzione di espressione come errore a meno che non sia una sintassi palesemente non valida. Il bug era un errore semantico. Il programma confrontava lo stato, produceva un booleano e lo scartava. Poiché il confronto stesso poteva restituire False, il processo rimaneva bloccato nel suo stato precedente e il ciclo esterno non aveva motivo di interrompersi.
A questo aggiungi il bias di conferma. Sai di aver digitato un'assegnazione perché la tua intenzione era quella di fare un'assegnazione. Quando leggi il codice per la quinta volta, il tuo cervello corregge automaticamente il simbolo. Ecco perché il rubber ducking funziona: ti costringe ad articolare ogni riga abbastanza lentamente da rendere visibile il divario tra ciò che è scritto e ciò che intendevi.
Piccoli bug come questo sono più difficili da trovare rispetto a crash drammatici. Un segfault o un errore di sintassi si manifestano immediatamente. Un no-op silenzioso corrompe semplicemente lo stato e lascia che il programma proceda a stento. Il fallimento avviene a valle, e il tuo istinto è quello di fare il debug del sintomo piuttosto che della causa.
Una difesa migliore
Non puoi fidarti solo dei tuoi occhi. Dopo questo episodio, ho cambiato alcune abitudini che avrebbero permesso di individuare l'errore prima.
In primo luogo, se stai mantenendo lo stato in un dizionario, considera l'uso di una dataclass o di un enum.Enum per gli stati del processo. Definisci i tuoi stati come costanti o membri di un enum:
from enum import Enum
class ProcessState(Enum):
READY = "ready"
RUNNING = "running"
FINISHED = "finished"
Con i tipi espliciti, strumenti come mypy possono segnalare confronti sospetti durante l'analisi statica. Un confronto accidentale dove dovrebbe esserci un'assegnazione diventa molto più facile da individuare quando i tipi non corrispondono alle aspettative.
In secondo luogo, scrivi unit test per le transizioni di stato prima di scrivere la logica dello scheduler. Un semplice test che crea un processo con un singolo tick di lavoro, esegue lo scheduler e verifica che lo stato finale sia FINISHED avrebbe fallito immediatamente. Quel fallimento avrebbe ristretto la ricerca alla logica di aggiornamento dello stato, invece di lasciarmi vagare attraverso l'intero ciclo.
