如何编写一个基于Ractor的网络服务器?

71 阅读5分钟

几个月前,我发表了一篇关于使用Ractors在Ruby中编写一个简单网络服务器的文章。这篇文章只用了20行代码,它能够利用Ruby的多个CPU,而不需要通过全局解释器锁(GIL)。这是一个很好的预演,预示着Ractor基元将提供什么。

从那时起,Ruby 3.0发布了,Ractor的实现也变得更加成熟。在这篇文章中,我们将使我们基于Ractor的Web服务器做更多的事情。

在这篇文章的最后,你会了解到Ractor的约束条件,并熟悉我为使其工作而必须打开的三个MRI的PR文件。

开始吧

下面是我们在上一篇文章中的结果。

require 'socket'

pipe = Ractor.new do
  loop do
    Ractor.yield(Ractor.receive, move: true)
  end
end

CPU_COUNT = 4
workers = CPU_COUNT.times.map do
  Ractor.new(pipe) do |pipe|
    loop do
      s = pipe.take
      puts "taken from pipe by #{Ractor.current}"

      data = s.recv(1024)
      puts data.inspect

      s.print "HTTP/1.1 200\r\n"
      s.print "Content-Type: text/html\r\n"
      s.print "\r\n"
      s.print "Hello world!\n"
      s.close
    end
  end
end

listener = Ractor.new(pipe) do |pipe|
  server = TCPServer.new(8080)
  loop do
    conn, _ = server.accept
    pipe.send(conn, move: true)
  end
end

loop do
  Ractor.select(listener, *workers)
  # if the line above returned, one of the workers or the listener has crashed
end

我们的网络服务器没有解析传入的请求,而是用硬编码的Hello world! 字符串进行响应。让我们把它变得更加动态。

我们将利用WEBrick,一个与Ruby一起提供的简单Web服务器,来解析HTTP请求。这应该是最简单的。

req = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP)
req.parse(sock)

# req is the HTTPRequest object with all attributes populated by `parse`

让我们试试吧。

require 'webrick'

pipe = Ractor.new do
  loop do
    Ractor.yield(Ractor.receive, move: true)
  end
end

CPU_COUNT = 4
workers = CPU_COUNT.times.map do
  Ractor.new(pipe) do |pipe|
    loop do
      s = pipe.take

      req = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP.merge(RequestTimeout: nil))
      req.parse(s)

      puts req.inspect

      s.print "HTTP/1.1 200\r\n"
      s.print "Content-Type: text/html\r\n"
      s.print "\r\n"
      s.print "Hello world!\n"
      s.close
    end
  end
end

listener = Ractor.new(pipe) do |pipe|
  server = TCPServer.new(8080)
  loop do
    conn, _ = server.accept
    pipe.send(conn, move: true)
  end
end

loop do
  Ractor.select(listener, *workers)
end

我们会看到它的失败。

ractor_v0.rb:28:in `block (3 levels) in <main>': can not access non-shareable objects in constant WEBrick::Config::HTTP by non-main Ractor. (Ractor::IsolationError)
	from ractor_v0.rb:25:in `loop'
	from ractor_v0.rb:25:in `block (2 levels) in <main>'

值得庆幸的是,这个问题很容易解决:因为WEBrick::Config::HTTP 不是一个冻结的对象,我们需要明确地冻结它,使它可以在Ractors之间共享。

我们必须在服务器的代码中加入类似这样的内容。

Ractor.make_shareable(WEBrick::Config::HTTP)
Ractor.make_shareable(WEBrick::LF)
Ractor.make_shareable(WEBrick::CRLF)
Ractor.make_shareable(WEBrick::HTTPRequest::BODY_CONTAINABLE_METHODS)
Ractor.make_shareable(WEBrick::HTTPStatus::StatusMessage)

我在上游打开了一个修复程序,使其默认工作。在使其他代码工作的过程中,我也不得不为Time 类做同样的事情

URI解析的故事

一旦我们声明这些对象是可共享的,我们就会看到它的失败,例如。

/opt/rubies/3.0.0/lib/ruby/3.0.0/uri/common.rb:77:in `for': can not access class variables from non-main Ractors (Ractor::IsolationError)
	from /opt/rubies/3.0.0/lib/ruby/3.0.0/uri/rfc3986_parser.rb:72:in `parse'
	from /opt/rubies/3.0.0/lib/ruby/3.0.0/uri/common.rb:171:in `parse'
	from /Users/kir/.gem/ruby/3.0.0/gems/webrick-1.7.0/lib/webrick/httprequest.rb:504:in `parse_uri'
	from /Users/kir/.gem/ruby/3.0.0/gems/webrick-1.7.0/lib/webrick/httprequest.rb:218:in `parse'

我们必须记住,Ractors对于并发的数据访问是很严格的,类的变量在并发读取时是不安全的。

我们可以把这个错误归结为。

r = Ractor.new do
  res = URI.parse("https://ruby-lang.org/")
  puts res.inspect
end

如果我们查一下URI 实现,我们会发现它使用了一个类的实例变量。

module URI
  # ...
  def self.for(scheme, *arguments, default: Generic)
    if scheme
      # @@schemes is the class instance variable
      uri_class = @@schemes[scheme.upcase] || default
    else
      uri_class = default
    end

    return uri_class.new(scheme, *arguments)
  end

在不改变URI模块代码的情况下,我们无法使其在多个Ractors中安全访问。这是试图修复的PR

