如何用Ruby中编写一个基于Ractor的网络服务器的方法

91 阅读4分钟

网络服务器里有什么?

网络服务器是一个接受TCP套接字,从套接字中读取信息,解析HTTP头信息,并以HTTP正文进行响应的东西。它是一个基于文本的协议,很容易实现。

下面是一个请求示例(你从套接字中读出的内容)。

GET / HTTP/1.1
Host: localhost:10000
User-Agent: curl/7.64.1
Accept: */*

和一个响应样本(你会写什么)。

HTTP/1.1 200
Content-Type: text/html

Hello world

我们将从AppSignal的[Building a 30 line HTTP server in Ruby]帖子中抓取一个Gist。

require 'socket'
server = TCPServer.new(8080)

while session = server.accept
  request = session.gets
  puts request

  session.print "HTTP/1.1 200\r\n"
  session.print "Content-Type: text/html\r\n"
  session.print "\r\n"
  session.print "Hello world! The time is #{Time.now}"

  session.close
end

开始使用Ractor

现在,让我们把上面的例子包装成Ractor。

require 'socket'
server = TCPServer.new(8080)
CPU_COUNT = 4
workers = CPU_COUNT.times.map do
  Ractor.new do
    loop do
      # receive TCPSocket
      s = Ractor.recv

      request = s.gets
      puts request

      s.print "HTTP/1.1 200\r\n"
      s.print "Content-Type: text/html\r\n"
      s.print "\r\n"
      s.print "Hello world! The time is #{Time.now}\n"
      s.close
    end
  end
end

loop do
  conn, _ = server.accept
  # pass TCPSocket to one of the workers
  workers.sample.send(conn, move: true)
end

我们启动相当于CPU数量的worker,让主线程监听套接字上的连接,并将接受的连接发送到一个随机的Ractor。我们可以通过使用curl 进行请求来验证它的工作是否符合预期。

然而,使用workers.sample ,在工作者之间分配请求并不是很有效。那个随机的工作器可能还在忙着为之前的请求提供服务。我们宁愿让工人从一个共享队列中抽出,在那里我们会发送所有的请求。

我想把这部分做得更好,但我没有找到任何适合Ractor的队列实现。然而,文档中建议使用一个像队列一样的管道。让我们试试吧!

require 'socket'

# pipe aka a queue
pipe = Ractor.new do
  loop do
    Ractor.yield(Ractor.recv, move: true)
  end
end

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

      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

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

它成功了!通过使用管道,我能够让所有的工作者拉动套接字,这改善了负载平衡的部分。

但不太妙的是,没有任何东西可以监控工作者,以防其中一个工作者意外死亡。与Puma的架构类似,如果有一个单独的线程来等待套接字准备好,然后再把它们传递给实际的工作者,会更有效率。

我能够把监听器移到它自己的Ractor中,并让主线程观察所有的Ractor。

require 'socket'

pipe = Ractor.new do
  loop do
    Ractor.yield(Ractor.recv, 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

又一次,它成功了!

实现Web服务器的下一步是建立一个HTTP解析器来读取请求头。有一个http-parsergem,它使用C语言扩展,我听说Ractor还不支持这个。

我找到了一个HTTP解析器,作为WEBrick的一部分,它是Ruby标准库中的一部分。

我试了一下下面的片段。

require 'webrick'

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

      # raises "can not access non-sharable objects in constant HTTP by non-main Ractors (NameError)"
      req = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP)
      req.parse(s)

      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

WEBrick::Config::HTTP 变成了一个带有一些配置对象的可变哈希值。由于该常量和哈希值是在主线程中初始化的,所以不允许从ractors中安全使用。我通过内联哈希定义来解决这个问题,但后来我遇到了另一个从WEBrick代码中引用的不可共享的常量,这个常量也不容易内联。

这可能是很快会在上游改进的部分。毕竟,这是最早的Ractor实现。

结束语

Ractor模型似乎很强大,并且已经准备好进行实验性使用。在接下来的6个月内(Ruby 3.0版本计划在12月发布),我预见到基于Ractor的网络服务器将会出现,以利用这一特性并最大限度地利用服务器CPU。这是一个学习并发编程和为Ruby社区作出贡献的好机会。