简体   繁体   English

Redis + ActionController::活动线程没有死

[英]Redis + ActionController::Live threads not dying

Background: We've built a chat feature in to one of our existing Rails applications.背景:我们在现有的 Rails 应用程序中构建了一个聊天功能。 We're using the new ActionController::Live module and running Puma (with Nginx in production), and subscribing to messages through Redis. We're using EventSource client side to establish the connection asynchronously.我们正在使用新的ActionController::Live模块并运行 Puma(生产中使用 Nginx),并通过 Redis 订阅消息。我们正在使用EventSource客户端异步建立连接。

Problem Summary: Threads are never dying when the connection is terminated.问题摘要:当连接终止时,线程永远不会死亡。

For example, should the user navigate away, close the browser, or even go to a different page within the application, a new thread is spawned (as expected), but the old one continues to live.例如,如果用户导航离开、关闭浏览器,甚至 go 到应用程序中的另一个页面,就会产生一个新线程(如预期的那样),但旧线程继续存在。

The problem as I presently see it is that when any of these situations occur, the server has no way of knowing whether the connection on the browser's end is terminated, until something attempts to write to this broken stream, which would never happen once the browser has moved away from the original page.我目前看到的问题是,当任何这些情况发生时,服务器无法知道浏览器端的连接是否终止,直到有人尝试写入这个损坏的 stream,一旦浏览器就永远不会发生已经离开原来的页面。

This problem seems to be documented on github , and similar questions are asked on StackOverflow here (pretty well exact same question) and here (regarding getting number of active threads) .这个问题似乎记录在 github 上,并且在 StackOverflow此处(完全相同的问题)此处(关于获取活动线程数)提出了类似的问题。

The only solution I've been able to come up with, based on these posts, is to implement a type of thread / connection poker.基于这些帖子,我能够想出的唯一解决方案是实现一种线程/连接扑克。 Attempting to write to a broken connection generates an IOError which I can catch and properly close the connection, allowing the thread to die.尝试写入断开的连接会生成一个IOError ,我可以捕获它并正确关闭连接,从而使线程终止。 This is the controller code for that solution:这是该解决方案的 controller 代码:

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

(Note: the events method seems to get blocked on the subscribe method invocation. Everything else (the streaming) works properly so I assume this is normal.) (注意: events方法似乎在subscribe方法调用时被阻止。其他一切(流媒体)都正常工作,所以我认为这是正常的。)

(Other note: the flusher thread concept makes more sense as a single long-running background process, a bit like a garbage thread collector. The problem with my implementation above is that a new thread is spawned for each connection, which is pointless. Anyone attempting to implement this concept should do it more like a single process, not so much as I've outlined. I'll update this post when I successfully re-implement this as a single background process.) (其他注意事项:flusher 线程概念作为单个长时间运行的后台进程更有意义,有点像垃圾线程收集器。我上面的实现的问题是为每个连接生成一个新线程,这是没有意义的。任何人尝试实现这个概念应该更像一个单一的进程,而不是像我概述的那样。当我成功地将它重新实现为一个单一的后台进程时,我会更新这篇文章。)

The downside of this solution is that we've only delayed or lessened the problem, not completely solved it.这个解决方案的缺点是我们只是延迟或减轻了问题,并没有完全解决它。 We still have 2 threads per user, in addition to other requests such as ajax, which seems terrible from a scaling perspective;除了其他请求(例如 ajax)之外,我们每个用户仍然有 2 个线程,从扩展的角度来看这似乎很糟糕; it seems completely unattainable and impractical for a larger system with many possible concurrent connections.对于具有许多可能并发连接的大型系统来说,这似乎是完全无法实现和不切实际的。

I feel like I am missing something vital;我觉得我错过了一些重要的东西; I find it somewhat difficult to believe that Rails has a feature that is so obviously broken without implementing a custom connection-checker like I have done.我发现很难相信 Rails 有一个功能,如果没有像我一样实现自定义连接检查器,那么它显然是坏掉的。

Question: How do we allow the connections / threads to die without implementing something corny such as a 'connection poker', or garbage thread collector?问题:我们如何在不实施诸如“连接扑克”或垃圾线程收集器之类的陈词滥调的情况下允许连接/线程终止?

