Ruby'de Bellek Sorunlarını Ortadan Kaldırmak: Kesin Bir Kılavuz
Yayınlanan: 2022-03-11Eminim bellekle ilgili hiçbir sorunla karşılaşmayacak şanslı Ruby geliştiricileri vardır, ancak geri kalanımız için bellek kullanımının kontrolden çıktığı yeri bulmak ve düzeltmek inanılmaz derecede zor. Neyse ki, modern bir Ruby (2.1+) kullanıyorsanız, yaygın sorunlarla başa çıkmak için bazı harika araçlar ve teknikler vardır. Bu duyguda yalnız olsam da, hafıza optimizasyonunun eğlenceli ve ödüllendirici olabileceği de söylenebilir.
Tüm optimizasyon biçimlerinde olduğu gibi, kod karmaşıklığı ekleyecektir, bu nedenle ölçülebilir ve önemli kazançlar olmadıkça yapmaya değmez.
Burada açıklanan her şey, kanonik MRI Ruby, sürüm 2.2.4 kullanılarak yapılır, ancak diğer 2.1+ sürümlerin benzer şekilde davranması gerekir.
Bu Bir Bellek Sızıntısı Değil!
Bir bellek sorunu keşfedildiğinde, bir bellek sızıntısı olduğu sonucuna varmak kolaydır. Örneğin, bir web uygulamasında, sunucunuzu çalıştırdıktan sonra, aynı uç noktaya yapılan tekrarlanan çağrıların, her istekte bellek kullanımını daha da artırmaya devam ettiğini görebilirsiniz. Meşru bellek sızıntılarının meydana geldiği durumlar kesinlikle vardır, ancak bahse girerim, bu aynı görünüme sahip olan ve aslında sızıntı olmayan bellek sorunları bakımından sayıca çok daha fazladır.
(Yapma) bir örnek olarak, art arda büyük bir karma dizisi oluşturan ve onu atan biraz Ruby koduna bakalım. İlk olarak, bu gönderideki örnekler boyunca paylaşılacak bazı kodlar:
# 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
Ve dizi oluşturucu:
# 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, geçerli Ruby işlemi tarafından kullanılan belleği almanın uygun bir yoludur. Gördüğümüz, yukarıda açıklanan davranışın aynısı, bellek kullanımında sürekli bir artış.
$ 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
Ancak, daha fazla yineleme yaparsak, sonunda platoya ulaşırız.
$ 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
Bu platoya ulaşmak, gerçek bir bellek sızıntısı olmamasının ayırt edici özelliğidir veya bellek sızıntısı, bellek kullanımının geri kalanına kıyasla görünür olmayacak kadar küçüktür. Sezgisel olmayan şey, ilk yinelemeden sonra bellek kullanımının neden büyümeye devam ettiğidir. Sonuçta, büyük bir dizi oluşturdu, ancak hemen onu attı ve aynı boyutta yeni bir dizi oluşturmaya başladı. Önceki dizi tarafından boşaltılan alanı kullanamaz mı? Sorunumuzu açıklayan cevap hayır. Çöp toplayıcıyı ayarlamanın yanı sıra, ne zaman çalıştığı üzerinde kontrolünüz yok ve build_arrays.rb
örneğinde gördüğümüz, eski, atılan nesnelerimizin çöp toplama işleminden önce yapılan yeni bellek tahsisleridir.
Bunun Ruby'ye özgü bir tür korkunç bellek yönetimi sorunu olmadığını, ancak genellikle çöpten toplanan diller için geçerli olduğunu belirtmeliyim. Sadece bundan emin olmak için, Go ile aynı örneği yeniden ürettim ve benzer sonuçlar gördüm. Ancak, bu tür bir bellek sorunu yaratmayı kolaylaştıran Ruby kitaplıkları vardır.
Böl ve fethet
Öyleyse, büyük veri yığınlarıyla çalışmamız gerekiyorsa, sorunumuza çok fazla RAM atmaya mahkum muyuz? Neyse ki, durum böyle değil. build_arrays.rb
örneğini alır ve dizi boyutunu küçültürsek, bellek kullanımının plato olduğu noktada dizi boyutuyla kabaca orantılı bir azalma görürüz.
Bu, aynı anda çok fazla nesnenin bulunmasını önlemek ve işlemek için çalışmamızı daha küçük parçalara ayırabilirsek, bellek ayak izini önemli ölçüde azaltabileceğimiz anlamına gelir. Ne yazık ki, bu genellikle güzel, temiz bir kod almak ve onu aynı şeyi yapan daha fazla koda dönüştürmek anlamına gelir, sadece bellek açısından daha verimli bir şekilde.
Bellek Kullanım Noktalarını Yalıtma
Gerçek bir kod tabanında, bir bellek sorununun kaynağı muhtemelen build_arrays.rb
örneğindeki kadar açık olmayacaktır. Bir bellek sorununu gerçekten araştırmaya ve düzeltmeye çalışmadan önce yalıtmak çok önemlidir çünkü soruna neyin neden olduğu konusunda yanlış varsayımlarda bulunmak kolaydır.
Bellek sorunlarını izlemek için genellikle iki yaklaşımı bir arada kullanırım: kodu olduğu gibi bırakmak ve etrafına bir profil oluşturucu sarmak ve kodun farklı bölümlerini devre dışı bırakırken/etkinleştirirken işlemin bellek kullanımını izlemek sorunlu olabilir. Profil oluşturma için burada memory_profiler kullanacağım, ancak Ruby-prof başka bir popüler seçenektir ve derailed_benchmarks'ın Rails'e özgü bazı harika yetenekleri vardır.
İşte, bellek kullanımını en çok hangi adımın artırdığı hemen belli olmayabilecek, bir sürü bellek kullanacak bazı kodlar:
# 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 kullanarak, çok sayıda Person
kaydı oluşturulduğunda çok fazla bellek kullandığını hızlı bir şekilde doğrulayabiliriz.
# before_and_after.rb require_relative "./people" print_usage_before_and_after do run(ARGV.shift.to_i) end
Sonuç:
$ ruby before_and_after.rb 10000 Before - MEMORY USAGE(MB): 37 After - MEMORY USAGE(MB): 96
Kodu incelerken, çok fazla bellek kullanmak için iyi adaylar gibi görünen birden çok adım vardır: büyük bir dizi dizi oluşturmak, büyük bir Aktif Kayıt nesnesi dizisi oluşturmak için bir Aktif Kayıt ilişkisi üzerinde #to_a
çağırmak (harika bir fikir değil) , ancak gösterim amacıyla yapılmıştır) ve Aktif Kayıt nesneleri dizisini seri hale getirme.
Daha sonra, bellek tahsislerinin nerede olduğunu görmek için bu kodun profilini çıkarabiliriz:
# profile.rb require "memory_profiler" require_relative "./people" report = MemoryProfiler.report do run(1000) end report.pretty_print(to_file: "profile.txt")
Burada run
üzere beslenen sayının önceki örneğin 1/10'u olduğunu unutmayın, çünkü profil oluşturucunun kendisi çok fazla bellek kullanır ve zaten yüksek bellek kullanımına neden olan kodun profilini çıkarırken aslında belleğin tükenmesine neden olabilir.
Sonuçlar dosyası oldukça uzundur ve mücevher, dosya ve konum seviyelerinde bellek ve nesne tahsisi ve saklamayı içerir. Keşfedilecek çok sayıda bilgi var, ancak işte birkaç ilginç pasaj:
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
En çok ayırmanın Active Record içinde gerçekleştiğini görüyoruz; bu, ya records
dizisindeki tüm nesneleri başlatmaya ya da #to_json
ile serileştirmeye işaret ediyor gibi görünüyor. Ardından, bu şüphelileri devre dışı bırakırken profil oluşturucu olmadan bellek kullanımımızı test edebiliriz. records
almayı devre dışı bırakamıyoruz ve yine de serileştirme adımını yapabiliyoruz, bu yüzden önce serileştirmeyi devre dışı bırakmayı deneyelim.
# File.open("people.txt", "w") { |out| out << records.to_json }
Sonuç:

