以下の様々な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が生成できるプロセスの数には限度があるので、リクエストの並列数が一定数になった時点で死んでしまう
- リクエストの度にプロセスを立ち上げるのはオーバーヘッドが大きい。
これらの問題について考える前に、マルチスレッドなサーバーについてみておきます。
マルチスレッドモデル
マルチプロセスと比較して、 fork
が Thread.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モデルをスレッドでも行うモデルです。
実装は fork
を Thread.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周りをちゃんと書きつつ、良い感じの重いリクエストを送るクライアントコードを書く必要があり、記事の分量が多くなるため省略します。
また、今回は RequestHandler
で sleep
を挟んでいましたが、こういった同期的な処理があるとそこで処理が止まるため、最初に書いたシングルスレッドのシンプルなサーバーと性能は同じになります。
もしこれも考慮してパフォーマンスを最大限出したければ、リクエストの処理の中身も非同期的にする必要がある(そして、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が有名です。