背景:我们在现有的Rails应用程序中构建了一个聊天功能。我们正在使用新的ActionController::Live
模块并运行Puma(在生产中使用Nginx),并通过Redis订阅消息。我们正在使用EventSource
客户端异步建立连接。
问题摘要:当连接终止时,线程永远不会消亡。
例如,如果用户离开,关闭浏览器,或者甚至转到应用程序中的其他页面,则会生成一个新线程(如预期的那样),但旧线程将继续存在。
我现在看到的问题是,当出现任何这种情况时,服务器无法知道浏览器端的连接是否被终止,直到某些东西试图写入这个破碎的流,这种情况永远不会发生一旦浏览器离开原始页面。
此问题似乎已记录在案on github,并在StackOverflow here (pretty well exact same question)和here (regarding getting number of active threads)上提出类似问题。
基于这些帖子,我能够提出的唯一解决方案是实现一种线程/连接扑克。尝试写入断开的连接会生成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
方法调用中被阻止。其他所有内容(流式传输)都正常工作,所以我认为这是正常的。)
(其他注意:冲洗线程概念作为单个长时间运行的后台进程更有意义,有点像垃圾线程收集器。上面我的实现的问题是为每个连接产生一个新线程,这是任何试图实现这个概念的人都应该更像是一个过程,而不是我概述的那样。当我成功地将其作为单个后台进程重新实现时,我会更新这篇文章。)
这个解决方案的缺点是我们只是延迟或减轻了问题,而没有完全解决它。除了其他请求(如ajax)之外,我们仍然有每个用户2个线程,从扩展的角度来看这似乎很糟糕;对于具有许多可能的并发连接的大型系统来说,这似乎是完全无法实现的,也是不切实际的。
我觉得我错过了一些至关重要的东西;我觉得有点难以相信Rails有一个明显被破坏的功能而没有像我那样实现自定义连接检查器。
问题:我们如何在不实现“连接扑克”或垃圾线程收集器之类的错误的情况下允许连接/线程死亡?
如果我遗漏了任何东西,请随时告诉我。
更新
只是为了添加一些额外的信息:Huetsch在github发布this comment指出SSE基于TCP,它通常在连接关闭时发送FIN数据包,让另一端(服务器在这种情况下)知道关闭连接是安全的。 Huetsch指出浏览器没有发送该数据包(可能是EventSource
库中的错误?),或者Rails没有捕获它或对它做任何事情(如果是这种情况,肯定是Rails中的错误) 。搜索继续......
另一次更新 使用Wireshark,我确实可以看到FIN数据包被发送。不可否认,我对协议级别的东西知之甚少或不熟悉,但据我所知,当我使用浏览器中的EventSource建立SSE连接时,我肯定会检测到从浏览器发送的FIN数据包,如果我发送了NO数据包删除该连接(意味着没有SSE)。虽然我对TCP的知识并不十分了解,但这似乎向我表明,客户端确实正确地终止了连接;也许这表明Puma或Rails中存在错误。
又一次更新
@JamesBoutcher / boutcheratwest(github)向我指出了discussion on the redis website regarding这个问题,特别是关于.(p)subscribe
方法永远不会关闭的事实。该站点上的海报指出了我们在此处发现的相同内容,即当客户端连接关闭时,Rails环境永远不会得到通知,因此无法执行.(p)unsubscribe
方法。他询问.(p)subscribe
方法的超时,我认为这种方法也可行,但我不确定哪种方法(我上面描述的连接扑克,或者他的超时建议)会是更好的解决方案。理想情况下,对于连接扑克解决方案,我想找到一种方法来确定连接是否在另一端关闭而不写入流。就像现在一样,正如你所看到的,我必须实现客户端代码来分别处理我的“戳”消息,我认为这是一种突兀和愚蠢的行为。
答案 0 :(得分:15)
我刚才做的一个解决方案(从@teeg借了很多)似乎工作正常(没有失败测试它,所以)
配置/初始化/ 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
答案 1 :(得分:4)
我目前正在创建一个围绕ActionController的应用程序:Live,EventSource和Puma以及那些遇到关闭流等问题的应用程序,而不是在Rails 4.2中拯救IOError
,你需要解救{ {1}}。示例:
ClientDisconnected
我在这篇论坛帖子中找到了这个方便的提示(一直在底部):http://railscasts.com/episodes/401-actioncontroller-live?view=comments
答案 2 :(得分:2)
在@James Boutcher的基础上,我在群集Puma中使用了以下2个worker,因此我在config / initializers / redis.rb中只为心跳创建了1个线程:
配置/ 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
答案 3 :(得分:1)
这是一个可能更简单的解决方案,不使用心跳。经过大量的研究和实验,这里是我用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
redis连接阻止on.message并且只接受(p)subscribe /(p)unsubscribe命令。取消订阅后,redis连接不再被阻止,并且可以由初始sse请求实例化的Web服务器对象释放。当您在redis上收到消息时,它会自动清除,并且集合数组中不再存在与浏览器的连接。
答案 4 :(得分:1)
这里你是超时的解决方案,将退出阻止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
你必须使用红色。(p)取消订阅以结束此阻止通话。没有例外可以打破这个。
我的简单应用包含有关此修复程序的信息:https://github.com/piotr-kedziak/redis-subscribe-stream-puma-fix
答案 5 :(得分:0)
不是向所有客户端发送心跳,而是为每个连接设置监视程序可能更容易。 [感谢@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