4

因此,我正在尝试一种情况,即我想通过我的服务器将大型文件从第三方 URL 流式传输到请求客户端。

到目前为止,我已经尝试使用 Curb 或 Net::HTTP 来实现这一点,方法是遵循“eachable”响应体的标准 Rack 实践,如下所示:

class StreamBody
  ...
  def each
    some_http_library.on_body do | body_chunk |
      yield(body_chunk)
    end
  end
end

但是我不能让这个系统使用少于 40% 的 CPU(在我的 MacBook Air 上)。如果我尝试对 Goliath 做同样的事情,使用 em-synchrony(如 Goliath 页面上的建议)我可以将 CPU 使用率降低到大约 25% CPU,但是我无法刷新标题。我的流式下载“挂起”在请求客户端中,并且在将整个响应发送到客户端后,无论我提供什么标头,标头都会显示。

我是否正确地认为这是 Ruby 非常糟糕的情况之一,而我不得不转向世界上的 go 和 nodejs ?

相比之下,我们目前使用 PHP 从 CURL 流式传输到 PHP 输出流,并且 CPU 开销很小。

还是有我可以要求处理我的东西的上游代理解决方案?问题是 - 一旦整个主体被发送到套接字,我想可靠地调用一个 Ruby 函数,而像 nginx 代理这样的东西不会为我做这件事。

更新:我尝试为 HTTP 客户端做一个简单的基准测试,看起来大部分 CPU 使用都是 HTTP 客户端库。Ruby HTTP 客户端有一些基准测试,但它们基于响应接收时间——而从未提及 CPU 使用率。在我的测试中,我执行了一个 HTTP 流式下载,将结果写入/dev/null,并获得了一致的 30-40% 的 CPU 使用率,这与我通过任何 Rack 处理程序进行流式传输时的 CPU 使用率相匹配。

更新:事实证明,大多数 Rack 处理程序(Unicorn 等)在响应主体上使用 write() 循环,当无法足够快地写入响应时,它可能会进入繁忙等待(CPU 负载高)。这可以通过使用rack.hijack和写入输出套接字来在write_nonblock一定程度上缓解IO.select(惊讶于服务器自己不这样做)。

lambda do |socket|
  begin
    rack_response_body.each do | chunk |
      begin
        bytes_written = socket.write_nonblock(chunk)
        # If we could write only partially, make sure we do a retry on the next
        # iteration with the remaining part
        if bytes_written < chunk.bytesize
          chunk = chunk[bytes_written..-1]
          raise Errno::EINTR
        end
      rescue IO::WaitWritable, Errno::EINTR # The output socket is saturated.
        IO.select(nil, [socket]) # Then let's wait on the socket to be writable again
        retry # and off we go...
      rescue Errno::EPIPE # Happens when the client aborts the connection
        return
      end
    end
  ensure
    socket.close rescue IOError
    rack_response_body.close if rack_response_body.respond_to?(:close)
  end
end
4

1 回答 1

1

没有答案,但最后我们确实设法找到了解决方案。它非常成功,因为我们每天都通过它传输数 TB 的数据。以下是关键成分:

  • 赞助人作为 HTTP 客户端。我将在答案中解释选择
  • 强大的线程网络服务器(如 Puma)
  • 发送文件宝石

希望用 Ruby 构建这样的东西的主要问题是我称之为字符串流失的东西。基本上,在 VM 中分配字符串不是免费的。当您推送大量数据时,您最终将为从上游源接收的每个数据块分配一个 Ruby 字符串,并且如果您无法将write()整个数据块分配给代表您的套接字,您也可能最终会分配字符串客户端通过 TCP 连接。因此,在我们尝试过的所有方法中,我们都无法找到一种可以避免字符串流失的解决方案——也就是说,在我们偶然发现 Patron 之前。

事实证明,Patron 是唯一一个允许在用户空间直接写入文件的 Ruby HTTP 客户端。这意味着您可以通过 HTTP 下载一些数据,而无需为您提取的数据分配 ruby​​ 字符串。Patron 有一个函数,它将打开一个FILE*指针并使用 libCURL 回调直接写入该指针。这发生在 Ruby GVL 解锁时,因为所有内容都被折叠到 C 级别。实际上,这意味着在“拉”阶段,不会在 Ruby 堆中分配任何内容来存储响应正文。

请注意,另一个广泛使用的 CURL 绑定库,curb没有该功能 - 它会在堆上分配 Ruby 字符串并将它们交给您,这违背了目的。

下一步是将该内容提供给 TCP 套接字。碰巧 - 再次 - 有三种方法可以做到这一点。

  • 从您下载的文件中读取数据到 Ruby 堆中,并将其写入套接字
  • 编写一个精简的 C 填充程序,为您执行套接字写入,避免 Ruby 堆
  • 使用sendfile()系统调用在内核空间执行文件到套接字的操作,完全避开用户空间。

无论哪种方式,您都需要访问 TCP 套接字——因此您需要获得全部或部分机架劫持支持(验证您的网络服务器文档是否支持)。

我们决定采用第三种选择。sendfile是 Unicorn 和 Rainbows 的作者的一个绝妙的 gem,它实现了这一点 - 给它一个 Ruby File 对象,TCPSocket然后它会要求内核将文件发送到套接字,从而绕过尽可能多的机器。同样,您不必将任何内容读入堆中。所以,最后,这是我们采用的方法(伪代码,不处理边缘情况):

# Use Tempfile to allocate a unique file name
tf = Tempfile.new('chunk')

# Download a part of the file using the Range header 
Patron::Session.new.get_file(the_url, tf.path, {'Range' => '..-..'})

# Use the blocking sendfile call (for demo purposes, you can also send in chunks).
# Note that non-blocking sendfile() is broken on OSX
socket.sendfile(file, start_reading_at=0, send_bytes=tf.size)

# Make sure to get rid of the file
tf.close; tf.unlink

这使我们能够以非常小的 CPU 负载和非常小的堆压力为多个连接提供服务,而无需事件。我们经常看到为数百名用户提供服务的盒子在这样做时使用大约 2% 的 CPU。Ruby GC 很高兴。本质上,我们唯一不喜欢这个实现的是 MRI 强加的每个线程 8MB 的 RAM 开销。然而,要解决这个问题,我们需要切换到事件服务器(意大利面条代码大量)或编写我们自己的 IO 反应器,将大量连接多路复用到更小的线程齐发上,这当然是可行的,但也需要很多时间。

希望这会对某人有所帮助。

于 2016-05-16T16:12:17.650 回答