ليلة الافتتاح

ليلة الافتتاح

تمرين تعلّمي

مقدمة

العمليات الذرّية

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

add qword [rel counter], 5

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

  1. قراءة القيمة الحالية من الذاكرة؛
  2. تعديل هذه القيمة في مسجّل، بإضافة خمسة إليها؛
  3. كتابة النتيجة مرة أخرى.
mov rax, qword [rel counter] ; 1. load the current value
add rax, 5                   ; 2. add five
mov qword [rel counter], rax ; 3. store the result back

وهذا نمط شائع يُسمّى القراءة-التعديل-الكتابة (RMW).

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

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

هذا هو تسابق البيانات، وهو مشكلة شائعة في الكود متعدد الخيوط.

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

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

Note

من الشائع وصف التطبيقات التي تعمل على عدة نوى بأنها متعددة الخيوط. غير أن الخيط ليس هو النواة. قد يعمل خيطان بالتزامن على النواة نفسها، متداخلَين مع بعضهما، أو بالتوازي على نواتين مختلفتين.

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

أما الذرّية الحقيقية عبر عدة نوى، فهي ما توفّره التعليمات التالية.

التبادل الذرّي

تبادل تعليمة xchg بين معاملين. يصبح المعامل الوجهة مساويًا للقيمة السابقة في المعامل المصدر، بينما يصبح المعامل المصدر مساويًا للقيمة السابقة في المعامل الوجهة. ومن حيث المفهوم، يمكن تصوّرها كتعليمتَي mov تحدثان في آنٍ واحد.

وكالمعتاد، يمكن استخدامها مع معاملين من نوع مسجّل، أو مع معامل واحد في الذاكرة ومعامل واحد في مسجّل:

mov  eax, 1
xchg dword [rdi], eax ; [rdi] = 1, eax = the old value of [rdi]
mov ecx, 2
mov edx, 3
xchg edx, ecx         ; edx = 2, ecx = 3

عند استخدامها مع معامل في الذاكرة، تكون xchg ذرّية دائمًا.

Caution

تكون xchg ذرّية تلقائيًا عندما يكون أحد المعاملين موقعًا في الذاكرة. وهذا يعني أيضًا أن العملية أبطأ بكثير في هذه الحالة.

إن لم تكن بحاجة إلى الذرّية، فأجرِ التبديل عبر مسجّل احتياطي باستخدام تعليمات mov العادية.

بادئة lock

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

lock add qword [rel counter], 5 ; the read, the modify, and the write are one step

لا تعمل lock إلا عندما تكون الوجهة في الذاكرة، وفقط على التعليمات التي تقرأ وتعدّل وتكتب تلك الذاكرة:

  1. عمليات حسابية، مثل add وsub وinc وdec وneg؛
  2. عمليات على مستوى البتات، مثل and وor وxor وnot؛
  3. عمليات البتات bts وbtr وbtc؛
  4. بعض التعليمات المتخصصة الأخرى، مثل xadd وcmpxchg، الموضّحة أدناه.
Caution

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

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

التبادل والإضافة

تحدّث lock add العادية الذاكرة لكنها تُلقي بالقيمة القديمة. وكثيرًا ما تكون القيمة القديمة هي المطلوب بالضبط، مثلًا لمنح كل خيط رقم تذكرة مميزًا.

تعليمة xadd (حيث x بمعنى exchange) تُرجع القيمة السابقة في أثناء الإضافة. فتكتب المجموع في الوجهة وتترك القيمة الأصلية للوجهة في مسجّل المصدر.

mov  rax, 1
lock xadd qword [rdi], rax ; [rdi] = [rdi] + rax = [rdi] + 1
                           ; rax = the old value of [rdi]

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

وكما في lock add، ينتهي العدّاد عند العدد الدقيق للدعوات. غير أنه على خلاف lock add، تُرجع كل قيمة وسيطة أيضًا، واحدة لكل مستدعٍ.

المقارنة والتبادل

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

يستخدم cmpxchg dest, src المسجّل rax كمُراكِم ضمني، ويقارنه بـ dest:

  • إذا كان dest == rax، فإن dest = src وZF = 1.
  • إذا كان dest != rax، فإن rax = dest وZF = 0.

لاحظ أن dest لا يُحدّث إلا عندما يساوي القيمة المتوقعة، المحمّلة سابقًا في rax. وتضمن هذه المساواة أن dest ما زال يحمل القيمة التي حُسبت منها القيمة الجديدة، فلا يُطبّق أبدًا تحديث مبني على قراءة قديمة. وهذا يجعل cmpxchg لبنة بناء التحديث الذرّي، المعروف أيضًا بـ المقارنة والتبديل (CAS):

    mov rax, qword [rdi]          ; rax = the value we expect to find
