الخيوط وتنفيذ الشيفرة في ريلز

من موسوعة حسوب

بعد قراءة هذا الدليل، ستتلعم:

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

التزامن التلقائي

تسمح ريلز تلقائيًا بإجراء عمليات مختلفة في نفس الوقت.

عند استخدام خادم ويب ذي خيوط، مثل الخادم Puma الافتراضي، ستُخدَّم طلبات HTTP متعددة في وقت واحد؛ تقدم مع كل طلب نسخة لوحدة التحكم الخاص به.

محولات Active Job التي تدعم تعدد الخيوط، تتضمن المحول Async المدمج، ستنفذ أيضًا العديد من المهام في نفس الوقت. قنوات Action Cable تدار بهذه الطريقة أيضًا.

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

يصف الجزء المتبقي من هذا الدليل الآليات التي تستخدمها ريلز لجعلها "قابلة للتجاهل غالبًا" ، وكيف يمكن للملحقات والتطبيقات ذات الاحتياجات الخاصة استخدامها.

المُنفِّذ

يقوم مُنفِّذ ريلز (Rails Executor) بفصل شيفرة التطبيق عن شيفرة الإطار: في أي وقت يستدعي الإطار الشيفرة التي كتبتها للتطبيق الخاص بك، ستُغلَّف من قبل المُنفِّذ.

يتكون المُنفِّذ من ردي نداء: to_run و to_complete. يُستدعَى رد النداء الأول قبل شيفرة التطبيق، ويُستدعى رد النداء الثاني بعدها.

رد النداء الافتراضي

في تطبيق ريلز الافتراضي، تُستخدَم ردود نداء المُنفِّذ لإجراء ما يلي:

  • تتبع أية خيوط تقع في المواقع الآمنة للتحميل التلقائي وإعادة التحميل.
  • تمكين وتعطيل ذاكرة التخزين المؤقت لاستعلام Active Record.
  • إعادة اتصالات Active Record المكتسبة إلى المجمَّع (pool).
  • تقييد عمر ذاكرة التخزين المؤقت الداخلية.

قبل الإصدار 5.0 من ريلز، بعض هذه الأمور جرى معالجتها عبر أصناف وسيطة منفصلة لـ Rack (مثل ActiveRecord::ConnectionAdapters::ConnectionManagement)، أو تغليف الشيفرة مباشرةً بتوابع مثل ActiveRecord::Base.connection_pool.with_connection. يستبدل المُنفِّذ هذه بواجهة واحدة أكثر تجريدية.

تغليف شيفرة التطبيق

إذا كنت تكتب مكتبة أو مكوّنًا سيستدعي شيفرة التطبيق، فيجب عليك تغليفها باستدعاءٍ إلى المُنفِّذ:

Rails.application.executor.wrap do
  # استدعي شيفرة التطبيق هنا
end

ملاحظة: إذا استدعيت شيفرة التطبيق بشكل متكرر من عملية مستمرة منذ فترة طويلة، قد ترغب في إجراء التغليف باستخدام Reloader بدلًا من ذلك. يجب أن يُغلَّف كل خيط قبل أن يُشغِّل شيفرة التطبيق، لذلك إذا قام التطبيق الخاص بك يدويًا بتفويض العمل إلى خيوط أخرى، مثل عبر Thread.new أو ميزات روبي المتزامنة التي تستخدم مجمعات خيوط (thread pools)، فيجب عليك تغليف الكتلة مباشرةً:

Thread.new do
  Rails.application.executor.wrap do
    # your code here
  end
end

ملاحظة: يستخدم التزامن في روبي ThreadPoolExecutor، الذي تضبطه في بعض الأحيان مع الخيار executor على الرغم من أنَّ الاسم لا علاقة له به.

يعاد مشاركة المُنفِّذ بأمان؛ إذا كان نشطًا بالفعل على خيط الحالي، فإنَّ wrap هو عملية فارغة (no-op).

إذا كان من غير العملي تغليف شيفرة التطبيق في الكتلة (على سبيل المثال، تسبب واجهة Rack البرمجية هذه الإشكالية)، يمكنك أيضًا استخدام الزوج run! / complete!‎:

Thread.new do
  execution_context = Rails.application.executor.run!
  # ضع شيفرتك هنا
ensure
  execution_context.complete! if execution_context
end

التزامن

