قد تبدو التعليمة الواحدة كفعل غير قابل للتجزئة، لكنها ليست كذلك على الإطلاق. خذ إضافة خمسة إلى قيمة في الذاكرة:
add qword [rel counter], 5
في الباطن، لا يملك المعالج طريقة للإضافة إلى الذاكرة مباشرة. بل يفكّك هذه التعليمة الواحدة إلى ثلاث خطوات أصغر تُسمّى العمليات الدقيقة:
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 مجموعة من التعليمات لحل هذه المشكلة، بجعل التعليمة غير قابلة للتجزئة. فتتصرّف كوحدة واحدة ليس بالنسبة للنواة التي تعالجها فحسب، بل لكل نواة أخرى أيضًا. والعملية التي تتماسك، بحيث لا تستطيع أي نواة أخرى تجزئتها، تُسمّى ذرّية.
من الشائع وصف التطبيقات التي تعمل على عدة نوى بأنها متعددة الخيوط. غير أن الخيط ليس هو النواة. قد يعمل خيطان بالتزامن على النواة نفسها، متداخلَين مع بعضهما، أو بالتوازي على نواتين مختلفتين.
يمكن للخيوط المتداخلة أن تتسابق بالفعل عبر القراءة-التعديل-الكتابة إذا فُكّكت إلى عدة تعليمات، لأن نظام التشغيل يستطيع التبديل بين الخيوط بين أي تعليمتين. أما النافذة داخل تعليمة واحدة، فلا يكشفها إلا الكود المتوازي الحقيقي. ولأن نظام التشغيل لا يبدّل الخيوط إلا بين التعليمات، لا داخل التعليمة الواحدة، فإن التعليمة الواحدة آمنة بطبيعتها على نواة واحدة.
أما الذرّية الحقيقية عبر عدة نوى، فهي ما توفّره التعليمات التالية.
تبادل تعليمة 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 ذرّية دائمًا.
تكون xchg ذرّية تلقائيًا عندما يكون أحد المعاملين موقعًا في الذاكرة.
وهذا يعني أيضًا أن العملية أبطأ بكثير في هذه الحالة.
إن لم تكن بحاجة إلى الذرّية، فأجرِ التبديل عبر مسجّل احتياطي باستخدام تعليمات mov العادية.
lock
أكثر طريقة شيوعًا لجعل تعليمة ما ذرّية في x86-64 هي إضافة البادئة lock.
فهي تدمج القراءة والتعديل والكتابة في خطوة واحدة غير قابلة للتجزئة.
وهذا يعني أن النواة تحتفظ بالذاكرة حصريًا طوال العملية بأكملها، فلا تستطيع أي نواة أخرى قراءة ذلك الموقع أو الكتابة فيه في أثناء ذلك.
lock add qword [rel counter], 5 ; the read, the modify, and the write are one step
لا تعمل lock إلا عندما تكون الوجهة في الذاكرة، وفقط على التعليمات التي تقرأ وتعدّل وتكتب تلك الذاكرة:
add وsub وinc وdec وneg؛and وor وxor وnot؛bts وbtr وbtc؛xadd وcmpxchg، الموضّحة أدناه.الاحتفاظ بموقع ما حصريًا وإبقاء كل نواة أخرى خارجه ليس مجانيًا.
فالعملية المسبوقة بـ 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 على ترتيب ذاكرة قوي بين عمليات التحميل والتخزين العادية، بحيث على كل نواة:
الترتيب الوحيد الممكن هو أن يبدو تخزين ما مكتملًا بعد تحميل لاحق لعنوان مختلف.
التعليمة المسبوقة بـ lock، أو xchg مع معامل في الذاكرة، حاجز كامل: لا يبدو أن شيئًا يتحرك عبره في أي من الاتجاهين.
ولهذا فهي كافية لضمان الترتيب الكامل في معظم الحالات.
pause
التعليمات التي تضبط علمًا بينما تُرجع حالته السابقة تُعرف بـ اختبار-وتعيين. ويمكن استخدامها كأساس لـ قفل الدوران، بما يضمن لنواة وصولًا حصريًا إلى جزء من الكود.
هذه هي الخوارزمية العامة، باستخدام تعليمة xchg مع علم ثنائي:
0.1.1، فهذا يعني أن القفل محتجز من نواة أخرى.
فتنتظر النواة الحالية وتُعيد محاولة حيازة القفل.0، فهذا يعني أن القفل كان حرًا.
وقد ضبطته xchg الآن على 1، وستنتظر النوى الأخرى حتى تحرّره هذه النواة.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 يحمله كل تخزين عادي.
يمكن استخدام أي تعليمة تختبر موقع ذاكرة وتضبطه ذرّيًا من أجل قفل الدوران.
على سبيل المثال، يمكن استخدام lock bts بدلًا من xchg، لضبط بت معيّن مع التحقق مما إذا كان مضبوطًا بالفعل.
لاحظ أن الأعلام، مثل CF الذي تعدّله bts، جزء من rflags، وهو مسجّل.
وهذا يعني أنها خاصة بكل خيط.
ليلة الافتتاح في Grand Marquee، وكل العاملين يعملون في الوقت نفسه. يتدفق الجمهور من كل الأبواب، وتبيع عدة شبابيك للتذاكر من البكرة نفسها، والعرض الأول على وشك نفاد التذاكر، وفي غرفة العرض في الأعلى يجب أن تستمر البكرات في الدوران.
كل ما يتشاركه العاملون يقع عند عنوان ذاكرة، وتستدعي الاختبارات دوالك من عدة خيوط في الوقت نفسه. القيمة التي تُقرأ ثم تُعدَّل ثم تُكتب مرة أخرى في خطوات منفصلة ستفقد تحديثاتها تحت هذا الحمل، لذا يجب أن تجعل كل دالة تحديثها ذرّيًّا.
جميع القيم المشتركة في هذا التمرين أعداد صحيحة موقّعة بطول 64 بت.
لديك خمس مهام.
يجب تنفيذ التحديثات في هذا التمرين بتعليمات ذرّية. تشغّل الاختبارات دوالك بشكل متزامن وتتحقق من عدم فقدان أي تحديث، لذا فمن المرجّح جدًّا أن تفشل عملية قراءة-تعديل-كتابة غير ذرّية.
الأبواب مفتوحة، وفي كل باب يقف منظّم يقطع التذاكر. تُحتسب كل مجموعة يُسمح لها بالدخول في عدّاد مشترك واحد للداخلين، ومع إحصاء كل الأبواب في الوقت نفسه، يجب أن يظل عدد الحضور صحيحًا.
نفّذ الدالة admit_group، التي تضيف حجم المجموعة إلى العدّاد بشكل ذرّي.
تأخذ هذه الدالة الوسائط التالية بهذا الترتيب:
admissions: عنوان الذاكرة لعدّاد الداخلين المشترك، عدد صحيح موقّع بطول 64 بت.group_size: عدد الأشخاص في المجموعة، عدد صحيح موقّع بطول 64 بت، دائمًا أكبر من 0.admissions = 120, group_size = 4
// admissions is now 124
تُدخل الاختبارات مجموعات عبر عدة أبواب في الوقت نفسه. يجب أن يساوي العدّاد النهائي مجموع كل المجموعات المسموح لها بالدخول.
لا تُرجع هذه الدالة أي قيمة. ينبغي تحديث العدّاد في مكانه.
يثبّت جهاز العرض بكرة واحدة فقط. يجب أن يكون تثبيت البكرة التالية وإخراج البكرة الحالية حركة واحدة: فمع وجود عاملَي عرض عند الجهاز، قد تُسلّم خطوتان منفصلتان البكرة نفسها لكليهما، أو تُسقط إحداهما بكرة على الأرض.
نفّذ الدالة change_reel، التي تثبّت البكرة التالية بشكل ذرّي وتُرجع البكرة التي كانت مثبّتة.
تأخذ هذه الدالة الوسائط التالية بهذا الترتيب:
mounted: عنوان الذاكرة لمعرّف البكرة المثبّتة، عدد صحيح موقّع بطول 64 بت.next_reel: معرّف البكرة المراد تثبيتها، عدد صحيح موقّع بطول 64 بت.mounted = 7, next_reel = 9
// => 7, and mounted is now 9
تُرجع هذه الدالة معرّف البكرة المثبّتة سابقًا كعدد صحيح موقّع بطول 64 بت. تجعل الاختبارات عدة خيوط تبدّل البكرات عبر نقطة التثبيت نفسها في الوقت نفسه، وتتحقق من أن كل بكرة تُسلَّم تخرج مرة واحدة بالضبط.
تبيع كل نافذة انطلاقًا من عدّاد مشترك واحد للتذاكر المبيعة. لا يكفي هنا مجرد الإضافة إلى عدّاد كما تفعل الأبواب: يجب أن تعرف كل عملية بيع الرقم الذي أُعطي لها. لا يجوز أن تشترك عمليتا بيع في الرقم نفسه، وبعد التدفق يجب أن يساوي العدّاد عدد التذاكر المبيعة بالضبط.
نفّذ الدالة 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 بت. تبيع الاختبارات من عدة نوافذ في الوقت نفسه: يجب أن يكون كل رقم مُرجَع مختلفًا، ويجب أن يساوي العدّاد النهائي عدد عمليات البيع.
للعرض الأول عدد ثابت من المقاعد، وكل نافذة تبيع له. لا يجوز أن تمر عملية بيع إلا ما دام هناك مقعد متبقٍّ، ويجب ألا يبيع العرض أكثر من طاقته أبدًا، ولا حتى عندما تتنافس كل النوافذ على المقعد الأخير في اللحظة نفسها. إن التحقق من العدّاد ثم الإضافة إليه في خطوتين منفصلتين يترك فجوة بينهما، لذا يجب أن ينجح التحقق والتحديث معًا أو يفشلا معًا.
نفّذ الدالة 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 بت.
تتّسع غرفة العرض لشخص واحد.
تحدد كلمة في الذاكرة ما إذا كانت مشغولة: 0 تعني متاحة، و1 تعني مشغولة.
يتوافد العاملون طوال الليل، ولكل منهم مهمة يؤديها في الداخل: توقيع سجل العمل، أو تمرير الفيلم في جهاز العرض، أو أرشفة بكرة.
ومهما تكن المهمة، فقاعدة الغرفة واحدة، وتطبيقها مهمتك: شخص واحد في الداخل في كل مرة، من بداية مهمته حتى نهايتها.
نفّذ الدالة visit_booth، التي:
بينما تشغلها شخص آخر، ينبغي أن تنتظر بلطف باستخدام pause.
تأخذ هذه الدالة الوسائط التالية بهذا الترتيب:
booth: عنوان الذاكرة لكلمة الإشغال، عدد صحيح موقّع بطول 64 بت قيمته إما 0 أو 1.task: مهمة الزائر، دالة لا تأخذ أي معاملات ولا تُرجع أي قيمة.لاحظ أن الاختبارات تشغّل مهام تنظر إلى الغرفة وإلى سجل عمل مشترك. هذا يعني أن الغرفة يجب أن تظل مشغولة طوال المهمة، وأن كل زيارة يجب أن تشغّل مهمتها مرة واحدة بالضبط، وأنه مع وجود عدة زوار في الوقت نفسه يجب أن يخرج سجل العمل دقيقًا تمامًا.
مهمة الزائر دالة عادية تتبع اصطلاح الاستدعاء:
قد يظهر انتهاك أي من القاعدتين على هيئة انهيار بدلًا من فشل في التحقق.
سجّل في Exercism لتتعلّم وتتقن x86-64 Assembly عبر 22 مفهومًا130 تمرينًا، وإرشاد بشري حقيقي، وكل ذلك مجانًا.