التزامن والتوازي روبي: برنامج تعليمي عملي

نشرت: 2022-03-11

لنبدأ بتوضيح نقطة الالتباس الشائعة بين مطوري Ruby ؛ وهي: التزامن والتوازي ليسا نفس الشيء (أي متزامن! = متوازي).

على وجه الخصوص ، يكون التزامن في روبي هو الوقت الذي يمكن فيه بدء مهمتين وتشغيلهما وإكمالهما في فترات زمنية متداخلة . لا يعني ذلك بالضرورة أنهما سيعملان في نفس اللحظة (على سبيل المثال ، خيوط متعددة على جهاز أحادي النواة). في المقابل ، يحدث التوازي عندما يتم تشغيل مهمتين حرفيًا في نفس الوقت (على سبيل المثال ، مؤشرات ترابط متعددة على معالج متعدد النواة).

النقطة الأساسية هنا هي أن سلاسل العمليات و / أو العمليات المتزامنة لن تعمل بالضرورة بالتوازي.

يقدم هذا البرنامج التعليمي علاجًا عمليًا (وليس نظريًا) للتقنيات والأساليب المختلفة المتوفرة للتزامن والتوازي في روبي.

لمزيد من أمثلة Ruby الواقعية ، راجع مقالتنا حول Ruby Interpreters and Runtime.

حالة الاختبار لدينا

بالنسبة لحالة اختبار بسيطة ، سأقوم بإنشاء فئة Mailer وإضافة وظيفة Fibonacci (بدلاً من طريقة sleep() ) لجعل كل طلب أكثر كثافة في وحدة المعالجة المركزية ، على النحو التالي:

 class Mailer def self.deliver(&block) mail = MailBuilder.new(&block).mail mail.send_mail end Mail = Struct.new(:from, :to, :subject, :body) do def send_mail fib(30) puts "Email from: #{from}" puts "Email to : #{to}" puts "Subject : #{subject}" puts "Body : #{body}" end def fib(n) n < 2 ? n : fib(n-1) + fib(n-2) end end class MailBuilder def initialize(&block) @mail = Mail.new instance_eval(&block) end attr_reader :mail %w(from to subject body).each do |m| define_method(m) do |val| @mail.send("#{m}=", val) end end end end

يمكننا بعد ذلك استدعاء فئة Mailer هذه على النحو التالي لإرسال البريد:

 Mailer.deliver do from "[email protected]" to "[email protected]" subject "Threading and Forking" body "Some content" end

(ملاحظة: الكود المصدري لحالة الاختبار هذه متاح هنا على جيثب.)

لإنشاء خط أساس لأغراض المقارنة ، فلنبدأ بإجراء اختبار معياري بسيط ، باستدعاء مرسل البريد 100 مرة:

 puts Benchmark.measure{ 100.times do |i| Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end }

أسفر ذلك عن النتائج التالية على معالج رباعي النواة مع MRI Ruby 2.0.0p353:

 15.250000 0.020000 15.270000 ( 15.304447)

عمليات متعددة مقابل تعدد العمليات

لا توجد إجابة "مقاس واحد يناسب الجميع" عندما يتعلق الأمر بتقرير ما إذا كنت تريد استخدام عمليات متعددة أو لتعدد مؤشرات الترابط لتطبيق Ruby. يلخص الجدول أدناه بعض العوامل الرئيسية التي يجب مراعاتها.

العمليات الخيوط
يستخدم المزيد من الذاكرة يستخدم ذاكرة أقل
إذا مات أحد الوالدين قبل خروج الأطفال ، يمكن أن يصبح الأطفال عمليات الزومبي تموت جميع الخيوط عندما تموت العملية (لا توجد فرصة للزومبي)
أكثر تكلفة بالنسبة للعمليات المتشعبة لتبديل السياق لأن نظام التشغيل يحتاج إلى حفظ كل شيء وإعادة تحميله تحمل الخيوط قدرًا أقل بكثير من الحمل نظرًا لأنها تشترك في مساحة العنوان والذاكرة
يتم منح العمليات المتشعبة مساحة ذاكرة افتراضية جديدة (عزل العملية) تشترك الخيوط في نفس الذاكرة ، لذا تحتاج إلى التحكم في مشكلات الذاكرة المتزامنة والتعامل معها
يتطلب الاتصال بين العمليات يمكن "التواصل" عبر قوائم الانتظار والذاكرة المشتركة
أبطأ في الإنشاء والتدمير أسرع في الإنشاء والتدمير
أسهل في البرمجة والتصحيح يمكن أن يكون أكثر تعقيدًا بشكل ملحوظ في التعليمات البرمجية وتصحيح الأخطاء

