preforkサーバーを作ってみよう

さて、前回は、fork するとファイルディスクリプタ(以下fdと表記)は複製されるけどオープンファイル記述は共有されるというのを見ました。これを利用して、preforkモデルのサーバーを実際に書いてみましょう。

tcp socketはファイルである

以前見たとおり、Linuxではプロセスの入出力はファイルを通じて行います。とうぜん、ネットワークごしに入出力する tcp socket もファイルです。ここで「ファイルです」が意味するのは、プロセスがソケットを通じて入出力をしようと思えば、socket の fd を通じて write や read を行うということですね。では、実際に socket がファイルであるところを見てみましょう

# -*- coding: utf-8 -*-
require "socket"

# 12345 portで待ち受けるソケットを開く
listening_socket = TCPServer.open(12345)

# ソケットもファイルなので fd がある
puts listening_socket.fileno

# ひとまずなにもせず閉じる
listening_socket.close

上記のような Ruby スクリプトを実行してみると、openしたソケットがfdを持つことが確認できるかと思います。

クライアントの接続を受け入れる

今は socket を開いてなにもせずにすぐ閉じてしまいましたが、今度はクライアントの接続を受け入れてみましょう。

# -*- coding: utf-8 -*-
require "socket"

# 12345 portで待ち受けるソケットを開く
listening_socket = TCPServer.open(12345)

puts listening_socket.fileno

# acceptでクライアントからの接続を待つ
# 接続されるまでブロックする
puts "accepting..."
socket = listening_socket.accept
puts "accepted!"

# 接続されると新しいsocketが作られる
# このsocketを通じてクライアントと通信する
# あたらしいsocketなのでfdの番号がlistening_socketと違う
puts socket.fileno

# なにもせずクライアントとのコネクションを切る
socket.close

# 待ち受けソケットも閉じる
listening_socket.close

上記のような Rubyスクリプトを適当な名前で作って、実行してみましょう。listening_socket の fd が出力されたあとに、accepting…と出力されて、そこで止まってしまいプロンプトが帰ってこないかと思います。なぜこういう動きをするか、いままでこのドキュメントを読み進めてきたみなさんはもう理解できますね。listen している socket で accept を呼び出すと、プロセスはそこでブロックして、クライアントからの接続を待ちます。そこでブロック中になっているため、プロセスがそれ以上進まないわけですね。

では、今度はそのままターミナルをもうひとつ開いて、ここにコネクションを貼ってみましょう。

# べつのターミナルで
$ telnet localhost 12345

上記のように、 telnet コマンドで、さっきのプロセスが待ち受けてる 12345 ポートに接続してみましょう。一瞬で接続が切られてしまうかと思います。

一方、今度はさっきプロンプトが返ってこないままになっていたターミナルを再度見てみてください。 accepted! のあとに、listening_socket の fd とはまた違う数字の fd が出力されて、プロンプトが返ってきたかと思います。これは、telnetでクライアントから接続されたことにより、accept の行でブロック中になっていたプロセスが動き出したためです。accept はクライアントから接続されるとブロック中から抜け出し、新しい socket を作り出して返します。サーバーのプロセスは、この新しい socket を通じてクライアントと通信をします。この socket にたいして write をすればクライアントへデータを送ることになるし、この socket から read をすれば、クライアントからの入力を受け取るという感じですね。とうぜん、この socket を close するとクライアントとのコネクションは切断されます。

今回はなにもせずに socket を close したので、クライアント側(telnetコマンドを打った側)ではすぐにサーバーからコネクションが切られてしまったわけですね。

クライアントから送られてくるデータを読み込む

さっきはなにもせず socket を close してしまいましたが、今度はクライアントからデータが送られてきたらそれを読む、という動きにしてみましょう。

# -*- coding: utf-8 -*-
require "socket"

listening_socket = TCPServer.open(12345)

# クライアント受け入れ無限地獄
loop do
  puts "accepting..."
  socket = listening_socket.accept
  puts "accepted a client!"

  # クライアントからの入力受け取り無限地獄
  loop do
    # クライアントからの入力を1行読む
    # 入力されるまでブロックする
    line = socket.gets
    line.gsub!(/[\r\n]/,"") #改行コード捨てる

    # exitと入力されてたらソケット閉じてループを抜ける
    if line == "exit"
      socket.close
      puts "closed a connection!"
      break
    end

    # そうでなければ標準出力に出力
    puts line
  end
