Memburu Masalah Memori Di Ruby: Panduan Definitif

Diterbitkan: 2022-03-11

Saya yakin ada beberapa pengembang Ruby yang beruntung di luar sana yang tidak akan pernah mengalami masalah dengan memori, tetapi bagi kita semua, sangat sulit untuk mencari di mana penggunaan memori tidak terkendali dan memperbaikinya. Untungnya, jika Anda menggunakan Ruby modern (2.1+), ada beberapa alat dan teknik hebat yang tersedia untuk menangani masalah umum. Bisa juga dikatakan bahwa pengoptimalan memori bisa menyenangkan dan bermanfaat meskipun saya mungkin sendirian dalam sentimen itu.

Memburu Masalah Memori Di Ruby

Jika menurut Anda bug itu mengganggu, tunggu sampai Anda mencari masalah memori.
Menciak

Seperti semua bentuk optimasi, kemungkinan besar itu akan menambah kompleksitas kode, jadi tidak ada gunanya dilakukan kecuali ada keuntungan yang terukur dan signifikan.

Semua yang dijelaskan di sini dilakukan menggunakan Ruby MRI kanonik, versi 2.2.4, meskipun versi 2.1+ lainnya harus berperilaku serupa.

Ini Bukan Kebocoran Memori!

Ketika masalah memori ditemukan, mudah untuk menyimpulkan bahwa ada kebocoran memori. Misalnya, dalam aplikasi web, Anda mungkin melihat bahwa setelah menjalankan server, panggilan berulang ke titik akhir yang sama terus mendorong penggunaan memori lebih tinggi dengan setiap permintaan. Tentu saja ada kasus di mana kebocoran memori yang sah terjadi, tetapi saya berani bertaruh mereka kalah jumlah dengan masalah memori dengan penampilan yang sama yang sebenarnya bukan kebocoran.

Sebagai contoh (dibikin), mari kita lihat sedikit kode Ruby yang berulang kali membuat array besar hash dan membuangnya. Pertama, inilah beberapa kode yang akan dibagikan di seluruh contoh di posting ini:

 # 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

Dan pembuat array:

 # 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

Permata get_process_mem hanyalah cara mudah untuk mendapatkan memori yang digunakan oleh proses Ruby saat ini. Apa yang kami lihat adalah perilaku yang sama yang dijelaskan di atas, peningkatan penggunaan memori secara terus-menerus.

 $ 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

Namun, jika kita menjalankan lebih banyak iterasi, pada akhirnya kita akan mendatar.

 $ 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

Mencapai dataran tinggi ini adalah ciri dari bukan kebocoran memori yang sebenarnya, atau kebocoran memori sangat kecil sehingga tidak terlihat dibandingkan dengan penggunaan memori lainnya. Apa yang mungkin tidak intuitif adalah mengapa penggunaan memori terus meningkat setelah iterasi pertama. Bagaimanapun, itu membangun sebuah array besar, tetapi kemudian segera membuangnya dan mulai membangun yang baru dengan ukuran yang sama. Tidak bisakah itu hanya menggunakan ruang yang dibebaskan oleh array sebelumnya? Jawabannya, yang menjelaskan masalah kita, adalah tidak. Selain menyetel pengumpul sampah, Anda tidak memiliki kendali saat dijalankan, dan apa yang kita lihat dalam contoh build_arrays.rb adalah alokasi memori baru dibuat sebelum pengumpulan sampah dari objek lama kita yang dibuang.

Jangan panik jika Anda melihat penggunaan memori aplikasi Anda meningkat secara tiba-tiba. Aplikasi dapat kehabisan memori karena berbagai alasan - bukan hanya kebocoran memori.

Saya harus menunjukkan bahwa ini bukan semacam masalah manajemen memori yang mengerikan khusus untuk Ruby, tetapi umumnya berlaku untuk bahasa yang dikumpulkan sampah. Hanya untuk meyakinkan diri saya tentang hal ini, saya mereproduksi pada dasarnya contoh yang sama dengan Go dan melihat hasil yang serupa. Namun, ada perpustakaan Ruby yang memudahkan untuk membuat masalah memori semacam ini.

Memecah dan menaklukkan

Jadi jika kita perlu bekerja dengan potongan data yang besar, apakah kita ditakdirkan untuk membuang banyak RAM pada masalah kita? Untungnya, bukan itu masalahnya. Jika kita mengambil contoh build_arrays.rb dan mengurangi ukuran array, kita akan melihat penurunan di titik di mana penggunaan memori mendatar yang kira-kira sebanding dengan ukuran array.

Ini berarti bahwa jika kita dapat memecah pekerjaan kita menjadi bagian-bagian yang lebih kecil untuk diproses dan menghindari terlalu banyak objek yang ada pada satu waktu, kita dapat secara dramatis mengurangi jejak memori. Sayangnya, itu sering berarti mengambil kode yang bagus dan bersih dan mengubahnya menjadi lebih banyak kode yang melakukan hal yang sama, hanya dengan cara yang lebih hemat memori.

