Help us understand the problem. What is going on with this article?

Rubyで学ぶWebサーバーアーキテクチャ(Preforking, ThreadPool, イベント駆動モデル)

以下の様々なWebサーバーアーキテクチャパターンについて、Rubyの簡潔な実装を例示しながら紹介します。

  • シンプルなWebサーバー
  • マルチプロセスモデル
  • マルチスレッドモデル
  • マルチプロセスモデル(Preforking)
  • マルチスレッドモデル(Thread Pool)
  • イベント駆動モデル
  • ハイブリッドモデル

対象読者

  • TCP,HTTP,ソケットの概念を何となくは理解している
  • webサーバーの実装やパフォーマンスに興味がある

参考

この記事はこの本の後半の内容をhttpサーバーの例に変えつつ簡潔に説明したものになります。
より詳しい情報が必要な場合は、上記をお読みください。平易な言葉でrubyを使ってソケットプログラミングについて学べる良本です。

実装ではなく概論について知りたい場合は上記をどうぞ。(上記の記事を読んで、詳細な理解に自信がなかったためこの記事を書きました)

環境

ruby 2.5.1

シンプルなWebサーバー

まずはごくシンプルなWebサーバーを書いてみます。
この先の説明の簡略化のため、共通処理は前もって AbstractServer クラスに切り出しておきます。

AbstractServer
require 'socket'

class AbstractServer
  CHUNK_SIZE = 1024 * 16

  def initialize(port)
    @socket = TCPServer.new(port) # select(2), bind(2), listen(2)
    @handler = RequestHandler.new
    addr = @socket.local_address
    puts "Listening #{addr.ip_address}:#{addr.ip_port}"
  end

  class RequestHandler
    def handle(_request)
      # リクエストの処理。ここでは代わりにsleepしておく。
      sleep(1)

      # 今回はリクエストの中身は何も考慮せず、200を返す
      <<~HTTP
        HTTP/1.0 200 OK
        Content-Type: text/plain

        200
      HTTP
    end
  end
end
SerialServer
require './abstract_server'

class SerialServer < AbstractServer
  def run
    loop do
      client_socket = @socket.accept
      request = client_socket.readpartial(CHUNK_SIZE) # 本来は適切に中身をparseしつつ全てのデータを読み込む必要があるが、ここでは省略
      response = @handler.handle(request)
      client_socket.write(response)
      client_socket.close
    end
  end
end

SerialServer.new(3000).run

解説

ごくシンプルにソケット通信を行なっています。ソケットプログラミングの初学者向けに、サーバー側が行うべきソケットの処理を以下に整理します。

ソケットとはTCP通信(など)を行うためのファイルディスクリプタです。ファイルディスクリプタの仲間としては、標準入出力やファイルへの入出力があり、つまり標準入出力を扱うのと同じようにしてTCP通信を行うことができる、素晴らしい仕組みです。

ソケットは以下のシステムコール(OSへの命令)を使って扱うことができます。

システムコール 説明 対応するRubyのコード
socket(2) ソケットを作成する socket = Socket.new(:INET, :STREAM)
bind(2) ソケットに名前付け(IPとポート番号を指定) socket.bind(Socket.pack_sockaddr_in(3000, '0.0.0.0'))
listen(2) 接続を待つ socket.listen(LISTEN_QUEUE_MAX)
accept(2) 接続を受け付け、新しいソケットを作成する client = socket.accept
read(2) 読み込み socket.read socket.gets など
write(2) 書き込み socket.write socket.puts など
close(2) 接続を切る socket.close

SerialServer では愚直に上記の処理を行なっていることがお分かりかと思います。

※上記のうち、 socket(2), bind(2), listen(2) の処理は TCPServer.new(port) という便利なシンタックスシュガーを使っています。

socket(2)(2) は、 man ページのセクション番号です。セクション2がシステムコールとなっています。 引数ではありません。

性能検証

ab を使って簡単に性能を検証してみましょう。

$ ab -n 100 -c 10  http://0.0.0.0:3000/
# Requests per second:    1.00 [#/sec] (mean)

handle(request) にて、遅い外部API呼び出しを想定した sleep(1)があり、1リクエストを処理するのに1秒かかるので、1秒に捌けるリクエストの数は1つです。
sleep(1) さえなければ、このシンプルなサーバーでもそれなりの性能になりますが、シングルプロセスなのでCPUリソースを使い切ることはできなそうです。

マルチプロセスモデル

accept() の度に子プロセスを生成し、並列化してパフォーマンス向上を狙います。
コードはほぼ変わらず、 fork を使うだけでこれを達成できます。

require './abstract_server'

class ProcessPerConnectionServer < AbstractServer
  def run
    loop do
      client_socket = @socket.accept
      fork do
        request = client_socket.readpartial(CHUNK_SIZE)
        response = @handler.handle(request)
        client_socket.write(response)
        client_socket.close
      end
      client_socket.close
    end
  end
end

ProcessPerConnectionServer.new(3000).run

