echo サーバを作ってみよう (3)

前へ << echo サーバを作ってみよう (2) echo サーバを作ってみよう (4) >> 次へ

問題点

さて、ちゃんと動いているかのように見える echo サーバですが、 問題点があります。サーバを実行して、telnet を2つ使って 同時にサーバにアクセスしてください。先に接続したものは 普通にデータのやりとりができますが、もう1つの telnet の方は 文字列をタイプしても何も反応がありません。先に接続した telnet を終了させると、 後の方の処理が始まります。

まぁこれは問題点というか、仕様であると言ってしまえば それでおしまいなのかもしれません。でもやはり同時に 複数のクライアントの相手をできる方が賢いでしょう。 今回はこの点を改善してみます。

同時に複数のクライアントの相手をする

さて、具体的な方法ですが、 ここでは複数のプロセスを使って、作業を分担させることにしましょう。

最初に実行されていたサーバ (親プロセス) はポートを見張り続けます。 クライアントから接続があった場合は子プロセスを生成し、 その後のクライアント相手は子プロセスにまかせることにします。 親プロセスは子プロセスを生成した後は、クライアントとは 関わりあわず、引続きポートを見張ることに専念します。

このように、同時に複数のクライアントが接続できるようなサーバを、 マルチスレッドサーバといいます。

ここでいう「マルチスレッドサーバ」というのは、 「同時に複数のクライアントが接続できる」という機能的な特徴を指しています。 プロセスより小さな実行単位である「スレッド」という言葉がありますが、 この「スレッド」を使っているから「マルチスレッドサーバ」と呼ぶわけではありません。 ただし、「スレッド」を使って「マルチスレッドサーバ」を実現することもできます。

改良版 echo サーバ

同時に複数のクライアントの応対ができる echo サーバです。

echo-server-fork.pl

    1: #!/usr/local/bin/perl -w
    2: 
    3: # $Id: echo-server-fork.pl,v 1.2 2002/02/17 11:07:27 68user Exp $
    4: 
    5: use Socket;
    6: $port = 5000;
    7:                 # ソケット生成
    8: socket(CLIENT_WAITING, PF_INET, SOCK_STREAM, 0)
    9:      or die "ソケットを生成できません。$!";
   10: 
   11:                 # ソケットオプション設定
   12: setsockopt(CLIENT_WAITING, SOL_SOCKET, SO_REUSEADDR, 1)
   13:      or die "setsockopt に失敗しました。$!";
   14: 
   15:                 # ソケットにアドレス(=名前)を割り付ける
   16: bind(CLIENT_WAITING, pack_sockaddr_in($port, INADDR_ANY))
   17:      or die "bind に失敗しました。$!";
   18: 
   19:                 # ポートを見張る
   20: listen(CLIENT_WAITING, SOMAXCONN)
   21:      or die "listen: $!";
   22: 
   23: print "親プロセス($$): ポート $port を見張ります。\n";
   24: 
   25:                 # while(1)することで、1つの接続が終っても次の接続に備える
   26: while (1){
   27:     $paddr = accept(CLIENT, CLIENT_WAITING);
   28: 
   29:                 # ホスト名、IPアドレス、クライアントのポート番号を取得
   30:     ($client_port, $client_iaddr) = unpack_sockaddr_in($paddr);
   31:     $client_hostname = gethostbyaddr($client_iaddr, AF_INET);
   32:     $client_ip = inet_ntoa($client_iaddr);
   33: 
   34:     print "接続: $client_hostname ($client_ip) ポート $client_port\n";
   35: 
   36:                 # forkで子プロセスを生成
   37:     if ( $pid = fork() ){
   38:                 # こちらは親プロセス
   39:         print "親プロセス($$): 引続きポート $port を見張ります。\n";
   40:         print "親プロセス($$): クライアントの相手はプロセス $pid が行います。\n";
   41: 
   42:                 # 親プロセスはソケットをクローズ
   43:         close(CLIENT);
   44:         next;
   45:     } else {
   46:                 # こっちは子プロセス
   47: 
   48:                 # クライアントに対してバッファリングしない
   49:         select(CLIENT); $|=1; select(STDOUT);
   50:         while (<CLIENT>){
   51:             print "子プロセス($$): メッセージ $_";
   52:                 # クライアントにメッセージを返す
   53:             print CLIENT $_;
   54:         }
   55:         close(CLIENT);
   56:         print "子プロセス($$): 接続が切れました。終了します。\n";
   57:                 # ポートの監視は親プロセスが行っているので、
   58:                 # クライアントとのやりとりが終了すれば exit
   59:         exit;
   60:     }
   61: }
