أنماط برمجة الألعاب › طابور الأحداث (event queue) 16 / 26 السابق التالي

طابور الأحداث

^title طابور الأحداث ^section أنماط فصل الترابط

النية

افصل لحظة إرسال رسالة أو حدث عن لحظة معالجته.

الدافع

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

حلقات أحداث واجهات المستخدم

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

ولكي تستقبل هذه الرسائل، يوجد في أعماق شيفرتك مكان ما حلقة أحداث (event loop). وهي تبدو تقريباً على هذا النحو:

^code event-loop

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

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

طابور أحداث. نظام التشغيل يضع فيه أحداث Shift وDown وUp وClick، والدالة getNextEvent() تسحبها منه.

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

ناقل أحداث مركزي

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

لنفترض أن لديك نظام تعليمي (tutorial) لعرض صناديق مساعدة بعد أحداث معيّنة داخل اللعبة. مثلاً، في المرة الأولى التي يقتل فيها اللاعب وحشاً شريراً، تريد أن تُظهر فقاعة صغيرة تقول: «اضغط X لتلتقط الغنائم!»

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

وبالمثل، يستطيع أي نظام في اللعبة أن يستقبل أحداثاً من الطابور. فمحرّك التعليم يسجّل نفسه في الطابور ويبيّن أنه يريد أن يستقبل أحداث «مات عدوّ». وبهذه الطريقة، تنتقل معرفة موت عدوّ من نظام القتال إلى محرّك التعليم دون أن يكون أحدهما مطّلعاً على الآخر.

طابور أحداث مركزي تقرأ منه وتكتب إليه شيفرتا Combat وTutorial.

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

ما الذي نقوله؟

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

لكي نُهيّئ لعبتنا للاستماع إلى الصوت، سنبدأ بأبسط نهج ممكن ونرى كيف يسير. سنضيف محرّك «صوت» صغيراً يملك واجهة برمجية (API) لتشغيل صوت معيّن بناءً على مُعرِّف ومستوى صوت:

^code sync-api

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

^code sync-impl

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

^code menu-bloop

بعد أن فعلنا ذلك، نلاحظ أن أحياناً، حين تبدّل عناصر القائمة، تتجمّد الشاشة كلها لبضع إطارات. لقد أصبنا أول مشكلتنا:

  • المشكلة 1: الواجهة البرمجية تحجب المستدعي حتى يعالج محرّك الصوت الطلب بالكامل.

دالتنا playSound() متزامنة (synchronous) -- فهي لا تعود إلى المستدعي حتى تخرج النغمات من السمّاعات. فإن كان على ملف صوت أن يُحمَّل من القرص أولاً، فقد يستغرق ذلك وقتاً. وفي هذه الأثناء، تكون بقية اللعبة متجمّدة.

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

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

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

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

  • المشكلة 2: لا يمكن معالجة الطلبات مجتمعةً.

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

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

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

  • المشكلة 3: تُعالَج الطلبات على الخيط الخطأ.

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

النمط

يخزّن طابور (queue) سلسلة من الإشعارات أو الطلبات بترتيب «أول داخل أول خارج». وإرسال إشعار يدفع الطلب إلى الطابور ويعود. ثم يعالج معالج الطلبات عناصر من الطابور في وقت لاحق. ويمكن معالجة الطلبات مباشرةً أو توجيهها إلى الأطراف المهتمّة. وهذا يفصل المرسِل عن المستقبل في الزمن وأيضاً على نحو ثابت (statically).

متى تستخدمه

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

أفكّر في الأمر من منظور الدفع والسحب. فلديك شيفرة A تريد من جزء آخر B أن يؤدي بعض العمل. والطريقة الطبيعية كي تبدأ A ذلك هي أن تدفع الطلب نحو B.

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

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

في بالك

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

طابور الأحداث المركزي متغيّر عام

أحد الاستخدامات الشائعة لهذا النمط هو أن يكون نوع من محطة «غراند سنترال» تمرّ منها جميع أجزاء اللعبة رسائلها. إنها بنية تحتية قوية، لكن القوة لا تعني دائماً الجودة.

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

