(第7回)サーバアプリを動かしてみる

2007/10/23

あなたは 人目のお客様です.

そろそろOSとしてもそこそこ動くようになってきたので, 単なる実験用のサンプルプログラムでなく, ちょっとした実用的なそれっぽいサービスを動かしてみたい. ということで,今回は telnetd と httpd と, あと時計アプリ(1秒おきに時刻を表示するだけだけど)を動かしてみよう.

で,実装なのだけど,ソケット開いて bind() して accept() して単に select() するだけ,ってだけでは芸が無い.サーバアプリを2つも動かすのだから, ソケットの select() 部分はうまく共通化したい.

とりあえず簡単に思い付くのは, kz_timer()使って一定時間ごとに select() でソケットの状態をポーリングして, 受信データがあるならば,当該のスレッドに kz_send() でメッセージを送る, というものだ.しかしポーリングするというのはいまいちなあ... ちなみに前回まででとくに言及はしなかったのだが,main.c の内部では while (1) で待ってしまっていたため,CPU負荷がやたら高くなってしまう, という問題があった. なにもやることが無いときには while (1) に入ってきて,無限ループをえんえんと 繰り返しているということだ.

せっかく select() を使うならば,ソケット待ちでスリープすることで CPU負荷を一気に下げたいところだ. ということで,こーいう実装はどうだろう.

ほんとは外部割り込みっぽく実装してみたいのだけど(このため extintr という 名前になっている),とりあえずサーバ動かしてみたいし, いちどにあれもこれも説明するのもたいへんなので, とりあえず今回はこれでいいとしよう.

で,修正後のソースは以下になる. 今回は extintr.c, clock.c, telnetd.c, httpd.c が追加されている.

(2009/04/10 ライセンスに関する文書として,KL-01とLICENSEを追加. 詳しくは第43回を参照)

以下は前回からの差分.

