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

前へ << echo サーバを作ってみよう (3) FTP クライアントを作ってみよう (1) >> 次へ

ファイルディスクリプタ

この項は少し難しいので、おなかいっぱいな人は先に進んで下さい。

前節では、fork を使ったマルチスレッド版 echo サーバを作りました。 一つ一つのプロセスは単純な動作しかしていないので、作るのも 理解するのも簡単なのですが、欠点は「プロセス数が増加しすぎること」です。

クライアントが接続していない状態でも 1 プロセス、 5 クライアントが接続していると 6 つものプロセスが実行されることになります。

今度は select を使って、全てを 1 つのプロセスの中で処理することにしましょう。 ここでいう select とは 4引数 select のことです。 perl には 1引数の select と 4引数の select があります。 1引数 select はデフォルトのファイルハンドルを選択する関数ですが、 4引数 select とは全く関係ありません。

まず、 C言語のファイル入出力関数を使おう を読んで、ファイルの入出力はファイルディスクリプタという番号で 制御されることを理解して下さい。 perl では <STDIN> とか <CLIENT> などのファイルハンドルで ファイルやソケットとの入出力ができますが、perl の内部では やはりファイルディスクリプタという番号で処理されています。

perl でファイルディスクリプタの番号を調べるには、fileno という関数を使います。

#!/usr/local/bin/perl

printf("fileno(STDIN)=%d\n", fileno(STDIN));
printf("fileno(STDOUT)=%d\n", fileno(STDOUT));
printf("fileno(STDERR)=%d\n", fileno(STDERR));

open(IN, "a");    # 常にファイルのオープンに成功するものと仮定します
printf("fileno(IN)=%d\n", fileno(IN));
open(OUT, ">b");  # 常にファイルのオープンに成功するものと仮定します
printf("fileno(OUT)=%d\n", fileno(OUT));
実行結果は
fileno(STDIN)=0
fileno(STDOUT)=1
fileno(STDERR)=2
fileno(IN)=3
fileno(OUT)=4
となります。同様にソケットについても fileno の結果を見てみましょう。
use Socket;
socket(SOCKET, PF_INET, SOCK_STREAM, 0);
printf("fileno(SOCKET)=%d\n", fileno(SOCKET));
結果は
fileno(SOCKET)=5
となります。つまり、ソケットもファイルハンドルも、 ファイルディスクリプタという番号で識別されているわけです。 C言語のファイル入出力関数を使おうC言語でHTTPクライアントを作ってみよう を見ていただけるとわかりますが、
  • ファイルを扱う場合は open でファイルディスクリプタを得る。
  • ソケットを扱う場合は socket でファイルディスクリプタを得る。
という違いはあるものの、その後はファイルとソケットを区別することなく、read・write で 書き込み/読み込み・データ送受信ができます。

4 引数 select

次に、4 引数 select について解説します。 これを使うと、指定したファイルディスクリプタにデータがあるかどうかを調べることができます。 普通に
$in = <SOCKET>;
とすると、ソケットにデータが入っている場合は、その内容が $in に代入されますが、もしソケットにデータがない場合はブロックされてしまいます。 つまり、データが送られてくるまで永久に他の仕事ができなくなるのです。 そこで、select を使ってソケットから読み込む前に「ソケットに読み込むべきデータがあるかどうか」 を調べることにしましょう。select は
select($read_bits, undef, undef, $timeout)
という書式を取ります。 $read_bits には、調べたいディスクリプタの情報を事前にセットしておきます。 具体的には、
  • ファイルディスクリプタ 0 番について調べたい場合は 0 ビット目を 1 にする
  • ファイルディスクリプタ 1 番について調べたい場合は 1 ビット目を 1 にする
  • ファイルディスクリプタ 2 番について調べたい場合は 2 ビット目を 1 にする
となります。 例えば、ファイルディスクリプタ 3 に読み込むべきデータがあるかどうかを調べたい場合は $read_bits の 3 ビット目を 1 に、つまり `00001000' とします (右端が 0 ビット目)。 ここでは複数のファイルディスクリプタを指定することもできます。 例えば、ファイルディスクリプタ 3 番・4 番・5番について調べたい場合は $read_bits を `00111000' とします。そして
$ret = select($read_bits, undef, undef, $timeout);
を実行すると、OS は $read_bits の内容を読んで、指定されたディスクリプタに 読み込むべきデータがあるかどうかを調べます。 そして読み込むべきデータがあるものだけ $read_bits のビット値を 1 にします (select は $read_bits の値を書き換えます)。