حالة العالم يمكن أن تتغيّر تحتك

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

وفي الوقت نفسه، يريد نظام الخبرة أن يتتبّع عدد قتلاها البطلة ومكافأتها على كفاءتها القاتلة. فهو يستقبل كل حدث «مات كيان» ويحدّد نوع الكيان المقتول وصعوبة القتل ليتمكن من منح مكافأة مناسبة.

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

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

قد تعلق في حلقات تغذية راجعة

على جميع أنظمة الأحداث والرسائل أن تتعامل مع الدورات:

  1. يرسل A حدثاً.
  2. يستقبله B ويرد بإرسال حدث.
  3. يتبيّن أن ذلك الحدث هو أحد الأحداث التي يهمّ A، فيستقبله. وردّاً على ذلك، يرسل حدثاً...
  4. عد إلى 2.

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

شيفرة نموذجية

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

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

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

^code play-message

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

  • لا تخصيص ديناميكي للذاكرة.

  • لا نفقة ذاكرة إضافية لمعلومات الحفظ أو المؤشرات.

  • استخدام ذاكرة متّصلة صديق للذاكرة المؤقتة.

إذن، لنفعل ذلك:

^code pending-array

يمكننا معايرة حجم المصفوفة لتغطية أسوأ حالة عندنا. و لتشغيل صوت، فإننا ببساطة نُدرج رسالة جديدة في نهايتها:

^code array-play

يتيح هذا لـplaySound() أن تعود فوراً تقريباً، لكننا ما زلنا بحاجة إلى تشغيل الصوت، بالطبع. فتلك الشيفرة تحتاج إلى أن تذهب إلى مكان ما، وذلك المكان هو دالة update():

^code array-update

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

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

مخزن حلقي

هناك طرق كثيرة لتنفيذ الطوابير، لكن المفضّل لديّ يُسمّى مخزناً حلقياً (ring buffer). فهو يحافظ على كل ما هو رائع في المصفوفات، مع السماح لنا بإزالة العناصر من مقدّمة الطابور تدريجياً.

الآن، أعرف ما تفكّر فيه. إن أزلنا العناصر من بداية المصفوفة، أليس علينا أن نزيح كل العناصر المتبقّية؟ أليس ذلك بطيئاً؟

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

  • رأس الطابور هو المكان الذي تُقرأ منه الطلبات. والرأس هو أقدم طلب معلّق.

  • الذيل (tail) هو الطرف الآخر. وهو الخانة في المصفوفة التي سيُكتب فيها الطلب التالي المُدرج. ولاحظ أنه يقع فقط خارج نهاية الطابور. يمكنك أن تconsideredه نطاقاً نصف مفتوح إن كان ذلك يساعد.

بما أن playSound() تُلحق طلبات جديدة في نهاية المصفوفة، فيبدأ الرأس عند العنصر صفر وينمو الذيل إلى اليمين.

مصفوفة من الأحداث. الرأس يشير إلى العنصر الأول، والذيل يتمدد نحو اليمين.

لنكتب ذلك. أولاً، سنعدّل حقولنا قليلاً كي نجعل هذين المؤشّرَين واضحين في الصنف:

^code head-tail

في تنفيذ playSound()، حلّ tail_ محلّ numPending_، لكن ما عدا ذلك فهو نفسه:

^code tail-play

والتغيير الأكثر إثارة للاهتمام هو في update():

^code tail-update

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

الآن لدينا طابور -- يمكننا الإضافة إلى النهاية والإزالة من المقدّمة. لكن هناك مشكلة واضحة. فمع مرور الطلبات عبر الطابور، يواصل الرأس والذيل زحفهما إلى اليمين. وفي النهاية، يصطدم tail_ بنهاية المصفوفة، ووقت الحفل ينتهي. وهنا تصبح الأمور ماكرة.

المصفوفة نفسها لكن الرأس يتحرك الآن نحو اليمين تاركًا خلايا متاحة على اليسار.

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

