几个月前,我发表了一篇关于使用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 方法是我们感兴趣的地方。它做了三件事。
我们可以借用其中的一些代码,让它像这样工作(见完整版本)。
# 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中有一些全局状态,可能会让它变得更加棘手。