ちなみに今回,システムコールとして kz_pending() というのを追加してある. 受信メッセージがあるかどうかを調べるためのシステムコールだ. こんなふうにして使う.
if (kz_pending()) { /* 受信メッセージがあるならば,受信して処理する */
  size = kz_recv(&id, &p);
  ...
で,各ソースを説明しよう.まずは extintr.c だ.

まず extintr.c は intrbuf という構造体と,setintrbuf() という関数を持つ. これらは各スレッドからソケットを通知された際に,それらのソケットを リンクリスト管理しておくためのものだ.まああまり難しくはないと思うので, ここでは説明しない.

extintr スレッドのメインループは,以下のようになっている.

int extintr_main(int argc, char *argv[])
{
  fd_set fds;
  char *p, *buffer;
  int fd, id, ret, size, s, len;
  intrbuf *ibp;

  FD_ZERO(&readfds);
  maxfd = 0;

  while (1) {
    while (kz_pending()) {
      fd = kz_recv(&id, &p);
      if (fd) { /* from thread */
	setintrbuf(fd, id, (struct sockaddr *)p);
      }
    }

    fds = readfds;
    ret = select(maxfd + 1, &fds, NULL, NULL, NULL);

    if (ret > 0) {
      for (ibp = interrupts; ibp; ibp = ibp->next) {
	if (FD_ISSET(ibp->fd, &fds)) {
	  switch (ibp->type) {
	  case INTR_TYPE_ACCEPT:
	    len = ibp->addr.sa_len;
	    s = accept(ibp->fd, &ibp->addr, &len);
	    if (s > 0)
	      kz_send(ibp->id, s, NULL);
	    break;
	  case INTR_TYPE_READ:
	    buffer = kz_memalloc(BUFFER_SIZE);
	    size = read(ibp->fd, buffer, BUFFER_SIZE);
	    if (size > 0)
	      kz_send(ibp->id, size, buffer);
	    break;
	  }
	}
      }
    }
  }
}
まず最初に,
  while (1) {
    while (kz_pending()) {
      fd = kz_recv(&id, &p);
      if (fd) { /* from thread */
	setintrbuf(fd, id, (struct sockaddr *)p);
      }
    }
という部分で,各スレッドからのメッセージを受け付ける. 各スレッドからは,見張ってほしいソケットをメッセージで送ってくるので, それを受信して setintrbuf() を呼び出し,構造体 intrbuf のリンクリストに 追加する.ちなみにソケット情報は, というようにして送られてくる.なので setintrbuf() では,データとして ポインタが送られてきたかNULLが送られてきたかで,accept() 用のソケットなのか read() 用のソケットなのかを判断している.

kz_send() の戻り値がサイズではなくソケット番号になっていることに注意. 前回だか前々回だかに説明したが,kz_send() ではパラメータ名は size と なっているが,実際には指定されたパラメータ値をそのまま送るだけなので, このようにソケット番号を送ることに利用してもいいわけだ (本当は通信用の専用の構造体を作るべきだろうが, 面倒なのでまあこれでいいとする).

さらに,select() によって無限待ちする.

    fds = readfds;
    ret = select(maxfd + 1, &fds, NULL, NULL, NULL);
select()から抜けるのは,ソケットがなんらかのデータを受信して 読み込み可能になったか,もしくはシグナル(ここでは SIGALRM のみ)が発生したかだ. 読み込むデータがある場合には select() の戻り値が正の数 (読み込めるソケットの個数)になるので,受信処理を行う.
    if (ret > 0) {
      for (ibp = interrupts; ibp; ibp = ibp->next) {
	if (FD_ISSET(ibp->fd, &fds)) {
	  switch (ibp->type) {
	  case INTR_TYPE_ACCEPT:
	    len = ibp->addr.sa_len;
	    s = accept(ibp->fd, &ibp->addr, &len);
	    if (s > 0)
	      kz_send(ibp->id, s, NULL);
	    break;
	  case INTR_TYPE_READ:
	    buffer = kz_memalloc(BUFFER_SIZE);
	    size = read(ibp->fd, buffer, BUFFER_SIZE);
	    if (size > 0)
	      kz_send(ibp->id, size, buffer);
	    break;
	  }
	}
      }
    }
ソケットは accept() 用のものと,read() 用のものがある. これらの種類に応じて accept() もしくは read() を行い, 実行結果を当該のスレッドにメッセージにして送っている. select()でチェックした後なので,accept(),read()は必ず成功するはずだ. にもかかわらず accept(),read() の戻り値チェックをしているのは, 万が一これらと同時に SIGALRM が発生し, (そのようなことがあり得るのかどうかちょっと不明だけど) シグナル抜けしてしまったときの対処だ.

では次に,各種アプリについて説明しよう. まずは時計アプリである clock.c から.

int clock_main(int argc, char *argv[])
{
  time_t t;
  char *p;

  while (1) {
    kz_timer(1000);
    kz_recv(NULL, NULL);

    t = time(NULL);
    p = kz_memalloc(128);
    strcpy(p, ctime(&t));
    kz_send(outlog_id, 0, p);
  }
}
メインループでは,単にタイマを1秒おきでかけて時刻を表示しているだけ. 表示は outlog スレッドにまかせている.

次に telnetd.c について.

int telnetd_main(int argc, char *argv[])
{
  int sockt, s, ret;
  char hostname[256];
  struct hostent *host;
  struct sockaddr_in address;
  int backlog = 5;

#if 1
  gethostname(hostname, sizeof(hostname));
#else
  strcpy(hostname, "localhost");
#endif

  host = gethostbyname(hostname);
  if (host == NULL) {
    fprintf(stderr, "gethostbyname() failed.\n");
    exit (-1);
  }

  memset(&address, 0, sizeof(address));
  address.sin_family = AF_INET;
  address.sin_len = sizeof(address);
  address.sin_port = htons(PORT);
  memcpy(&(address.sin_addr), host->h_addr, host->h_length);

  sockt = socket(AF_INET, SOCK_STREAM, 0);
  if (sockt < 0) {
    fprintf(stderr, "socket() failed.\n");
    exit (-1);
  }

  ret = bind(sockt, (struct sockaddr *)&address, sizeof(address));
  if (ret < 0) {
    fprintf(stderr, "bind() failed.\n");
    exit (-1);
  }

  ret = listen(sockt, backlog);
  if (ret < 0) {
    fprintf(stderr, "listen() failed.\n");
    exit (-1);
  }
ここまでは通常のネットワークアプリなので,とくに説明はない. ソケットを開いて各種準備をするだけだ.

で,問題は次からだ.

  kz_send(extintr_id, sockt, (char *)&address);

  while (1) {
    s = kz_recv(NULL, NULL);
    kz_run(command_main, "command", 7, s, NULL);
  }

  close(sockt);

  return 0;
}
listen()の直後に kz_send() によって extintr にメッセージを送る. これにより,accept() 用のソケット番号とアドレス情報を extintr に通知し, あとは extintr が select() でソケットを見張って,accept() できる状態 (telnetに対する接続要求が来たとき)になったらメッセージを投げてくる.

kz_send() の後は kz_recv() でメッセージ待ちに入る.extintr からの メッセージを待つわけだ.で,接続要求が来たら extintr のほうで accept() して ソケット番号を(kz_send()の size パラメータを使って)送ってくるので, そのソケット番号をパラメータにしてコマンド処理用スレッドを起動する. ちなみに telnetd はこの後再び kz_recv() による accept() 待ちに入り, 接続のたびにコマンドスレッドを新規に起動するので,複数の telnet 接続を 受け付けることができる.

コマンドスレッドのメインループは以下のようになっている.

static int command_main(int s, char *argv[])
{
  char *p;
  char buffer[128];
  int len, size;

  kz_send(extintr_id, s, NULL);

  len = 0;
  write(s, "> ", 2);

  while (1) {
    size = kz_recv(NULL, &p);
    memcpy(buffer + len, p, size);
    kz_memfree(p);
    len += size;
    buffer[len] = '\0';

    p = strchr(buffer, '\n');
    if (p == NULL) continue;

    if (!strncmp(buffer, "echo", 4)) {
      write(s, buffer + 4, strlen(buffer + 4));
    } else if (!strncmp(buffer, "date", 4)) {
      time_t t;
      t = time(NULL);
      strcpy(buffer, ctime(&t));
      write(s, buffer, strlen(buffer));
    } else if (!strncmp(buffer, "threads", 7)) {
      kz_thread *thp;
      int i;
      for (i = 0; i < THREAD_NUM; i++) {
	thp = &threads[i];
	if (!thp->id) break;
	write(s, thp->name, strlen(thp->name));
	write(s, "\n", 1);
      }
    } else if (!strncmp(buffer, "exit", 4)) {
      break;
    }

    len = 0;
    write(s, "OK\n> ", 5);
  }

  kz_send(extintr_id, s, NULL);
  close(s);

  return 0;
}
順番に説明していこう. まず,先頭で kz_send() により extintr にメッセージを送っている.
  kz_send(extintr_id, s, NULL);
これはソケットを extintr に登録しておいて, 受信データがあったときにやはりメッセージで知らせてもらうためだ. で,kz_recv() により受信データ待ちになる.
  while (1) {
    size = kz_recv(NULL, &p);
先程の kz_send() で extintr に対してソケット番号を通知してあるので, あとは extintr が select() して,受信データがあれば kz_send() で メッセージにして送ってくる.

で,メッセージを受信したら,受信データの内容に応じてコマンドを実行する. ちなみに受信データは extintr 側で kz_memalloc() によって獲得したメモリに 格納して送られてくるので,コマンド処理側で kz_memfree() によって解放している.

    memcpy(buffer + len, p, size);
    kz_memfree(p);
    len += size;
    buffer[len] = '\0';
で,以下がコマンド処理部分になる.
    if (!strncmp(buffer, "echo", 4)) {
      write(s, buffer + 4, strlen(buffer + 4));
    } else if (!strncmp(buffer, "date", 4)) {
      time_t t;
      t = time(NULL);
      strcpy(buffer, ctime(&t));
      write(s, buffer, strlen(buffer));
    } else if (!strncmp(buffer, "threads", 7)) {
      kz_thread *thp;
      int i;
      for (i = 0; i < THREAD_NUM; i++) {
	thp = &threads[i];
	if (!thp->id) break;
	write(s, thp->name, strlen(thp->name));
	write(s, "\n", 1);
      }
    } else if (!strncmp(buffer, "exit", 4)) {
      break;
    }

    len = 0;
    write(s, "OK\n> ", 5);
  }
現在実装してあるコマンドは以下の4つだけだ. exitが打たれると,以下の終了処理を行う.
  kz_send(extintr_id, s, NULL);
  close(s);
kz_send()により extintr に対してもう一度ソケット番号を通知することで, そのソケットを監視対象から外す (extintr はメッセージを受けるとソケットの監視を開始し, もう一度メッセージを受けると監視から外す). で,ソケットをクローズしている.

さて次は httpd だ.まあ telnetd とあんまし変わらないのだが, 異なる部分だけいちおう説明する.

httpdのメインループは,telnetdとほとんど同じだ. 接続要求がくると処理用のスレッドとして以下を起動する.

static int http_proc(int s, char *argv[])
{
  char *p;
  char buffer[1024];
  int len, size;

  kz_send(extintr_id, s, NULL);

  len = 0;

  do {
    size = kz_recv(NULL, &p);
    if (len + size >= sizeof(buffer)) {
      break;
    }
    memcpy(buffer + len, p, size);
    kz_memfree(p);
    len += size;
    buffer[len] = '\0';
  } while (strchr(buffer, '\n') == NULL);

  write(s, mes, sizeof(mes));

  kz_send(extintr_id, s, NULL);
  close(s);

  return 0;
}
HTTPのプロトコルでは,たしか要求の最後に改行が入っていたような... (あれ,空行だったかな?),なので,てきとうに要求を受けた後に HTMLのメッセージを返すようになっている.メッセージは以下の簡単なものだ.
static char mes[] = 
"<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML 1.0//EN\">\n"
"<html>\n"
"<head><title>hello</title></head>\n"
"<body>\n"
"<center><h1>KOZOS</h1></center>\n"
"<p><h2>This is HTTP server running on KOZO-OS.</h2>\n"
"</body></html>\n";
メインの関数は以下になる. ここで,各スレッドの起動部分について説明する.
int mainfunc(int argc, char *argv[])
{
  extintr_id  = kz_run(extintr_main,  "extintr",  31, 0, NULL);
  outlog_id   = kz_run(outlog_main,   "outlog",    1, 0, NULL);
  clock_id    = kz_run(clock_main,    "clock",     7, 0, NULL);
  telnetd_id  = kz_run(telnetd_main,  "telnetd",   8, 0, NULL);
  httpd_id    = kz_run(httpd_main,    "httpd",     9, 0, NULL);

  return 0;
}
extintr を一番最初に起動しているのだが, 優先度は31と最低になっていることに注意してほしい. 優先度が31になっているのはずっと上のほうで説明したように, 内部で select() で待つので,他のスレッドが固まってしまうのを防止するためだ. まあ優先度が最低ということは,なにもやることがないときには extintr に 処理が渡って select() でプロセス自体がスリープするということなので, これはけっこうまともな設計だと思う. (実際のOSでも,多くは idle スレッドみたいなものが最低の優先度で起動していて, なにもやることがない場合にはCPUを省電力モードにする,などの動作をする)

telnetd, httpd は extintr に対してメッセージを投げるので, telnetd, httpd が起動する前に extintr は存在していなければならない. なので extintr を一番最初に起動している(ただし優先度が最低なので, 起動しただけで処理は何も行われておらず,存在しているだけの状態である). outlog を2番目に起動しているのも,まあ似たような理由だ. clock は outlog に対してメッセージを送信するので,clock よりも前に outlog が起動している必要がある. ちなみに前回や前々回とかでは,メインとなるスレッドは,他のスレッドを 全部起動した後に自身の優先度を最低に変更して while(1) で無限ループしていた. つまり,最後に idle スレッドになっていた(しかし無限ループなのでCPU負荷が 異常に高くなっていた).しかし今回は extintr が select() で待つことで idleスレッドの役割をこなすので,メインスレッドはすべてのスレッドの起動後は そのまま終了してしまっている.(メインスレッドは優先度がゼロで一番高いので, メインスレッドの終了後に各スレッドが動作を開始することになる)

では,いよいよサンプルプログラムによって動作確認してみよう. で,動作確認の前に1点注意.今回は telnetd と httpd が gethostbyname() によって ホスト情報を取得しているが,ここでネットワーク接続されていない端末 (もしくは接続されていても)でホスト名がてきとうだったりすると, DNSから情報が得られなくてエラーになってしまう. 対策としては,/etc/hosts にホスト名とIPアドレスの対応を書いて名前解決 できるようにしておくか,もしくは telnetd_main(), httpd_main()でgethostname()しているところを

-#if 1
+#if 0
   gethostname(hostname, sizeof(hostname));
 #else
   strcpy(hostname, "localhost");
 #endif
のように書き換えて localhost を使うようにするか,対処してほしい.

では起動してみよう.コンパイルしたら実行形式を起動する.

% ./koz
Tue Oct 23 22:32:08 2007
Tue Oct 23 22:32:09 2007
Tue Oct 23 22:32:10 2007
Tue Oct 23 22:32:11 2007
Tue Oct 23 22:32:12 2007
Tue Oct 23 22:32:13 2007
Tue Oct 23 22:32:14 2007
Tue Oct 23 22:32:15 2007
Tue Oct 23 22:32:16 2007
Tue Oct 23 22:32:17 2007
Tue Oct 23 22:32:18 2007
Tue Oct 23 22:32:19 2007
...
時刻が1秒おきに表示されている.

この状態で,telnet で接続してみよう. 説明を忘れていたが,telnetd.c ではポート番号 20001 で待っているので, 20001 番ポートに対して接続する. 前述したようにDNSでうまく名前解決できなければ,localhost を使うか IPアドレス直書きでもいいだろう.ここではIPアドレス直書きで接続する.

% telnet 192.168.0.3 20001
Trying 192.168.0.3...
Connected to 192.168.0.3.
Escape character is '^]'.
> 
おお,無事に接続できた.

echoコマンドを実行してみよう.

> echo test
 test
OK
問題ないようだ.
> date
Tue Oct 23 22:32:50 2007
OK
> threads
command
extintr
outlog
clock
telnetd
httpd
OK
> 
date, threads コマンドも問題無し.

前述したように,telnetd は接続ごとに処理用スレッドを起動するので, この状態(すでに接続されている状態)で,抜けないままでさらに別の接続を することができる.試してみよう.

別の kterm 上から,telnet を起動する.

% telnet 192.168.0.3 20001
Trying 192.168.0.3...
Connected to 192.168.0.3.
Escape character is '^]'.
> 
問題なく接続できた.

コマンドを実行してみよう.

> date
Tue Oct 23 22:33:11 2007
OK
> threads
command
extintr
outlog
clock
telnetd
httpd
command
OK
> 
threads によるスレッド一覧表示で,command スレッドが2つ表示されていることに 注目(さっきはひとつだけだった).2箇所から同時接続して,コマンド処理スレッドが 2つ起動しているので,このようなことになっている.

次に,ブラウザでHTTP接続してみよう. こちらはポート番号は 30001 (httpd.c参照)なので,

http://192.168.0.3:30001/
として接続する.

おー,問題無く表示された.ちょっと感動. この間も時刻は1秒ごとに表示されているし,telnetによるコマンド実行も もちろんできる. 時刻表示やtelnetによるコマンド処理も行いながら,webサーバも同時にできている ということに注目してほしい.つまり,スレッドのディスパッチがきちんと行われて いるということだ.

telnet接続は,exitで抜けることができる.

> exit
Connection closed by foreign host.
% 
さて,ここまでで,各種サーバ機能と時刻表示を動かすことができたのだが, たとえばこれをスレッド構成にしないで,普通の作りにしたらどんな感じになるか 考えてみてほしい.

まあ,いちばん最初に思い付くのは

signal(SIGALRM, alarm_handler);
...
while (1) {
  ...
  r = select(...);
  if (r < 0) {
    /* シグナルによってselect()が終了した(※1) */
    ...
  } else if (r == 0) {
    /* select()がタイムアウトした */
    ...
  } else {
    /* ソケットに受信データが到着した */
    for (i = 0; i <= maxfd; i++) {
      if (FD_ISSET(i, &fds)) {
        /* ソケットの受信処理(※2) */
        ...
  }
みたいにして,select()で待ってその戻り値に応じて各種動作を行うというものだ. これでもたしかに,やりたいことはできるだろう.

しかしこの書き方だと,時刻表示とtelnetサーバとhttpサーバの処理を, 完全に独立させて書くことはまあできない. それらはすべて select() から派生して処理が行われるような構造になるからだ. つまり,上記の(※1)や(※2)の位置から関数を呼び出すことで各種処理を 行わなければならない.このため,関数の構造などは select() から呼び出せる ようにしなければならず,それなりに制約を受ける. たとえば今回の telnetd.c のように,accept() したらコマンド処理してまた accept() の無限ループ,のようなことはできない.無限ループしてしまっては, select() に処理が戻らずに,他の動作が固まってしまうからだ.

これに対して,今回の clock.c, telnetd.c, httpd.c の実装を見てほしい. まるでひとつひとつが,単なるアプリとなっていて, select() から派生して動くとかいったことは考える必要が無い. extintr にメッセージを投げておけば,あとは適切にメッセージが返ってくるので, それに対して処理を行うだけだ. telnetd.c の中では無限ループにより accept() を行っているが, 無限ループのために他のサービスが固まってしまうだとかいったことを心配する 必要は無い.OSがてきとうにスレッドディスパッチをして,処理を振り分けて くれるからだ.

ここで,たとえばスレッドを用いずに select() のみで処理するような実装で, telnet 接続したときに,あるコマンドを叩いたら複雑な時間のかかる処理を 行うような場合を考えてみよう.たとえばソートとかだ.

ソートに10秒かかるとして,それをコマンドスレッドが何も考えずに実行して しまったら,10秒間 select() には戻ってこない.つまり,他の全てのサービスが 固まってしまう.これを防ぐためには,ソート処理の途中でいったん抜けて select() に戻り,しばらくしたらまたソート処理を呼んでもらってまた途中まで 処理して,というような複雑なことを行わなければならない. このためにはソートの現在状態のセーブ/ロード処理を実装しなければならなくなる. これは面倒だし,なによりも本来のソート処理よりも, このような本筋ではない対応のほうに手間がかかってしまうことになる. 場合によってはプログラムの構造自体を変更しなければならなくなる.

しかし今回のように,スレッド化してあれば簡単だ. 簡単,というより,何も考えずにソート処理を行えばよい. とはいってもまあ実は現状のKOZOSの実装だと,コマンドで重い処理に入ると select() が呼ばれずに同じようなことになってしまう. とりあえず重い処理をしたいならば,とりあえずはソート処理の要所に

kz_timer(10);
kz_recv(NULL, NULL);
とか入れておく必要がある.まあこれでも プログラム全体の構成を変える必要は無いわけで,とっても嬉しいともいえる.

この問題には,外部割り込み実装という解決策がある. まあこれはまた後で紹介しよう.


メールは kozos(アットマーク)kozos.jp まで