سيضع المُنفِّذ الخيط الحالي في الوضع running عند تداخل التحميل (Load Interlock). ستُحجَز هذه العملية مؤقتًا إذا كان هناك خيط آخر إمَّا يحمِّل تلقائيًا لثابت أو يغلي / يعيد تحميل التطبيق حاليًا.

إعادة تحميل

بشكل مشابه للمُنفِّذ، يغلف معيد التحميل Reloader أيضًا شيفرة التطبيق. إذا لم يكن المُنفِّذ نشطًا بالفعل في الخيط الحالي، سيستدعيه Reloader لك، لذلك تحتاج فقط إلى الاتصال به. هذا يضمن أيضا أن كل شيء يقوم به Reloader، بما في ذلك جميع استدعاءات ردود النداء الخاصة به، يحدث وهو مغلَّف داخل المُنفِّذ.

Rails.application.reloader.wrap do
  # استدعي شيفرة التطبيق هنا
end

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

ردود النداء

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

كما يوفر معيد التحميل أيضًا ردي النداء to_run و to_complete؛ يستدعيان في المكان نفسه حيث يستدعى ردي نداء المُنفِّذ المقابلين لهما، ولكن فقط عندما يبدأ التنفيذ الحالي بإعادة تحميل التطبيق. عندما لا تكون هناك حاجة إلى إعادة التحميل، سيستدعي الأمر إعادة تحميل الكتلة المغلفة دون أي استدعاءات أخرى.

إلغاء تحميل صنف

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

في كثير من الأحيان، يجب تنفيذ إجراءات إعادة التحميل الإضافية إما قبل أو بعد انتهاء تحميل الصنف تمامًا، لذلك يوفر معيد التحميل أيضًا ردي النداء before_class_unload و after_class_unload.

التزامن

يجب أن تستدعي عمليات "المستوى الأعلى" طويلة الأمد معيد التحميل Reloader، لأنه إذا دعت الحاجة إلى إعادة التحميل، فستُحظَر العملية إلى أن تنتهي جميع الخيوط من أي استدعاءات للمُنفِّذ.

إن كان هذا سيحدث في خيط فرعي (ابن)، مع انتظار الأب داخل المُنفِّذ، فسيتسبَّب في إحداث قفل ميت لا مفر منه: يجب أن تحدث إعادة التحميل قبل تنفيذ الخيط الفرعي (الابن)، ولكن لا يمكن تنفيذه بأمان في حين أن الخيط الرئيسي (الأب) هو منتصف التنفيذ. يجب أن تستخدم الخيوط الفرعية (الأبناء) المٌنفِّذ (Executor ) بدلًا من ذلك.

سلوك الإطار

تستخدم مكونات إطار ريلز هذه الأدوات لإدارة احتياجات التزامن الخاصة بهم أيضًا.

ActionDispatch::Executor و ActionDispatch::Reloader هي برمجيات Rack وسيطة تغلف الطلب مع مُنفِّذ (Executor) أو معيد تحميل (Reloader) معطى، على التوالي. وتُضمَّن تلقائيًا في مكدس التطبيقات الافتراضي. سيأكد معيد التحميل من تخديم أي طلب HTTP قادم ليقدم مع نسخة محملة حديثًا من التطبيق في حالة حدوث أي تغييرات في الشيفرة.

تغلف Active Job أيضًا عمليات التنفيذ مع معيد التحميل، وتحميل أحدث شيفرة لتنفيذ كل وظيفة يحين دورها في طابور الانتظار.

يستخدم Action Cable المُنفِّذ بدلًا من ذلك: نظرًا لأن اتصال الربط (Cable connection) مرتبط بنسخة محددة من صنفٍ، لا يمكن إعادة التحميل لكل رسالة قادمة من مقبس ويب websocket. على أية حال، يغلف معالج الرسالة فقط؛ لا يمنع اتصال الربط طويل الأمد عملية إعادة تحميل شُغلَت بواسطة طلب جديد أو مهمة واردة. بدلًا من ذلك، يستخدم "Action Cable" الاستدعاء before_class_unload الخاص بمعيد التحميل Reloader لفصل جميع اتصالاته. عندما يعيد العميل الاتصال تلقائيًا، سوف يتحدث إلى الإصدار الجديد من الشيفرة.

