寻找 Ruby 中的内存问题:权威指南

已发表: 2022-03-11

我敢肯定有一些幸运的 Ruby 开发人员永远不会遇到内存问题,但对于我们其他人来说,找出内存使用失控的地方并修复它是非常具有挑战性的。 幸运的是,如果您使用的是现代 Ruby(2.1+),那么有一些很棒的工具和技术可用于处理常见问题。 也可以说,内存优化可以是有趣和有益的,尽管我可能是唯一一个有这种感觉的人。

寻找 Ruby 中的内存问题

如果您认为错误令人讨厌,请等到您寻找内存问题。
鸣叫

与所有形式的优化一样,它很可能会增加代码复杂性,因此除非有可衡量且显着的收益,否则不值得这样做。

此处描述的所有内容都是使用规范的 MRI Ruby 版本 2.2.4 完成的,尽管其他 2.1+ 版本的行为应该类似。

这不是内存泄漏!

当发现内存问题时,很容易得出存在内存泄漏的结论。 例如,在 Web 应用程序中,您可能会看到在启动服务器后,对同一端点的重复调用会随着每个请求而不断提高内存使用率。 当然,在某些情况下会发生合法的内存泄漏,但我敢打赌,具有相同外观但实际上并非泄漏的内存问题远远超过了它们。

作为一个(人为的)示例,让我们看一些 Ruby 代码,它重复构建一个大的哈希数组并丢弃它。 首先,这里有一些将在本文的示例中共享的代码:

 # common.rb require "active_record" require "active_support/all" require "get_process_mem" require "sqlite3" ActiveRecord::Base.establish_connection( adapter: "sqlite3", database: "people.sqlite3" ) class Person < ActiveRecord::Base; end def print_usage(description) mb = GetProcessMem.new.mb puts "#{ description } - MEMORY USAGE(MB): #{ mb.round }" end def print_usage_before_and_after print_usage("Before") yield print_usage("After") end def random_name (0...20).map { (97 + rand(26)).chr }.join end

和数组构建器:

 # build_arrays.rb require_relative "./common" ARRAY_SIZE = 1_000_000 times = ARGV.first.to_i print_usage(0) (1..times).each do |n| foo = [] ARRAY_SIZE.times { foo << {some: "stuff"} } print_usage(n) end

get_process_mem gem 只是获取当前 Ruby 进程正在使用的内存的便捷方式。 我们看到的是与上述相同的行为,即内存使用量的持续增加。

 $ ruby build_arrays.rb 10 0 - MEMORY USAGE(MB): 17 1 - MEMORY USAGE(MB): 330 2 - MEMORY USAGE(MB): 481 3 - MEMORY USAGE(MB): 492 4 - MEMORY USAGE(MB): 559 5 - MEMORY USAGE(MB): 584 6 - MEMORY USAGE(MB): 588 7 - MEMORY USAGE(MB): 591 8 - MEMORY USAGE(MB): 603 9 - MEMORY USAGE(MB): 613 10 - MEMORY USAGE(MB): 621

但是,如果我们运行更多的迭代,我们最终会趋于平稳。

 $ ruby build_arrays.rb 40 0 - MEMORY USAGE(MB): 9 1 - MEMORY USAGE(MB): 323 ... 32 - MEMORY USAGE(MB): 700 33 - MEMORY USAGE(MB): 699 34 - MEMORY USAGE(MB): 698 35 - MEMORY USAGE(MB): 698 36 - MEMORY USAGE(MB): 696 37 - MEMORY USAGE(MB): 696 38 - MEMORY USAGE(MB): 696 39 - MEMORY USAGE(MB): 701 40 - MEMORY USAGE(MB): 697

达到这个高原是不是真正的内存泄漏的标志,或者内存泄漏是如此之小以至于与其他内存使用情况相比它是不可见的。 可能不直观的是为什么内存使用量在第一次迭代后继续增长。 毕竟,它建了一个大阵列,但很快就将其丢弃,并开始构建一个相同大小的新阵列。 不能只使用前一个数组释放的空间吗? 答案,这解释了我们的问题,是否定的。 除了调整垃圾收集器之外,您无法控制它何时运行,而我们在build_arrays.rb示例中看到的是在对旧的丢弃对象进行垃圾收集之前进行新的内存分配。