.retry:
    lea rcx, [rax + 10]           ; rcx = the new value we want to install
    lock cmpxchg qword [rdi], rcx ; if [rdi] still equals rax, store rcx and set ZF
                                  ; otherwise reload rax with the current value, clear ZF
    jnz  .retry                   ; ZF is cleared, so another thread won the race. Recompute and retry

هذه حلقة إعادة المحاولة هي قلب التحديثات بدون أقفال. النافذة بين القراءة والمقارنة والتبادل هي بالضبط اللحظة التي قد يتدخل فيها خيط آخر، ويلتقط cmpxchg ذلك برفضه تخزين قيمة محسوبة من قراءة قديمة.

ترتيب الذاكرة

لمست كل عملية حتى الآن موقعًا واحدًا. وعندما تتناسق الخيوط عبر أكثر من موقع، يظهر سؤال جديد: بأي ترتيب تصبح كتابات خيط ما مرئية لخيط آخر؟ والقواعد التي تجيب عن هذا السؤال هي ترتيب الذاكرة للمعالج.

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

يحافظ x86-64 على ترتيب ذاكرة قوي بين عمليات التحميل والتخزين العادية، بحيث على كل نواة:

  1. لا يُعاد ترتيب عملية تحميل بعد عملية تحميل لاحقة؛
  2. لا يُعاد ترتيب عملية تخزين بعد عملية تخزين لاحقة؛
  3. لا يُعاد ترتيب عملية تحميل بعد عملية تخزين لاحقة.

الترتيب الوحيد الممكن هو أن يبدو تخزين ما مكتملًا بعد تحميل لاحق لعنوان مختلف.

التعليمة المسبوقة بـ lock، أو xchg مع معامل في الذاكرة، حاجز كامل: لا يبدو أن شيئًا يتحرك عبره في أي من الاتجاهين. ولهذا فهي كافية لضمان الترتيب الكامل في معظم الحالات.

الدوران وpause

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

هذه هي الخوارزمية العامة، باستخدام تعليمة xchg مع علم ثنائي:

  1. يبدأ العلم بالقيمة 0.
  2. لحيازة القفل، تبادل نواة القيمة في العلم مع 1.
  3. إذا كانت القيمة المُرجعة 1، فهذا يعني أن القفل محتجز من نواة أخرى. فتنتظر النواة الحالية وتُعيد محاولة حيازة القفل.
  4. إذا كانت القيمة المُرجعة 0، فهذا يعني أن القفل كان حرًا. وقد ضبطته xchg الآن على 1، وستنتظر النوى الأخرى حتى تحرّره هذه النواة.
  5. بمجرد أن تنتهي النواة الحالية من عملها، يؤدي تحديث العلم بالقيمة 0 إلى تحرير القفل.
acquire:
    mov  eax, 1
    xchg dword [rdi], eax ; try to take the lock; eax = its old value
    test eax, eax
    jnz  .held            ; old value was 1: someone else holds it
    ret                   ; old value was 0: the lock is ours
.held:
    pause                 ; wait before trying again
    jmp  acquire

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

بمجرد أن ينتهي الخيط من عمله، يمكنه تحرير القفل بتخزين عادي للقيمة 0 باستخدام mov. لا يلزم أكثر من mov، لأن عمليات التحميل والتخزين على x86-64 لا يُعاد ترتيبها بعد تخزين لاحق. والتخزين الذي لا يتجاوز أبدًا الوصولات التي قبله يقال إنه يمتلك ترتيب التحرير، وعلى x86 يحمله كل تخزين عادي.

Note

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

لاحظ أن الأعلام، مثل CF الذي تعدّله bts، جزء من rflags، وهو مسجّل. وهذا يعني أنها خاصة بكل خيط.

التعليمات

ليلة الافتتاح في Grand Marquee، وكل العاملين يعملون في الوقت نفسه. يتدفق الجمهور من كل الأبواب، وتبيع عدة شبابيك للتذاكر من البكرة نفسها، والعرض الأول على وشك نفاد التذاكر، وفي غرفة العرض في الأعلى يجب أن تستمر البكرات في الدوران.

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

جميع القيم المشتركة في هذا التمرين أعداد صحيحة موقّعة بطول 64 بت.

لديك خمس مهام.

Note

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

1. عُدّ الداخلين

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

نفّذ الدالة admit_group، التي تضيف حجم المجموعة إلى العدّاد بشكل ذرّي.

تأخذ هذه الدالة الوسائط التالية بهذا الترتيب:

  • admissions: عنوان الذاكرة لعدّاد الداخلين المشترك، عدد صحيح موقّع بطول 64 بت.
  • group_size: عدد الأشخاص في المجموعة، عدد صحيح موقّع بطول 64 بت، دائمًا أكبر من 0.
