Skip to content

Latest commit

 

History

History
305 lines (208 loc) · 19.3 KB

File metadata and controls

305 lines (208 loc) · 19.3 KB

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 したほうは普通に動くのだけれど、もういっこのほうはいくら入力してもサーバープロセスがうんともすんとも言わないのが見て取れると思います。

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

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

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

# -*- 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
    # 子プロセス:クライアントからの接続を待つ
    puts "Child #{Process.pid} is accepting..."
    socket = listening_socket.accept
    puts "Child #{Process.pid} accepted a client!"
    
    # 簡単なメッセージを送って終了
    socket.puts "Hello from child #{Process.pid}!"
    socket.close
    exit
  end
end

# 親プロセスは子プロセスの終了を待つ
Process.waitall

この状態で実行して、別のターミナルから telnet localhost 12345 で接続すると、メッセージが表示されてすぐに切断されるはずです。一体なにが起こっているでしょうか?

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

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 できて新しいソケットを獲得した子プロセスは、自分の標準出力に"Child #{Process.pid} accepted a client!"を出力し、相手に対して"Hello from child #{Process.pid}!"を出力し、終了します。

動きが確認できたら、繰り返しtelnet localhost 12345を実行してみましょう。四度目のtelnet localhost 12345はエラーになるはずです。これは、子プロセスがみっつだけ実行されており、acceptしたsocketをcloseしたあとに終了しているので、もう「acceptするために待ち受けている子プロセス」が残っていないから、ということになります。

最後に、完全なpreforkサーバーに仕上げます:

# -*- 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 "Child #{Process.pid} accepting..."
      # 子プロセスは全部ここでブロックする
      socket = listening_socket.accept
      puts "Child #{Process.pid} accepted a client!"

      # クライアントの入力受け取り無限地獄
      loop do
        line = socket.gets
        # クライアントが接続を切った場合はnilが返る
        break if line.nil?
        
        line.gsub!(/[\r\n]/, "")

        if line == "exit"
          socket.close
          puts "Child #{Process.pid} closed a connection!"
          break
        end

        # エコーサーバーとして動作
        puts "Child #{Process.pid} received: #{line}"
      end
    end
  end
end

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

さて、さきほどは子プロセスは、エコーしたあと終了していましたが、今度は、二重の無限地獄に突入します。

内側のループ「入力受け取り無限地獄」を見てみましょう。クライアントから入力を受け取り、その入力が「exit」であれば、コネクションを切って(socketをcloseして)内側のループを抜けます。それ以外ならば、エコーサーバーとして動作し、クライアントからの次の入力を待ちます。

外側のループ「クライアント受け入れ無限地獄」のほうはどうでしょうか? クライアントとの接続が切れたら、再度acceptを行おうとしてそこでブロックする仕組みになっています。

さて、いま、このサーバーに対して、1クライアントが接続してきたとしましょう。このとき、3つの子プロセスが「acceptの取り合い」を行います。首尾よくaccpetできた子プロセスは、内側の「入力受け取り無限地獄」に入り、このクライアントに対してエコーサーバーとして振舞います。

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

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

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

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

preforkサーバーの利点と欠点

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

欠点:リソース効率の問題

1. 同時処理数の制限

  • worker(子プロセス)の数 = 最大同時接続数
  • 上の例では3つのプロセスなので、4つ目のクライアントは待機状態になる
  • 大量の同時接続に対応するには、その分だけプロセスを事前に生成する必要がある

2. メモリ使用量の問題

  • プロセス1つあたりそれぞれがメモリを消費
  • 1000の同時接続 = 1000個のプロセスがそれぞれにメモリを消費する
  • スレッドやイベント駆動型と比べてメモリ効率が悪い

3. プロセス生成・切り替えのオーバーヘッド

  • プロセスの生成・破棄にはそれなりのCPU時間が必要
  • プロセス間のコンテキストスイッチもコストが高い

利点:堅牢性とシンプルさ

1. アーキテクチャの単純さ

  • 各プロセスが独立しているため、コードの理解・デバッグが容易
  • プロセス間でメモリを共有しないため、データ競合が起きない
  • そのため、複雑な排他制御(ロック)が不要

2. 障害の影響範囲が限定的

  • 1つのプロセスがクラッシュしても、他のクライアントには影響しない
  • メモリリークが発生しても、そのプロセスが終了すれば完全にクリーンアップされる
  • バグがあるコードでも、影響は1つの接続に限定される

3. セキュリティの向上

  • プロセス間の分離により、セキュリティホールの影響を限定化
  • システムコールレベルでの分離が効いている

4. 安定性

  • 実績のあるアーキテクチャ(Apache HTTPDなどで長年使用)
  • プロセスの独立性により、予期しない相互作用が少ない

次回予告

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