تلتفّ المصفوفة حول نهايتها ويستطيع الرأس الآن أن يعود إلى البداية.

تنفيذ ذلك سهل إلى حدٍّ لافت. فحين نُدرج عنصراً، لا نلزم سوى جعل الذيل يلتفّ إلى بداية المصفوفة حين يبلغ نهايتها:

^code ring-play

استبدال tail_++ بزيادة على حجم المصفوفة يجعل الذيل يلتفّ عائداً. والتغيير الآخر هو التحقّق (assertion). فنحن بحاجة إلى ضمان عدم فيض الطابور. ما دام في الطابور عدد طلبات أقل من MAX_PENDING، فستوجد فجوة صغيرة من الخلايا غير المستخدَمة بين الرأس والذيل. وإذا امتلأ الطابور، فستختفي تلك الفجوة، وسيصطدم الذيل بالرأس -- تماماً كما تفعل أفعو بوروبوروس الغريبة -- ويبدأ في الكتابة فوقه. والتحقّق يضمن ألّا يحدث ذلك.

في update()، نلتفّ بالرأس أيضاً:

^code ring-update

إذن أصبح لدينا طابور بلا تخصيص ديناميكي للذاكرة، وبلا نسخ عناصر، ومع صِداقة الذاكرة المؤقتة لمصفوفة بسيطة.

تجميع الطلبات

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

^code drop-dupe-play

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

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

لكن هذا يضع عبء المعالجة على المستدعي. فاستدعاء playSound() سيمرّ على الطابور بأكمله قبل أن يعود، وقد يكون ذلك بطيئاً إن كان الطابور كبيراً. قد يكون التجميع في update() بدلاً من ذلك أكثر منطقية.

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

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

امتداد عبر الخيوط

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

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

نحن في وضع جيد للقيام بذلك الآن بعد أن توفر لدينا ثلاث قطع أساسية:

  1. شيفرة طلب الصوت مفصولة عن الشيفرة التي تعزفه.

  2. لدينا طابور للتنسيق بينهما.

  3. الطابور مُغلف عن بقية البرنامج.

كل ما تبقّى هو جعل الدالتين اللتين تعدّلان الطابور -- playSound() وupdate() -- آمنتين تجاه الخيوط. عادةً ما كنت لأكتب بعض الشيفرة الملموسة لذلك، لكن بما أن هذا كتاب عن المعمارية، فلا أريد أن أغرق في تفاصيل أي واجهة برمجية أو آلية قفل بعينها.

على المستوى الأعلى، كل ما نحتاج إلى فعله هو ضمان عدم تعديل الطابور بشكل متزامن. وبما أن playSound() تقوم بقدر ضئيل جداً من العمل -- أساساً إسناد بعض الحقول -- فهي تستطيع القفل دون أن تحجب المعالجة طويلاً. وفي update()، ننتظر على متغيّر شروط (condition variable) كي لا نهدر دورات المعالج حتى يوجد طلب للمعالجة.

قرارات التصميم

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

ما الذي يدخل الطابور؟

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

  • إن كنت تضع أحداثاً في الطابور:

    يصف «الحدث» أو «الإشعار» شيئاً قد وقع بالفعل، مثل «مات الوحش». فأنت تضعه في الطابور كي تستطيع الكائنات الأخرى أن تستجيب للحدث، تقريباً مثل نمط مراقب غير متزامن.

    • من المرجّح أن تسمح بمستمعين متعدّدين. فبما أن الطابور يحتوي على أمور وقعت بالفعل، فمن المرجّح ألا يهمّ المرسِل مَن الذي يستقبله. فمن منظوره، يقع الحدث في الماضي وقد نُسي بالفعل.

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

  • إن كنت تضع رسائل في الطابور:

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

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

مَن الذي يستطيع القراءة من الطابور؟

