Ruby on rails Redis+;ActionController::活动线程未消亡

Ruby on rails Redis+;ActionController::活动线程未消亡,ruby-on-rails,multithreading,redis,ruby-on-rails-4,publish-subscribe,Ruby On Rails,Multithreading,Redis,Ruby On Rails 4,Publish Subscribe,背景:我们在现有的Rails应用程序中构建了一个聊天功能。我们正在使用新的ActionController::Live模块并运行Puma(Nginx已投入生产),并通过Redis订阅消息。我们正在使用EventSource客户端异步建立连接 问题摘要:当连接终止时,线程永远不会消亡 例如,如果用户导航离开,关闭浏览器,甚至转到应用程序中的其他页面,则会产生一个新线程(如预期的那样),但旧线程仍然有效 我目前看到的问题是,当出现上述任何一种情况时,服务器无法知道浏览器端的连接是否终止,直到有东西试

背景:我们在现有的Rails应用程序中构建了一个聊天功能。我们正在使用新的
ActionController::Live
模块并运行Puma(Nginx已投入生产),并通过Redis订阅消息。我们正在使用
EventSource
客户端异步建立连接

问题摘要:当连接终止时,线程永远不会消亡

例如,如果用户导航离开,关闭浏览器,甚至转到应用程序中的其他页面,则会产生一个新线程(如预期的那样),但旧线程仍然有效

我目前看到的问题是,当出现上述任何一种情况时,服务器无法知道浏览器端的连接是否终止,直到有东西试图写入此断开的流,而一旦浏览器离开原始页面,这种情况就永远不会发生

这个问题似乎已被记录在案,在StackOverflow和上也有类似的问题

基于这些帖子,我能想到的唯一解决方案是实现一种线程/连接扑克。尝试写入断开的连接会生成一个
IOError
,我可以捕获该错误并正确关闭连接,从而导致线程死亡。这是该解决方案的控制器代码:

def events
  response.headers["Content-Type"] = "text/event-stream"

  stream_error = false; # used by flusher thread to determine when to stop

  redis = Redis.new

  # Subscribe to our events
  redis.subscribe("message.create", "message.user_list_update") do |on| 
    on.message do |event, data| # when message is received, write to stream
      response.stream.write("messageType: '#{event}', data: #{data}\n\n")
    end

    # This is the monitor / connection poker thread
    # Periodically poke the connection by attempting to write to the stream
    flusher_thread = Thread.new do
      while !stream_error
        $redis.publish "message.create", "flusher_test"
        sleep 2.seconds
      end
    end
  end 

  rescue IOError
    logger.info "Stream closed"
    stream_error = true;
  ensure
    logger.info "Events action is quitting redis and closing stream!"
    redis.quit
    response.stream.close
end
(注意:
events
方法似乎在
subscribe
方法调用中被阻塞。其他所有内容(流)都正常工作,因此我认为这是正常的。)

(另请注意:flusher线程的概念作为一个长期运行的后台进程更有意义,有点像垃圾线程收集器。我上面实现的问题是,每个连接都会产生一个新线程,这是毫无意义的。任何试图实现此概念的人都应该像单个进程一样来实现,而不是像mu一样正如我所概述的。当我成功地将其作为一个单一的后台过程重新实现时,我将更新这篇文章。)

这个解决方案的缺点是,我们只是延迟或减少了问题,而不是完全解决了问题。除了像ajax这样的其他请求外,我们每个用户还有2个线程,从扩展的角度来看,这似乎很糟糕;对于一个有许多可能并发连接的更大系统来说,这似乎完全不可能实现,也不切实际。

我觉得我遗漏了一些重要的东西;我发现,如果不实现像我这样的自定义连接检查器,就很难相信Rails有一个如此明显地被破坏的特性

问题:我们如何允许连接/线程在没有实现诸如“连接扑克”或垃圾线程收集器之类的陈词滥调的情况下消亡

如往常一样,如果我遗漏了什么,请告诉我

更新 只需添加一点额外的信息:github的Huetsch指出SSE基于TCP,通常在连接关闭时发送FIN数据包,让另一端(本例中的服务器)知道关闭连接是安全的。Huetsch指出,要么浏览器没有发送该数据包(可能是
EventSource
library中的一个bug?),或者Rails没有捕捉到它或对它做任何事情(如果是这样的话,肯定是Rails中的一个bug)。搜索继续

另一次更新 使用Wireshark,我确实可以看到发送的FIN数据包。诚然,我对协议级别的内容不是很了解,也没有太多经验,但是从我所知,当我使用浏览器中的EventSource建立SSE连接时,我肯定会检测到从浏览器中发送的FIN数据包,如果我删除该连接,则不会发送数据包(意思是没有SSE)。虽然我对TCP的了解不是很深入,但这似乎向我表明连接确实被客户端正确终止了;这可能表明Puma或Rails中存在错误

又一次更新 @JamesBoutcher/boutcheratwest(github)向我指出了这个问题的一个重要原因,特别是关于
(p)subscribe
方法从不关闭。该网站上的海报指出了我们在这里发现的同一件事,即当客户端连接关闭时,Rails环境从未收到通知,因此无法执行
(p)unsubscribe
方法。他询问
(p)的超时subscribe
方法,我认为这也可以,但我不确定是哪种方法(我上面描述的连接扑克,或者他的超时建议)将是一个更好的解决方案。理想情况下,对于连接扑克解决方案,我想找到一种方法来确定连接是否在另一端关闭,而无需写入流。正如您所看到的,现在我必须实现客户端代码来处理我的“戳”另外,我认为这是一个突兀和愚蠢的消息。