أمثلة على حلول Ruby التي تستخدم عمليات متعددة:

  • Resque: مكتبة Ruby المدعومة من Redis لإنشاء وظائف في الخلفية ووضعها في قوائم انتظار متعددة ومعالجتها لاحقًا.
  • Unicorn: خادم HTTP لتطبيقات Rack مصمم لخدمة العملاء السريعين فقط على اتصالات ذات زمن انتقال منخفض وعالي النطاق والاستفادة من الميزات الموجودة في نواة تشبه Unix / Unix.

أمثلة على حلول Ruby التي تستخدم تعدد مؤشرات الترابط:

  • Sidekiq: إطار عمل معالجة خلفية كامل الميزات لروبي. تهدف إلى أن تكون سهلة الاندماج مع أي تطبيق حديث من Rails وأداء أعلى بكثير من الحلول الحالية الأخرى.
  • Puma: خادم ويب Ruby مصمم للتزامن.
  • رفيع: خادم ويب Ruby سريع جدًا وبسيط.

عمليات متعددة

قبل أن ننظر في خيارات Ruby multithreading ، دعنا نستكشف المسار الأسهل لإنتاج عمليات متعددة.

في Ruby ، ​​يتم استخدام استدعاء نظام fork() لإنشاء "نسخة" من العملية الحالية. تمت جدولة هذه العملية الجديدة على مستوى نظام التشغيل ، بحيث يمكن تشغيلها بالتزامن مع العملية الأصلية ، تمامًا مثل أي عملية مستقلة أخرى. ( ملاحظة: fork() هي مكالمة نظام POSIX وبالتالي فهي غير متوفرة إذا كنت تقوم بتشغيل Ruby على نظام Windows الأساسي.)

حسنًا ، لنقم بتشغيل حالة الاختبار الخاصة بنا ، ولكن هذه المرة باستخدام fork() لتوظيف عمليات متعددة:

 puts Benchmark.measure{ 100.times do |i| fork do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end Process.waitall }

(ينتظر Process.waitall جميع العمليات التابعة للخروج ويعيد مجموعة من حالات العملية.)

ينتج عن هذا الرمز الآن النتائج التالية (مرة أخرى ، على معالج رباعي النواة مع MRI Ruby 2.0.0p353):

 0.000000 0.030000 27.000000 ( 3.788106)

ليس برث للغاية! لقد جعلنا جهاز الإرسال أسرع بنحو 5 أضعاف عن طريق تعديل سطرين من التعليمات البرمجية (على سبيل المثال ، باستخدام fork() ).

لا تتحمس بشكل مفرط بالرغم من ذلك. على الرغم من أنه قد يكون من المغري استخدام التقسيم لأنه حل سهل لتزامن روبي ، إلا أن له عيبًا كبيرًا وهو مقدار الذاكرة التي سيستهلكها. التشعب مكلف إلى حد ما ، خاصةً إذا لم يتم استخدام النسخ على الكتابة (CoW) من قبل مترجم روبي الذي تستخدمه. إذا كان تطبيقك يستخدم 20 ميغا بايت من الذاكرة ، على سبيل المثال ، فإن فكها 100 مرة يمكن أن يستهلك ما يصل إلى 2 جيجابايت من الذاكرة!

أيضًا ، على الرغم من أن تعدد مؤشرات الترابط له تعقيداته الخاصة أيضًا ، إلا أن هناك عددًا من التعقيدات التي يجب مراعاتها عند استخدام fork() ، مثل واصفات الملفات المشتركة والإشارات (بين العمليات المتشعبة الأصل والطفل) ، والحاجة إلى التواصل عبر الأنابيب ، وما إلى ذلك وهلم جرا.

روبي تعدد

حسنًا ، لنحاول الآن جعل البرنامج نفسه أسرع باستخدام تقنيات Ruby multithreading بدلاً من ذلك.

تحتوي سلاسل العمليات المتعددة داخل عملية واحدة على مقدار حمل أقل بكثير من عدد مماثل من العمليات نظرًا لأنها تشترك في مساحة العنوان والذاكرة.

مع وضع ذلك في الاعتبار ، دعنا نعيد النظر في حالة الاختبار الخاصة بنا ، ولكن هذه المرة باستخدام فئة Thread روبي:

 threads = [] puts Benchmark.measure{ 100.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }

ينتج عن هذا الرمز الآن النتائج التالية (مرة أخرى ، على معالج رباعي النواة مع MRI Ruby 2.0.0p353):

 13.710000 0.040000 13.750000 ( 13.740204)