Mengisolasi Hotspot Penggunaan Memori

Dalam basis kode nyata, sumber masalah memori kemungkinan tidak akan sejelas contoh build_arrays.rb . Mengisolasi masalah memori sebelum mencoba untuk benar-benar menggali dan memperbaikinya sangat penting karena mudah untuk membuat asumsi yang salah tentang apa yang menyebabkan masalah.

Saya biasanya menggunakan dua pendekatan, seringkali dalam kombinasi, untuk melacak masalah memori: membiarkan kode tetap utuh dan membungkus profiler di sekitarnya, dan memantau penggunaan memori dari proses sambil menonaktifkan/mengaktifkan berbagai bagian kode yang saya duga bisa bermasalah. Saya akan menggunakan memory_profiler di sini untuk membuat profil, tetapi ruby-prof adalah opsi populer lainnya, dan derailed_benchmarks memiliki beberapa kemampuan khusus Rails yang hebat.

Berikut beberapa kode yang akan menggunakan banyak memori, di mana mungkin tidak segera jelas langkah mana yang paling banyak mendorong penggunaan memori:

 # 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

Menggunakan get_process_mem, kita dapat dengan cepat memverifikasi bahwa itu menggunakan banyak memori ketika ada banyak catatan Person yang dibuat.

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

Hasil:

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

Melihat melalui kode, ada beberapa langkah yang tampak seperti kandidat yang baik untuk menggunakan banyak memori: membangun array besar string, memanggil #to_a pada relasi Rekaman Aktif untuk membuat array besar objek Rekaman Aktif (bukan ide yang bagus , tetapi dilakukan untuk tujuan demonstrasi), dan membuat serialisasi array objek Rekaman Aktif.

Kami kemudian dapat membuat profil kode ini untuk melihat di mana alokasi memori terjadi:

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

Perhatikan bahwa angka yang diumpankan untuk run di sini adalah 1/10 dari contoh sebelumnya, karena profiler itu sendiri menggunakan banyak memori, dan sebenarnya dapat menyebabkan kehabisan memori saat membuat kode profil yang telah menyebabkan penggunaan memori yang tinggi.

File hasil agak panjang dan mencakup memori dan alokasi objek dan retensi pada tingkat permata, file, dan lokasi. Ada banyak informasi untuk dijelajahi, tetapi berikut adalah beberapa cuplikan menarik:

 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

Kami melihat alokasi paling banyak terjadi di dalam Rekaman Aktif, yang tampaknya mengarah pada pembuatan instance semua objek dalam larik records , atau serialisasi dengan #to_json . Selanjutnya, kami dapat menguji penggunaan memori kami tanpa profiler sambil menonaktifkan tersangka ini. Kami tidak dapat menonaktifkan pengambilan records dan masih dapat melakukan langkah serialisasi, jadi mari kita coba nonaktifkan serialisasi terlebih dahulu.

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

Hasil:

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

Itu memang tampaknya di mana sebagian besar memori pergi, dengan delta memori sebelum/sesudah turun 81% dengan melewatkannya. Kita juga dapat melihat apa yang terjadi jika kita berhenti memaksa kumpulan record yang besar untuk dibuat.

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

Hasil:

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

Ini juga mengurangi penggunaan memori, meskipun ini adalah urutan pengurangan yang lebih kecil daripada menonaktifkan serialisasi. Jadi pada titik ini, kami mengetahui penyebab terbesar kami, dan dapat membuat keputusan tentang apa yang akan dioptimalkan berdasarkan data ini.

Meskipun contoh di sini dibuat-buat, pendekatannya secara umum dapat diterapkan. Hasil profiler mungkin tidak mengarahkan Anda ke tempat yang tepat dalam kode Anda di mana masalahnya terletak, dan juga dapat disalahartikan, jadi ada baiknya untuk menindaklanjuti dengan melihat penggunaan memori yang sebenarnya sambil menghidupkan dan mematikan bagian kode. Selanjutnya, kita akan melihat beberapa kasus umum di mana penggunaan memori menjadi masalah dan bagaimana mengoptimalkannya.

Deserialisasi

Sumber masalah memori yang umum adalah deserializing data dalam jumlah besar dari XML, JSON, atau format serialisasi data lainnya. Menggunakan metode seperti JSON.parse atau Hash.from_xml Dukungan Aktif sangat nyaman, tetapi ketika data yang Anda muat besar, struktur data yang dihasilkan yang dimuat dalam memori bisa sangat besar.

Jika Anda memiliki kontrol atas sumber data, Anda dapat melakukan hal-hal untuk membatasi jumlah data yang Anda terima, seperti menambahkan pemfilteran atau dukungan pagination. Tetapi jika itu adalah sumber eksternal atau yang tidak dapat Anda kendalikan, opsi lain adalah menggunakan deserializer streaming. Untuk XML, Ox adalah salah satu opsi, dan untuk JSON yajl-ruby tampaknya beroperasi dengan cara yang sama, meskipun saya tidak memiliki banyak pengalaman dengannya.

Hanya karena Anda memiliki memori terbatas tidak berarti Anda tidak dapat mengurai dokumen XML atau JSON yang besar dengan aman. Deserializer streaming memungkinkan Anda mengekstrak secara bertahap apa pun yang Anda butuhkan dari dokumen ini dan tetap menjaga jejak memori tetap rendah.

Berikut adalah contoh penguraian file XML 1,7 MB, menggunakan Hash#from_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

111MB untuk file 1.7MB! Ini jelas tidak akan meningkat dengan baik. Inilah versi parser streaming.

 # 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

Ini membawa kita ke peningkatan memori yang dapat diabaikan dan harus mampu menangani file yang jauh lebih besar. Namun, tradeoffnya adalah kami sekarang memiliki 28 baris kode handler yang tidak kami perlukan sebelumnya, yang sepertinya akan rawan kesalahan, dan untuk penggunaan produksi harus ada beberapa tes di sekitarnya.

Serialisasi

Seperti yang kita lihat di bagian tentang mengisolasi hotspot penggunaan memori, serialisasi dapat memiliki biaya memori yang tinggi. Inilah bagian penting dari people.rb dari sebelumnya.

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

Menjalankan ini dengan 100.000 catatan dalam database, kami mendapatkan:

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

Masalah dengan memanggil #to_json di sini adalah ia membuat instance objek untuk setiap record, dan kemudian mengkodekan ke JSON. Membuat record-by-record JSON sehingga hanya satu objek record yang perlu ada pada satu waktu akan mengurangi penggunaan memori secara signifikan. Tak satu pun dari perpustakaan Ruby JSON yang populer tampaknya menangani ini, tetapi pendekatan yang umumnya direkomendasikan adalah membuat string JSON secara manual. Ada permata json-write-stream yang menyediakan API yang bagus untuk melakukan ini, dan mengonversi contoh kita menjadi seperti ini:

 # 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

Sekali lagi, kami melihat pengoptimalan telah memberi kami lebih banyak kode, tetapi hasilnya tampaknya sepadan:

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

Sedang malas

Fitur hebat yang ditambahkan ke Ruby dimulai dengan 2.0 adalah kemampuan untuk membuat enumerator menjadi malas. Ini bagus untuk meningkatkan penggunaan memori saat merantai metode pada enumerator. Mari kita mulai dengan beberapa kode yang tidak malas:

 # 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

Hasil:

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

Apa yang terjadi di sini adalah bahwa pada setiap langkah dalam rantai, ia mengulangi setiap elemen dalam pencacah, menghasilkan larik yang memiliki metode berikutnya dalam rantai yang dipanggil, dan seterusnya. Mari kita lihat apa yang terjadi ketika kita membuat lazy ini, yang hanya membutuhkan penambahan panggilan ke lazy pada enumerator yang kita dapatkan dari times :

 # 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

Hasil:

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

Terakhir, sebuah contoh yang memberi kita keuntungan penggunaan memori yang besar, tanpa menambahkan banyak kode tambahan! Perhatikan bahwa jika kita tidak perlu mengumpulkan hasil apa pun di akhir, misalnya, jika setiap item disimpan ke database dan kemudian dapat dilupakan, penggunaan memori akan lebih sedikit. Untuk membuat evaluasi enumerable yang malas di akhir rantai, cukup tambahkan panggilan terakhir ke force .

Hal lain yang perlu diperhatikan tentang contoh ini adalah bahwa rantai dimulai dengan panggilan ke times sebelum lazy , yang menggunakan sangat sedikit memori karena hanya mengembalikan enumerator yang akan menghasilkan bilangan bulat setiap kali dipanggil. Jadi jika enumerable dapat digunakan sebagai pengganti array besar di awal rantai, itu akan membantu.

Menyimpan semuanya dalam susunan dan peta yang besar itu mudah, tetapi dalam skenario dunia nyata, Anda jarang perlu melakukannya.

Salah satu aplikasi dunia nyata untuk membangun enumerable untuk dimasukkan dengan malas ke dalam semacam pipa pemrosesan adalah memproses data paginasi. Jadi, daripada meminta semua halaman dan memasukkannya ke dalam satu larik besar, mereka dapat diekspos melalui enumerator yang menyembunyikan semua detail pagination dengan baik. Ini bisa terlihat seperti:

 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

Kesimpulan

Kami telah melakukan beberapa karakterisasi penggunaan memori di Ruby, dan melihat beberapa alat umum untuk melacak masalah memori, serta beberapa kasus umum dan cara untuk memperbaikinya. Kasus-kasus umum yang kami jelajahi sama sekali tidak komprehensif dan sangat bias oleh jenis masalah yang saya alami secara pribadi. Namun, keuntungan terbesar mungkin hanya dalam pola pikir berpikir tentang bagaimana kode akan berdampak pada penggunaan memori.

Terkait: Ruby Concurrency and Parallelism: Tutorial Praktis