2017-10-25

WEBrickはシングルスレッドではない

表題の通り、WEBrickはシングルスレッドなアプリサーバではなくマルチスレッドです。

にも関わらず、Web上にはWEBrickがシングルスレッドであるかのような記事が多く、Wikipediaでも

Unlike most of the servers that are used in production, 
WEBrick is not scalable since it is a single threaded web server by default.

と記載があり、とても誤解をまねきやすいです(デフォルトで、と言っているので完全に間違っているとは言い難いけど)

今回は、WEBrickがマルチスレッドサーバである根拠と、なぜシングルスレッドと誤解されているのかをRailsのバージョンの話も交えて書いていきます。

WEBrickがマルチスレッドである根拠

どのバージョンを見ても、acceptしたsocketに対してThread.newで別スレッドを作ってそのスレッド内でHTTPハンドリングをしてます。かなり省略していますが、こんな感じなコードになっています。
# webrick/lib/webrick/server.rb
module WEBrick
  class GenericServer
    def start(&block)
# ...
      server_type.start{
# ...
        begin
          while @status == :Running
            begin
              sp = shutdown_pipe[0]
              if svrs = IO.select([sp, *@listeners], nil, nil, 2.0)
# ...
                svrs[0].each{|svr|
                  @tokens.pop          # blocks while no token is there.
                  if sock = accept_client(svr)
                    unless config[:DoNotReverseLookup].nil?
                      sock.do_not_reverse_lookup = !!config[:DoNotReverseLookup]
                    end
                    th = start_thread(sock, &block)
                    th[:WEBrickThread] = true
                    thgroup.add(th)
# ...
    def start_thread(sock, &block)
      Thread.start{
        begin
          Thread.current[:WEBrickSocket] = sock
# ...
          block ? block.call(sock) : run(sock)
# ...

試しにsleepするだけのRackアプリをを用意して、curlを連続で叩くと、sleepで指定した秒数の後に同時にレスポンスが返ってきます。

#!/usr/bin/env ruby
# sleep-rack.rb

require 'rack'

app = Proc.new do |env|
  sleep 5
  ['200', {'Content-Type' => 'text/html'}, ['hoge.']]
end
Rack::Handler::WEBrick.run app
$ bundle exec ruby ./sleep-rack.rb
$ for i in {1..3}; do curl "localhost:8080" &; done

なぜシングルスレッドであると誤解されているのか

Rails4ではrails serverしたときのデフォルトのアプリサーバとしてWEBrickが使われます。Rails4の時点ではautoloadがスレッドセーフではないため、同時アクセスがある場合にconst_missing内の定数ロードによってコンフリクトが発生する可能性があります。
Circular dependency detected while autoloading constant Hoge

そのため、Railsではこのようなスレッドセーフではないautoloadに起因するエラーを防ぐため、WEBrickにおいてはRack::Lockのミドルウェアによって同時実行制御を行っており、結果としてシングルスレッドであるような振る舞いをrails serverに付与しています。

# lib/rails/commands/server.rb
module Rails
  class Server < ::Rack::Server
# snip...
    def middleware
      middlewares = []
      if RUBY_VERSION < '2.0.0'
        middlewares << [Rails::Rack::Debugger] if options[:debugger]
      end
      middlewares << [::Rack::ContentLength]

      # FIXME: add Rack::Lock in the case people are using webrick.
      # This is to remain backwards compatible for those who are
      # running webrick in production. We should consider removing this
      # in development.
      if server.name == 'Rack::Handler::WEBrick'
        middlewares << [::Rack::Lock]
      end

      Hash.new(middlewares)
    end

コメントにも記載があるようにproductionでWEBrickを使っているユーザに対して、後方互換のためにRack::Lockを入れた、とのことです。後方互換と言っているのはおそらくRails3のallow_concurrencyのデフォルトがfalseなので、そのままproductionで利用するとシングルスレッドで動くわけですが、Rails4のallow_concurrencyのデフォルトはtrueなので、そのままproductionで動かすとマルチスレッドで動いてしまいます。Rails3でもallow_concurrencyをtrueにすればWEBrickでもマルチスレッドで動いたりautoloadのバッティングが発生することは確認済みです。

まとめると、Rails3のデフォルトではallow_concurrencyはデフォルトfalseであったことや、Rails4は後方互換のためにWEBrickをシングルスレッドで動かしていて、そのためWEBrick自体はマルチスレッドなアプリサーバであるにも関わらず、シングルスレッドであるかのような記事が見受けられる、ということになります。

ちなみにコードや挙動の確認以外にも以下のような記事があり、マルチスレッドであることを確認しています。

Rails5ではどうなっているか

Rails5では特に設定をしなくてもマルチスレッドで動きます。コードを読むとmiddlewareがごっそりなくなっています。
def middleware
  Hash.new([])
end

また、Rackのミドルウェアもallow_concurrencyが明示的にfalseが指定されていなければコンカレントに動きます。Rails4まではデフォ値のnilのときはRack::Lockを入れるような処理になっていました。

module Rails
  class Application
    class DefaultMiddlewareStack
# snip...
      def build_stack
          if config.allow_concurrency == false
            # User has explicitly opted out of concurrent request
            # handling: presumably their code is not threadsafe

            middleware.use ::Rack::Lock
          end

さらにautoload自体がDependencies.load_interlockによって、スレッドセーフになっているっぽいです。そのためCircular dependency...のエラーが起きづらくなっているようです(というか起こす術がわからん…)

 module ActiveSupport #:nodoc:
  module Dependencies #:nodoc:
   def require_or_load(file_name, const_path = nil)
# snip...
      Dependencies.load_interlock do

まとめ

参考URL

このエントリーをはてなブックマークに追加