المشكله. هذا بالتأكيد ليس مثيرًا للإعجاب! ماذا يحصل؟ لماذا ينتج عن هذا نفس النتائج تقريبًا التي حصلنا عليها عندما قمنا بتشغيل الكود بشكل متزامن؟

الجواب ، وهو لعنة وجود العديد من مبرمجي Ruby ، ​​هو Global Interpreter Lock (GIL) . بفضل GIL ، لا يدعم CRuby (تنفيذ التصوير بالرنين المغناطيسي) حقاً استخدام خيوط المعالجة.

يعد Global Interpreter Lock آلية مستخدمة في مترجمي لغة الكمبيوتر لمزامنة تنفيذ مؤشرات الترابط بحيث يمكن تنفيذ مؤشر ترابط واحد فقط في كل مرة. سيسمح المترجم الذي يستخدم GIL دائمًا بتنفيذ خيط واحد وخيط واحد فقط في كل مرة ، حتى لو تم تشغيله على معالج متعدد النواة. يعد Ruby MRI و CPython من أكثر الأمثلة شيوعًا للمترجمين الفوريين المشهورين الذين لديهم GIL.

عودة إلى مشكلتنا ، كيف يمكننا استغلال تعدد مؤشرات الترابط في Ruby لتحسين الأداء في ضوء GIL؟

حسنًا ، في التصوير بالرنين المغناطيسي (CRuby) ، الإجابة المؤسفة هي أنك عالق بشكل أساسي وهناك القليل جدًا مما يمكن أن تفعله تعدد العمليات من أجلك.

يمكن أن يكون تزامن روبي بدون التوازي مفيدًا جدًا ، على الرغم من ذلك ، للمهام الثقيلة في عمليات الإدخال والإخراج (على سبيل المثال ، المهام التي تحتاج إلى الانتظار بشكل متكرر على الشبكة). لذلك يمكن أن تظل الخيوط مفيدة في التصوير بالرنين المغناطيسي ، لمهام الإدخال / الإخراج الثقيلة. هناك سبب وراء اختراع الخيوط واستخدامها حتى قبل انتشار الخوادم متعددة النواة.

ولكن بعد قولي هذا ، إذا كان لديك خيار استخدام إصدار آخر غير CRuby ، ​​فيمكنك استخدام تطبيق Ruby بديل مثل JRuby أو Rubinius ، نظرًا لعدم امتلاكهما لـ GIL وهما يدعمان خيوط Ruby المتوازية الحقيقية.

مترابطة مع JRuby

لإثبات هذه النقطة ، إليك النتائج التي نحصل عليها عندما نقوم بتشغيل نفس الإصدار المترابط بالضبط من الكود كما كان من قبل ، ولكن هذه المرة قم بتشغيله على JRuby (بدلاً من CRuby):

 43.240000 0.140000 43.380000 ( 5.655000)

الآن نحن نتحدث!

ولكن…

المواضيع ليست مجانية

قد يؤدي الأداء المحسن باستخدام سلاسل عمليات متعددة إلى الاعتقاد بأنه يمكننا الاستمرار في إضافة المزيد من سلاسل الرسائل - بشكل أساسي بلا حدود - للاستمرار في جعل الكود الخاص بنا يعمل بشكل أسرع وأسرع. سيكون هذا أمرًا رائعًا حقًا إذا كان صحيحًا ، ولكن الحقيقة هي أن الخيوط ليست مجانية ، وبالتالي ، عاجلاً أم آجلاً ، سوف تنفد الموارد.

لنفترض ، على سبيل المثال ، أننا نريد تشغيل نموذجنا البريدي ليس 100 مرة ، بل 10000 مرة. دعونا نرى ما سيحدث:

 threads = [] puts Benchmark.measure{ 10_000.times do |i| threads << Thread.new do Mailer.deliver do from "eki_#{i}@eqbalq.com" to "jill_#{i}@example.com" subject "Threading and Forking (#{i})" body "Some content" end end end threads.map(&:join) }

فقاعة! لقد تلقيت خطأ في نظام التشغيل OS X 10.8 بعد إنتاج حوالي 2000 موضوع:

 can't create Thread: Resource temporarily unavailable (ThreadError)

كما هو متوقع ، سنبدأ عاجلاً أم آجلاً في سحق الموارد أو نفادها بالكامل. لذا فإن قابلية التوسع في هذا النهج محدودة بشكل واضح.

تجميع الموضوع

لحسن الحظ، هناك طريقة أفضل؛ وهي تجميع الخيوط.