前のバージョンと違うところは、
   37:     if ( $pid = fork() ){
   38:                 # こちらは親プロセス
   39:         print "親プロセス($$): 引続きポート $port を見張ります。\n";
   40:         print "親プロセス($$): クライアントの相手はプロセス $pid が行います。\n";
   41: 
   42:                 # 親プロセスはソケットをクローズ
   43:         close(CLIENT);
   44:         next;
   45:     } else {
   46:                 # こっちは子プロセス
   47: 
   48:                 # クライアントに対してバッファリングしない
   49:         select(CLIENT); $|=1; select(STDOUT);
   50:         while (<CLIENT>){
   51:             print "子プロセス($$): メッセージ $_";
   52:                 # クライアントにメッセージを返す
   53:             print CLIENT $_;
   54:         }
   55:         close(CLIENT);
   56:         print "子プロセス($$): 接続が切れました。終了します。\n";
   57:                 # ポートの監視は親プロセスが行っているので、
   58:                 # クライアントとのやりとりが終了すれば exit
   59:         exit;
   60:     }
だけで、それより前の部分は全く同じです。少しずつみていきましょう。

fork!

   37:     if ( $pid = fork() ){
さて、いきなりよくわからない方もいるかもしれません。 fork とは新しいプロセスを生成するシステムコールです。 UNIX では新しいプロセスを生成する方法は、 システムコール fork(2) を使うしかありません。
念のため書いておきますが、exec(2) や system(3) は 新しいプロセスを生成する関数ではありません。exec 系は 「現在のプロセスに新しいプロセスを上書きする」ためのシステムコールです。 元のプロセスはなくなってしまうので (新しいプロセスに上書きされてしまうから)、 OS全体としてのプロセス数は変わりません。また、system(3) は内部で fork/exec を 呼び出しているライブラリ関数です。

なお、プロセスを作成する方法は fork(2) のみ、というのは ほんとは嘘で、vfork(2)、rfork(2) というシステムコールもあります。

fork によるプロセスの生成は、(知らない人にとっては)とても特徴的です。 fork で作られる子プロセスは、親プロセス (fork を呼んだ側) の コピーなのです。ファイルハンドル、変数、環境変数など、 子プロセスには親プロセスと全く同じ状態が引き継がれます。

唯一違うのが fork の戻り値です。サンプルを見てもらうと話は 早いのですが、

if ( $pid = fork() ){
    ここに処理がくると親プロセス
} else {
    ここに処理がくると子プロセス
}
fork はプロセスの分身を作り出したあと、値を返します。 ここで親プロセスには「新しく作ったプロセスのプロセス番号 (=子プロセスのプロセス番号)」が返され、 子プロセスには「0」が返されます。fork の戻り値を調べないと、 自分自身が親プロセスなのか子プロセスなのかはわかりません。

もう少しわかりやすく書くと、

$pid = fork();
if ( $pid != 0 ){
    ここに処理がくると親プロセス
} else {
    ここに処理がくると子プロセス
}
というわけです。上に少し書いたとおり、ファイルハンドル、変数、環境変数などは 全て同じものが渡されます。これは子プロセスのメモリ領域に、親プロセスのメモリ領域のデータを まるごとコピーしたということです。
ちなみに、ここで子プロセスが exec(2) を使って他のプログラムを実行すると、 シェルのできあがりです。sh や csh、tcsh、bash などは こういう仕組みで コマンドを実行しているのです (もちろん他にもたくさんの処理をしていますが、 基本の部分はコレです)。

とにかくこれで子プロセスが生成されました。 まずは親プロセスから片付けましょう。
   37:     if ( $pid = fork() ){
   38:                 # こちらは親プロセス
   39:         print "親プロセス($$): 引続きポート $port を見張ります。\n";
   40:         print "親プロセス($$): クライアントの相手はプロセス $pid が行います。\n";
   41: 
   42:                 # 親プロセスはソケットをクローズ
   43:         close(CLIENT);
   44:         next;
   45:     } else {
とっても短いですね。まずメッセージを出力した後、 ソケット CLIENT を close します。クライアントとのやりとりは 子プロセスにまかせるのですから、もうクライアントとの 出入口であるソケットは必要ありません。親プロセスの担当は CLIENT_WAITING の方です。
なお、親プロセスと子プロセスは同じソケット CLIENT を持っていますが、 親子が同時に CLIENT に向けて出力しようとすると、クライアントには 両方のデータが届くようです。また、クライアントからのデータは 親プロセスに渡るか子プロセスに渡るかは、そのとき次第のようです。 ただし、こういったちょっと illegal なことは OS によって挙動が違う可能性が ありますので、親プロセスと子プロセスが同時に同じソケットに対して データのやりとりを行うことはお勧めできません。親か子のどちらかがソケットを close して、クライアントとのやりとりは他方にまかせましょう。
そして次の next によって、処理は
   27:     $paddr = accept(CLIENT, CLIENT_WAITING);
に移ります。親プロセスは子プロセスを生成したら、引続きポートを 監視し続けます。別のクライアントが接続してこないと、 ここで動作は止まります (ブロックしている)。 新たなクライアントがやってくると accept から戻り、再度 fork します。
次に子プロセス側です。
   45:     } else {
   46:                 # こっちは子プロセス
   47: 
   48:                 # クライアントに対してバッファリングしない
   49:         select(CLIENT); $|=1; select(STDOUT);
   50:         while (<CLIENT>){
   51:             print "子プロセス($$): メッセージ $_";
   52:                 # クライアントにメッセージを返す
   53:             print CLIENT $_;
   54:         }
   55:         close(CLIENT);
   56:         print "子プロセス($$): 接続が切れました。終了します。\n";
   57:                 # ポートの監視は親プロセスが行っているので、
   58:                 # クライアントとのやりとりが終了すれば exit
   59:         exit;
   60:     }
やっていることは、ほとんど前バージョンと違いはありませんが、大事なのは最後の
   59:         exit;
です。ポートを見張る仕事は親プロセスが行うのですから、 子プロセスはクライアントとのやりとりが終了すれば exit で自分自身のプロセスを終了させます。もし exit が ないと、クライアントからの接続が終っても、意味のない プロセスが残ってしまいます。

この改良版を動かして、同時に複数の telnetで接続してみてください。 どの telnet でも、タイプした文字が即座に返ってくるはずです。

listen と accept

最初の echo サーバの説明では、listen と accept については軽く触れただけでした。 ここで詳しい説明をしておきましょう。
   20: listen(CLIENT_WAITING, SOMAXCONN)
   21:      or die "listen: $!";
   27:     $paddr = accept(CLIENT, CLIENT_WAITING);
listen というのは、OS に対して「ポートに接続してきたクライアントとの コネクションを確立しておいてね」と命令しているのです。

ここで思い出して欲しいのですが、非マルチスレッド版 echo サーバ (最初に作った echo サーバ)に対して複数の telnet で接続すると、 後から接続した方は、前のクライアントが終了するまで 待たされはしたものの、ちゃんとデータのやりとりはできていましたよね? 「今このクライアントの相手をしているから、あなたはダメ」なんて言われて 接続拒否、なんてことはありませんでした。

これは、クライアントが待たされていたときには、既にコネクションは 確立されていたからです。OS は一つのポートに同時に複数の 接続要求があると、片っ端からコネクションを確立していきます。 そして先に来たクライアントから順に、待ち行列に登録していきます。

ちなみに、その待ち行列の長さを指示しているのが、listen の第二引数である SOMAXCONN です。これを上回る数のクライアントが接続してきた場合は、 OS はそのクライアントは無視します。

しかし listen した時点では、サーバプロセスからはクライアントがやってきたことを 知ることはできません。コネクションの確立は、あくまでもOSが陰でやっていることなのです。

一方、accept というのは、待ち行列の先頭のクライアントと繋がった出入口(=ソケット)を 新しく生成し、そのクライアントを待ち行列から外すシステムコールです。 accept した時点で、初めてサーバプロセスはクライアントが待っていたことを 知ることができます。

イメージしにくい方のために、受け付け嬢の例え話をしましょう。

  • お客さん … クライアント
  • 受け付け嬢 … OS
  • 担当社員 … サーバ
とします。

お客さんが担当の社員に会いに、会社にやってきます (connect)。

お客さんはまず受け付け嬢のところに向かいます。 受け付け嬢はお客さんを来客者名簿に登録し、 「担当の者が参りますので、しばらくお待ち下さい」 と言うのです。これが listen です。

担当社員が accept すると、お客さんとの話が始まります。 その間に新たなお客さんがやってくると、受け付け嬢のところで待たされます。 担当社員とお客さんの話が終わり、再び accept するまでお客さんは 受け付け嬢のところで待たなければいけません。

つまり、最初の echo サーバは、担当社員が1人しかいなかったので、 同時に複数のお客さんを相手にすることができなかったのです。 しかし、マルチスレッド版 echo サーバでは、担当社員が複数存在します。 複数の社員が accept することで、受け付け嬢のところでお客さんが 待たなくてもよい、というわけです。

backlog

   20: listen(CLIENT_WAITING, SOMAXCONN)
SOMAXCONN について説明しましょう。ここに指定する値は backlog といって、 まだ accept されていないコネクション (OS が待たせているコネクション) の 最大数を指定するものです。もし backlog 以上のクライアントが同時に connect してきた場合は、何もレスポンスを返しません。
先の受け付け嬢の例え話を出すと、お客さんがやってくると 受け付け嬢はお客さんを来客者名簿に登録し、 「担当の者が参りますので、しばらくお待ち下さい」と対応します。 しかし、担当者を待っているお客さんが backlog の数を越えてしまうと 処理しきれなくなるので、お客さんを無視します (レスポンスを返さない)。
直接数字を指定してもいいですし、このように SOMAXCONN を指定すると、 OS が許す最大値を意味します。FreeBSD 2.2.7-RELEASE では /usr/include/sys/socket.h で
#define   SOMAXCONN   128
と定義されているので、
listen(CLIENT_WAITING, SOMAXCONN)
listen(CLIENT_WAITING, 128)
は同じ意味です。

ただし、注意しなければいけないのは、ここで指定した値がそのまま 使われないかもしれない、ということです。backlog の解釈は OS によって異なり、

  • BSD 系 OS では、backlog に 1.5 を乗じた値が使われる
  • Solaris 2.6 や HP-UX では、適当な係数を乗じた値が使われる
  • Linux では backlog に指定した値がそのまま使われる
  • Solaris2.5.1 では +1 した値が使われる
などと解釈されます。 特に理由がなければ SOMAXCONN を指定しておくといいでしょう。

なお、このスクリプトの中で SOMAXCONN という定数を設定しているのは、 Socket モジュールです。


ちなみに、FreeBSD 2.2.7-RELEASE では、listen すると 以下のカーネルソース部分に処理が渡ります。

@kern/uipc_syscalls.c (/usr/src/sys/kern/uipc_syscalls.c)
int listen(p, uap, retval){
    ....
    return (solisten((struct socket *)fp->f_data, uap->backlog));
}
ここでは下請け関数 solisten の2番目の引数に backlog をそのまま渡しています。
@kern/uipc_socket.c
int solisten(so, backlog){
    ...
    if (backlog < 0 || backlog > somaxconn)
            backlog = somaxconn;
    so->so_qlimit = backlog;
    ....
}
solisten では backlog の値をチェックして 待ち行列の最大数を表す so_qlimit に代入します。
@kern/uipc_socket2.c
struct socket *sonewconn1(head, connstatus){
    if (head->so_qlen > 3 * head->so_qlimit / 2)
        return ((struct socket *)0);
クライアントから接続されたときは、 まだ accept されていないコネクション数 so_qlen が so_qlimit の 1.5倍 (3分の2) より多ければ 何もせず return します。

SOMAXCONN は 128 なので、まだ accept していない コネクションは 192 個 (=128*1.5) 待たせておけるわけです。 なお、accept 済のコネクションはこの中に含まれません。

ああ、オープンソースって素晴らしい。

前へ << echo サーバを作ってみよう (2) echo サーバを作ってみよう (4) >> 次へ

ご意見・ご指摘は Twitter: @68user までお願いします。