性能検証

$ ab -n 100 -c 10  http://0.0.0.0:3000/
# Requests per second:    8.98 [#/sec] (mean)

並列化されて、ほぼリクエストの並列度と同じ値になっています。

解説

先に挙げた問題は解決でき、パフォーマンスが向上したように見えますが、新しい問題として以下があります。

  • プロセスが無限に立ち上がってしまう。OSが生成できるプロセスの数には限度があるので、リクエストの並列数が一定数になった時点で死んでしまう
  • リクエストの度にプロセスを立ち上げるのはオーバーヘッドが大きい。

これらの問題について考える前に、マルチスレッドなサーバーについてみておきます。

マルチスレッドモデル

マルチプロセスと比較して、 forkThread.new になっただけなので、コードを省略します。
また、親プロセスで client_socket.close する必要はありません。

解説

マルチプロセスとマルチスレッドの違いについて、ここでは詳しく述べませんが、簡単には以下が言えます。

  • 特にRubyの場合、GILにより同時実行されるスレッドは必ず1つなので、本来の意味でのマルチスレッドにはなっていない。マルチコアを使いたい場合、マルチプロセスにする必要がある
  • スレッドの方が軽量である(コピーオンライトの仕組みがあるため、マルチプロセスでもマルチスレッドでもfork直後はメモリ管理における差異はあまりないが、時間がたつにつれマルチプロセスはメモリが太っていく※1)
  • マルチスレッドの場合、メモリが共有されるためプログラミングをする上で気をつけることが多い

※1 この辺について、結局どちらがどれだけ良いのかの知見がない

マルチプロセス・Preforkingモデル

シンプルなモデルが続きましたが、そろそろ本題に入っていきます。
先に挙げた問題を解決するため「プロセスを事前に立ち上げておく」という方式をとります。このモデルはunicornで使われているモデルになります。

このモデルでもう一つ特徴的なのは、 socket.accept を単一の親プロセスではなく、複数の子プロセスが行う という点です。
1つのリクエストが複数のプロセスで処理されないか不安になってしまうところですが、ここはカーネル側で処理してくれるため問題なく、すなわちカーネル側がリクエストをロードバランシングしてくれる形になります。

require './abstract_server'

class PreforkingServer < AbstractServer
  CONCURRENCY = 10

  def run
    CONCURRENCY.times.each { spawn_child }

    # 本当は子プロセスの監視を行う必要がありますが、簡略化のためここでは省略
    sleep
  end

  def spawn_child
    fork do
      loop do
        client_socket = @socket.accept
        request = client_socket.readpartial(CHUNK_SIZE)
        response = @handler.handle(request)
        client_socket.write(response)
        client_socket.close
      end
    end
  end
end

PreforkingServer.new(3000).run

このモデルの考察は一旦置いておきます。

マルチスレッド・Thread Poolモデル

Preforkingモデルをスレッドでも行うモデルです。
実装は forkThread.new に置き換えるだけなので、ここでは省略します。
また、パフォーマンスの差異も既に述べたものと変わりがないので省略します。

このモデルの実装はpumaが有名ですが、やや実装が異なります。この点については後で補足します。

ここまでの考察

さて、ここまでみてきたモデルでパフォーマンスは最適化されたでしょうか?
実はまだ最適化の余地があるのですが、まずはブロッキングIOについて触れる必要があります。

ここまで特に気にしてきませんでしたが、ここまで使ってきた Socket#accept IO#read IO#write といったメソッドは、ソケットが利用可能になるまで待機してから返り値を返します。
例えば、回線状態が悪い状態でクライアントが重いファイルをアップロードするようなケースで、 IO#read を使おうとすると、全てのデータを受信するまで処理がここで止まることになります。マルチプロセスモデルであれば、このアップロードが終わるまで1プロセスが何もできないことになり、CPUリソースは余っているのにパフォーマンスが低下することになります。
こういったIOのことをブロッキングIOと呼びます。

また、こういったコネクションが同時に1万リクエストあったら、マルチプロセスモデルでは対応できそうにありません。1プロセス1コネクションというモデルにも限界がありそうです(これはいわゆるC10K問題です)。

そこで登場するのがイベント駆動モデルです。

イベント駆動モデル(ノンブロッキングIO、I/O多重化)

いわゆるイベント駆動モデルでは、上記の問題を以下のように解決しています。

  • ノンブロッキングIOを用いる
  • シングルスレッドで複数のコネクションを扱う(I/O多重化)

ノンブロッキングI/O

ノンブロッキングIOは IO#read_nonblock IO#write_nonblock でその名の通り扱うことができます。これらのメソッドは、読み込み/書き込みができなかったときにエラーを返す代わりに、ここでブロックされることがありません。

I/O多重化

