2010年06月17日

並列1000コネクションに耐える! Ruby のイベント駆動ライブラリ Rev と EventMachine の HTTPクライアント

はてなブックマークに登録

こんにちは、takada-at です。
Rubyのイベント駆動型ネットワークプログラミングフレームワーク Rev と EventMachine で HTTPクライアントを動かしてみました。
イベント駆動型ネットワークプログラミングフレームワークとは何か説明しだすと難しいですが、一言で言うと、以下のようになります。
# ふつうのフロー駆動型プログラム
Net::HTTP.start(host, port){|http|
    res = http.get(path) #この処理が終わってから
}
puts "done" #この次の処理が実行される
# イベント駆動型プログラム
client = Rev::HttpClient::connect(host, port)
client.get(path) #この処理が終わってないのに
puts "not done" #すぐこの行が実行されてしまう
一見するとプログラムの複雑さが増えるだけのように思えますが、処理をブロックしないということは無駄がないということでもあります。これによって、
  • 複数のファイルディスクリプタを同時に開き
  • 読み込み可能なものから処理していく
なんてこともできるようになります。I/O多重化、ノンブロッキングI/O と呼ばれる方法です。


イベント駆動ライブラリのサンプルでは、サーバーを書くことが多いですが、クライアントの同時接続数の限界に挑戦したかったので、今回は HTTPクライアントを書いてみます。


Ruby のイベント駆動ライブラリとして Ruby Revと EventMachine の2つを使ってレスポンスタイムを計測しました。どちらも独自の HTTPクライアントをライブラリに組み込んでいます。

EventMachine
http://rubyeventmachine.com/

Rev
http://rubyforge.org/projects/rev/


RevEventMachine も Python の TwistedTornado、Perl の AnyEvent のように、ノンブロッキング I/O を利用することで並行性が高く高速なネットワークプログラムを組むためのフレームワークです。


どちらのライブラリもまだあまり資料がありませんが、内部の実装を見つつサンプルコードを作成してみました。
結論から言うと、Revはとても高速です。


サーバー側では単純に100ms待ってからレスポンスを返すだけのページを用意しておき、クライアントは並列に接続してレスポンスタイムの平均を取りました。要するに、並列数を増やしてもレスポンスタイムが100ms に近ければ近いほど、高速なクライアントということになります。
以下
  • Rubyのスレッド + net/http ライブラリ
  • Rev/HttpClient
  • EventMachine/HttpClient2
の3パターンで試しました。Ruby の バージョン は 1.8.7 です。
なお、試験に利用したサーバーは、Python の Tornado で書かれており、十分に高速です。
(EventMachine にはなぜか HTTPクライアントが2つありますが、HttpClient という名前の方はまともに動作しなかったので試験対象から外しました)。


10コネクション
target: http://hornet.klab.org:8000/
concurrency: 10
net/http + thread
        avg: 140.5132
EventMachine/HttpClient2
        avg: 101.3227
Rev/HttpClient
        avg: 101.4915

スレッドの方はすでに多少の遅延が派生してます。


100コネクション
target: http://hornet.klab.org:8000/
concurrency: 100
net/http + thread
        avg: 166.59175
EventMachine/HttpClient2
        avg: 110.23086
Rev/HttpClient
        avg: 103.69137

EventMachine, Rev はほぼ問題の無い処理時間です。


500コネクション
target: http://hornet.klab.org:8000/
concurrency: 500
net/http + thread
        avg: 2720.349032
EventMachine/HttpClient2
        avg: 132.847788
Rev/HttpClient
        avg: 113.132662

EventMachine に多少の遅延が発生するようになりました。スレッドはほとんど使いものにならない遅さです。


1000コネクション
target: http://hornet.klab.org:8000/
concurrency: 1000
net/http + thread
        avg: 10968.612545
EventMachine/HttpClient2
        avg: 136.243228
Rev/HttpClient
        avg: 122.174841


EventMachine もかなり高速ですが、1000を少し越えたあたりからまともに動作しなくなります。
一方 Rev は1000コネクション程度でも問題なく動作するようです。



なお注意事項として、この試験をする前に、ファイルディスクリプタの制限を増やしておく必要があります。
デフォルトでは1024以上のファイルディスクリプタを扱うことができず、ソケットの数にも制限がくわえられてしまいます。
Linux では、/etc/security/limits.conf を編集することでユーザーのファイルディスクリプタの上限を増やすことができます。



さらに、TIME_WAIT 状態のコネクションを使い回せるようにtcp_tw_reuse=1 を設定しておくとよいでしょう。
/etc/sysctl.conf に net.ipv4.tcp_tw_reuse = 1 を設定することで、TIME_OUT 状態のコネクションを再利用し、TCPコネクションの上限を引き上げることができます。



以下に今回使用したテストコードを掲載します。
イベント駆動を駆使してるため多少読みにくいですが、どのパターンも 1秒待ってからHTTPリクエストを並列に送信し、レスポンスの時間を計測しています。