تجمع مؤشرات الترابط عبارة عن مجموعة من مؤشرات الترابط مسبقة التكوين والقابلة لإعادة الاستخدام والمتاحة لأداء العمل حسب الحاجة. تكون تجمعات سلاسل الرسائل مفيدة بشكل خاص عندما يكون هناك عدد كبير من المهام القصيرة التي يتعين تنفيذها بدلاً من عدد صغير من المهام الأطول. هذا يمنع الاضطرار إلى تحمل عبء إنشاء خيط عدد كبير من المرات.

عادةً ما تكون معلمة التكوين الرئيسية لتجمع مؤشرات الترابط هي عدد مؤشرات الترابط في التجمع. يمكن إما إنشاء مثيل لهذه الخيوط دفعة واحدة (على سبيل المثال ، عند إنشاء التجمع) أو كسول (على سبيل المثال ، حسب الحاجة حتى يتم إنشاء الحد الأقصى لعدد الخيوط في التجمع).

عندما يتم تسليم مهمة لأداء التجمع ، فإنه يقوم بتعيين المهمة إلى أحد مؤشرات الترابط الخاملة حاليًا. إذا لم تكن هناك سلاسل رسائل خاملة (وتم بالفعل إنشاء الحد الأقصى لعدد سلاسل الرسائل) ، فإنها تنتظر حتى يكمل مؤشر ترابط عمله ويصبح خاملاً ثم يقوم بتعيين المهمة إلى هذا الموضوع.

تجميع الموضوع

لذا ، بالعودة إلى مثالنا ، سنبدأ باستخدام Queue (نظرًا لأنه نوع بيانات آمن لمؤشر الترابط) ونستخدم تنفيذًا بسيطًا لتجمع مؤشرات الترابط:

تتطلب "./lib/mailer" تتطلب "معيار الأداء" تتطلب "مؤشر ترابط"

 POOL_SIZE = 10 jobs = Queue.new 10_0000.times{|i| jobs.push i} workers = (POOL_SIZE).times.map do Thread.new do begin while x = jobs.pop(true) Mailer.deliver do from "eki_#{x}@eqbalq.com" to "jill_#{x}@example.com" subject "Threading and Forking (#{x})" body "Some content" end end rescue ThreadError end end end workers.map(&:join)

في الكود أعلاه ، بدأنا بإنشاء قائمة انتظار jobs التي يتعين القيام بها. استخدمنا Queue لهذا الغرض نظرًا لأنه آمن في مؤشر الترابط (لذلك إذا تم الوصول إليه في نفس الوقت ، فسيحافظ على التناسق) مما يتجنب الحاجة إلى تنفيذ أكثر تعقيدًا يتطلب استخدام كائن المزامنة (mutex).

ثم دفعنا معرّفات مرسلي البريد إلى قائمة انتظار الوظائف وأنشأنا مجموعتنا المكونة من 10 سلاسل رسائل للعاملين.

داخل كل مؤشر ترابط عامل ، نخرج العناصر من قائمة انتظار الوظائف.

وبالتالي ، فإن دورة حياة مؤشر ترابط العامل هي الانتظار باستمرار حتى يتم وضع المهام في قائمة انتظار المهمة وتنفيذها.

لذا فإن الخبر السار هو أن هذا يعمل ويتطور دون أي مشاكل. لسوء الحظ ، هذا معقد إلى حد ما حتى بالنسبة لبرنامجنا التعليمي البسيط.

شريط سينمائي

بفضل نظام Ruby Gem البيئي ، يتم تغليف الكثير من تعقيد خيوط المعالجة المتعددة بدقة في عدد من أحجار Ruby Gems سهلة الاستخدام.

وخير مثال على ذلك هو السليلويد ، أحد جواهر الياقوت المفضلة لدي. إطار عمل السليلويد هو طريقة بسيطة ونظيفة لتنفيذ الأنظمة المتزامنة القائمة على الفاعل في روبي. يمكّن السيلولويد الأشخاص من إنشاء برامج متزامنة من كائنات متزامنة بنفس سهولة إنشاء برامج متسلسلة من كائنات متسلسلة.

في سياق مناقشتنا في هذا المنشور ، أركز بشكل خاص على ميزة Pools ، لكن تفضل بنفسك وتحقق من ذلك بمزيد من التفصيل. باستخدام Celluloid ، ستتمكن من إنشاء برامج Ruby متعددة الخيوط دون القلق بشأن المشكلات السيئة مثل المآزق ، وستجد أنه من التافه استخدام ميزات أخرى أكثر تعقيدًا مثل Futures and Promises.

