前回の課題で実装したスレッド・ライブラリは、暗黙のうちにスレッドを切り替える機能をもたず、プログラムの中で明示的に ThreadYield() を呼ばなければならなかった。 しかしながら、ある種の状況の下では、おおむね常にスレッドを切り替えてよい場合がある。 その代表的なものは I/O の実行待ちである。 例えばあるスレッドがコンソールからの入力を待っているとすると、その間、そのスレッドは何もやることがなく、プロセッサが遊んでしまう (したがって OS は、プロセス切り替えをおこない、他のプロセスを実行してしまう)。 このような状態を、スレッドがブロック (block) されている、という。 そのような間は、明らかにスレッドを切り替えて、他のスレッドを動かした方が効率がよい。
今回は、スレッド・ライブラリに、ストリーム(ファイル)を読むための関数 ThreadRead() を追加する。 一般に read() システムコールを使って読み出しをおこなうと、ストリームが空で読み出しをすぐにおこなえない場合、読み出しが可能になるまでプログラムの実行が一時停止してしまう。 例えばそのストリームがコンソール入力で、まだ何もキー入力がなければ、そのストリームは読み出しをおこなえずプログラムの実行が一時停止する。 一方、この関数は、呼ばれると暗黙のうちにスレッドを切り替え、後に読み出しが可能になってから、この関数を呼んだスレッドを再開する。 このため I/O 待ちの間もプロセッサが遊んでしまうことはない。
ThreadRead() の定義は簡単で次のような形になる。
int ThreadRead(int fd, char* buf, int size) { currentThread->status = WAITING; currentThread->fd = fd; ThreadYield(); return read(fd, buf, size); }
I/O 待ちを表わすスレッドの状態として新たに WAITING を導入する。 ThreadRead() はまず自分の状態を WAITING にし、スレッドを切り替え、後に read() を実行する。 また Thread 構造体を手直しして、読もうとしているストリームを表わすファイル・ディスクリプタを保存できるようにする。 この情報は ThreadYield() の中で使われる。
WAITING という新しい状態を導入したので、ThreadYield() の方も変更しなければならない。 これまで、ThreadYield() は RUNNING 状態のスレッドの中から、次に実行するスレッドを選んでいた。 これを変更して、もし WAITING 状態のスレッドの中で、そのスレッドが読もうとしているストリームが即座に読み出し可能になっているものがあれば、それも次に実行するスレッドとして選ばれるようにしなければならない。
あるスレッドが読み出し可能になっているかどうかは、select() システムコールで調べることができる。
select システムコールを使うと、複数のファイル・ディスクリプタの中から、「ブロックせずに read (もしくはwrite、accept) できるもの」を見つけることができる。 もしブロックしないで済むファイルディスクリプタが1つも無いと、select システムコール自体がブロックして、いずれかが read (もしくは write、accept)できるようになるまで待つことになるが、この場合にも、「最高何秒までブロックするか」を指定することができる(指定しないと無限にブロックする)。
selectは次のように5つの引数を取る。
int select(int width, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
width は、扱いたいファイルディスクリプタの最大値+1を指定する。fd_set 型は、ファイルディスクリプタの集合を表す型で、マクロ FD_SET、FD_CLR、FD_ISSET、FD_ZERO を使ってアクセスすることができる。readfds、writefds、exceptfdsは、それぞれ、read または accept できるか調べたい fd の集合、write できるか調べたい fd の集合、例外的状態 (※1) になっているか調べたい fd の集合を指定する。 timeout には、最大何秒までブロックするかを指定する。timeout に NULL を指定すると、無限にブロックする。
select が返ると、readfds、writefds、exceptfds にはそれぞれ、read (socket の場合は accept) できる fd の集合、writeできる fd の集合、例外的状態になっている fd の集合がセットされる。
※1 例外的状態には、(1)帯域外データの到着、(2)疑似端末からの制御信号の到着の2つがある。
selectの使い方を簡単な例を使って説明する。
次に示す関数は、引数 fd1, fd2 が示すストリームが読み出し可能かどうかを検査する。
#include <sys/types.h> #include <sys/time.h> #include <unistd.h> void readability_check(int fd1, int fd2) { fd_set readable; struct timeval timeout; int maxfd; FD_ZERO(&readable); // readableを空にする。 FD_SET(fd1, &readable); // readableにfdをセット FD_SET(fd2, &readable); // 〃 maxfd = max(fd1, fd2) + 1; timeout.tv_sec = 0; // タイムアウトは 0 秒 timeout.tv_usec = 0; if (select(maxfd, &readable, (fd_set *)0, (fd_set *)0, &timeout) < 0) { // select失敗 perror("select"); exit(1); } if (FD_ISSET(fd1, &readable)) { // fd1 が read 可能。 } else if (FD_ISSET(fd2, &readable)) { // fd2 が read 可能。 } else { // タイムアウト。fd1 も fd2 も read 可能ではない。 } }
元の ThreadYield() の定義は次のようなものであった。
void ThreadYield() { 現在実行中のスレッド currentThread 以外で、 実行可能 (RUNNING) なスレッド t を探す。 if (t が発見された) { Thread* cur = currentThread; currentThread = t; _ContextSwitch(cur->context, t->context); } else if (残りは FINISH 状態の main() スレッドのみ) main() スレッドの実行を再開する。 return; }
これを変更して、RUNNING 状態のスレッドがないときには、WAITING 状態のスレッドの中からも次に実行するスレッドが選ばれるようにすればよい。 新しい ThreadYield() の定義は次のようになるだろう。
void ThreadYield() { 現在実行中のスレッド currentThread 以外で、 実行可能 (RUNNING) なスレッド t を探す。 if (t が発見されない && WAITING スレッドが存在する) { do { select() を実行する。 即座に読み出しを実行できる (I/O 待ちでない) WAITING スレッドの状態を RUNNING に変える。 現在実行中のスレッド currentThread 以外で、 実行可能 (RUNNING) なスレッド t を探す。 } while (t が発見されない && currentThread が RUNNING でない); } if (t が発見された) { Thread* cur = currentThread; currentThread = t; _ContextSwitch(cur->context, t->context); } else if (残りは FINISH 状態の main() スレッドのみ) main() スレッドの実行を再開する。 return; }
最初から select() を実行することも可能だが、まず既に RUNNING になっているスレッドがないかどうかを調べ、その中で見つからない場合にのみ、select() を実行する方が、平均的なスレッドの切り替え時間が短くなるだろう。 また select() を実行した結果、即座に読み出しを実行可能になっていることがわかったスレッドを、全ていったん RUNNING 状態に変えている点も、平均切り替え時間の短縮につながる。
Unix カーネルのプログラムは、ちょうどここまで実装してきたスレッド・ライブラリのようになっている。 スレッドが Unix プロセスに、ライブラリが提供する関数がシステムコールに対応する。 ThreadCreate() がプロセスを生成するシステムコール、ThreadRead() が read() システムコールである。
システムコールの場合、プロセッサの状態を特権モードに変えるため、普通の関数呼出ではなく、特別な機械語命令を使って関数を呼び出すが、呼び出した後の処理はスレッド・ライブラリの場合とおおむね同じである。 例えば read() システムコールは、まず I/O 装置に対して読み出しを要求し、プロセスの状態を WAITING に変え、ThreadYield() に対応するカーネル内部の関数を呼び出してプロセスの切り替えをおこなう。 Unix は一定時間が経過するとプロセスを強制的に切り替えるが、I/O の実行待ちの際にもプロセスを自動的に切り替え、プロセッサが無駄に遊ばないようにする。 また I/O 処理を伴わないシステムコールも、終了間際に ThreadYield() に対応するカーネル内部の関数を呼び出して、必要ならプロセスを自動的に切り替えるようになっている。 Unix プロセスは、一定時間が経過したときにだけ切り替わるのではなく、システムコールを呼び出した際にも切り替わる。
完成後、test2.c を使って、ライブラリが正しく動くか確かめよ。 コンパイルするには次のようにすればよい。
% gcc -g -o test2 test2.c thread.c csw-i386.S
Copyright (C) 1999-2000 Shigeru Chiba
Email: chiba@is.tsukuba.ac.jp