end

はい、ちょっと書き換えてみました。

ターミナルを立ち上げて、これを実行してみましょう。このターミナル上で動いてるのがサーバープロセスになります。今は accepting… が出力されたところでプロセスがブロックしてると思います。ここまではさっきとおなじですね。では、またべつのターミナルを開いて、telnetコマンドでサーバープロセスに接続してみましょう。

$ telnet localhost 12345

今度は切断されないと思います。

ではまたサーバープロセスが走ってるほうのターミナルを見てみましょう。"accepted a client"と出力されて、そこでプロセスがブロックしていると思います。line = socket.gets のところで、クライアントからのデータを読み込もうとしていますが、クライアントがまだなにもデータを送っていないのでここでブロックしているわけですね。

では今度は telnet のほうのターミナルで、なんかを入力して、改行してみましょう。

再度サーバープロセスのほうを見てみると、今 telnet で入力した一行が、標準出力に書き出されているのが見て取れると思います。

では telnet のほうに戻って(何度も往復してたいへんだ!)、今度は exit と入力して改行してみましょう。すると、サーバープロセスが socket を close したことにより、接続が切れるかと思います。

サーバープロセスのほうを見てみると、"closed a connection!" が出力されたあと、また "accepting…" が出力されて、ブロックしてると思います。これは、break でクライアントからの入力受け取り無限地獄を抜けたはいいけれど、今度はクライアント受け入れ無限地獄loopによりまた listening_socket.accept しているところでブロックしてるわけですね。

動きを確認したら、サーバープロセスのほうで Ctrl + C を入力して、プロセスを終了してあげましょう。

いまは puts line で標準出力にクライアントからの入力を出力していますが、この行を socket に対する書き込みにすれば、いわゆるエコーサーバーとして動くプロセスになります。そのあたりは宿題とするので、自分で書き換えて動きを見てみてください。

このサーバーは出来損ないだ、たべられないよ

さて、これでクライアントから接続を待って、クライアントに接続されたらそのクライアントとネットワーク越しに入出力することができました。しかし、このサーバーには欠陥があります。わかりますか?

そう、このままでは、同時にひとつのクライアントしか処理できないのです。クライアントからの接続を accept したあとは、このプロセスは「クライアントの入力受け取り無限地獄」にいます。その無限地獄にいる限り、このプロセスは次の listening_socket.accept に到達することはありません。なので、「クライアントの入力受け取り無限地獄」を抜けるまでは新しく接続してこようとするクライアントを受け入れることができないのです。これは困りましたね。

じっさい、このサーバープロセスを立ち上げた状態で、さらにターミナルをふたつ立ち上げて、両方で

$ telnet localhost 12345

をしてみると、先に telnet したほうは普通に動くのだけれど、もういっこのほうはいくら入力してもサーバープロセスがうんともすんとも言わないのが見て取れると思います。

明日の同じ時間にここに来てください。本当のサーバーってやつを見せてあげますよ

べつに用意する食材もないので、明日の同じ時間を待つ必要はありません。すぐにコードを書き換えてしまいましょう。

# -*- coding: utf-8 -*-
require "socket"

number_of_workers = 3
listening_socket = TCPServer.open(12345)

number_of_workers.times do
  pid = fork

  if pid
    # 親プロセスは次々に fork で子プロセスを作る
    next
  else
    # 子プロセス

    # クライアント受け入れ無限地獄
    loop do
      puts "accepting..."
      # 子プロセスは全部ここでブロックする。
      socket = listening_socket.accept
      puts "accepted a client!"

      # クライアントの入力受け取り無限地獄
      loop do
        line = socket.gets
        line.gsub!(/[\r\n]/,"")

        if line == "exit"
          socket.close
          puts "closed a connection!"
          break
        end

        puts line
      end
    end
  end
end