(takada-at)
require 'net/http'
require 'benchmark'
require 'rubygems'
require 'rev'
require 'eventmachine'
require 'thread'

class RevHttp < Rev::HttpClient
    def on_body_data data
        @content = data
    end
    def on_connect
        super
    end
    def request method, path
        @starttime = Time::now
        super
    end
    def on_request_complete
    end
    event_callback :on_request_complete
end
class EMHttp2 < EventMachine::Protocols::HttpClient2
    def connection_completed
        super
    end
    def request args
        @starttime = Time::now
        super
    end
    attr_reader :starttime
end
class EventMachine::Protocols::HttpClient2::Request
    def starttime
        @conn.starttime
    end
end
def start_rev times, host, port, path, callback
    counter = times
    loop = Rev::Loop::default
    sumtime = 0
    on_complete = lambda{
        counter -= 1
        sumtime += Time::now - @starttime
        if counter==0
            #全リクエスト終了
            loop.stop
        end
    }
    1.upto(times) do
        m = RevHttp::connect(host, port)
        m.on_request_complete &on_complete
        #1秒待ってからリクエスト
        timer = Rev::TimerWatcher::new(1)
        timer.on_timer{
            m.request("GET", path)
        }
        m.attach(loop)
        timer.attach(loop)
    end
    loop.run
    puts "Rev/HttpClient"
    puts "	avg: #{sumtime*1000/times}"
end
def start_em2 times, host, port, path, callback
    counter = times
    sumtime = 0
    on_complete = lambda{|res|
        counter -= 1
        sumtime += Time::now - res.starttime
        if counter==0
            #全リクエスト終了
            callback.call
            EventMachine::stop
        end
    }
    EventMachine::run{
        1.upto(times) do
            m = EMHttp2::connect(host, port)
            #1秒待ってからリクエスト
            timer = EventMachine::Timer::new(1){
                req = m.get(path)
                req.callback &on_complete
            }
        end
    }
    puts "EventMachine/HttpClient2"
    puts "	avg: #{sumtime*1000/times}"
end
def start_nethttp times, host, port, path, callback
    Net::HTTP.version_1_2
    counter = times
    sumtime = 0
    1.upto(times) do
        Thread::start{
            sleep 1 #1秒待つ
            Net::HTTP.start(host, port){|http|
                starttime = Time::now
                res = http.get(path)
                sumtime += Time::now - starttime
            }
            counter -= 1
            if counter==0
                puts "net/http + thread"
                puts "	avg: #{sumtime*1000/times}"
                callback.call
            end
        }
    end
end

def wait &b
    cv = ConditionVariable::new
    mutex = Mutex::new
    f = lambda{
        mutex.synchronize{
            cv.signal
        }
    }
    th = Thread::start do
        mutex.synchronize do
            cv.wait(mutex)
            # fがcallされるまで待つ
        end
    end
    b.call(f)
    th.join
end
def main times=nil
    target = {
        :host => 'hornet.klab.org',
        :port => 8000,
        :path => '/'
    }

    url = "http://#{target[:host]}:#{target[:port]}#{target[:path]}"
    times = 10 if times.zero?
    puts "target: #{url}"
    puts "concurrency: #{times}"
    #以下スクリプト実行時の第2引数を見て、実行するコードを変更
    wait{|f|
        start_nethttp(times, target[:host], target[:port], target[:path], f)
    } if ARGV[1].to_i == 0
    e = lambda{}
    start_em2(times, target[:host], target[:port], target[:path], e) if ARGV[1].to_i == 1
    start_rev(times, target[:host], target[:port], target[:path], e) if ARGV[1].to_i == 2
end

main ARGV[0].to_i

klab_gijutsu2 at 21:53│Comments(2)TrackBack(0)ruby 

トラックバックURL

この記事へのコメント

1. Posted by mad-p   2010年06月18日 12:17
せっかくイベント駆動のテストをしているのですから、wait()内でビジーループせずにConditionVariableを使った方がよいと思います。
2. Posted by takada-at   2010年06月18日 17:57
ご指摘ありがとうございます!
ConditionVariable を使用するように修正しました。

この記事にコメントする

名前:
URL:
  情報を記憶: 評価: 顔   
 
 
 
Blog内検索
Archives
このブログについて
DSASとは、KLab が構築し運用しているコンテンツサービス用のLinuxベースのインフラです。現在5ヶ所のデータセンタにて構築し、運用していますが、我々はDSASをより使いやすく、より安全に、そしてより省力で運用できることを目指して、日々改良に勤しんでいます。
このブログでは、そんな DSAS で使っている技術の紹介や、実験してみた結果の報告、トラブルに巻き込まれた時の経験談など、広く深く、色々な話題を織りまぜて紹介していきたいと思います。
最新コメント