如果您发现应用程序的内存使用量突然上升,请不要惊慌。 应用程序可能因各种原因耗尽内存 - 不仅仅是内存泄漏。

我应该指出,这不是 Ruby 特有的某种可怕的内存管理问题,但通常适用于垃圾收集语言。 为了让自己放心,我用 Go 复制了基本相同的示例,并看到了类似的结果。 但是,有一些 Ruby 库可以很容易地创建此类内存问题。

分而治之

因此,如果我们需要处理大量数据,我们是否注定要在我们的问题上投入大量内存? 值得庆幸的是,事实并非如此。 如果我们以build_arrays.rb为例并减小数组大小,我们将看到内存使用稳定点的减少与数组大小大致成正比。

这意味着,如果我们可以将工作分成更小的部分进行处理并避免一次存在太多对象,我们可以显着减少内存占用。 不幸的是,这通常意味着采用漂亮、干净的代码并将其转换为更多执行相同操作的代码,只是以更节省内存的方式。

隔离内存使用热点

在真实的代码库中,内存问题的根源可能不像build_arrays.rb示例中那样明显。 在尝试实际挖掘并修复它之前隔离内存问题是必不可少的,因为很容易对导致问题的原因做出错误的假设。

我通常使用两种方法(通常结合使用)来跟踪内存问题:保持代码完整并在其周围包装一个分析器,并监控进程的内存使用情况,同时禁用/启用我怀疑可能有问题的代码的不同部分。 我将在这里使用 memory_profiler 进行分析,但 ruby​​-prof 是另一个流行的选项,并且 derailed_benchmarks 具有一些很棒的 Rails 特定功能。

下面是一些将使用大量内存的代码,可能无法立即清楚哪个步骤会最大程度地增加内存使用量:

 # people.rb require_relative "./common" def run(number) Person.delete_all names = number.times.map { random_name } names.each do |name| Person.create(name: name) end records = Person.all.to_a File.open("people.txt", "w") { |out| out << records.to_json } end

使用 get_process_mem,我们可以快速验证在创建大量Person记录时它确实使用了大量内存。

 # before_and_after.rb require_relative "./people" print_usage_before_and_after do run(ARGV.shift.to_i) end

结果:

 $ ruby before_and_after.rb 10000 Before - MEMORY USAGE(MB): 37 After - MEMORY USAGE(MB): 96

查看代码,有多个步骤似乎可以很好地使用大量内存:构建一个大字符串数组,在 Active Record 关系上调用#to_a以创建一个大 Active Record 对象数组(不是一个好主意,但用于演示目的),并序列化 Active Record 对象数组。

然后我们可以分析这段代码来查看内存分配发生在哪里:

 # profile.rb require "memory_profiler" require_relative "./people" report = MemoryProfiler.report do run(1000) end report.pretty_print(to_file: "profile.txt")

请注意,此处要run的数字是上一个示例的 1/10,因为分析器本身使用大量内存,并且在分析已经导致高内存使用的代码时实际上可能导致内存耗尽。

结果文件相当长,包括 gem、文件和位置级别的内存和对象分配和保留。 有大量信息可供探索,但这里有一些有趣的片段:

 allocated memory by gem ----------------------------------- 17520444 activerecord-4.2.6 7305511 activesupport-4.2.6 2551797 activemodel-4.2.6 2171660 arel-6.0.3 2002249 sqlite3-1.3.11 ... allocated memory by file ----------------------------------- 2840000 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activesupport-4.2.6/lib/activ e_support/hash_with_indifferent_access.rb 2006169 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activerecord-4.2.6/lib/active _record/type/time_value.rb 2001914 /Users/bruz/code/mem_test/people.rb 1655493 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activerecord-4.2.6/lib/active _record/connection_adapters/sqlite3_adapter.rb 1628392 /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activesupport-4.2.6/lib/activ e_support/json/encoding.rb