# 子プロセスは無限ループしてるからここには届かない
# 親プロセスでは子プロセスの終了を待ち続ける
Process.waitall

listening_socket を作ったあとに、3回 fork するようにしてみました。親プロセスでは fork したあとに何もしないで子プロセスの終了を待ちます。一方、子プロセスでは、 accept を呼んでブロックしていますね。

さて、ここで前回の内容が役に立ちますよ。

listening_socket はファイルでした。そのため、fd を持ちます。そして、forkした場合、fd は複製されるけど、複製された fd は複製もとと同じオープンファイル記述を参照しているのでしたね。

というわけで、今、listening_socket を作ったあとに fork したことで、オープンファイル記述、つまり「ソケットは12345 portで待ち受けてるよ」と書かれた「ファイルどうなってたっけメモ」を全てのプロセスで共有している状態になっているわけです。ここまではいいですか?

そして、親プロセスではその listening_socket に対して何もせず、子プロセスで accept していますね。この3つの子プロセスは、「クライアントからの接続を獲得して新しい socket を作ろう」と身構えてブロックしている状態なわけです。ここで、あるクライアントが 12345 ポートに対して接続してきたとしましょう。なにが起こりますか?

3つの子プロセスは、それぞれがクライアントからの接続を受け入れて新しい socket を作ろうとしますが、オープンファイル記述が共有されているため、クライアントからの接続を受け入れられるのはたったひとつの子プロセスだけです。前回の内容を思い出して下さい。file を open したあと fork して、両方のプロセスでその file を読み込んだ場合、片方のプロセスでしか読み込むことができていなかったと思います。これと同じことが accept でも起こっているわけですね。

さて、首尾よく accept できて新しいソケットを獲得した子プロセスは、クライアントからの入力受け取り無限地獄へと突入します。というわけで、今接続してきたクライアントとのやり取りは、この子プロセスにまかせることができました。一方、残念ながら accept できなかった他の子プロセスは、さっきとおなじところでブロックしたままです。

さて、ここに、さらに新しいクライアントが接続してきた場合はどうなるでしょうか?子プロセスのうちひとつはまだクライアントからの入力受け取り無限地獄にいますが、ふたつのプロセスは accept でブロック中になっています。こんどはこのふたつのプロセスのうちのどちらかが accpet に成功して新しいソケットを作ってクライアントとやりとりすることになるわけです。

こんなふうに、あらかじめ子プロセスをいくらか fork しておいて、その子プロセスでクライアントからの接続を受け入れて処理するような仕組みを、「prefork」といいます。先に(pre)forkしておくサーバーってことですね。

さて、これで無事、同時に複数のクライアントからの接続を受け入れることが可能になりました。今回は 3 回forkしたので、同時接続数は 3 つまでですね。サーバープロセスの他にもターミナルをたくさん立ち上げて、それぞれで telnet localhost 12345 してみてください。3つまでは同時に処理できるけど、4つを超えると同時に処理できてないことが見て取れるかと思います。

今までの話で、preforkサーバーが書けて、さらにどうしてそんなことが可能なのかも理解できましたね!

preforkの利点、欠点

さて、上に見たように、prefork サーバーはひとつのプロセスがひとつのクライアントを受け持つようなアーキテクチャになっています。このことは、いくつかの利点と欠点をもたらします。

まず、上に見たように、worker(子プロセス)の数が少ないと、同時に処理できるクライアントの数が少なくなってしまいます。同時処理の数を増やしたければ、その数だけプロセスを生成する必要があるわけです。プロセスが生成されればそのぶんだけメモリーは消費されるので、この方法は意外と効率がよくないんですね。

一方で、アーキテクチャが単純なので、コードが追いやすいです。シンプルであることは、とても大切なことです。さらに、ひとつのクライアントがひとつのプロセスで処理されるためたとえばプロセスが暴走したとかそういうときの影響範囲がせまくなります。ひとつのプロセス内でたくさんのクライアントを受け持つと、そのプロセスがなんかおかしなことになったときに影響範囲が大きくなって困りますね。

次回予告

次回はちょっと話を戻して、forkした際に親が先に死んだり終わったりしたらどうなるのとかそういう話をしたいなって思います。