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

falm · 发布于 2017年09月19日 · 最后由 lanzhiheng 回复于 2017年09月21日 · 1833 次阅读
E0fcf5
本帖已被设为精华帖!

首先非常感谢这次大会的组织者们( @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

共收到 16 条回复
1107 jasl 将本帖设为了精华贴 09月19日 21:11
0b45a6

哇,这个图画得好高级

Fd2847

👍

7072

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

602

赞!

9770

膜拜一下大神

744a59

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

1638

👍 👍 👍 👍

7072

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

E0fcf5

CentOS 6.7 Intel Xeon E31225 3.10GHz 16G RAM

E0fcf5

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

744a59
E0fcf5falm 回复

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

744a59
E0fcf5falm 回复

Thank you very much

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册