في مثالنا، الطابور مُغلف ولا يستطيع قراءته إلا صنف Audio. أما في نظام أحداث واجهة المستخدم، فبإمكانك تسجيل المستمعين بقدر ما تشاء. وتسمع أحياناً مصطلحي «single-cast» و«broadcast» للتمييز بينهما، وكلا الأسلوبين مفيد.

  • طابور أحادي الإرسال:

    هذا هو الملاءمة الطبيعية حين يكون الطابور جزءاً من واجهة صنف ما. تماماً كما في مثال الصوت، فمن منظور المستدعي يرى مجرّد دالة playSound() يستطيع استدعاءها.

    • يصبح الطابور تفصيلاً من تفاصيل التنفيذ بالنسبة للقارئ. فكل ما يعرفه المرسِل هو أنه أرسل رسالة.

    • الطابور أكثر تغليفاً. فمع تساوي كل الأمور، التغليف الأكبر هو الأفضل عادةً.

    • لا عليك أن تقلق بشأن التنافس بين المستمعين. فمع المستمعين المتعدّدين، عليك أن تقرّر هل يحصل جميعهم على كل عنصر (broadcast) أم هل يُوزَّع كل عنصر في الطابور على مستمع واحد (وهذا أشبه بطابور أعمال).

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

  • طابور بثّ:

    هكذا تعمل معظم أنظمة «الأحداث». فإذا كان لديك عشرة مستمعين حين يأتي حدث، فكل العشرة سيرون الحدث.

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

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

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

  • طابور أعمال:

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

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

مَن الذي يستطيع الكتابة إلى الطابور؟

هذا هو الجانب المقابل لقرار التصميم السابق. فهذا النمط يعمل مع جميع تكوينات القراءة/الكتابة الممكنة: واحد إلى واحد، وواحد إلى متعدّد، ومتعدّد إلى واحد، ومتعدّد إلى متعدّد.

  • مع كاتب واحد:

    هذا النمط هو الأقرب إلى نمط مراقب المتزامن. ف لديك كائن واحد مميّز يولّد أحداثاً يمكن للبقية استقبالها.

    • تعرف ضمنياً من أين يأتي الحدث. فبما أنه لا وجود إلا لكائن واحد يستطيع الإضافة إلى الطابور، فيستطيع أي مستمع أن يفترض بأمان أن ذلك هو المرسِل.

    • عادةً ما تسمح بقارئين متعدّدين. فبإمكانك امتلاك طابور مرسِل واحد ومستقبل واحد، لكن ذلك يبدأ بالشعور بأنه أقل شبهاً بنظام التواصل الذي يتحدث عنه هذا النمط وأكثر شبهاً ببنية بيانات طابور عادية.

  • مع كاتبين متعدّدين:

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

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

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

ما عمر الكائنات الموجودة في الطابور؟

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

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

  • نقل الملكية:

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

  • مشاركة الملكية:

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

  • الطابور يملكها:

    خيار آخر هو أن تعيش الرسائل دائماً في الطابور. بدلاً من تخصيص الرسالة نفسها، يطلب المرسِل واحدة «طازجة» من الطابور. فيعيد الطابور إشارة إلى رسالة موجودة أصلاً في الذاكرة داخل الطابور، ويملأها المرسِل. وحين تُعالَج الرسالة، يشير المستقبل إلى الرسالة نفسها في الطابور.

انظر أيضاً

  • لقد ذكرتُ هذا عدّة مرات، لكن هذا النمط هو في جوانب كثيرة فعيلة غير متزامنة (cousin) لنمط المراقب المعروف جيداً.

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

    والمصطلح الآخر هو «نشر/اشتراك» (publish/subscribe)، ويُختصر أحياناً إلى «pubsub». تماماً كما «طابور الرسائل»، فهو عادةً ما يشير إلى أنظمة موزّعة أكبر، على عكس نمط البرمجة المتواضع الذي نركّز عليه هنا.

  • تتطلب آلة الحالة المحدودة، الشبيهة بنمط الحالة عند «الرباعي»، تدفّقاً من المدخلات. فإذا أردتها أن تستجيب لتلك المدخلات بشكل غير متزامن، فمن المنطقي أن تضعها في طابور.

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

  • نوع «القناة» (channel) المدمج في لغة البرمجة Go هو في جوهره طابور أحداث أو رسائل.