sleepとwakeup関数

古典的なUNIXでは、カーネル内で走行中のプロセスがバッファやIOなどの共有資源にアクセスするときの同期機構としてsleepとwakeup関数を提供している。ちょっと注意が必要なのは、これらの関数はsleep(1)と直接動作に関係ないこと。実はV6にはsleep(1)を実装するためにsleep(2)があるのだが、V7以降はpause(2)、alarm(2)が導入されたためライブラリ関数になった。

ちょっと教科書風に書くと、UNIXカーネルはリエントラント(再入可能)であり、カーネル内では複数のプロセスが実行可能状態にある。プロセスはカーネル内のデータ構造を共有しているので、それを排他的に利用するために同期機構が必要である。ユニプロセッサでは、同時に一つのプロセスだけが実行でき、他のプロセスはCPUや他の資源待ちでブロッキングされている。このようにプロセスがあるイベントやIO処理を待つためにCPUを放棄することを「ブロック」と呼ぶ。古典的なUNIXはこのようなカーネル内の同期を実現するブロッキング手段として、sleep/wakeup関数を提供している。V6の実装は非常にシンプルであり、スリープキューのような仕組みやマルチプロセッサ対応が入ってくるのはもっと後である*1

閑話休題、イベントを待つプロセスはsleepを呼んで、running状態からsleep状態に遷移してCPUを放棄する。これとは逆に、イベントが発生したので、ブロッキングしているすべてのプロセスを起こすのがwakeupである*2。sleepとwakeupは待ち合わせる資源のアドレスを引数に取る。このアドレスをWCHAN(waiting channel)と呼ぶ。WCHANとして、よく使われるのはtout変数やproc配列のアドレスである。sleepでブロッキングしているプロセスのproc構造体のp_wchanフィールドにはそのアドレスが記録される。

プロセスがどんな条件でsleepしているか調べるには、psにlオプションを付けて、WCHANの項目を見ればよい。今どきのUNIXはWCHANとしてカーネル関数名が見えて優しいけど(System.mapとか/proc/pid/wchanあたりを参照してカーネルシンボルを探しているんだと思う)、V6はアドレスが表示されるだけだ。

# ps alx
TTY F S UID   PID PRI ADDR  SZ  WCHAN COMMAND
?:  3 S   0     0-100 1176   2  63142 ,??j??
?:  1 W   0     1  40 1273   6  63204 /etc/init
8:  1 W   0     8  40 1441  19  63232 -
8:  1 R   0    15 100 1770  17        ps alx
?:  1 W   0     5  90 1703   5  62704 /etc/update

最後の行に/etc/updateってのが見える。これを追ってみよう。man update(8)を読むと、30秒周期でsyncを実行しているデーモンプログラムだとわかる。こいつのWCHANは062704。カーネルイメージ(rkunix)をnmしてみると、ビンゴ! _toutがヒットした。

# nm rkunix|grep 062
:
062704B _tout

updateのプログラムを以下に示すけど、アセンブリだとこんなに短くデーモンプログラムが書けるんだな。Cよりわかりやすくない?

        sys     fork
                br 1f
        sys     exit
1:
        clr     r0
        sys     close
        mov     $1,r0
        sys     close
        mov     $2,r0
        sys     close
1:
        sys     sync
        mov     $30.,r0
        sys     sleep
        br      1b
sleep = 35.
sync = 36.

デーモンプログラムのお約束で、子プロセスを作って、親は死ぬことで端末から切り離している(psの結果でもTTYは「?」になっている)。ちょっと補足すると、fork後の親プロセスの戻りアドレスは「br 1f」じゃなくて「sys exit」になる。これはforkシステムコールから戻るときにPCに細工をしているから。Cで書くとforkの戻り値で場合分けするけど、アセンブリで書くと戻りアドレスで場合分けするという使い方になる。そして標準入出力をcloseして、sync、30秒sleep、syncを繰り返す。つまり、先のps実行時点ではupdateはこのsleepシステムコール処理でsleep状態になっていることがわかる。

また、/etc/initのWCHANは063204。nmで調べると&proc[0]が063156なので、sizeof(proc)=22バイトを加えた063204が&proc[1]になる。これはinitがwait(2)を呼び出してカーネルに入り、wait内のsleepでブロッキングしていることを意味する。exitとwaitの詳細は@superhogeさんが素晴らしいまとめを書いてくださっているので、ここでは割愛する。

(追記:2011-04-17)ついでなので、simhのダンプ機能を使って、updateプロセスのプロセス構造体(struct proc)を見てみよう。「//」以下に対応するメンバと値をコメントしてみた。

sim> e 63306-63332
63306:     000402  // p_flag (SLOAD); p_stat (SWAIT);
63310:     000132  // p_sig (0); p_pri (0132);
63312:     077400  // p_time (0377); p_uid (0);
63314:     000000  // p_nice (0); p_cpu (0);
63316:     000000  // p_ttyp (0);
63320:     000005  // p_pid (5);
63322:     000001  // p_ppid (1);
63324:     001703  // p_addr (01703);
63326:     000045  // p_size (045);
63330:     062704  // p_wchan (062704);
63332:     000000  // p_textp (0);

SWAIT状態、つまり低優先度(シグナル受信可能)でsleepしていて*3、プロセスはメモリ上に存在する(SLOAD)。TTYからは切り離されていて(p_ttyp 0)、親PIDもinit(PID 1)になっていることがわかる。また、p_wchanの値はpsで見た結果と同じだ。

*1:Plan 9のsleep/wakeup関数について以前書いたことをすっかり忘れていた。

*2:wakeupはプロセスの状態を変えるけど、コンテキストスイッチまでは行わないことに注意。スイッチ自体はswtch関数で行われる。

*3:V7以降はSWAITとSSLEEPで状態をわけることはせずに、SSLEEPで一本化されたようだ。