ما سبق هو نقاط الدخول إلى الإطار، لذا فهم مسؤولون عن ضمان حماية الخيوط المعنية لهم، وتحديد ما إذا كانت إعادة التحميل ضرورية أم لا. المكونات الأخرى تحتاج فقط إلى استخدام المُنفِّذ عند توليد خيوط إضافية.

الضبط

يقوم معيد التحميل Reloader بفحص تغييرات الملف فقط عندما تكون قيمة cache_classes هي false وتكون قيمة reload_classes_only_on_change هي true (وهو الإعداد الافتراضي في بيئة التطوير).

عندما تكون قيمة cache_classes هي true (في مرحلة الإنتاج، بشكل افتراضي)، فإن معيد التحميل يمر مباشرةً إلى المُنفِّذ.

المُنفِّذ لديه دائمًا عمل مهم للقيام به، مثل إدارة اتصال قاعدة البيانات. عندما تكون قيمة كل من cache_classes و eager_load هي true (في مرحلة الإنتاج)، لن يحدث أي تحميل ذاتي أو إعادة تحميل للصنف، لذلك لا تحتاج إلى تداخل التحميل (Load Interlock). إذا كانت قيمة أيًا منهما هي false (في مرحلة التطوير)، فسيستخدم المُنفِّذ تداخل التحميل (Load Interlock) لضمان تحميل الثوابت فقط عندما تكون آمنة.

تداخل التحميل

يسمح تداخل التحميل (Load Interlock) بتمكين التحميل التلقائي وإعادة التحميل في بيئة تشغيل متعددة الخيوط.

عند إجراء التحميل التلقائي لخيط واحد عن طريق تقييم تعريف الصنف من الملف المناسب، من المهم أن لا يواجه خيط آخر مرجعًا إلى ثابت معرف جزئيًّا.

وبالمثل، من الآمن فقط إجراء إلغاء تحميل / إعادة تحميل عندما لا يوجد شيفرة تطبيق في منتصف التنفيذ: بعد إعادة التحميل، قد يشير ثابت المستخدم، على سبيل المثال، إلى صنف مختلف. بدون هذه القاعدة، فإن إعادة التحميل في توقيت سيئ سيعني User.new.class == User أو حتى User == User قد يكون خطأ.

كل من هذين القيدين يعالجان عبر تداخل التحميل (Load Interlock). إنه يحتفظ بتتبع أي خيط يُشغل شيفرة التطبيق أو يحمِّل صنفًا أو يلغي تحميل الثوابت المحملة تلقائيًّا في الوقت الحالي.

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

permit_concurrent_loads

يكتسب المُنفِّذ Executor تلقائيًا قفل تشغيل (القفل running) طوال فترة الحظر، ويعرف التحميل التلقائي متى تجري الترقيةإلى قفل التحميل (القفل load)، ثم الرجوع إلى القفل running مرة أخرى بعد ذلك.

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

على سبيل المثال، بافتراض أن المستخدم لم يُحمَّل بعد، سيؤدي المأزق التالي إلى:ra

Rails.application.executor.wrap do
  th = Thread.new do
    Rails.application.executor.wrap do
      User # الخيط الداخلي ينتظهر هنا. إن لا يستطيع
           # أثناء عمل خيط آخر User تحميل
    end
  end
 
  th.join # 'running' الخيط الخارجي ينتظر هنا ويحمل القفل
end

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

Rails.application.executor.wrap do
  th = Thread.new do
    Rails.application.executor.wrap do
      User # 'load' الخيط الداخلي يمكن أن يكسب القفل
           # ويكمل User ويحمل
    end
  end
 
  ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
    th.join # ينتظر القفل الخارجي هنا ولكن لا يملك أي قفل
  end
end

مثال آخر، باستخدام تزامن روبي:

Rails.application.executor.wrap do
  futures = 3.times.collect do |i|
    Concurrent::Future.execute do
      Rails.application.executor.wrap do
        # نفّذ العمل هنا
      end
    end
  end
 
  values = ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
    futures.collect(&:value)
  end
end

ActionDispatch::DebugLocks

إذا كان التطبيق الخاص بك يعلق في أحد الأقفال الميتة وكنت تعتقد أن تداخل التحميل قد يكون متضمنًا، يمكنك إضافة الوسيط ActionDispatch::DebugLocks مؤقتًا إلى config/application.rb:

config.middleware.insert_before Rack::Sendfile,
                                  ActionDispatch::DebugLocks

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

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

مصادر