尋找 Ruby 中的內存問題:權威指南
已發表: 2022-03-11我敢肯定有一些幸運的 Ruby 開發人員永遠不會遇到內存問題,但對於我們其他人來說,找出內存使用失控的地方並修復它是非常具有挑戰性的。 幸運的是,如果您使用的是現代 Ruby(2.1+),那麼有一些很棒的工具和技術可用於處理常見問題。 也可以說,內存優化可以是有趣和有益的,儘管我可能是唯一一個有這種感覺的人。
與所有形式的優化一樣,它很可能會增加代碼複雜性,因此除非有可衡量且顯著的收益,否則不值得這樣做。
此處描述的所有內容都是使用規範的 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
這也減少了內存使用量,儘管它比禁用序列化減少了一個數量級。 所以在這一點上,我們知道了我們最大的罪魁禍首,並且可以根據這些數據來決定優化什麼。
儘管此處的示例是人為設計的,但這些方法通常是適用的。 探查器結果可能無法指出代碼中問題所在的確切位置,也可能會被誤解,因此最好在打開和關閉代碼部分時查看實際內存使用情況。 接下來,我們將研究一些內存使用成為問題的常見情況以及如何優化它們。
反序列化
內存問題的一個常見來源是從 XML、JSON 或其他一些數據序列化格式反序列化大量數據。 使用JSON.parse
或 Active Support 的Hash.from_xml
之類的方法非常方便,但是當您加載的數據很大時,加載到內存中的結果數據結構可能會非常龐大。
如果您可以控制數據源,則可以限制接收的數據量,例如添加過濾或分頁支持。 但如果它是外部源或您無法控制的源,另一種選擇是使用流式反序列化器。 對於 XML,Ox 是一種選擇,對於 JSON,yajl-ruby 的操作似乎類似,儘管我沒有太多經驗。
這是一個使用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 中的內存使用進行了一些表徵,並查看了一些用於跟踪內存問題的通用工具,以及一些常見情況和改進方法。 我們探索的常見案例絕不是全面的,並且對我個人遇到的問題有很大的偏見。 然而,最大的收穫可能只是進入思考代碼將如何影響內存使用的心態。