admissions = 120, group_size = 4
// admissions is now 124

تُدخل الاختبارات مجموعات عبر عدة أبواب في الوقت نفسه. يجب أن يساوي العدّاد النهائي مجموع كل المجموعات المسموح لها بالدخول.

لا تُرجع هذه الدالة أي قيمة. ينبغي تحديث العدّاد في مكانه.

2. بدّل البكرة

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

نفّذ الدالة change_reel، التي تثبّت البكرة التالية بشكل ذرّي وتُرجع البكرة التي كانت مثبّتة.

تأخذ هذه الدالة الوسائط التالية بهذا الترتيب:

  • mounted: عنوان الذاكرة لمعرّف البكرة المثبّتة، عدد صحيح موقّع بطول 64 بت.
  • next_reel: معرّف البكرة المراد تثبيتها، عدد صحيح موقّع بطول 64 بت.
mounted = 7, next_reel = 9
// => 7, and mounted is now 9

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

3. بِع تذكرة

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

نفّذ الدالة sell_ticket، التي تضيف واحدًا إلى العدّاد بشكل ذرّي وتُرجع رقم التذكرة التي بِيعت للتو. رقم التذكرة هو العدّاد بعد عملية البيع هذه: فعندما كان العدّاد 41، تكون هذه التذكرة 42.

تأخذ هذه الدالة الوسيط التالي:

  • tickets_sold: عنوان الذاكرة للعدّاد المشترك للتذاكر المبيعة، عدد صحيح موقّع بطول 64 بت.
tickets_sold = 0
// => 1, and tickets_sold is now 1

tickets_sold = 41
// => 42, and tickets_sold is now 42

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

4. املأ العرض الأول

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

نفّذ الدالة claim_seat، التي تحجز المقعد التالي بشكل ذرّي عندما يبقى مقعد شاغر. عندما يُحجز مقعد، تُرجع الدالة رقم المقعد، وهو عدد المقاعد المحجوزة بعد هذا الحجز. وعندما تُنفد تذاكر العرض، تُرجع 0 وتترك العدّاد دون تغيير.

تأخذ هذه الدالة الوسائط التالية بهذا الترتيب:

  • seats_taken: عنوان الذاكرة لعدّاد المقاعد المحجوزة، عدد صحيح موقّع بطول 64 بت، لا يتجاوز capacity أبدًا.
  • capacity: عدد المقاعد في العرض، عدد صحيح موقّع بطول 64 بت، دائمًا أكبر من 0.
seats_taken = 0, capacity = 150
// => 1, and seats_taken is now 1

seats_taken = 82, capacity = 150
// => 83, and seats_taken is now 83

seats_taken = 149, capacity = 150
// => 150, and seats_taken is now 150

seats_taken = 150, capacity = 150
// => 0, and seats_taken stays 150

تُرجع هذه الدالة رقم المقعد، أو 0 عندما تُنفد تذاكر العرض، كعدد صحيح موقّع بطول 64 بت.

5. انتبه لغرفة العرض

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

نفّذ الدالة visit_booth، التي:

  1. تنتظر حتى تصبح الغرفة متاحة؛
  2. تحجز الغرفة؛
  3. تشغّل مهمة الزائر؛
  4. تعيد فتح الغرفة بعد المهمة وقبل أن تُرجع.

بينما تشغلها شخص آخر، ينبغي أن تنتظر بلطف باستخدام pause.

تأخذ هذه الدالة الوسائط التالية بهذا الترتيب:

  • booth: عنوان الذاكرة لكلمة الإشغال، عدد صحيح موقّع بطول 64 بت قيمته إما 0 أو 1.
  • task: مهمة الزائر، دالة لا تأخذ أي معاملات ولا تُرجع أي قيمة.

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

Caution

مهمة الزائر دالة عادية تتبع اصطلاح الاستدعاء:

  • قد تكتب فوق أي سجل محفوظ للمستدعي، لذا ينبغي أن تحفظ أي شيء ما زلت بحاجة إليه بعد المهمة في مكان لا يمكن للاستدعاء المساس به؛
  • وهي تتوقع أيضًا أن يكون المكدّس محاذيًا لـ 16 بايت عند نقطة الاستدعاء.

قد يظهر انتهاك أي من القاعدتين على هيئة انهيار بدلًا من فشل في التحقق.

تعديل عبر GitHub يفتح الرابط في نافذة أو علامة تبويب جديدة
x86-64 Assembly Exercism

مستعد لبدء ليلة الافتتاح؟

سجّل في Exercism لتتعلّم وتتقن x86-64 Assembly عبر 22 مفهومًا130 تمرينًا، وإرشاد بشري حقيقي، وكل ذلك مجانًا.