つまり、$read_bits を `00111000' として

$ret = select($read_bits, undef, undef, $timeout);
を実行したとき、select によって書き換えられた $read_bits
  • `00000000' ならば、どのファイルハンドルにも読み込むべきデータは存在しない
  • `00001000' ならば、ディスクリプタ 3 番に、読み込むべきデータが存在する
  • `00011000' ならば、ディスクリプタ 3 番と 4 番に、読み込むべきデータが存在する
  • `00101000' ならば、ディスクリプタ 3 番と 5 番に、読み込むべきデータが存在する
  • `00111000' ならば、ディスクリプタ 3 番・4 番・5 番に、読み込むべきデータが存在する
ということです。select を実行するたびに $read_bits の値が書き換えられるので、 次の select を実行する前に、$read_bits を最初の値にセットしなおさないといけません。

$timeout には、タイムアウトまでの秒数を設定します。 $timeout=5 ならば、5秒待ってもどのディスクリプタも入力可能にならなかった場合は select から戻ります。5秒の間に、あるディスクリプタから読み出し可能になると、 即座に (5秒待たずに) select から戻ります。

なお、$timeout には 0.1 や 0.01 などの細かな値を設定することができます。 ただし、0.01 を指定したからといって、正確に 0.01 秒間 待ってくれることを 期待しない方がよいでしょう。あくまでも目安の時間です。
$timeout として undef を指定すると、どれかのディスクリプタが読み出し可能になるまで ブロックされます。もしどのディスクリプタも読み出し可能にならなければ、 永遠に select から戻ってきません。

ビット操作