$ ruby before_and_after.rb 10000 Before: 36 MB After: 47 MB
Bu gerçekten de belleğin çoğunun gittiği yer gibi görünüyor, bellek deltasının öncesi/sonrası atlanarak %81 oranında düşüyor. Büyük kayıt dizisini oluşturmaya zorlamayı bırakırsak ne olacağını da görebiliriz.
# records = Person.all.to_a records = Person.all # File.open("people.txt", "w") { |out| out << records.to_json }
Sonuç:
$ ruby before_and_after.rb 10000 Before: 36 MB After: 40 MB
Bu, serileştirmeyi devre dışı bırakmaktan çok daha az bir azalma olmasına rağmen, bellek kullanımını da azaltır. Dolayısıyla bu noktada en büyük suçlularımızı biliyoruz ve bu verilere dayanarak neyi optimize edeceğimize karar verebiliriz.
Buradaki örnek yapmacık olmasına rağmen, yaklaşımlar genel olarak uygulanabilir. Profil oluşturucu sonuçları, sizi kodunuzda sorunun tam olarak bulunduğu noktayı göstermeyebilir ve ayrıca yanlış yorumlanabilir, bu nedenle, kod bölümlerini açarken ve kapatırken gerçek bellek kullanımına bakarak takip etmek iyi bir fikirdir. Ardından, bellek kullanımının sorun haline geldiği bazı yaygın durumlara ve bunların nasıl optimize edileceğine bakacağız.
seri durumdan çıkarma
Yaygın bir bellek sorunları kaynağı, XML, JSON veya diğer bazı veri serileştirme biçiminden büyük miktarda verinin seri hale getirilmesidir. JSON.parse
veya Active Support'un Hash.from_xml
gibi yöntemleri kullanmak inanılmaz derecede uygundur, ancak yüklediğiniz veriler büyük olduğunda, belleğe yüklenen sonuçta ortaya çıkan veri yapısı çok büyük olabilir.
Verilerin kaynağı üzerinde denetiminiz varsa, aldığınız veri miktarını sınırlamak için filtreleme veya sayfalandırma desteği eklemek gibi şeyler yapabilirsiniz. Ancak bu harici bir kaynaksa veya kontrol edemediğiniz bir kaynaksa, başka bir seçenek de bir akış seri kaldırıcı kullanmaktır. XML için Ox bir seçenektir ve JSON için yajl-ruby benzer şekilde çalışıyor gibi görünüyor, bununla birlikte fazla deneyimim yok.
Burada, Hash#from_xml
kullanarak 1.7MB'lık bir XML dosyasını ayrıştırmanın bir örneği verilmiştir.
# 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 dosya için 111MB! Bu açıkça iyi ölçeklenmeyecek. İşte akış ayrıştırıcı sürümü.
# 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
Bu bizi ihmal edilebilir bir bellek artışına götürür ve çok daha büyük dosyaları işleyebilmelidir. Bununla birlikte, ödünleşim şu ki, daha önce ihtiyaç duymadığımız 28 satır işleyici koduna sahibiz, bu hataya açık gibi görünüyor ve üretim kullanımı için çevresinde bazı testler olması gerekiyor.
seri hale getirme
Bellek kullanımı etkin noktalarını ayırma ile ilgili bölümde gördüğümüz gibi, serileştirme yüksek bellek maliyetlerine sahip olabilir. İşte daha önce people.rb
önemli kısmı.
# to_json.rb require_relative "./common" print_usage_before_and_after do File.open("people.txt", "w") { |out| out << Person.all.to_json } end
Bunu veritabanında 100.000 kayıtla çalıştırarak şunları elde ederiz:
$ ruby to_json.rb Before: 36 MB After: 505 MB
Burada #to_json
sorunu, her kayıt için bir nesneyi başlatması ve ardından JSON'a kodlamasıdır. Bir seferde yalnızca bir kayıt nesnesinin var olması gerekmesi için JSON kayıt kayıt oluşturma, bellek kullanımını önemli ölçüde azaltır. Popüler Ruby JSON kitaplıklarının hiçbiri bunu ele almıyor gibi görünmektedir, ancak yaygın olarak önerilen bir yaklaşım, JSON dizesini manuel olarak oluşturmaktır. Bunu yapmak için güzel bir API sağlayan bir json-write-stream gem var ve örneğimizi buna dönüştürmek şuna benziyor:
# 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
Bir kez daha, optimizasyonun bize daha fazla kod verdiğini görüyoruz, ancak sonuç buna değer görünüyor:
$ ruby json_stream.rb Before: 36 MB After: 56 MB
Tembel olmak
2.0 ile başlayan Ruby'ye eklenen harika bir özellik, numaralandırıcıları tembelleştirme yeteneğidir. Bu, bir numaralandırıcıda yöntemleri zincirlerken bellek kullanımını iyileştirmek için harikadır. Tembel olmayan bazı kodlarla başlayalım:
# 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
Sonuç:
$ ruby not_lazy.rb 1_000_000 Before: 36 MB After: 546 MB
Burada olan, zincirdeki her adımda, numaralandırıcıdaki her öğe üzerinde yinelenerek, zincirde sonraki yöntemin çağrıldığı bir dizi üretmesidir ve bu böyle devam eder. times
aldığımız numaralandırıcıya lazy
için bir çağrı eklemeyi gerektiren bu tembelliği yaptığımızda ne olacağını görelim:
# 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
Sonuç:
$ ruby lazy.rb 1_000_000 Before: 36 MB After: 52 MB
Son olarak, fazladan kod eklemeden bize büyük bir bellek kullanımı kazandıran bir örnek! Sonunda herhangi bir sonuç toplamamız gerekmeseydi, örneğin her bir öğe veritabanına kaydedilseydi ve sonra unutulabilseydi, daha da az bellek kullanımı olurdu. Zincirin sonunda tembel bir numaralandırılabilir değerlendirme yapmak için, force
öğesine son bir çağrı eklemeniz yeterlidir.
Örnekle ilgili dikkat edilmesi gereken başka bir şey de, zincirin, her çağrıldığında bir tamsayı üretecek bir numaralandırıcı döndürdüğü için çok az bellek kullanan lazy
önce bir times
çağrısıyla başlamasıdır. Bu nedenle, zincirin başında büyük bir dizi yerine bir numaralandırma kullanılabilirse, bu yardımcı olacaktır.
Bir tür işleme hattına tembelce beslemek için bir numaralandırma oluşturmanın gerçek dünyadaki bir uygulaması, sayfalandırılmış verilerin işlenmesidir. Bu nedenle, tüm sayfaları isteyip büyük bir diziye yerleştirmek yerine, tüm sayfalandırma ayrıntılarını güzel bir şekilde gizleyen bir numaralandırıcı aracılığıyla ifşa edilebilirler. Bu şöyle görünebilir:
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
Çözüm
Ruby'de bellek kullanımının bazı karakterizasyonlarını yaptık ve bellek sorunlarını izlemek için bazı genel araçlara ve ayrıca bazı yaygın durumlara ve bunları iyileştirmenin yollarına baktık. Araştırdığımız yaygın vakalar hiçbir şekilde kapsamlı değildir ve kişisel olarak karşılaştığım türden sorunlar nedeniyle oldukça önyargılıdır. Bununla birlikte, en büyük kazanç, kodun bellek kullanımını nasıl etkileyeceğini düşünme zihniyetine girmek olabilir.