我们看到 Active Record 内部发生的分配最多,这似乎指向实例化records数组中的所有对象,或者使用#to_json进行序列化。 接下来,我们可以在禁用这些可疑对象的同时在不使用分析器的情况下测试我们的内存使用情况。 我们不能禁用检索records ,但仍然可以执行序列化步骤,所以让我们先尝试禁用序列化。

 # File.open("people.txt", "w") { |out| out << records.to_json }

结果:

 $ ruby before_and_after.rb 10000 Before: 36 MB After: 47 MB

这确实似乎是大部分内存的去向,通过跳过它,之前/之后的内存增量下降了 81%。 如果我们停止强制创建大量记录,我们还可以看到会发生什么。

 # records = Person.all.to_a records = Person.all # File.open("people.txt", "w") { |out| out << records.to_json }

结果:

 $ ruby before_and_after.rb 10000 Before: 36 MB After: 40 MB

这也减少了内存使用量,尽管它比禁用序列化减少了一个数量级。 所以在这一点上,我们知道了我们最大的罪魁祸首,并且可以根据这些数据来决定优化什么。

尽管此处的示例是人为设计的,但这些方法通常是适用的。 Profiler 结果可能无法指出代码中问题所在的确切位置,也可能会被误解,因此最好在打开和关闭代码部分时查看实际内存使用情况。 接下来,我们将研究一些内存使用成为问题的常见情况以及如何优化它们。

反序列化

内存问题的一个常见来源是从 XML、JSON 或其他一些数据序列化格式反序列化大量数据。 使用JSON.parse或 Active Support 的Hash.from_xml之类的方法非常方便,但是当您加载的数据很大时,加载到内存中的结果数据结构可能会非常庞大​​。

如果您可以控制数据源,则可以限制接收的数据量,例如添加过滤或分页支持。 但如果它是外部源或您无法控制的源,另一种选择是使用流式反序列化器。 对于 XML,Ox 是一种选择,对于 JSON,yajl-ruby 的操作似乎类似,尽管我没有太多经验。

内存有限并不意味着您不能安全地解析大型 XML 或 JSON 文档。 流式反序列化器允许您从这些文档中逐步提取您需要的任何内容,并且仍然保持较低的内存占用。

这是一个使用Hash#from_xml解析 1.7MB XML 文件的示例。

 # parse_with_from_xml.rb require_relative "./common" print_usage_before_and_after do # From http://www.cs.washington.edu/research/xmldatasets/data/mondial/mondial-3.0.xml file = File.open(File.expand_path("../mondial-3.0.xml", __FILE__)) hash = Hash.from_xml(file)["mondial"]["continent"] puts hash.map { |c| c["name"] }.join(", ") end
 $ ruby parse_with_from_xml.rb Before - MEMORY USAGE(MB): 37 Europe, Asia, America, Australia/Oceania, Africa After - MEMORY USAGE(MB): 164

1.7MB 文件需要 111MB! 这显然不会很好地扩大规模。 这是流解析器版本。

 # parse_with_ox.rb require_relative "./common" require "ox" class Handler < ::Ox::Sax def initialize(&block) @yield_to = block end def start_element(name) case name when :continent @in_continent = true end end def end_element(name) case name when :continent @yield_to.call(@name) if @name @in_continent = false @name = nil end end def attr(name, value) case name when :name @name = value if @in_continent end end end print_usage_before_and_after do # From http://www.cs.washington.edu/research/xmldatasets/data/mondial/mondial-3.0.xml file = File.open(File.expand_path("../mondial-3.0.xml", __FILE__)) continents = [] handler = Handler.new do |continent| continents << continent end Ox.sax_parse(handler, file) puts continents.join(", ") end
 $ ruby parse_with_ox.rb Before - MEMORY USAGE(MB): 37 Europe, Asia, America, Australia/Oceania, Africa After - MEMORY USAGE(MB): 37

这使我们的内存增加可以忽略不计,并且应该能够处理更大的文件。 然而,权衡是我们现在有 28 行我们以前不需要的处理程序代码,这似乎很容易出错,并且对于生产使用它应该有一些测试。

序列化

