RubyConf [RubyConfChina2017 话题分享] Ruby Web 实时通讯方案剖析 (资料和参会感受)

falm · September 19, 2017 · Last by kafei replied at December 21, 2017 · 11032 hits
Topic has been selected as the excellent topic by the admin.

首先非常感谢这次大会的组织者们( @jasl @lgn21st @Rei @martin91 还有其他我不认识的小伙伴) 可以想象举办一个 500 人规模的技术大会,而且还要保持高质量是何其的不容易,谢谢你们。

其他讲师的分享

这里我先分享一下几个我印象比较深比较感兴趣的的讲师的演讲,(其他讲师的分享也很精彩,只不过有些并未涉猎)

nginx with ruby - @tylerdiaz

tylerdiaz 分享的主题的 mruby,据我了解是被广泛应用于日本的嵌入式开发领域。国内在这一块的应用没怎么听过,这也是 Ruby 社区近几年推的几个发展方向之一吧,国内的 Ruby 社区的路子相对来说没有那么宽。tylerdiaz 作为开场的英文演讲舞台风格非常好,尤其考虑到了我们英语可能听不懂听不清怕,刻意的把自己的语速降的很慢 (英文演讲的语速都比较快)。

异步编程奥德赛 - @dsh0416

之前队长有过多少次交流,在上海的分享会上也聊过关于异步编程的话题,midori 在我研究实时通讯的时候研究过它的源代码,本来是在我的分享上也准备了 midori 的介绍,不过后来看到队长的分享排在了我的前面,所以也就无所谓讲了,另外队长在台下和我说,这么多人的分享还是非常紧张的。不过他上台之后在聚光灯下,幽默风趣的演讲风格一点也看不出来紧张哈哈。

为 Ruby 设计一款 AOT - @psvr

这个工具做的很实用,我之前的工作中就是有一个将 Rails 项目打包发布的需求,当时考虑的还是使用 JRuby 打成一个 Jar 包进行处理,现在有这种解决方案,可以解决很多的现实问题。

金数据是如何鉴黄的 - @warmwind

金数据使用 SVM 做鉴黄的分享非常棒,近些年的技术发展的方向,Ruby 社区现在也开始关注机和实践机器学习了,薄荷其实也在广告过滤上面应用了机器学习不过使用的是贝叶斯方法。

机器学习入门 - @ihower
ihower 的分享作为机器学习的 101 非常不错,我之前对深度学习是没有什么了解的,其实在之前吃饭交流的时候,ihower 就已经给我们讲了一下关于深度神经网络的相关知识,可以说收获颇丰。

我的分享 - Ruby Web 实时通讯方案剖析

报名讲师的契机是在大会征集题目开始后,@vincent就和我说,我们要不要把最近这大半年在 Ruby Web 实时通讯上面的研究和实践给大家分享一下,(其实今年 3 月份的时候,Ruby 上海活动的时候分享了一下)这样我们才真正的把之前涉及到的关于 web 实时通讯的演讲的方案,重新整理,有些还不是很透彻的地方,有重新温习。最后整理出一份比较完整的介绍 RubyWeb 实时通讯方案的分享。

我自己的分享感觉语速和节奏没有控制好,有一有要讲的东西没表达出来,虽然我经常在公司内部做分享,但是十几二十人和 500 人还是比不了的。还有当@bhuztez 说出成功人士才跑 benchmark 之后压力山大,我的分享里面有六个 benchmark🤣。

Ruby Web 实时通讯方案剖析 Slides

下面内容是摘取自我在薄荷内部分享的关于实时通讯的四篇文章:

一 Hijacking API VS『native』Websocket

hijacking api 是在 Rack 1.5 是被加入的功能,因为 Rack API 本身无法支持想 http streaming websocket 这两长连接的请求,但是这个 API 是在 Rack 1.x 的临时方案,基本上不会再 Rack 2.0 中出现。

native websocket 这里的 native 是指定由 application server 提供内置的 websocket 功能,通过对外暴露 on_message on_open 这些回调接接口来进行请求处理,而不是像 hijacking API 通过直接暴露 socket io 对象。

