学习ruby线程泄漏的隐藏成本

72 阅读4分钟

寻觅错误

最近我一直在处理一个小程序,它将逐渐变得越来越慢。虽然有很多原因,但我发现其中一个很有趣。

给大家介绍一下背景:这个应用是一个简单的单主题传统Kafka消费者。我把它改写成了Karafka,所有的逻辑看起来像这样。

class EventsConsumer < Karafka::BaseConsumer
  def initialize(...)
    super
    @processor = Processor.new
  end

  def consume
    @processor.call(params_batch.payloads)
  end
end

而处理器看起来是这样的(我删除了所有不相关的代码)。

class Processor
  def initialize
    @queue = Queue.new
    @worker = Thread.new { work }
  end

  def call(events)
    # do something with events
    results = complex_inline_computation(events)
    @queue << results
  end

  private

  def work
    while true
      result = @queue.pop
      # some sort of async storing operation should go here
      p result
    end
  end

  def complex_inline_computation(events)
    events.join('-')
  end
end

所以,我们有一个带处理器的Karafka消费者,有一个后台线程,负责异步刷新数据。没有什么特别的,抛开潜在的线程崩溃,一切看起来都很好。

然而,这段代码中有一个隐藏的问题,随着时间的推移,这个消费者的性能会慢慢降低,从而暴露出来。

Karafka在每个主题分区使用一个持久的消费者实例。当我们第一次开始处理一个给定主题的特定分区时,会创建一个新的消费者实例。这本身就意味着,我们最终的线程数量直接由我们用一个Karafka进程处理的主题及其分区的数量决定。

如果这就是全部,我想说这还不算太糟。虽然对于一个有20个分区的单一主题消耗进程来说,我们确实最终会有额外的20个线程,但在达到这个数字后,退化应该停止。

它并没有。

还有一种情况,我们的传统消费者和Karafka会因为处理器的重新创建而旋转起额外的线程。Kafka再平衡。当重新平衡发生时,新的消费者实例被初始化。这意味着,每次缩放发生时,无论是会增加还是删除实例,都会创建一个新的处理器线程。

修复该问题

在不重新设计的情况下修复这个问题是相当简单的。只要我们能接受单子,并且知道我们的代码不会被几个线程并行执行,我们就可以直接把处理器变成单子。

class Processor
  include Singleton
  
  # Remaining code
end

class EventsConsumer < Karafka::BaseConsumer
  def initialize(...)
    super
    @processor = Processor.instance
  end

  # Remaining code
end


虽然这不是最佳解决方案,但在我的案例中,这已经足够了。

性能影响

还有一个问题:让陈旧的线程什么都不做,对性能有什么影响?

我将尝试用一个比我更直接的案例来回答这个问题。

require 'benchmark'

MAX_THREADS = 100
STEP = 10
ITERS = 50000000

(0..MAX_THREADS).step(STEP).each do |el|
  STEP.times do
    Thread.new do
      q = Queue.new
      q.pop
    end
  end unless el.zero?

  # Give it a bit of time to initialize the threads
  sleep 5

  # warmup for jruby - thanks Charles!
  5.times do
    ITERS.times do ; a = "1"; end
  end

  Benchmark.bm do |x|
    x.report { ITERS.times do ; a = "1"; end }
  end
end

我把这段代码运行了100次,用平均时间来尽量减少这台机器上运行的其他任务的随机性影响。

下面是Ruby 2.7.2、3.0.0-preview2(有JIT和无JIT)和JRuby 9.2.13的结果,都用time taskset -c 1 ,以确保JRuby在同样的条件下运行(单核)。

CRuby的性能下降或多或少是线性的。你有越多的线程什么都不做,整体处理速度就越慢。这并不影响JRuby,因为JVM的线程支持与CRuby完全不同。

不过,更让我担心的是,Ruby 3.0似乎比2.7.2退化得更厉害。我的大胆猜测是,这是因为Ractors代码的开销和其他影响线程调度器的变化。

下面你可以找到CRuby所有变体的时间比较。

在这种情况下,3.0比2.7.2慢,这很吸引人,我将在接下来的几个月里尝试研究其背后的原因。

注意:我不认为这是JIT的最佳用例,所以请不要根据上面的图表对其性能提出强烈的要求。

总结

你构建的应用程序越复杂,你在某些时候必须有线程的机会就越大。如果发生这种情况,请注意它们对你的应用程序的整体性能的影响。

另外,请记住,当你引入后台线程的时候,你就应该围绕它们引入适当的工具。