4 引数 select を使う際は、ビット単位の操作が必要になりますので、 perl でビット操作を行う方法を説明しておきます。任意のビットを取り出すには vec という関数を使います。
vec($var, $bit, $len)
は、変数 $var の $bit 目から $len ビットを取り出します。 $var が `00110111' のとき、
vec($var,0,3) は `111'
vec($var,2,2) は `01'
vec($var,3,1) は `0'
となります。ビットを設定するには、vec を左辺値として使います。 $var が `00000000' のとき、
vec($var,0,1)=1 とすると $var は `00000001'
vec($var,1,1)=1 とすると $var は `00000010'
vec($var,2,1)=1 とすると $var は `00000100'
となります。
ここで言う `00000001' などは、'00000001' という文字列を 代入しているわけでありません。変数の内容をビット単位で操作しているのです。

select 版 マルチスレッド echo サーバ

echo-server-select.pl

    1: #!/usr/local/bin/perl -w
    2: 
    3: # $Id: echo-server-select.pl,v 1.2 2002/02/05 17:53:08 68user Exp $
    4: 
    5: use Socket;     # Socket モジュールを使う
    6: 
    7: $port = 5000;
    8:                 # ソケット生成
    9: socket(CLIENT_WAITING, PF_INET, SOCK_STREAM, 0)
   10:      || die "ソケットを生成できません。$!";
   11: 
   12:                 # ソケットオプション設定
   13: setsockopt(CLIENT_WAITING, SOL_SOCKET, SO_REUSEADDR, 1)
   14:      || die "setsockopt に失敗しました。$!";
   15: 
   16:                 # ソケットにアドレス(=名前)を割り付ける
   17: bind(CLIENT_WAITING, pack_sockaddr_in($port, INADDR_ANY))
   18:      || die "bind に失敗しました。$!";
   19: 
   20:                 # ポートを見張る
   21: listen(CLIENT_WAITING, SOMAXCONN)
   22:      || die "listen: $!";
   23: 
   24: print "ポート $port を見張ります。\n";
   25: 
   26: %data_sockets = ();  # 現在有効なデータコネクション用のハッシュテーブル
   27: $client_num = 0;     # クライアントの通し番号
   28: 
   29: $rin = &set_bits(CLIENT_WAITING);  # ビット列を生成
   30: 
   31: while (1){
   32:     $ret = select($rout=$rin, undef, undef, undef);
   33:     printf("\$ret=$ret \$rout=%s,\$rin=%s\n", &to_bin($rout), &to_bin($rin));
   34:     
   35:     if ( vec($rout, fileno(CLIENT_WAITING), 1) ){   # 新たにクライアントがやってきた
   36: 
   37:                       # ソケット名は毎回違う名前にする
   38:         $new_socket = "CLIENT_$client_num";
   39:         $sockaddr = accept($new_socket, CLIENT_WAITING);
   40: 
   41:                 # ホスト名、IPアドレス、クライアントのポート番号を取得
   42:         ($client_port, $client_iaddr) = unpack_sockaddr_in($sockaddr);
   43:         $client_hostname = gethostbyaddr($client_iaddr, AF_INET);
   44:         $client_ip = inet_ntoa($client_iaddr);
   45: 
   46:         print "接続: $client_hostname ($client_ip) ポート $client_port\n";
   47:         print "ソケット $new_socket を生成します。\n";
   48: 
   49:                 # クライアントに対してバッファリングしない
   50:         select($new_socket); $|=1; select(STDOUT);
   51: 
   52:                 # 接続中のクライアントをテーブルに登録
   53:         $data_sockets{$new_socket} = 1;
   54: 
   55:                 # select に渡すビット列を更新
   56:         $rin = &set_bits(CLIENT_WAITING, keys %data_sockets);
   57:         $client_num++;
   58: 
   59:     } elsif ( $ret ){ # 接続中のクライアントから、データが送信されてきた
   60: 
   61:         foreach $sock ( sort keys %data_sockets ){  # どのクライアントかを一つずつ確かめる
   62:             print "  check... $sock\n";
   63:             if ( vec($rout, fileno($sock), 1) ){
   64:                 if ( $in = <$sock> ){            # 1行読んでそのまま返す
   65:                     print "    $sock からの入力 .. $in";
   66:                     print $sock "$in";
   67:                 } else {                         # エラー発生=コネクション切断
   68:                     print "    コネクション切断 $sock\n";
   69:                     close($sock);                # ファイルハンドルを close
   70:                     delete $data_sockets{$sock}; # テーブルから削除
   71:                                                  # select に渡すビット列を更新
   72:                     $rin = &set_bits(CLIENT_WAITING, keys %data_sockets);
   73:                 }
   74:             }
   75:         }
   76:     }
   77: }
   78: 
   79: 
   80: #----------------------------------------------------
   81: # 1個以上のファイルハンドルを受け取り、fileno で各ファイルハンドルの
   82: # ディスクリプタ番号を調べ、それに対応するビットを立てたデータを返す。
   83: # 例えば
   84: #   fileno(CLIENT_WAITING)==3
   85: #   fileno(CLIENT_1)      ==4
   86: #   fileno(CLIENT_3)      ==6
   87: # のとき、
   88: #   &set_bits(CLIENT_WAITING, CLIENT_1, CLIENT_3)
   89: # は
   90: #   01011000
   91: # というデータを返す。
   92: 
   93: sub set_bits {
   94:     @sockets = @_;
   95: 
   96:     print "select に渡すビット列 \$rin を生成します。\n";
   97:     $rin="";
   98:     foreach $sock (@sockets){
   99:                             # $rin の、右から数えて fileno($sock) 番目のビットを1にする。
  100:         vec($rin, fileno($sock), 1)=1;
  101:         printf("  fileno($sock) は %d なので \$rin は %s になります。\n",
  102:                fileno($sock),
  103:                &to_bin($rin),
  104:               );
  105:     }
  106:     return $rin;
  107: }
  108: 
  109: 
  110: #----------------------------------------------------
  111: # 引数を受け取り、2進数の文字列(010111...)に変換して返す。
  112: 
  113: sub to_bin {
  114:     return unpack "B*", $_[0];
  115: }
まず、おおまかな流れを説明します。
  1. ソケット CLIENT_WAITING を作り、ポート5000 を listen します。
  2. CLIENT_WAITING に対して、4引数 select で入力があるか調べます。
  3. 入力があった場合、つまりクライアントが接続してきた場合は accept して、データコネクション用ソケット CLIENT_0 を生成します。
  4. CLIENT_WAITING と CLIENT_0 に対して、4引数 select で入力があるか調べます。 CLIENT_WAITING に対して入力があった場合は、3 と同様に accept して、新たなデータコネクション用 ソケット CLIENT_1 を生成します。その後、新たなコネクションが張られるたびに、 CLIENT_2、CLIENT_3 … というソケットが生成されます。
  5. CLIENT_0 や CLIENT_1 などのデータコネクションが読み出し可能なら、 ソケットから1行読み出して、ソケットに同じ内容を送り返します (echo サーバだから)。

ん〜文章で説明してもわけわからんな。では、実際に動かしてみましょう。
% ./echo-server-select.pl
ポート 5000 を見張ります。
select に渡すビット列 $rin を生成します。
  fileno(CLIENT_WAITING) は 4 なので $rin は 00010000 になります。
これは
   24: print "ポート $port を見張ります。\n";
   25: 
   26: %data_sockets = ();  # 現在有効なデータコネクション用のハッシュテーブル
   27: $client_num = 0;     # クライアントの通し番号
   28: 
   29: $rin = &set_bits(CLIENT_WAITING);  # ビット列を生成
   30: 
   31: while (1){
   32:     $ret = select($rout=$rin, undef, undef, undef);
の部分が実行されたわけです。

ポート監視用ソケット CLIENT_WAITING のファイルディスクリプタは 4番だったので、&set_bits によって $rin の 4ビット目 (右端が0番目) が 1にセットされました。 4 引数 select のタイムアウトとして undef を指定したので、 ここで実行は止まっています。クライアントが接続してくるまで このままブロックし続けるのです。

select の第一引数 select($rout=$rin,..) というのは、perl のちょっとした小技です。 select($rin,...) とすると、select が $rin の値を書き換えてしまうので、 次の select を実行する前に再度 $rin を初期化しないといけません。 しかし $rout=$rin とすると、$rout$rin が代入され、select は $rout を書き換えます。つまり $rin の値は破壊されないので、 再度 select($rout=$rin,...) と書くことができます。
では、別のコンソールから
% telnet localhost 5000
として、ポート 5000 に接続して下さい。 すると echo サーバは
$ret=1 $rout=00010000,$rin=00010000
接続: localhost (127.0.0.1) ポート 57603
ソケット CLIENT_0 を生成します。
というデバッグ情報を出力します。この部分に対応するコードは
   33:     printf("\$ret=$ret \$rout=%s,\$rin=%s\n", &to_bin($rout), &to_bin($rin));
   34:     
   35:     if ( vec($rout, fileno(CLIENT_WAITING), 1) ){   # 新たにクライアントがやってきた
   36: 
   37:                       # ソケット名は毎回違う名前にする
   38:         $new_socket = "CLIENT_$client_num";
   39:         $sockaddr = accept($new_socket, CLIENT_WAITING);
   40: 
   41:                 # ホスト名、IPアドレス、クライアントのポート番号を取得
   42:         ($client_port, $client_iaddr) = unpack_sockaddr_in($sockaddr);
   43:         $client_hostname = gethostbyaddr($client_iaddr, AF_INET);
   44:         $client_ip = inet_ntoa($client_iaddr);
   45: 
   46:         print "接続: $client_hostname ($client_ip) ポート $client_port\n";
   47:         print "ソケット $new_socket を生成します。\n";
   48: 
   49:                 # クライアントに対してバッファリングしない
   50:         select($new_socket); $|=1; select(STDOUT);
   51: 
   52:                 # 接続中のクライアントをテーブルに登録
   53:         $data_sockets{$new_socket} = 1;
です。$rout が `00010000' となっています。つまり 「ファイルディスクリプタ 4 番に入力があった」ということです。 ソケット監視用ポートへの入力があるということは、新たなクライアントがやってきたということです。 デバッグ情報により、クライアントは localhost(127.0.0.1) のポート 57603 から 接続していることがわかります。