HijackingAPI 和『native』websocket 的主要区别就是两种方式使用 reactor 和事件循环的数量。Hijacking API 是从 Server 中获取 socket,这就意味着应用程序需要自己监听 socket 的事件,因为这时候 server 已经忽略了这个 socket. 也就是说你需要自己在应用程序中管理你的 socket,再启动一个 reactor,一个事件循环器和线程池。使用 hijacking API 同时也需要应用程序自己去管理 socket 的 output buffer ( IO#write 和 read 在 network buffer 已满的时候,只能做部分写入或读取,application server 为处理 HTTP 请求的时候就已经内置该功能了,所以你又要为 websocket 在来实现一套)。

同样 websocket 协议的处理也是需要应用程序自己处理的(当然如果 server 和应用程序都没有使用 C 扩展或其他来加速处理协议的话,也无所谓)

总结下来没有使用 hijacking API 的“native”websocket 可以用少的代码和资源获得更好的性能,并且所有的事件和网络处理都可以集中一个模块还中,而 hijacking API 的实现需要在 server 和应用程序中各自实现一套。

二 Understanding Midori

注:本文基于 em-midori v0.4.1 版本所写

Midori从 web 框架的角度去看,它是一种类 Sinatra 式的 Web 开发框架,不同点就是它是基于事件循环模式的异步 web 框架,这样它真正要对标的就是 async_sinatra 这里的异步框架。与 sinatra 一样,midori 不是一个全栈式的 MVC 框架,当前版本强调的是通过 DSL 来到达简洁易用,快速构建 API 系统的 web 框架。

从 em-midori 开始 midori 底层使用的 I/O 库是 EventMachine,并且是用 EventMachine 事件循环库。后期的 midori 该为使用 nio4r 来作为底层支持,使用它的原因是 nio4r 的开发支持,稳定性和特性要比 EM 好。midori 的路由解析器使用了 sinatra 出品的 Mustermann,在这方面我觉得是做的比较好的,使用业界成熟的经过考验的组件,有助于提高框架的稳定性和实用性,并且在路由匹配功能上使用深度优先搜索算法,加快路由速度。

结构

大体上来说 midori 的架构是建立在 fiber 和 nio4r 之上,也是框架本身宣传的特点异步框架,那么底层就要使用支持异步的框架。Midori 是使用自制生态的 Muraski 组件来使用 nio4r 的,而 nio4r 底层又是使用 libev 网络库实现 I/O 能力的。

Midori 框架启动和接收请求的执行流程:

image.png

Runner

Midori 的启动器Midori::Runner的内部使用了 Ruby 标准库的 TCPServer 来启动 web 服务,在将端口 i/o 对象通过 EventLoop 注册到 nio4r 的监听器上,当有 socket 数据通过 I/O 端口发送过来后,就会执行回调函数,调用Midori::Connection来处理请求。

class Midori::Runner
  ...
  def start
    return false if running? || EventLoop.running?
    @logger.info "Midori #{Midori::VERSION} is now running on #{bind}:#{port}".blue
    @server = TCPServer.new(@bind, @port)
    EventLoop.register(@server, :r) do |monitor|
      socket = monitor.io.accept_nonblock
      connection = Midori::Connection.new(socket)
      connection.server_initialize(@api, @logger)
    end
    async_fiber(Fiber.new do
      @before.call
    end)
    EventLoop.start
    nil
  end
  ...
end

EventLoop

整个事件循环和监听 I/O 的 nio4r 都被封装在了 Muraski 组件中。其核心代码就是支持 I/O 事件回调函数和启动事件循环。

module EventLoop
  class << self
    def register(io, interest=(:rw), &callback)
      config if @selector.nil?
      if @queue[io.to_i].nil?
        @queue[io.to_i] = Array.new
        config if @selector.nil?
        @selector.register(io, interest)
        @ios[io] = { callback: callback }
      else
        @queue[io.to_i] << [io, interest, callback]
      end
      nil
    end

    def start
      return if running?
      @stop = false
      until @stop
        config if @selector.nil?
        @selector.select(0.2) do |monitor| # Timeout for 0.2 secs
          @ios[monitor.io][:callback].call(monitor)
        end
      timer_once
      end
      @stop = nil
    end
  end
end

Connection & Server

当 EventLoop 注册的回调函数被调用后,从框架的角度就是一个连接被建立一个请求被接受,那么接下来就需用 Connection 去处理连接,在 Connection 的内部非常有意思的是,其主要职责就是再注册一个事件循环器来持续监听建立起来的 socket 连接,剩下的处理请求的数据是交由 mixin 的 Server 模块处理。

class Midori::Connection
  include Midori::Server
  def listen(socket)
    EventLoop.register(socket, :rw) do |monitor|
      @monitor = monitor
      if monitor.readable?
        receive_data(monitor)
      end
      if monitor.writable?
        if !@data == ''
          monitor.io.write_nonblock(@data)
          @data = ''
        elsif @close_flag
          close_connection
        end
      end
    end
  end
end

这样分解的作用就是,Midori 是作用一个同事支持短连接和 websocket 长连接的 web 框架,底层的应用服务器部分也是要自己处理的(因为没有使用 Rack)。那么对一个长连接的数据不是在 socket 建立和就发送过来的,而是会持续不断的发送和接受直到连接关闭,所以 Midori 在主事件循环监听 I/O 端口外,还内嵌了事件循环监听特定 socket 连接。

Fiber

Midori 中 Fiber 使用的最大用途其实是在 midori-contrib 的数据库驱动中,以 mysql2 为例 midori 利用元编程打开了 mysql2 的 I/O 查询部分,使用 Fiber 在 I/O 阻塞的时候挂起,等 I/O 返回时候恢复。

socket = MYSQL_SOCKETS[conn.socket]
await(Promise.new do |resolve|
  count = 0
  EventLoop.register(socket, :rw) do
    if (count == 0)
      # Writable
      count += 1
      conn.query(sql,
        database_timezone: timezone,
        application_timezone: Sequel.application_timezone,
        stream: stream,
        async: true)
    else
      # Readable
      begin
        EventLoop.deregister(socket)
        resolve.call(conn.async_result)
      rescue ::Mysql2::Error => e
        resolve.call(PromiseException.new(e))
        next
      end
    end
  end
end)

http 请求和 websocket 也都是通过 Fiber 作为执行单元来执行。但是 Fiber 在这里并没有起到什么实质性的作用 fiber 被创建后就直接被执行了,期间也没有挂起调度。它可能是要在 Midori 未来的版本中会有特殊的用途,目前的版本是完全可以使用 proc 对象替代的。

module Midori::Server
  def receive_data(monitor)
    lambda do
      async_fiber(Fiber.new do
        begin
          _sock_domain, remote_port, _remote_hostname, remote_ip = monitor.io.peeraddr
          port, ip = remote_port, remote_ip
          @request.ip = ip
          @request.port = port
          data = monitor.io.read_nonblock(16_384)
          if @request.parsed? && @request.body_parsed?
            websocket_request(StringIO.new(data))
          else
            @request.parse(data)
            receive_new_request if @request.parsed && @request.body_parsed?
          end
        rescue => e
          close_connection
          @logger.warn "#{@request.ip} - - #{e.class} #{e.backtrace.join("\n")}".yellow
        end
      end)
    end.call
  end
end

最后

Midori 作为一个年轻的 Ruby Web 框架站在巨人的肩膀上,做出了不错的性能,而且是国人所做,所以应当支持和鼓励,但是客观的说在目前国内的 Ruby 生产实践中,依然是以 Rails 为核心的全栈式 web 框架为主流,类似 sinatra 或其他的异步框架,市场份额非常少而且 Midori 现在发布稳定的正式版。另外从性能角度看 Midor 上随有优势,但按照 roadmap v2.0 版本,要支持 MVC 和 http2,在性能上难免有所下降。

造轮子容易,用轮子难,所以希望 Midori 项目能够改进不足,补齐文档,多加宣传。主厨推荐 (Omakase) 吃了这么多年,是不是该换换口味了。

三 Reactor & I/O 多路复用

I/O 多路复用

epoll

当应用程序(用户线程或进程)发送监听端口的系统调用后,内核会在自身不断的轮询给端口看看是否有网络数据到了(socket 套接字)如果有数据到了,就会通知刚才的用户线程,在这个阶段 I/O 还是阻塞的,但是不同点就是它在发送监听系统调用的时候是可以监听多个套接字的,这也就是在同一个路径是复用了 I/O,剩下其他的部分和 I/O 阻塞都是差不多的,别小看这点改进,在多路复用情况下,select 系统调用在 64bit 系统上可以 切换处理 2048 个 socket,这样的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

那么我们常用的 libevent 框架,它的底层就是使用了这个这些 I/O 多路复用的系统调用,包括 select epoll poll 和 kqueue,(这里说一下 libevent 是跨平台的 事件驱动系统函数库,主要作用就是抹平了,不同操作系统对于 I/O 多路复用系统调用函数的不同 API,(就是统一 API) )

epoll 和 kqueue 是这个模型上的 select 和 poll 的升级版,目前最流行使用(包括 nginx nodejs) 主要区别就是 select 在它监听的所有 socket(fd) 有任意一个有数据返回了,内核就会将所有的 fd 都复制会用户线程中,然后再由 select 去遍历看看到底是哪一个 fd 有数据了,最后再处理请求。而 epoll 是使用了一个 callback 的机制,在它监听的每一个 fd 上都有一个 callback,当内核收到数据后,就会使用有数据的 fd 的 callback 通知 epoll,具体是哪一个 fd 有了数据,然后在处理特定的请求,这样的好处就是可以有效减少 fd 集合在内核和用户空间之间的复制操作。

reactor

是一种服务器端的事件驱动模式,服务处理程序使用解多路分配策略,底层是一种 I/O 的多路复用,I/O 同步非阻塞它的是有以下几个部分组成:

  • 事件源 input 输入具体可以是 Linux 上的 fd(file description) windows 上的 socket。
  • event demultiplexer 事件多路分发机制。它是使用操作系统提供的 I/O 多路复用机制实现的,select epoll 这都是操作系统上的 I/O 多路复用 系统调用函数。它的处理过程是需要,先在分发器上注册具体的事件源,然后等待 reactor 通知触发特定的事件源对用的事件处理器。
  • reactor 反应器是 事件管理器,
  • 事件处理器

EventMachine 实际上在事件驱动模型上就是根据 reactor 模式实现的,上面介绍了它的工作原理,我们再看看它的优点:

  • 响应快,不必为单个同步时间所阻塞,虽然 Reactor 本身依然是同步的
  • 编程相对简单,可以最大程序避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销
  • 可扩展性,可以方便的通过增加 Reactor 实例个数来充分利用 CPU 资源
  • 可复用性,Reactor 框架本身与具体事件处理逻辑无关,具有很高的复用性

四 - 使用 C 提高 Web 实时性能 - Iodine

iodine 是支持 rack 的,事件驱动并发框架,作为 plezi 的底层库,基本上 I/O,多线程,pub/sub 都是使用 iodine 来实现的

plezi

Websockets

iodine 处理 websocket 的方式与其他 Rack based 的 server 有所不同,它没有使用 rack.env 的 hijack API 来截获 websocket upgrade 请求,而是使用 C 扩展在 Ruby 处理请求之前,就在 socket 上判断是否为 websocket upgrade 请求然后,再执行 websocket parser(在 Ruby 之前运行所以不受 GIL 的影响,parser 是 CPU 密集操作如果有 GIL 的话多个 I/O 请求需要排队执行 parser)最后设置 rack.env[websocket.upgrade?] 变量后面的 rack app 自行判断如何处理 websocket upgrade 请求。

PubSub

iodine 底层使用了 facil.io 库提供原生的 pub/sub 支持,同时也可以通过 Redis 进行扩展,iodine 的 RedisEngine 支持 redis 单机和集群两种模式。并且Iodine::PubSub::RedisEngine的 Redis 客户端是异步非阻塞的客户端。

require 'uri'
# 使用Redis 作为pub/sub queue
uri = URI(ENV["REDIS_URL"])
Iodine.default_pubsub = Iodine::PubSub::RedisEngine.new(uri.host, uri.port, 0, uri.password)
# 非阻塞Redis Client 发送命令使用block 回调
Iodine.default_pubsub.send("CLIENT LIST") { |reply| puts reply }
GIL & Lock

Iodine 使用里多线程(pthread) 处理一些可以并行执行的任务(例如协议解析)因有共享资源处理,所以 iodine 使用了 spin-locks,在处理锁的问题上,得益于应用使用同一个 reactor, 服务器可以检查资源锁的状态,然后重新调整安排其他任务的执行,最大化利用资源。多线程并行执行因为在 C 层面使用 pthread 处理,所以在事件转移到上层的应用程序之前,内部的处理都是不受 GIL 的影响的。

参考资料

支撑 ActionCable 的底层库
I/O 模型
Unix 网络编程
Websocket / Upgrade support proposal

jasl mark as excellent topic. 19 Sep 21:11

哇,这个图画得好高级

通读了一遍,明早再细读。等视频来了再看看视频。

膜拜一下大神

我想点 @tylerdiaz 发现没这个人 😅

👍 👍 👍 👍

@falm 能问一下,跑 benchmark 的机器是什么配置、什么系统的吗?

CentOS 6.7 Intel Xeon E31225 3.10GHz 16G RAM

@lanzhiheng 当时也是没找到他的 ID,就把名字写上了!

Reply to falm

Youtube 上有它的视频吗?能不能贴一下 😀

Reply to falm

Thank you very much

吐槽:

  1. 帖子跟实时通讯没有半毛钱关系
  2. midori 还不如用最简单的 select IO,高效点用 system API,比如 epoll,不知道是不是作者不会自己写,然后就用了别人的各种 libevent。就一个脚手架服务器,跟 puma 没有任何可比性,应该说,根本提不上,思想差太远。
  3. 多路复用是 IO 模型之一,不需要刻意用一些名词来掩盖它原本非常基础的本质。还 EventMachine,我再也不相信了。有时间多看看 C++ 的一些实现比如 asio,或者 Go 的 routine。

Ruby 的性能还是适合用来写点简单的东西,但是没必要浮夸。你拿 C 写一下 IO rw,跟 Ruby 原生写一下,你会发现 C 都完成了,Ruby 还在等待,为何,因为 Ruby 一般把 IO 都写到(可以认为是 append / insert after / push_back)String buffer,对象消息损耗了一点。加上频繁的 GC,就很慢了。

感觉你们社区涉嫌炒作,拿一些很简单的概念、小 gem 进行包装宣传,我已转 C++ & Tars。

jakit in 最近的一点小感悟 mention this topic. 31 Oct 11:19
You need to Sign in before reply, if you don't have an account, please Sign up first.