作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Bruz是一个拥有15年后端Ruby经验的全栈开发人员, databases, 基础设施和前端JavaScript.
我确信有一些幸运的Ruby开发人员永远不会遇到内存问题, but for the rest of us, 寻找内存使用失控的地方并加以修复是一项极具挑战性的工作. 幸运的是,如果您使用的是现代Ruby (2.1+),有一些很好的工具和技术可用于处理常见问题. 也可以说,记忆优化可以是有趣和有益的,尽管我可能是唯一有这种观点的人.
和所有形式的优化一样, 它很可能会增加代码的复杂性, 因此,除非有可衡量的显著收益,否则不值得这么做.
这里描述的所有内容都是使用规范的MRI Ruby版本2完成的.2.4, although other 2.1+版本的行为应该类似.
当发现内存问题时,很容易得出存在内存泄漏的结论. For example, in a web application, 在启动服务器后,您可能会看到这一点, 对同一端点的重复调用会随着每个请求不断增加内存使用量. 当然存在发生合法内存泄漏的情况, 但我敢打赌,它们的数量远远超过了内存问题,它们具有相同的外观,实际上不是泄漏.
As a (contrived) example, 让我们看一段Ruby代码,它反复构建一个大的哈希数组并丢弃它. 首先,这里有一些代码将在这篇文章的示例中分享:
# common.rb
require "active_record"
需要“active_support /所有”
需要“get_process_mem”
require "sqlite3"
ActiveRecord::Base.establish_connection(
adapter: "sqlite3",
database: "people.sqlite3"
)
class Person < ActiveRecord::Base; end
def print_usage(描述)
mb = GetProcessMem.new.mb
#{description} -内存使用情况(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
And the array builder:
# 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
The get_process_mem gem只是一种获取当前Ruby进程正在使用的内存的方便方法. 我们看到的是与上面描述的相同的行为,内存使用不断增加.
$ ruby build_arrays.rb 10
0 - MEMORY USAGE(MB): 17
1 -内存使用率(mb): 330
2 -内存使用率(mb): 481
3 -内存占用率(mb): 492
4 -内存占用率(mb): 559
5 -内存使用率(mb): 584
6 -内存占用率(mb): 588
7 -内存使用率(mb): 591
8 - MEMORY USAGE(MB): 603
9 - MEMORY USAGE(MB): 613
10 -内存占用率(mb): 621
然而,如果我们运行更多的迭代,我们最终会趋于平稳.
$ ruby build_arrays.rb 40
0 - MEMORY USAGE(MB): 9
1 -内存占用率(mb): 323
...
32 -内存使用率(mb): 700
33 -内存使用率(mb): 699
34 -内存使用率(mb): 698
35 -内存使用率(mb): 698
36 -内存使用率(mb): 696
37 -内存使用率(mb): 696
38 -内存使用率(mb): 696
39 -内存使用率(mb): 701
40 -内存使用率(mb): 697
达到这个平台是没有实际内存泄漏的标志, 或者内存泄漏非常小,与其他内存使用相比不可见. 为什么内存使用在第一次迭代之后继续增长,这可能不是直观的. After all, it built a big array, 但很快就把它丢弃了,并开始建造一个同样大小的新建筑. 它不能使用前一个数组释放的空间吗? 答案是否定的,这也解释了我们的问题. 除了调优垃圾收集器, 你无法控制它何时运行, 我们看到的是 build_arrays.rb
例如,在旧内存的垃圾收集之前分配新内存, discarded objects.
我应该指出,这并不是某种可怕的内存管理问题 Ruby,但通常适用于垃圾收集语言. 只是为了让自己放心, 我用Go重现了基本相同的例子,看到了类似的结果. 然而,有一些Ruby库可以很容易地创建这类内存问题.
所以如果我们需要处理大量的数据, 我们是否注定要浪费大量的内存来解决问题? 谢天谢地,情况并非如此. If we take the build_arrays.rb
示例并减小数组大小, 我们将看到内存使用趋于稳定时的下降,这与数组大小大致成正比.
这意味着如果我们可以将我们的工作分成更小的部分来处理,并避免一次存在太多的对象, 我们可以显著减少内存占用. Unfortunately, 这通常意味着友善, 清理代码,并将其转化为更多的代码,做同样的事情, 只是以一种更节省内存的方式.
在真实的代码库中,内存问题的根源可能不像在 build_arrays.rb
example. 在尝试真正深入研究和修复内存问题之前,隔离内存问题是至关重要的,因为很容易对导致问题的原因做出错误的假设.
我通常使用两种方法, often in combination, 跟踪内存问题:保持代码完整,并在其周围包装一个分析器, 监视进程的内存使用情况,同时禁用/启用我怀疑可能有问题的代码的不同部分. I’ll be using memory_profiler here for profiling, but 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
Using get_process_mem,我们可以快速验证它确实使用了很多内存,当有很多 Person
records being created.
# before_and_after.rb
require_relative "./people"
print_usage_before_and_after做
run(ARGV.shift.to_i)
end
Result:
$ ruby before_and_after.rb 10000
启用前—内存占用率(MB): 37
后记忆体使用率(MB): 96
Looking through the code, 有几个步骤似乎是使用大量内存的好选择:构建一个大的字符串数组, calling #to_a
在活动记录关系上创建一个大的活动记录对象数组(不是一个好主意), 但这样做只是为了演示), 并序列化活动记录对象数组.
然后我们可以分析这段代码,看看内存分配发生在哪里:
# profile.rb
需要“memory_profiler”
require_relative "./people"
report = MemoryProfiler.report do
run(1000)
end
report.pretty_print条件(to_file:“配置文件.txt")
请注意被输入的号码 run
这是前一个例子的1/10, 因为分析器本身使用大量内存, 并且在分析已经导致高内存使用的代码时,实际上会导致内存耗尽.
结果文件相当长,包括gem中的内存和对象分配和保留, file, and location levels. 有大量的信息可以探索,但这里有几个有趣的片段:
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 /用户/ bruz /代码/ mem_test /人.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
我们看到大多数分配发生在活动记录内部, 这似乎指向实例化中的所有对象 records
数组,或序列化 #to_json
. 接下来,我们可以在禁用这些疑点的同时,在没有分析器的情况下测试内存使用情况. 我们不能禁用检索功能 records
并且仍然能够执行序列化步骤,所以让我们先尝试禁用序列化.
# File.open("people.txt", "w") { |out| out << records.to_json }
Result:
$ 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 }
Result:
$ ruby before_and_after.rb 10000
Before: 36 MB
After: 40 MB
这也减少了内存的使用, 尽管这比禁用序列化要少一个数量级. So at this point, 我们知道最大的罪魁祸首, 并且可以根据这些数据做出优化的决定.
虽然这里的示例是人为的,但这些方法通常是适用的. 分析器的结果可能不会指向代码中问题所在的确切位置, 也可能被误解, 因此,在打开和关闭代码段时查看实际内存使用情况是个好主意. 接下来,我们将查看内存使用成为问题的一些常见情况,以及如何对它们进行优化.
内存问题的一个常见来源是对来自XML的大量数据进行反序列化, JSON或其他数据序列化格式. Using methods like JSON.parse
or Active Support’s Hash.from_xml
is incredibly convenient, 但是当你加载的数据很大的时候, 最终加载到内存中的数据结构可能非常庞大.
如果您可以控制数据的来源, 您可以做一些事情来限制接收的数据量, 比如添加过滤或分页支持. 但如果是外部的或是你无法控制的, 另一种选择是使用流反序列化器. For XML, Ox 是一个选项,对于JSON yajl-ruby 似乎运作相似,虽然我没有太多的经验.
下面是解析1的示例.7MB XML file, using Hash#from_xml
.
# parse_with_from_xml.rb
require_relative "./common"
print_usage_before_and_after做
# 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(文件)(“全世界范围的”)(“大陆”)
puts hash.map { |c| c["name"] }.join(", ")
end
$ ruby parse_with_from_xml.rb
启用前—内存占用率(MB): 37
欧洲,亚洲,美洲,澳大利亚/大洋洲,非洲
后内存使用率(MB): 164
111MB for a 1.7MB file! 这显然不会很好地扩大规模. 这是流解析器版本.
# 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如果@in_continent
end
end
end
print_usage_before_and_after做
# 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
启用前—内存占用率(MB): 37
欧洲,亚洲,美洲,澳大利亚/大洋洲,非洲
后内存使用率(MB): 37
这使我们的内存增加可以忽略不计,应该能够处理更大的文件. However, 这样做的代价是,我们现在有了以前不需要的28行处理程序代码, 这看起来很容易出错, 对于生产使用,它应该有一些测试.
正如我们在隔离内存使用热点一节中看到的那样, 序列化可能会有很高的内存开销. Here’s the key part of people.rb
from earlier.
# to_json.rb
require_relative "./common"
print_usage_before_and_after做
File.open("people.txt", "w") { |out| out << Person.all.to_json }
end
在数据库中运行100,000条记录,我们得到:
$ ruby to_json.rb
Before: 36 MB
After: 505 MB
The issue with calling #to_json
这里,它为每条记录实例化一个对象,然后编码为JSON. 逐条记录生成JSON,这样一次只需要存在一个记录对象,从而大大减少了内存使用. 没有一个流行的Ruby JSON库能够处理这个问题, 但通常推荐的方法是手动构建JSON字符串. There is a json-write-stream gem提供了一个很好的API来做这件事,把我们的例子转换成这样:
# json_stream.rb
require_relative "./common"
需要“json-write-stream”
print_usage_before_and_after做
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
Ruby从2开始添加了一个很棒的特性.0是使枚举器变懒的能力. 这对于在枚举数上链接方法时改善内存使用非常有用. 让我们从一些不懒惰的代码开始:
# not_lazy.rb
require_relative "./common"
number = ARGV.shift.to_i
print_usage_before_and_after做
names = number.times
.map { random_name }
.map { |name| name.capitalize }
.map {|name| "#{name} Jr." }
.select {|name| name[0] == "X"}
.to_a
end
Result:
$ ruby not_lazy.rb 1_000_000
Before: 36 MB
After: 546 MB
这里所发生的是,在链条的每一步, 它遍历枚举数中的每个元素, 生成一个数组,在该数组上调用链中的后续方法, and so forth. 让我们看看当我们将这个设置为lazy时发生了什么,这只需要添加一个调用 lazy
在枚举数上 times
:
# lazy.rb
require_relative "./common"
number = ARGV.shift.to_i
print_usage_before_and_after做
names = number.times.lazy
.map { random_name }
.map { |name| name.capitalize }
.map {|name| "#{name} Jr." }
.select {|name| name[0] == "X"}
.to_a
end
Result:
$ ruby lazy.rb 1_000_000
Before: 36 MB
After: 52 MB
最后,一个示例在不添加大量额外代码的情况下获得了巨大的内存使用优势! 注意,如果我们不需要在最后累积任何结果, for instance, 如果每个项目都保存到数据库中,然后可以忘记, 会有更少的内存使用. 要在链的末尾执行一个惰性可枚举的求值,只需最后调用 force
.
关于该示例的另一件要注意的事情是,该链从调用 times
prior to lazy
, 它使用很少的内存,因为它只是返回一个枚举器,每次调用它都会生成一个整数. 因此,如果可以在链的开头使用一个可枚举对象,而不是一个大数组,那将会有所帮助.
构建一个可枚举对象以惰性地提供给某种处理管道的一个实际应用是处理分页数据. 而不是请求所有页面并将它们放入一个大数组中, 它们可以通过枚举器公开,该枚举器很好地隐藏了所有分页细节. 这可能看起来像这样:
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中的内存使用做了一些描述, 并研究了一些用于跟踪内存问题的通用工具, 以及一些常见的情况和改进方法. 我们探讨的常见案例绝不是全面的,而且对我个人遇到的问题有很大的偏见. However, 最大的收获可能只是让你有了思考代码将如何影响内存使用的心态.
Bruz是一个拥有15年后端Ruby经验的全栈开发人员, databases, 基础设施和前端JavaScript.
世界级的文章,每周发一次.
世界级的文章,每周发一次.
Join the Toptal® community.