そしてクライアントと通信するためのソケットを生成します。 このクライアントとのデータ通信用ソケットは `CLIENT_0' です。 現在有効なデータ通信用ソケットを記録しておくため、 ハッシュ %data_sockets にソケット名を登録します。

さて、この時点でのソケットは CLIENT_WAITING (ポート監視用ソケット) と CLIENT_0 (クライアントとの通信用) の 2つです。 2 つのソケットに対して読み出し可能かどうかを調べるため、$rin を更新します。 その部分が

   55:                 # select に渡すビット列を更新
   56:         $rin = &set_bits(CLIENT_WAITING, keys %data_sockets);
   57:         $client_num++;
です。これに対応するデバッグ出力は
select に渡すビット列 $rin を生成します。
  fileno(CLIENT_WAITING) は 4 なので $rin は 00010000 になります。
  fileno(CLIENT_0) は 5 なので $rin は 00110000 になります。
になります。4ビット目と5ビット目 (右端が0ビット目) を 1 にしたので、 ファイルディスクリプタ 4 番 (CLIENT_WAITING) と 5番 (CLIENT_0) が 読み出し可能かどうか調べることになります。


では次に、別の telnet コマンドを実行して、ポート 5000 に接続して下さい。 すると、
接続: localhost (127.0.0.1) ポート 57631
ソケット CLIENT_1 を生成します。
select に渡すビット列 $rin を生成します。
  fileno(CLIENT_WAITING) は 4 なので $rin は 00010000 になります。
  fileno(CLIENT_0) は 5 なので $rin は 00110000 になります。
  fileno(CLIENT_1) は 6 なので $rin は 01110000 になります。
となります。1つクライアントが増え、ソケット CLIENT_1 が 生成されました。CLIENT_1 のファイルディスクリプタ番号は 6 なので、 それに対応する $rin の 6 ビット目が 1 になります。

では 2つの telnet 上で適当に文字をタイプして下さい。 最初に実行した telnet 上で abc とタイプすると、 echo-server-select.pl は

$ret=1 $rout=00100000,$rin=01110000
  check... CLIENT_0
    CLIENT_0 からの入力 .. abc
  check... CLIENT_1
と出力します。後から実行した telnet 上で def とタイプすると
$ret=1 $rout=01000000,$rin=01110000
  check... CLIENT_0
  check... CLIENT_1
    CLIENT_1 からの入力 .. def
となります。1つのプロセスでマルチスレッドサーバが実現できていることを確認して下さい。

クライアントからデータが送らると、ソケットから読み出し可能になるのですが、

   32:     $ret = select($rout=$rin, undef, undef, undef);
select から処理が戻ってきた時点では、クライアントからデータが送られてきたのか、 それとも新たなクライアントが接続してきたのかわかりません。しかし、
   35:     if ( vec($rout, fileno(CLIENT_WAITING), 1) ){   # 新たにクライアントがやってきた
で、CLIENT_WAITING に対応する 4 ビット目は 1 ではないことがわかるので、
   59:     } elsif ( $ret ){ # 接続中のクライアントから、データが送信されてきた
に処理が行きます。
   61:         foreach $sock ( sort keys %data_sockets ){  # どのクライアントかを一つずつ確かめる
   62:             print "  check... $sock\n";
   63:             if ( vec($rout, fileno($sock), 1) ){
   64:                 if ( $in = <$sock> ){            # 1行読んでそのまま返す
   65:                     print "    $sock からの入力 .. $in";
   66:                     print $sock "$in";
   67:                 } else {                         # エラー発生=コネクション切断
   68:                     print "    コネクション切断 $sock\n";
   69:                     close($sock);                # ファイルハンドルを close
   70:                     delete $data_sockets{$sock}; # テーブルから削除
   71:                                                  # select に渡すビット列を更新
   72:                     $rin = &set_bits(CLIENT_WAITING, keys %data_sockets);
   73:                 }
   74:             }
   75:         }
ここでは、ハッシュ %data_sockets から一つずつソケット名を $sock に 代入し、どのソケットに対応するビットが 1 になっているかを
   63:             if ( vec($rout, fileno($sock), 1) ){
で調べます。この if 文が真になったら、そのソケットは読み出し可能である、ということです。 しかし、コネクションが切断された場合も考慮しなくてはいけません。 実行中の telnet を
^] (Ctrl-]を入力)
telnet> quit
Connection closed.
と、強制的に終了すると、そのコネクションは切断されますが、 この場合も $rout の対応するビットが 1 になるのです。 ソケットからデータが送られてきたのか、それともコネクションが切断されたのかは、
   64:                 if ( $in = <$sock> ){            # 1行読んでそのまま返す
   65:                     print "    $sock からの入力 .. $in";
   66:                     print $sock "$in";
   67:                 } else {                         # エラー発生=コネクション切断
   68:                     print "    コネクション切断 $sock\n";
   69:                     close($sock);                # ファイルハンドルを close
   70:                     delete $data_sockets{$sock}; # テーブルから削除
   71:                                                  # select に渡すビット列を更新
   72:                     $rin = &set_bits(CLIENT_WAITING, keys %data_sockets);
   73:                 }
というふうに、ソケットからの読み出し $in = <sock> がエラーになったか どうかで判別できます。 エラーにならずに正常に読み込めたら、ソケットにそのまま内容を返します (echo サーバだから)。 エラーになったらコネクションが切断されたということなので、 ソケットを close し、ハッシュ %data_sockets からファイルハンドルを 削除、select に渡す $rin を更新します。


大体理解したら、
   32:     $ret = select($rout=$rin, undef, undef, undef);
の行を
$ret = select($rout=$rin, undef, undef, 1);
に変えてみて下さい (最後の undef を 1 にする)。

すると、1秒待ってもソケットが読み出し可能にならないときは select から処理が戻り、時々刻々と $rin$rout の 値を出力します。

telnet で接続したり、文字列を送信するたびに $rin$rout の値が変わっていくのがわかると思います。

前へ << echo サーバを作ってみよう (3) FTP クライアントを作ってみよう (1) >> 次へ

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