正如我们在隔离内存使用热点部分中看到的,序列化可能会产生高内存成本。 这是之前people.rb的关键部分。

 # to_json.rb require_relative "./common" print_usage_before_and_after do File.open("people.txt", "w") { |out| out << Person.all.to_json } end

使用数据库中的 100,000 条记录运行此程序,我们得到:

 $ ruby to_json.rb Before: 36 MB After: 505 MB

在这里调用#to_json的问题是它为每条记录实例化一个对象,然后编码为 JSON。 逐个记录生成 JSON 记录,以便一次只需要存在一个记录对象,从而显着降低了内存使用量。 似乎没有一个流行的 Ruby JSON 库可以处理这个问题,但通常推荐的方法是手动构建 JSON 字符串。 有一个 json-write-stream gem 提供了一个很好的 API 来执行此操作,将我们的示例转换为如下所示:

 # json_stream.rb require_relative "./common" require "json-write-stream" print_usage_before_and_after do file = File.open("people.txt", "w") JsonWriteStream.from_stream(file) do |writer| writer.write_object do |obj_writer| obj_writer.write_array("people") do |arr_writer| Person.find_each do |people| arr_writer.write_element people.as_json end end end end end

再一次,我们看到优化给了我们更多的代码,但结果似乎是值得的:

 $ ruby json_stream.rb Before: 36 MB After: 56 MB

懒惰

从 2.0 开始添加到 Ruby 的一个很棒的特性是使枚举器变得惰性的能力。 这对于在枚举器上链接方法时改善内存使用非常有用。 让我们从一些不懒惰的代码开始:

 # not_lazy.rb require_relative "./common" number = ARGV.shift.to_i print_usage_before_and_after do names = number.times .map { random_name } .map { |name| name.capitalize } .map { |name| "#{ name } Jr." } .select { |name| name[0] == "X" } .to_a end

结果:

 $ ruby not_lazy.rb 1_000_000 Before: 36 MB After: 546 MB 

这里发生的是,在链中的每一步,它遍历枚举器中的每个元素,生成一个数组,该数组中调用了链中的后续方法,依此类推。 让我们看看当我们使这个变得惰性时会发生什么,这只需要在我们从times得到的枚举器上添加一个对lazy的调用:

 # lazy.rb require_relative "./common" number = ARGV.shift.to_i print_usage_before_and_after do names = number.times.lazy .map { random_name } .map { |name| name.capitalize } .map { |name| "#{ name } Jr." } .select { |name| name[0] == "X" } .to_a end

结果:

 $ ruby lazy.rb 1_000_000 Before: 36 MB After: 52 MB

最后,一个例子给我们带来了巨大的内存使用胜利,而无需添加大量额外的代码! 请注意,如果我们最后不需要累积任何结果,例如,如果每个项目都保存到数据库中并且可以被遗忘,那么内存使用量会更少。 要在链的末尾进行惰性可枚举评估,只需添加对force的最终调用。

关于该示例需要注意的另一件事是,该链从调用lazy之前的times开始,它使用非常少的内存,因为它只返回一个枚举器,每次调用它时都会生成一个整数。 因此,如果可以在链的开头使用可枚举而不是大数组,那将有所帮助。

将所有内容保存在巨大的数组和地图中很方便,但在现实世界的场景中,您很少需要这样做。

构建可枚举以延迟馈入某种处理管道的一个实际应用是处理分页数据。 因此,与其请求所有页面并将它们放入一个大数组中,不如通过一个很好地隐藏所有分页细节的枚举器来公开它们。 这可能看起来像:

 def records Enumerator.new do |yielder| has_more = true page = 1 while has_more response = fetch(page) response.records.each { |record| yielder << record } page += 1 has_more = response.has_more end end end

结论

我们已经对 Ruby 中的内存使用进行了一些表征,并查看了一些用于跟踪内存问题的通用工具,以及一些常见情况和改进方法。 我们探索的常见案例绝不是全面的,并且对我个人遇到的问题有很大的偏见。 然而,最大的收获可能只是进入思考代码将如何影响内存使用的心态。

相关: Ruby并发和并行:实用教程