使其发挥作用

经过上面的三个改动,我们有了下面的代码。

require 'webrick'

# Fix: https://github.com/ruby/webrick/pull/65
Ractor.make_shareable(WEBrick::Config::HTTP)
Ractor.make_shareable(WEBrick::LF)
Ractor.make_shareable(WEBrick::CRLF)
Ractor.make_shareable(WEBrick::HTTPRequest::BODY_CONTAINABLE_METHODS)
Ractor.make_shareable(WEBrick::HTTPStatus::StatusMessage)

# To pick up changes from https://github.com/ruby/ruby/pull/4007
Object.send(:remove_const, :URI)
require '/Users/kir/src/github.com/ruby/ruby/lib/uri.rb'

pipe = Ractor.new do
  loop do
    Ractor.yield(Ractor.receive, move: true)
  end
end

CPU_COUNT = 4
workers = CPU_COUNT.times.map do
  Ractor.new(pipe) do |pipe|
    loop do
      s = pipe.take

      req = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP.merge(RequestTimeout: nil))
      req.parse(s)
      puts req.inspect

      s.print "HTTP/1.1 200\r\n"
      s.print "Content-Type: text/html\r\n"
      s.print "\r\n"
      s.print "Hello world!\n"
      s.close
    end
  end
end

listener = Ractor.new(pipe) do |pipe|
  server = TCPServer.new(8080)
  loop do
    conn, _ = server.accept
    pipe.send(conn, move: true)
  end
end

loop do
  Ractor.select(listener, *workers)
end

Yay!现在我们的服务器可以解析HTTP协议了,这要感谢WEBrick的解析器。

为Rack应用程序提供服务

所有Ruby中的网络应用都使用Rack作为网络服务器的模块化接口。让我们使我们的服务器与Rack接口兼容。

我们可以偷看一下Rack是如何与WEBrick集成的,并遵循同样的模式。service 方法是我们感兴趣的地方。它做了三件事。

  1. WEBrick::HTTPRequest 作为输入,并将其转化为Rack env
  2. 用该环境调用Rack应用程序
  3. 响应放到WEBrick::HTTPResponse

我们可以借用其中的一些代码,让它像这样工作(见完整版本)。

# has to be explicitly required from the main thread:
# https://bugs.ruby-lang.org/issues/17477
require 'pp'

def env_from_request(req)
  env = req.meta_vars
  env.delete_if { |k, v| v.nil? }

  rack_input = StringIO.new(req.body.to_s)
  rack_input.set_encoding(Encoding::BINARY)

  env.update(
    Rack::RACK_VERSION      => Rack::VERSION,
    Rack::RACK_INPUT        => rack_input,
    Rack::RACK_ERRORS       => $stderr,
    Rack::RACK_MULTITHREAD  => true,
    Rack::RACK_MULTIPROCESS => false,
    Rack::RACK_RUNONCE      => false,
    Rack::RACK_URL_SCHEME   => ["yes", "on", "1"].include?(env[Rack::HTTPS]) ? "https" : "http"
  )

  env[Rack::QUERY_STRING] ||= ""
  unless env[Rack::PATH_INFO] == ""
    path, n = req.request_uri.path, env[Rack::SCRIPT_NAME].length
    env[Rack::PATH_INFO] = path[n, path.length - n]
  end
  env[Rack::REQUEST_PATH] ||= [env[Rack::SCRIPT_NAME], env[Rack::PATH_INFO]].join
  env
end

CPU_COUNT = 4
workers = CPU_COUNT.times.map do
  Ractor.new(pipe) do |pipe|
    app = lambda do |e|
      [200, {'Content-Type' => 'text/html'}, ['hello world']]
    end

    loop do
      s = pipe.take

      req = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP.merge(RequestTimeout: nil))
      req.parse(s)

      env = env_from_request(req)

      status, headers, body = app.call(env)

      resp = WEBrick::HTTPResponse.new(WEBrick::Config::HTTP)

      begin
        resp.status = status.to_i
        io_lambda = nil
        headers.each { |k, vs|
          if k.downcase == "set-cookie"
            resp.cookies.concat vs.split("\n")
          else
            # Since WEBrick won't accept repeated headers,
            # merge the values per RFC 1945 section 4.2.
            resp[k] = vs.split("\n").join(", ")
          end
        }

        body.each { |part|
          resp.body << part
        }
      ensure
        body.close  if body.respond_to? :close
      end

      pp env

      resp.send_response(s)
    end
  end
end

现在我们有一个小小的Rack应用在多个CPU上运行,由Ractor原件提供动力这是很重要的,因为当我写第一篇文章时,Ractor还没有出现。到了Ruby 3.0版本,它已经成熟到我们只需打几个补丁就能将其与Rack集成。

总结

我希望这篇文章对Ruby中的Ractor模式的现状做了一些概述,对开发者和Ruby贡献者都是如此。

如果你略过了这篇文章,只是对内部结构感到好奇,你可以在这里看到最终版本的代码。下面是我写完后向上游报告的所有bug/补丁列表。

如果你需要一个关于Ractor的总体复习,你应该查看Ruby repo中的ractor.md

下一步将是尝试让我们的服务器运行Sinatra应用程序。理论上,Sinatra应用程序与Rack应用程序是一样的,但Sinatra和Rack中有一些全局状态,可能会让它变得更加棘手。