スクラッチからオペレーティングシステムを構築するのは、C言語を書くカーネルハッカーの仕事のように思えるかもしれません。しかし、Pythonを使えば午後のひとときで簡略化されたシミュレーションを立ち上げることができ、プロセス管理のロジックが高水準言語であっても同様に容赦ないものであることにすぐに気づくでしょう。私は身をもってそれを学びました。私は小さなOSシミュレータを書こうと席に着きました。目標はささやかなものでした。いくつかのプロセスを作成し、それらをスケジューリングし、作業が終わったら終了としてマークすることです。コードは短く、ロジックは完璧に思えました。しかし、実行してみると、何も終了しなかったのです。
なぜPythonでミニOSを作るのか?
本物のオペレーティングシステムは、メモリページング、ファイルシステム、ハードウェア割り込み、デバイスドライバなどを巧みに操ります。シミュレーションではそれらをすべて削ぎ落とし、「状態(state)」という核心的な概念に集中できます。プロセスを定義します。それにはPID、バースト時間、そしてライフサイクル状態(Ready、Running、Finished)があります。スケジューラーループが次の候補を選び、その状態を進め、タイムスライスをシミュレートし、完了へと遷移させます。
Pythonは、ポインタ演算やメモリのアライメントを無視できるため、この種の実験には最適な手段です。辞書のリストがプロセス・テーブルになり、whileループがカーネル・スケジューラになります。標準ライブラリのツールだけで、ラウンドロビン・スケジューリングや優先度付きキューを実装できます。親しみやすく感じられますが、それこそが、その後に発生したバグをこれほどまでに苛立たしいものにした理由なのです。
セットアップ
私のシミュレーションでは、process_tableというリストを使用しました。各エントリは、次のような形式の辞書でした。
{
"pid": 1,
"burst_time": 3,
"status": "ready"
}
スケジューラは単純なwhileループを実行していました。テーブルをスキャンして、ステータスが "finished" ではない最初のプロセスを探します。見つかった場合、ヘルパー関数 execute_tick(p) を呼び出して、そのプロセスを1シミュレーションサイクル分実行します。execute_tick の内部では、プロセスのステータスを "running" に設定し、バースト時間を減らし、残りの作業がゼロになったかどうかを確認していました。もしゼロになれば、ステータスを "finished" に更新します。外側のループは、すべてのプロセスが "finished" 状態に達したときに終了するはずでした。
理論上、フローは明快でした。準備完了のプロセスを見つける。実行する。完了を確認する。終わるまで繰り返す。スケジューラの動作を確認するために、print文さえ追加しました。プロセスが選ばれているのは確認できました。ループは回り続けていました。しかし、プロセスは永遠の現在形に入り込んだかのように、いつまでも実行され続け、先に進むことがありませんでした。
症状
これは最悪の種類の失敗、つまり「サイレントな失敗」です。ターミナルにスタックトレースが吐き出されることもありません。IndexError や KeyError が手がかりをくれることもありませんでした。インタープリタは全く問題なく動作していました。プログラムが単に、期待通りに動かなかっただけなのです。プロセスは開始されましたが、決して終了しませんでした。私は何時間もフローを遡って調査しました。
ループの条件が間違っていたのか? バースト時間の計算でオフバイワン・エラー(1の差による誤り)があったのか? プロセス・テーブルが、その場で更新される代わりにシャドウイングされたりコピーされたりしていなかったか? 終了条件が間違ったキーをチェックしていなかったか? print文を増やし、すべての論理式を監査しました。実際に重要だった、たった一行を除いて、あらゆることを疑いました。
原因
そして、それを見つけました。execute_tick の中に、こう書いていたのです。
p["status"] == "running"
イコールが2つ。代入ではなく、比較でした。修正は、わずか1文字の違いでした。
p["status"] = "running"
Pythonにおいて、p["status"] == "running" は完全に有効な式です。それは True または False を評価しますが、どこにも代入していないため、インタープリタはその結果を破棄します。その行は何の役にも立っていません。辞書のエントリは手つかずのまま、以前のステータスを保持し続け、プロセスはライフサイクルを進めることができませんでした。
イコールを1つに変えました。スクリプトを再実行すると、シミュレーションが息を吹き返しました。プロセスは計画通りに Ready、Running、Finished を循環しました。たった一文字の打ち間違いが、数時間の損失を招いたのです。
なぜこれらのバグは隠れるのか
これほどまでに痛手となる理由は、Pythonが構文として完全に無効でない限り、式文(expression statement)をエラーとしてフラグを立てないからです。バグは意味論的なタイポ(semantic typo)でした。プログラムはステータスを比較し、ブール値を生成し、それを捨てていました。比較自体が False を返し得るため、プロセスは前の状態に留まり続け、外側のループが終了する理由もなくなってしまったのです。
これに確証バイアスが重なります。代入しようと思っていたからこそ、代入したと思い込んでしまうのです。コードを5回目に読むとき、脳は記号を自動修正してしまいます。これこそがラバーダッキングが効果的な理由です。一行ずつゆっくりと言葉にすることで、書かれたものと意図したものとの間のギャップが可視化されるのです。
このような小さなバグは、劇的なクラッシュよりも見つけるのが困難です。セグフォや構文エラーはすぐに表面化します。しかし、静かなノーオプは、単に状態を破壊し、プログラムを不完全なまま動かし続けます。失敗は下流で発生するため、本能的に原因ではなく症状をデバッグしようとしてしまうのです。
より良い防御策
目だけを信じてはいけません。この一件を経て、私はミスをより早く発見できるような習慣をいくつか変えました。
第一に、辞書で状態を管理している場合は、プロセスの状態管理に dataclass や enum.Enum を使用することを検討してください。ステータスを定数またはenumメンバーとして定義します。
from enum import Enum
class ProcessState(Enum):
READY = "ready"
RUNNING = "running"
FINISHED = "finished"
明示的な型を使用すれば、mypy のようなツールが静的解析中に疑わしい比較をフラグ立てしてくれます。代入が行われるべき場所で誤って比較が行われていても、型が期待通りでない場合に、それを見つけるのがずっと容易になります。
第二に、スケジューラのロジックを書く前に、状態遷移のユニットテストを書いてください。1ティック分の作業を持つプロセスを作成し、スケジューラを実行して、最終的な状態が FINISHED であることをアサートする単純なテストがあれば、即座に失敗していたはずです。その失敗によって、ループ全体を彷徨うのではなく、状態更新ロジックへと調査範囲を絞り込むことができたでしょう。