我刚刚做的一个解决方案(从@teeg那里借用了很多东西)似乎工作正常(tho,还没有失败测试过它)

config/initializers/redis.rb

$redis = Redis.new(:host => "xxxx.com", :port => 6379)

heartbeat_thread = Thread.new do
  while true
    $redis.publish("heartbeat","thump")
    sleep 30.seconds
  end
end

at_exit do
  # not sure this is needed, but just in case
  heartbeat_thread.kill
  $redis.quit
end
然后在我的控制器中:

def events
    response.headers["Content-Type"] = "text/event-stream"
    redis = Redis.new(:host => "xxxxxxx.com", :port => 6379)
    logger.info "New stream starting, connecting to redis"
    redis.subscribe(['parse.new','heartbeat']) do |on|
      on.message do |event, data|
        if event == 'parse.new'
          response.stream.write("event: parse\ndata: #{data}\n\n")
        elsif event == 'heartbeat'
          response.stream.write("event: heartbeat\ndata: heartbeat\n\n")
        end
      end
    end
  rescue IOError
    logger.info "Stream closed"
  ensure
    logger.info "Stopping stream thread"
    redis.quit
    response.stream.close
  end

这里有一个可能更简单的解决方案,它不使用心跳。经过大量的研究和实验,下面是我在sinatra+sinatra sse gem中使用的代码(应该很容易适应Rails 4):

class EventServersettings.connections基于@James Boutcher,我在集群Puma中使用了以下两个worker,因此我在config/initializers/redis.rb中只为heartbeat创建了一个线程:

config/puma.rb

on_worker_boot do |index|
  puts "worker nb #{index.to_s} booting"
  create_heartbeat if index.to_i==0
end

def create_heartbeat
  puts "creating heartbeat"
  $redis||=Redis.new
  heartbeat = Thread.new do
    ActiveRecord::Base.connection_pool.release_connection
    begin
      while true
        hash={event: "heartbeat",data: "heartbeat"}
        $redis.publish("heartbeat",hash.to_json)
        sleep 20.seconds
      end
    ensure
      #no db connection anyway
    end
  end
end

我目前正在制作一个围绕ActionController的应用程序:Live、EventSource和Puma,以及那些遇到问题的应用程序
on_worker_boot do |index|
  puts "worker nb #{index.to_s} booting"
  create_heartbeat if index.to_i==0
end

def create_heartbeat
  puts "creating heartbeat"
  $redis||=Redis.new
  heartbeat = Thread.new do
    ActiveRecord::Base.connection_pool.release_connection
    begin
      while true
        hash={event: "heartbeat",data: "heartbeat"}
        $redis.publish("heartbeat",hash.to_json)
        sleep 20.seconds
      end
    ensure
      #no db connection anyway
    end
  end
end
def stream
  #Begin is not required
  twitter_client = Twitter::Streaming::Client.new(config_params) do |obj|
    # Do something
  end
rescue ClientDisconnected
  # Do something when disconnected
ensure
  # Do something else to ensure the stream is closed
end
class Stream::FixedController < StreamController
  def events
    # Rails reserve a db connection from connection pool for
    # each request, lets put it back into connection pool.
    ActiveRecord::Base.clear_active_connections!

    # Last time of any (except heartbeat) activity on stream
    # it mean last time of any message was send from server to client
    # or time of setting new connection
    @last_active = Time.zone.now

    # Redis (p)subscribe is blocking request so we need do some trick
    # to prevent it freeze request forever.
    redis.psubscribe("messages:*", 'heartbeat') do |on|
      on.pmessage do |pattern, event, data|
        # capture heartbeat from Redis pub/sub
        if event == 'heartbeat'
          # calculate idle time (in secounds) for this stream connection
          idle_time = (Time.zone.now - @last_active).to_i

          # Now we need to relase connection with Redis.(p)subscribe
          # chanel to allow go of any Exception (like connection closed)
          if idle_time > 4.minutes
            # unsubscribe from Redis because of idle time was to long
            # that's all - fix in (almost)one line :)
            redis.punsubscribe
          end
        else
          # save time of this (last) activity
          @last_active = Time.zone.now
        end
        # write to stream - even heartbeat - it's sometimes chance to
        # capture dissconection error before idle_time
        response.stream.write("event: #{event}\ndata: #{data}\n\n")
      end
    end
    # blicking end (no chance to get below this line without unsubscribe)
  rescue IOError
    Logs::Stream.info "Stream closed"
  rescue ClientDisconnected
    Logs::Stream.info "ClientDisconnected"
  rescue ActionController::Live::ClientDisconnected
    Logs::Stream.info "Live::ClientDisconnected"
  ensure
    Logs::Stream.info "Stream ensure close"
    redis.quit
    response.stream.close
  end
end
class Stream::FixedController < StreamController
  def events
    # Rails reserve a db connection from connection pool for
    # each request, lets put it back into connection pool.
    ActiveRecord::Base.clear_active_connections!

    redis = Redis.new

    watchdog = Doberman::WatchDog.new(:timeout => 20.seconds)
    watchdog.start

    # Redis (p)subscribe is blocking request so we need do some trick
    # to prevent it freeze request forever.
    redis.psubscribe("messages:*") do |on|
      on.pmessage do |pattern, event, data|
        begin
          # write to stream - even heartbeat - it's sometimes chance to
          response.stream.write("event: #{event}\ndata: #{data}\n\n")
          watchdog.ping

        rescue Doberman::WatchDog::Timeout => e
          raise ClientDisconnected if response.stream.closed?
          watchdog.ping
        end
      end
    end

  rescue IOError
  rescue ClientDisconnected

  ensure
    response.stream.close
    redis.quit
    watchdog.stop
  end
end