As always let me know if I've left anything out.一如既往地让我知道我是否遗漏了任何内容。

Update Just to add a bit of extra info: Huetsch over at github posted this comment pointing out that SSE is based on TCP, which normally sends a FIN packet when the connection is closed, letting the other end (server in this case) know that its safe to close the connection.更新只是为了添加一些额外的信息:github 的 Huetsch 发表了这条评论指出 SSE 基于 TCP,它通常在连接关闭时发送 FIN 数据包,让另一端(在本例中为服务器)知道关闭连接是安全的。 Huetsch points out that either the browser is not sending that packet (perhaps a bug in the EventSource library?), or Rails is not catching it or doing anything with it (definitely a bug in Rails, if that's the case). Huetsch 指出,要么是浏览器没有发送该数据包(可能是EventSource库中的一个错误?),要么是 Rails 没有捕获它或对它做任何事情(如果是这样的话,那肯定是 Rails 中的一个错误)。 The search continues...搜索继续...

Another Update Using Wireshark, I can indeed see FIN packets being sent.另一个使用 Wireshark 的更新,我确实可以看到正在发送的 FIN 数据包。 Admittedly, I am not very knowledgeable or experienced with protocol level stuff, however from what I can tell, I definitely detect a FIN packet being sent from the browser when I establish the SSE connection using EventSource from the browser, and NO packet sent if I remove that connection (meaning no SSE).诚然,我对协议级别的东西不是很了解或没有经验,但是据我所知,当我使用浏览器中的 EventSource 建立 SSE 连接时,我肯定检测到从浏览器发送的 FIN 数据包,如果我没有发送任何数据包删除该连接(意味着没有 SSE)。 Though I'm not terribly up on my TCP knowledge, this seems to indicate to me that the connection is indeed being properly terminated by the client;虽然我对 TCP 的了解并不十分清楚,但这似乎表明连接确实被客户端正确终止了; perhaps this indicates a bug in Puma or Rails.也许这表明 Puma 或 Rails 中存在错误。

Yet another update @JamesBoutcher / boutcheratwest(github) pointed me to a discussion on the redis website regarding this issue, specifically in regards to the fact that the .(p)subscribe method never shuts down.另一个更新@JamesBoutcher / boutcheratwest(github) 让我看到了 redis 网站上关于这个问题的讨论,特别是关于.(p)subscribe方法永远不会关闭的事实。 The poster on that site pointed out the same thing that we've discovered here, that the Rails environment is never notified when the client-side connection is closed, and therefore is unable to execute the .(p)unsubscribe method.该站点上的张贴者指出了我们在这里发现的同一件事,即当客户端连接关闭时,Rails 环境永远不会收到通知,因此无法执行.(p)unsubscribe方法。 He inquires about a timeout for the .(p)subscribe method, which I think would work as well, though I'm not sure which method (the connection poker I've described above, or his timeout suggestion) would be a better solution.他询问.(p)subscribe方法的超时,我认为它也可以工作,但我不确定哪种方法(我在上面描述的连接扑克,或他的超时建议)会是更好的解决方案. Ideally, for the connection poker solution, I'd like to find a way to determine whether the connection is closed on the other end without writing to the stream. As it is right now, as you can see, I have to implement client-side code to handle my "poking" message separately, which I believe is obtrusive and goofy as heck.理想情况下,对于连接扑克解决方案,我想找到一种方法来确定另一端的连接是否已关闭,而无需写入 stream。就像现在一样,如您所见,我必须实现客户端 -单独处理我的“戳”消息的辅助代码,我认为这很突兀和愚蠢。

A solution I just did (borrowing a lot from @teeg) which seems to work okay (haven't failure tested it, tho) 我刚刚做的一个解决方案(从@teeg借了很多)似乎工作正常(没有失败测试它,所以)

config/initializers/redis.rb 配置/初始化/ 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

And then in my controller: 然后在我的控制器中:

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

I'm currently making an app that revolves around ActionController:Live, EventSource and Puma and for those that are encountering problems closing streams and such, instead of rescuing an IOError , in Rails 4.2 you need to rescue ClientDisconnected . 我目前正在制作一个围绕ActionController的应用程序:Live,EventSource和Puma,对于那些在关闭流等问题时遇到问题,而不是在Rails 4.2中拯救IOError ,你需要拯救ClientDisconnected Example: 例:

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

I found this handy tip from this forum post (all the way at the bottom): http://railscasts.com/episodes/401-actioncontroller-live?view=comments 我从这个论坛帖子(一直到底)发现了这个方便的提示: http//railscasts.com/episodes/401-actioncontroller-live?view = comment

Building on @James Boutcher, I used the following in clustered Puma with 2 workers, so that I have only 1 thread created for the heartbeat in config/initializers/redis.rb: 在@James Boutcher的基础上,我在群集Puma中使用了以下2个worker,因此我在config / initializers / redis.rb中只为心跳创建了1个线程:

config/puma.rb 配置/ 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

Here's a potentially simpler solution which does not use a heartbeat. 这是一个可能更简单的解决方案,不使用心跳。 After much research and experimentation, here's the code I'm using with sinatra + sinatra sse gem (which should be easily adapted to Rails 4): 经过大量的研究和实验,这里是我与sinatra + sinatra sse gem使用的代码(应该很容易适应Rails 4):

class EventServer < Sinatra::Base
 include Sinatra::SSE
 set :connections, []
 .
 .
 .
 get '/channel/:channel' do
 .
 .
 .
  sse_stream do |out|
    settings.connections << out
    out.callback {
      puts 'Client disconnected from sse';
      settings.connections.delete(out);
    }
  redis.subscribe(channel) do |on|
      on.subscribe do |channel, subscriptions|
        puts "Subscribed to redis ##{channel}\n"
      end
      on.message do |channel, message|
        puts "Message from redis ##{channel}: #{message}\n"
        message = JSON.parse(message)
        .
        .
        .
        if settings.connections.include?(out)
          out.push(message)
        else
          puts 'closing orphaned redis connection'
          redis.unsubscribe
        end
      end
    end
  end
end

The redis connection blocks on.message and only accepts (p)subscribe/(p)unsubscribe commands. redis连接阻止on.message并且只接受(p)subscribe /(p)unsubscribe命令。 Once you unsubscribe, the redis connection is no longer blocked and can be released by the web server object which was instantiated by the initial sse request. 取消订阅后,redis连接不再被阻止,并且可以由初始sse请求实例化的Web服务器对象释放。 It automatically clears when you receive a message on redis and sse connection to the browser no longer exists in the collection array. 当您在redis上收到消息时,它会自动清除,并且集合数组中不再存在与浏览器的连接。

Here you are solution with timeout that will exit blocking Redis.(p)subscribe call and kill unused connection tread. 在这里你是超时的解决方案,将退出阻止Redis。(p)订阅呼叫并杀死未使用的连接。

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

You have to use reds.(p)unsubscribe to end this blocking call. 你必须使用红色。(p)取消订阅以结束此阻止通话。 No exception can break this. 没有例外可以打破这个。

My simple app with information about this fix: https://github.com/piotr-kedziak/redis-subscribe-stream-puma-fix 我的简单应用程序包含有关此修复程序的信息: https//github.com/piotr-kedziak/redis-subscribe-stream-puma-fix

If you can tolerate a small chance of missing a message you can use subscribe_with_timeout :如果您可以容忍错过消息的小概率,您可以使用subscribe_with_timeout

sse = SSE.new(response.stream)
sse.write("hi", event: "hello")
redis = Redis.new(reconnect_attempts: 0)
loop do
  begin
    redis.subscribe_with_timeout(5 * 60, 'mycoolchannel') do |on|
      on.message do |channel, message|
        sse.write(message, event: 'message_posted')
      end
    end
  rescue Redis::TimeoutError
    sse.write("ping", event: "ping")
  end
end

This code subscribes to a Redis channel, waits for 5 minutes, then closes connection to Redis and subscribes again.此代码订阅 Redis 频道,等待 5 分钟,然后关闭与 Redis 的连接并再次订阅。

Instead of sending a heartbeat to all the clients, it might be easier to just set a watchdog for each connection. 不是向所有客户端发送心跳,而是为每个连接设置监视器可能更容易。 [Thanks to @NeilJewers] [感谢@NeilJewers]

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

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM