مطاردة مشاكل الذاكرة في روبي: دليل نهائي
نشرت: 2022-03-11أنا متأكد من أن هناك بعض مطوري Ruby المحظوظين الذين لن يواجهوا مشاكل في الذاكرة أبدًا ، ولكن بالنسبة لبقيتنا ، من الصعب للغاية البحث عن الأماكن التي يخرج فيها استخدام الذاكرة عن السيطرة وإصلاحها. لحسن الحظ ، إذا كنت تستخدم Ruby حديثًا (2.1+) ، فهناك بعض الأدوات والتقنيات الرائعة المتاحة للتعامل مع المشكلات الشائعة. يمكن القول أيضًا أن تحسين الذاكرة يمكن أن يكون ممتعًا ومفيدًا على الرغم من أنني قد أكون وحدي في هذا الشعور.
كما هو الحال مع جميع أشكال التحسين ، من المرجح أنه سيضيف تعقيدًا للشفرة ، لذلك لا يستحق القيام به ما لم تكن هناك مكاسب قابلة للقياس وكبيرة.
كل ما تم وصفه هنا يتم باستخدام الإصدار 2.2.4 المتعارف عليه من MRI Ruby ، على الرغم من أن الإصدارات الأخرى 2.1+ يجب أن تتصرف بشكل مشابه.
إنه ليس تسرب للذاكرة!
عندما يتم اكتشاف مشكلة في الذاكرة ، من السهل القفز إلى استنتاج أن هناك تسربًا للذاكرة. على سبيل المثال ، في تطبيق ويب ، قد ترى أنه بعد تدوير الخادم الخاص بك ، تؤدي المكالمات المتكررة لنفس نقطة النهاية إلى زيادة استخدام الذاكرة مع كل طلب. هناك بالتأكيد حالات تحدث فيها تسريبات شرعية للذاكرة ، لكنني أراهن أنها تفوق عددًا كبيرًا بسبب مشكلات الذاكرة مع نفس المظهر الذي لا يمثل تسريبات في الواقع.
كمثال (مفتعل) ، دعنا نلقي نظرة على القليل من كود روبي الذي ينشئ بشكل متكرر مجموعة كبيرة من التجزئة ويتجاهلها. أولاً ، إليك بعض التعليمات البرمجية التي ستتم مشاركتها عبر الأمثلة في هذا المنشور:
# 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 هي مجرد طريقة مناسبة لاستخدام الذاكرة في عملية روبي الحالية. ما نراه هو نفس السلوك الموصوف أعلاه ، زيادة مستمرة في استخدام الذاكرة.
$ 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
بالنظر إلى الكود ، هناك عدة خطوات تبدو كمرشحة جيدة لاستخدام قدر كبير من الذاكرة: بناء مجموعة كبيرة من السلاسل ، استدعاء #to_a
على علاقة Active Record لإنشاء مجموعة كبيرة من كائنات 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 من المثال السابق ، نظرًا لأن ملف التعريف نفسه يستخدم الكثير من الذاكرة ، ويمكن أن يؤدي في الواقع إلى استنفاد الذاكرة عند إنشاء ملف تعريف يتسبب بالفعل في استخدام ذاكرة عالية.
ملف النتائج طويل نوعًا ما ويتضمن الذاكرة وتخصيص الكائن والاحتفاظ به على مستويات الأحجار الكريمة والملف والموقع. هناك ثروة من المعلومات لاستكشافها ، ولكن إليك بعض المقتطفات الشيقة:
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
أو Hash.from_xml
الخاص بالدعم النشط أمرًا مريحًا للغاية ، ولكن عندما تكون البيانات التي تقوم بتحميلها كبيرة ، يمكن أن تكون بنية البيانات الناتجة التي يتم تحميلها في الذاكرة هائلة.
إذا كنت تتحكم في مصدر البيانات ، يمكنك القيام بأشياء للحد من كمية البيانات التي تتلقاها ، مثل إضافة دعم التصفية أو ترقيم الصفحات. ولكن إذا كان مصدرًا خارجيًا أو مصدرًا لا يمكنك التحكم فيه ، فهناك خيار آخر وهو استخدام جهاز إلغاء التسلسل المتدفق. بالنسبة إلى XML ، يعد Ox أحد الخيارات ، ويبدو أن JSON yajl-ruby يعمل بشكل مشابه ، على الرغم من أنني لا أمتلك خبرة كبيرة في استخدامه.
فيما يلي مثال على تحليل ملف XML بحجم 1.7 ميغا بايت ، باستخدام 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
111 ميغا بايت لملف 1.7 ميغا بايت! من الواضح أن هذا لن يرتفع بشكل جيد. ها هي نسخة المحلل اللغوي المتدفقة.
# 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
بتشغيل هذا مع 100000 سجل في قاعدة البيانات ، نحصل على:
$ ruby to_json.rb Before: 36 MB After: 505 MB
تكمن مشكلة استدعاء #to_json
هنا في أنه ينشئ كائنًا لكل سجل ، ثم يشفر إلى JSON. يؤدي إنشاء سجل JSON لكل سجل بحيث يحتاج كائن سجل واحد فقط إلى الوجود في كل مرة إلى تقليل استخدام الذاكرة بشكل كبير. لا يبدو أن أيًا من مكتبات Ruby JSON الشهيرة تتعامل مع هذا الأمر ، ولكن الطريقة الموصى بها بشكل عام هي بناء سلسلة JSON يدويًا. هناك جوهرة json-write-stream توفر واجهة برمجة تطبيقات لطيفة للقيام بذلك ، ويبدو تحويل مثالنا إلى هذا كما يلي:
# 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
كسولا
تمت إضافة ميزة رائعة إلى Ruby بدءًا من 2.0 وهي القدرة على جعل العدّادات كسالى. يعد هذا أمرًا رائعًا لتحسين استخدام الذاكرة عند ربط الطرق على العداد. لنبدأ ببعض التعليمات البرمجية غير البطيئة:
# 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
ما يحدث هنا هو أنه في كل خطوة في السلسلة ، فإنه يتكرر فوق كل عنصر في العداد ، وينتج مصفوفة تحتوي على الطريقة اللاحقة في السلسلة التي تم استدعاؤها ، وهكذا دواليك. دعونا نرى ما يحدث عندما نجعل هذا كسولًا ، والذي يتطلب فقط إضافة مكالمة إلى lazy
على العداد الذي نحصل عليه من 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
نتيجة:
$ ruby lazy.rb 1_000_000 Before: 36 MB After: 52 MB
أخيرًا ، المثال الذي يمنحنا استخدامًا كبيرًا للذاكرة يفوز ، دون إضافة الكثير من التعليمات البرمجية الإضافية! لاحظ أنه إذا لم نكن بحاجة إلى تجميع أي نتائج في النهاية ، على سبيل المثال ، إذا تم حفظ كل عنصر في قاعدة البيانات ويمكن نسيانه بعد ذلك ، فسيكون هناك استخدام أقل للذاكرة. لعمل تقييم غير معدود كسول في نهاية السلسلة ، ما عليك سوى إضافة استدعاء أخير force
.
شيء آخر يجب ملاحظته حول المثال هو أن السلسلة تبدأ باستدعاء times
قبل 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 ، وألقينا نظرة على بعض الأدوات العامة لتعقب مشكلات الذاكرة ، بالإضافة إلى بعض الحالات الشائعة وطرق تحسينها. الحالات الشائعة التي استكشفناها ليست شاملة بأي حال من الأحوال ومنحازة للغاية بنوع المشكلات التي واجهتها شخصيًا. ومع ذلك ، قد يكون أكبر مكسب هو مجرد التفكير في كيفية تأثير الكود على استخدام الذاكرة.