Table of Contents
Table of Contents
CPUがプログラムを実行している(つまり、プロセスになっている)状態という のは、どういう状態でしょう?
何らかのプログラミング言語でソースコードがコンパイルさてオブジェクトコー ドになり、複数のオブジェクトコードがリンカで繋げられてできたプログラム が、メモリーにロードされ、その際にスタックが形成され、データ領域が割り 当てられ、そして、実行開始位置から CPU が命令コードを読み始めて、継続 的に動いている状態です...ちょっと待った。じゃ、プログラムをメモリにロー ドするとか、スタックやデータ領域を用意するのは誰?物事には始まりが必ず あります。一旦始まったものは、どうなるか分らないですが、少くとも始まり は。
では、コンピュータの電源をいれた直後はどうなのでしょうか?
CPU に電源が供給されて動き出すと、全てのレジスタの値やモードは初期化さ れ、予め定められた番地から実行を開始します。IA32のCPUだと F000:FFF0番 地から実行を始めます(この時点ではx86は16ビットCPUです)。680x0 だと 00000000 番地。その番地には ROM が割り当てられています。ROM ですから電 源オンオフは関係ありません。確実に命令が読み出せます。普通はすぐさま、 そのコンピュータ特有のアドレスにジャンプします。
そこには BIOS (Basic I/O System)と呼ばれるプログラムがあり、メモリの チェック、ディスクの有無のチェック、周辺機器の接続状態のチェックを行い ます。チェックが終ると、ハードディスクか CD-ROM の一番最初のブロック (セクタ)を読み込みます。そのブロックの一部には命令が格納されています。 今度はその命令にジャンプします。
ここには、ディスクから OS を引っ張り上げる(ブートストラップ)プログラム が格納されているファイルを読み込むプログラムが書かれています。それを実 行すると、OS がずるずると(ブーツの紐をっ引っように)引っ張りだされて、 OS がメモリの中に組み立てられて行きます。
ここまで、CPU はプログラムを何度か乗り換えましたが、あくまでも一本道で す。マルチタスクじゃありません。マルチタスクは OS が動き出してから有効 になるものなのです。もし、BIOS がマルチタスクの機能を持っていたら、複 数の OS を動かす事ができるでしょう。実際、VMWare 等の仮想PCシステムは そういう事をしているのです。さて、話を戻します。
さて、まだあなたの書いたプログラムは動き始めません(あなたがカーネルプ ログラマなら別ですが)。ここで動き出すのはカーネルの初期化プログラムで す。カーネルの初期化プログラムはコンピュータに用意されている実際のメモ リ(物理メモリ)の中を区画分けをします。カーネルが使う為の領域とプログラ ムが使うための領域に。次に、入出力装置(ディスクやネットワーク)のインタ フェイスを調べ適切なデバイスドライバをメモリに読み込みます。そして仮想 記憶のセットアップをします。
<続く>
イベント駆動の章で、書きましたが、GUIやサーバはユーザやネットワークの 入力に即座に反応しなければなりません。即座の反応ってどうやって実現する のでしょうか?
人間で考えてみましょう。人間は視覚情報に対して即座に反応はできません。 視覚のように複雑な感覚については脳で情報を処理をしてから、反応します。 一方、触覚はかなりレスポンスが速いです。これはなぜか?
視覚の場合は、視野をスキャンして情報を探します。スキャンしていない領域 で何が起こっても反応できません。一方、触覚はスキャンしなくても、その場 所から直に信号が届きます。視覚の場合でも視野に関係無く視覚感覚器官に反 応させてしまうような強い光にはすぐに反応しますが、その話は取り合えず無 視しましょう。この違いを計算機に当てはめてみれば分ります。
CPUには IRQ という端子が付いています。本数は少なくて16本程度。これが CPU の触覚神経です。この端子をオンにすると、CPU は飛び跳ねます。じゃな くて IRQ の番号に対応するアドレスを実行しはじめます。いま、どんなプロ グラムを実行してようと無視してです。これを割り込み(Interrupt)と言いま す。特に、この IRQ 端子によって発生する割り込みをハードウェア割り込み と言います。つまり、ソフトウェア割り込みというのもあると言うことですが、 ここでは取り上げません。IRQ に対応して動くプログラムを割り込みハンド ラと言います。通常は割り込みハンドラは OS の一部です。DOS の時代は自分 で割り込みハンドラを書くのは普通の事でしたが、今のマルチタスク/仮想記 憶OSの時代では簡単にはできませんし、自分で割り込みハンドラを書く必要性 はほとんどありません。
実際のハードウェア割り込みの割り込みハンドラで何をやるかは取り合えず置 いといて、取り合えず、簡単な割り込みハンドラを考えて見ます。
int interrupt_i = 0; void handler() { interrupt_i++; | handler:1 mov ax, interrupt_i | handler:2 add ax, 1 | handler:3 mov interrupt_i, ax | handler:4 ret }
例えば、キーボード割り込みのハンドラとして上記の関数を割り当てたとしま す。つまり、キーボードに触れるとキーボードがビクゥッとして、カウンタを 増やすということです。最後の iret 命令は、通常の関数からではなく、割り 込みからリターンする場合に使う命令です。普通の ret 命令と動きは微妙に 違いますが、取り合えず細かいことは無視しましょう。
さて、あなたが書いたプログラムが下記のようなものだったとします
int foo(int a, int b) { return a * b; | foo:1 mov ax, *(sp + 1) | foo:2 mov bx, *(sp + 2) | foo:3 add ax, bx | foo:4 ret }
もし、だれかが foo:3 を実行している最中にキーボードに触れたとしたらど うなるでしょう。プログラムの実行の流れを書いてみると下記のようになりま す。
foo:1 mov ax, *(sp + 1) foo:2 mov bx, *(sp + 2) foo:3 add ax, bx ---- int keyboard_irq handler:1 mov ax, interrupt_i handler:2 add ax, 1 handler:3 mov interrupt_i, ax handler:4 iret foo:4 ret
さて、IRQ に信号が入って CPU がビグゥとすると、そこで int という命令が 実行されます。そんな命令はプログラムの中にはどこにも書かれていません。 CPU がソフトウェアを無視して実行するのです。int という命令は簡単に言え ば関数呼び出しです。つまり、次の命令のアドレスを(ここではfoo:4)をスタッ クに積んで、指定されたアドレスにとびこみます。int で呼ばれた関数は、 call で呼ばれた関数の場合の ret 命令ではなくiret命令で帰ります。
さて、あなたの書いた関数 foo は a と b の和を計算して返す(ax レジスタ にいれる)のがお仕事ですが、ここでは interrut_i の値が返されてしまいま す。こんなタイミングでキーボードを押す奴が悪い!と叫びますか?それとも、 運が悪かったと泣き寝入りしますか?困りますよね。
void handler() __attribute__((interrupt("IRQ")) { SAVE_REGS | handler:1 push ax interrupt_i++; | handler:2 mov ax, interrupt_i | handler:3 add ax, 1 | handler:4 mov interrupt_i, ax RESTORE_REGS | handler:5 pop ax return; | handler:6 iret }
なので、普通は上記のように、割り込みハンドラが使うレジスタをスタックに 保存/復帰します。これなら、割り込みハンドラがどこに入っても、割り込ま れた側のプログラムにはタイミング以外の影響はありません。時計を見ない限 り、割り込まれた事すら気づきません。
foo:1 mov ax, *(sp + 1) foo:2 mov bx, *(sp + 2) foo:3 add ax, bx ---- int keyboard_irq handler:1 push ax handler:2 mov ax, interrupt_i handler:3 add ax, 1 handler:4 mov interrupt_i, ax handler:5 pop ax handler:6 iret foo:4 ret
さて、割り込みハンドラは割り込まれたプログラムに気づかれること無く任務 が遂行できることがわかりました。折角ですから、外部刺激への反応を記録す るというような簡単な任務ではなく、もっと高度な任務もこなせそうです。
int winner = 0; void foo() { for (unsigned int counter = 0; counter < UINT_MAX; ) counter += 1 if (winner == 0) winner = 1; // Michael Schumacher } void bar() { for (unsigned int counter = 0; counter < UINT_MAX; ) counter++; if (winner == 0) winner = 9; // Jenson Button }
例えば、上記のような foo() と bar() どっちが速いかリアルタイムで競争さ せるとします。リアルタイムで競争させるには、同時に走らせなければなりま せん。ラリー(ペター・ソルベルグがんがれ!)ではなくレース(ジェンソン・ バトンがんがれ!)をさせるということです。
割り込みを使えれば、二つの関数の実行を任意のタイミングで切替える事はで きそうな気がします。例えば、正確な時計からパルスを出してもらってそれで ハードウェア割り込みを起こせば良いでしょう。しかしそれ以前に、どうやっ て二つの関数を同時に起動したらよいのでしょうか?言い替えると、二つの関 数を独立に実行する環境ってどんなものなのでしょうか?そっちのほうが問題 です。
さて、メモリ管理の章で、スタックが関数の仕組みの基礎だと書きました。プ ロセス中のメモリの配置の図を見ると、スタック領域は一つしかありませんで した。もし、スタック領域を関数毎に取れば、つまり、foo() 用のスタック領 域と bar() 用のスタック領域を別に確保すれば、双方完全に独立した環境を 手にいれます。それどころか、foo()、bar() 用のスタック領域をもう一つづ つとる事も考えられます。チームメイトってことですね。後は、どうやってス タートさせるかです。
複数の関数を同時に走らせるためには、それを管理する仕組みが必要になりま す。通常は OS の重要な機能であるスケジューラと呼ばれる機能があります。 スケジューラは時計のパルスによるハードウェア割り込み(タイマ割り込みと 呼ぶ)のハンドラの中の一つの機能です。スケジューラはタイマ割り込みの中 で定期的に起動されます。そして、同時に走るように指定された関数のリスト を見ながら、順に切替えて行きます。新しい関数を起動したいときは、そのリ ストにその関数を登録しておきます。そうすると、スケジューラが見付けて起 動してくれます。
スケジューラが関数を切替える仕組みはかなり無茶なことをします。説明する のは難しいです。一言で言えば、スタックポインタの値を直接操作することで、 スタック領域を切替え、割り込みから戻り先を別の関数の中断した場所にして しまうのです。
foo: mov ax, *sp // この sp は foo の counter を指している ---- int scheduler ... // 現状をスタックに保存 ... // sp の値をリストの foo のエントリに保存 ... // スケジュール管理 ... // sp の値をリストの bar のエントリから取り出す ... // スタックから現状を復帰 iret bar: mov ax, *sp // この sp は bar の counter を指している
実は、これがマルチスレッドの仕組みです
さて、先節の競争の例ですが、マルチCPUのマシンで動かすと、一つの CPU を 交替で使うわけではないので、本当に foo() と bar() の2台は同時に実行さ れます。さて、ゴールの瞬間、すなわち winner に自分のゼッケンを記録する 瞬間を考えます。その部分はアセンブラでは下記のような感じになります。
if (winner == 0) | mov ax, winner | cmp ax, 0 | jump-if-not-equal skip_if winner = 9; | mov ax, 9 | mov winner, ax | skip_if:
さて、それぞれの CPU で走っている foo() と bar() は1クロックの差で bar() が先にゴールしたらどうなるでしょう。
foo() | bar() ... | mov ax, winner mov ax, winner | cmp ax, 0 cmp ax, 0 | jump-if-not-equal skip_if jump-if-not-equal skip_if | mov ax, 9 mov ax, 1 | mov winner, ax mov winner, ax | ret ret |
勝者は foo() です。なぜなら、winner に 1 が入っているからです。あれれ?
これと同じようなトラブルは実際にはシングルCPUでも起こります。
これを避けるには、winner 変数が同時にアクセスされないようにしなければ なりません。これを排他制御と言います。
通常、マルチタスクが可能なOSでは、プログラムのある領域が絶対に同時に実 行されない事を保証する関数を提供しています。一番簡単なタイプのものとし ては CRITICAL_SECTION (Windows)というのがあります。 EnterCriticalSection() と LeaveCriticalSection() という関数を呼びます。
int winner = 0; void checker(int number) { EnterCriticalSection(); if (winner != 0) winner = number; LeaveCriticalSection(); } void foo() { for (unsigned int counter = 0; counter < UINT_MAX; ) counter += 1 checker(1); // Michael Schumacher } void bar() { for (unsigned int counter = 0; counter < UINT_MAX; ) counter++; checker(9); // Jenson Button }
EnterCriticalSection()は、既に一つのスレッドが呼んでいると、後から呼び 出したスレッドをこの関数の中で止めてしまいます。先に入ったスレッドが LeaveCriticalSection() を呼ぶと、後から来たスレッドは EnterCriticalSection() から脱出できます。
マルチタスクのプログラムを書く場合、常にこのような排他制御の問題と対峙 しなければなりません。かなり大変です。
OSが起動する際の仕事のうち重要なのがハードウェア割り込みハンドラのセッ トアップです。とにかくこれがちゃんとしないとハードディスクがちゃんと読 み書きできません。割り込みハンドラの設定が済むと、デバイスドライバの読 み込みとセットアップをします。
デバイスドライバのセットアップが済んだら、いよいよ OS らしい仕事を始め ます。タスクスケジューラのセットアップです。タスクスケジューラは割り込 みハンドラの中で呼び出され、割り込みハンドラの最後でスタックの切替えに よって、タスクを順に切替えます。
最後の仕事は全ての親となるプロセスの生成です。全てのプロセスは一つの親 の子孫です(UNIXの場合)。この全ての親となる初期化プロセス(init)の起動が 済むと、OS 自身は沈黙します。あとは、割り込みの時やシステムコールが呼 ばれた時だけ活動します。
この初期化プロセスの仕事は、実際に各種の初期化を行うシェルスクリプトを 起動することです。初期化スクリプトの仕事はデバイスドライバの設定です。
デバイスドライバの設定が終ると、いよいよ各種のサーバ(デーモン)を立ち上 げます。そして、通常最後にコンソールのログインの為のデーモンを立ち上げ ます。
<続く>
もし、タスクの間でリアルタイムのデータ交換を全く行わないのであれば、何も留意することはありません。確実な動作をするマルチタスクOSを開発してくれた人に感謝しましょう。しかし、タスク間でのデータの交換が必要になったとたんに悪夢が始まります。
同一プロセス内では、変数はどのスレッドからもアクセスできます。グローバル変数はもちろん、スタック上に取られた変数であっても、別のスレッドからアクセスする事ができます。
グローバル変数であれば、同じ名前の変数は実際に同じアドレスを持っています。
一方、スタック上に変数については、同じ変数名だからと言っても実際のアドレスは違います。それぞれのスレッド用のスタック領域に取られているからです。ですから、ポインタを相手のスレッドに渡す必要があります。その為にはグローバル変数を通して渡すしかありません。なんであれ、スタック上の変数をスレッド間で共有するのは止めた方が良いです。なぜなら、スレッドが終了したらそのアドレスは別の用途で使われてしまうからです。
スレッド間で同一の変数にアクセスするような状況になったら、排他制御を行う必要があります。
同じプログラムを起動したとしても、別のプロセスの変数にはアクセスできません。仮想記憶空間が全く別だからです。仮に仮想空間上のアドレスが同じであっても、それが実際に存在する物理アドレス(RAM 上のアドレス)が違うはずです。偶然に全く同じ物理番地に割り当てられることがあるかもしれませんがそれでもダメです。プロセスというのは並行宇宙みたいなものだと考えられます。
これはできます。OS が用意する API を使うと複数のプロセスの仮想記憶の間に特別な窓を開けてくれます。その窓の中のメモリを変更すると、他のプロセスにもその変更が見えます。ただし、スレッド間での変数の共有と同様に、排他制御をする必要があります。