ノンブロッキングIOを使って、読み込みができなかったソケットは保持しておいて、読み込みができるようになったら読み込みを行うようにします。すなわち、場合によっては複数のソケットをしばらく保持しておくことになります(今までのモデルでは、1スレッドは1ソケットのみを扱っていました)。
こうやって複数のI/Oを管理する技術がI/O多重化(I/O Multiplexing)と呼ばれる技術で、 select(2) poll(2) epoll(2) などのシステムコールに相当します。例えば slect(2) は引数に与えたIOの配列のうち、利用可能なもののみを返します。rubyでは以下のように書けます。

readable, writable, exceptable = IO.select(sockets)

ちなみに select(2) はブロッキングI/Oです。またO(N)であるため引数のコネクション数が増えると性能が悪化します。
O(1)で同じことを実現するのが epoll(2) となるのですが、rubyではデフォルトで利用できないため割愛します。

実装

前置きが長くなりましたが、イベント駆動モデルの実装をみてみましょう。急にコードが難しくなります。

詳しい解説はしませんが、要点は以下です。

  • IO.select で利用可能なソケットのみを選ぶ
  • read_nonblock write_nonblock でノンブロッキングI/Oを使い、ブロッキングされずにループを回す
require './abstract_server'

class EventedServer < AbstractServer
  class Connection
    attr_accessor :socket, :should_read

    def initialize(socket, handler)
      @socket = socket
      @handler = handler
      @request = ''
      @response = ''
      @should_read = true
    end

    def on_data(data)
      @request << data
      @should_read = false # NOTE: 本来は適切にhttpをparseしつつ、何度もreadする必要があるが、ここでは省略。一度readしただけで全てがreadできると仮定する。
      handle_request
    end

    def handle_request
      @response << @handler.handle(@request)
    end

    def on_writable
      bytes = @socket.write_nonblock(@response)
      @response.slice!(0, bytes) # 書き込んだ分を削除する
    end

    def should_read?
      @should_read
    end

    def should_write?
      !@response.empty?
    end

    # IO.selectの引数に直接Connectionのインスタンスを渡せるようにするため、#to_ioメソッドを生やしておく
    def to_io
      @socket
    end
  end

  def run
    @connections = []

    loop do
      readables, writables, = IO.select(
        [
          @socket,
          *@connections.select(&:should_read?),
          *@connections.select(&:should_write?)
        ]
      )

      readables.each do |con|
        if con == @socket
          client_socket = @socket.accept
          @connections.push(Connection.new(client_socket, @handler))
        else
          begin
            data = con.socket.read_nonblock(CHUNK_SIZE)
            con.on_data(data)
            writables.push(con) if con.should_write?
          rescue Errno::EAGAIN
          rescue EOFError
            con.should_read = false
          end
        end
      end

      writables.each do |con|
        con.on_writable
        unless con.should_write?
          con.socket.close
          @connections.delete(con)
        end
      end
    end
  end
end

EventedServer.new(3000).run

性能検証

I/O多重化の効果を検証するには、リクエストのparse周りをちゃんと書きつつ、良い感じの重いリクエストを送るクライアントコードを書く必要があり、記事の分量が多くなるため省略します。

また、今回は RequestHandlersleep を挟んでいましたが、こういった同期的な処理があるとそこで処理が止まるため、最初に書いたシングルスレッドのシンプルなサーバーと性能は同じになります。
もしこれも考慮してパフォーマンスを最大限出したければ、リクエストの処理の中身も非同期的にする必要がある(そして、rubyだとそれは難しい)ことに注意してください。

参考までに、理想的なパフォーマンスがでるNode.js(express)のコードを示しておきます。

'use strict';

const express = require('express');

// Constants
const PORT = 8080;
const HOST = '0.0.0.0';

// App
const app = express();
app.get('/', (req, res) => {
  setTimeout(() => res.send('Hello world\n'), 1000)
});

app.listen(PORT, HOST);
console.log(`Running on http://${HOST}:${PORT}`);

ハイブリッドモデル(イベント駆動+マルチプロセス)

イベント駆動モデルは1スレッドで動きますが、マルチコアのCPUリソースを使うためには当然マルチプロセスにする必要があります。
ここでは実装は取り上げませんが、こういった処理を行なっている代表的なライブラリを挙げます。

nginx

Preforking + イベント駆動のハイブリッドです。

puma

Thread Pool + イベント駆動のハイブリッドです(と参考書籍で紹介されています)。ここまで紹介した実装とは趣が異なるので、簡単にコードを紹介します。

メインループ
https://github.com/puma/puma/blob/master/lib/puma/server.rb#L378
IO.select sockets accept_nonblock してI/O多重化、ノンブロッキングI/Oを行なっているのがわかります。

スレッドの中身の処理
https://github.com/puma/puma/blob/master/lib/puma/server.rb#L295
ThreadPool.new に与えたブロックが各スレッドで実行されます。
read(2)client.finish 周りで、リクエストの処理は process_client で行われます。
Client 周りのコードを読むと、 IO.select IO#read_nonblock を使って読み込みを行なっているのがわかると思います

EventMachine

rubyのイベント駆動I/OライブラリとしてはEventMachineが有名です。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away