إليك مدى بساطة استخدام إصدار متعدد مؤشرات الترابط من برنامج الإرسال الخاص بنا السيلولويد:

 require "./lib/mailer" require "benchmark" require "celluloid" class MailWorker include Celluloid def send_email(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end mailer_pool = MailWorker.pool(size: 10) 10_000.times do |i| mailer_pool.async.send_email(i) end

نظيف وسهل وقابل للتطوير وقوي. أكثر ما يمكن أن تسأل عنه؟

وظائف الخلفية

بالطبع ، هناك بديل آخر قابل للتطبيق ، اعتمادًا على متطلباتك التشغيلية والقيود وهو توظيف وظائف الخلفية. يوجد عدد من Ruby Gems لدعم معالجة الخلفية (أي حفظ المهام في قائمة الانتظار ومعالجتها لاحقًا دون حظر الموضوع الحالي). تشمل الأمثلة البارزة Sidekiq و Resque و Delayed Job و Beanstalkd.

بالنسبة لهذا المنشور ، سأستخدم Sidekiq و Redis (ذاكرة تخزين مؤقت مفتوحة المصدر ذات قيمة مفتاح ومخزن).

أولاً ، لنقم بتثبيت Redis وتشغيله محليًا:

 brew install redis redis-server /usr/local/etc/redis.conf

مع تشغيل مثيل Redis المحلي الخاص بنا ، دعنا نلقي نظرة على نسخة من نموذج برنامج الإرسال بالبريد ( mail_worker.rb ) باستخدام Sidekiq:

 require_relative "../lib/mailer" require "sidekiq" class MailWorker include Sidekiq::Worker def perform(id) Mailer.deliver do from "eki_#{id}@eqbalq.com" to "jill_#{id}@example.com" subject "Threading and Forking (#{id})" body "Some content" end end end

يمكننا تشغيل Sidekiq باستخدام ملف mail_worker.rb :

 sidekiq -r ./mail_worker.rb

ثم من IRB:

 ⇒ irb >> require_relative "mail_worker" => true >> 100.times{|i| MailWorker.perform_async(i)} 2014-12-20T02:42:30Z 46549 TID-ouh10w8gw INFO: Sidekiq client with redis options {} => 100

بذهول بسيط. ويمكنه التوسع بسهولة بمجرد تغيير عدد العمال.

خيار آخر هو استخدام Sucker Punch ، إحدى مكتبات معالجة RoR غير المتزامنة المفضلة لدي. سيكون التنفيذ باستخدام Sucker Punch متشابهًا جدًا. سنحتاج فقط إلى تضمين SuckerPunch::Job بدلاً من Sidekiq::Worker و MailWorker.new.async.perform() بدلاً MailWorker.perform_async() .

خاتمة

لا يمكن تحقيق التزامن العالي في Ruby فحسب ، ولكنه أيضًا أبسط مما تعتقد.

أحد الأساليب القابلة للتطبيق هو ببساطة إجراء عملية تشغيل لمضاعفة قدرتها على المعالجة. أسلوب آخر هو الاستفادة من تعدد مؤشرات الترابط. على الرغم من أن مؤشرات الترابط أخف من العمليات ، وتتطلب مقدارًا أقل من الحمل ، إلا أنه لا يزال بإمكانك نفاد الموارد إذا بدأت عددًا كبيرًا جدًا من مؤشرات الترابط في نفس الوقت. في مرحلة ما ، قد تجد أنه من الضروري استخدام تجمع مؤشرات الترابط. لحسن الحظ ، أصبحت العديد من تعقيدات تعدد الخيوط أسهل من خلال الاستفادة من أي عدد من الأحجار الكريمة المتاحة ، مثل السيلولويد ونموذج الممثل الخاص به.

هناك طريقة أخرى للتعامل مع العمليات التي تستغرق وقتًا طويلاً وهي استخدام المعالجة في الخلفية. هناك العديد من المكتبات والخدمات التي تتيح لك تنفيذ وظائف الخلفية في تطبيقاتك. تتضمن بعض الأدوات الشائعة أطر عمل مدعومة بقواعد البيانات وقوائم انتظار الرسائل.

التشعب والتركيب ومعالجة الخلفية كلها بدائل قابلة للتطبيق. يعتمد القرار بشأن أيهما يجب استخدامه على طبيعة التطبيق الخاص بك ، والبيئة التشغيلية ، والمتطلبات. نأمل أن يكون هذا البرنامج التعليمي قد قدم مقدمة مفيدة للخيارات المتاحة.