تطبيق SMP التمهيدي لأجهزة Android

تم تحسين الإصدارات 3.0 والإصدارات الأحدث من نظام التشغيل Android لتتوافق مع بُنى المعالجات المتعددة. يعرض هذا المستند المشاكل التي يمكن أن تنشأ عند كتابة رمز برمجي متعدد سلاسل المحادثات لأنظمة المعالجات المتعددة المتماثلة بلغات البرمجة C وC++‎ وJava (يُشار إليها فيما بعد باسم "Java" للاختصار). وهو مخصّص ليكون دليلاً تمهيديًا لمطوّري تطبيقات Android، وليس مناقشة كاملة حول هذا الموضوع.

مقدمة

‫SMP هو اختصار لـ "Symmetric Multi-Processor" (المعالجة المتعددة المتماثلة). ويصف تصميمًا تتشارك فيه نواتان أو أكثر من نوى وحدة المعالجة المركزية (CPU) المتطابقة في الوصول إلى الذاكرة الرئيسية. حتى قبل بضع سنوات، كانت جميع أجهزة Android تعمل بمعالج واحد (UP).

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

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

سيشرح الجزء المتبقي من هذا المستند السبب، وسيخبرك بما عليك القيام به لضمان أن يتصرف الرمز الخاص بك بشكل صحيح.

نماذج اتساق الذاكرة: لماذا تختلف معالجات SMP قليلاً

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

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

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

النموذج الذي اعتاد عليه معظم المبرمجين هو الاتساق التسلسلي، والذي يتم وصفه على النحو التالي (Adve & Gharachorloo):

  • يبدو أنّ جميع عمليات الذاكرة يتم تنفيذها واحدة تلو الأخرى
  • يبدو أنّ جميع العمليات في سلسلة محادثات واحدة يتم تنفيذها بالترتيب الموضّح في برنامج المعالج.

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

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

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

في ما يلي مثال بسيط، مع تشغيل الرمز على سلسلتين:

سلسلة المحادثات 1 سلسلة المحادثات 2
A = 3
B = 5
reg0 = B
reg1 = A

في هذا المثال وجميع أمثلة litmus المستقبلية، يتم تمثيل مواقع الذاكرة بأحرف كبيرة (A، B، C) وتبدأ سجلات وحدة المعالجة المركزية بـ "reg". تكون جميع الذاكرة في البداية صفرًا. يتم تنفيذ التعليمات من الأعلى إلى الأسفل. هنا، يخزّن الخيط 1 القيمة 3 في الموقع A، ثم القيمة 5 في الموقع B. يحمّل سلسلة المحادثات 2 القيمة من الموقع B إلى reg0، ثم يحمّل القيمة من الموقع A إلى reg1. (يُرجى العِلم أنّنا نكتب بترتيب معيّن ونقرأ بترتيب آخر).

يُفترض أن يتم تنفيذ السلسلة 1 والسلسلة 2 على أنوية مختلفة لوحدة المعالجة المركزية. يجب عليك دائمًا وضع هذا الافتراض عند التفكير في التعليمات البرمجية متعددة السلاسل.

يضمن الاتساق التسلسلي أنّه بعد انتهاء تنفيذ كلا السلسلتَين، ستكون السجلات في إحدى الحالات التالية:

عمليات التسجيل الولايات
reg0=5, reg1=3 ممكن (تم تشغيل سلسلة المحادثات 1 أولاً)
reg0=0, reg1=0 ممكن (تم تشغيل سلسلة التعليمات 2 أولاً)
reg0=0, reg1=3 ممكن (تنفيذ متزامن)
reg0=5, reg1=0 إطلاقاً

للوصول إلى حالة نرى فيها B=5 قبل أن نرى التخزين في A، إما أن تحدث القراءات أو الكتابات خارج الترتيب. على جهاز متسق تسلسليًا، لا يمكن أن يحدث ذلك.

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

وتختلف التفاصيل بشكل كبير. على سبيل المثال، يضمن معالج x86، على الرغم من عدم اتساقه تسلسليًا، أن يظل reg0 = 5 وreg1 = 0 مستحيلاً. يتم تخزين المتاجر مؤقتًا، ولكن يتم الحفاظ على ترتيبها. من ناحية أخرى، لا تستخدم معالجات ARM هذه التقنية. لا يتم الحفاظ على ترتيب المتاجر المخزنة مؤقتًا، وقد لا تصل المتاجر إلى جميع النوى الأخرى في نفس الوقت. هذه الاختلافات مهمة لمبرمجي التجميع. ومع ذلك، كما سنرى أدناه، يمكن لمبرمجي C أو C++ أو Java ويجب عليهم البرمجة بطريقة تخفي مثل هذه الاختلافات المعمارية.

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

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

بما أن برامج التجميع يمكنها أيضًا إعادة ترتيب عمليات الوصول إلى الذاكرة، فإن هذه المشكلة ليست جديدة على أنظمة المعالجة المتعددة المتماثلة (SMP). حتى في المعالج الأحادي، يمكن لبرنامج التجميع إعادة ترتيب عمليات التحميل إلى reg0 وreg1 في مثالنا، ويمكن جدولة سلسلة التعليمات 1 بين التعليمات التي تمت إعادة ترتيبها. ولكن إذا لم يُعِد برنامج التجميع ترتيب التعليمات، قد لا نواجه هذه المشكلة أبدًا. في معظم معالجات ARM SMP، حتى بدون إعادة ترتيب برنامج التجميع، من المحتمل أن تظهر إعادة الترتيب، ربما بعد عدد كبير جدًا من عمليات التنفيذ الناجحة. ما لم تكن تبرمج بلغة التجميع، فإنّ المعالجات المتعدّدة المتماثلة (SMP) تزيد بشكل عام من احتمال ظهور المشاكل التي كانت موجودة طوال الوقت.

البرمجة الخالية من سباق البيانات

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

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

ما هو "سباق البيانات"؟

يحدث سباق البيانات عندما يحاول مؤشرا ترابط على الأقل الوصول إلى البيانات العادية نفسها في الوقت نفسه، ويعدّل أحدهما على الأقل هذه البيانات. ونقصد بـ "البيانات العادية" البيانات التي لا تكون كائن مزامنة مخصّصًا للتواصل بين سلاسل المحادثات. إنّ الاستبعادات المتبادلة أو متغيرات الشروط أو متغيرات Java أو كائنات C++‎ الذرية ليست بيانات عادية، ويُسمح بالوصول إليها في حالة السباق. في الواقع، يتم استخدامها لمنع سباقات البيانات على كائنات أخرى.

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

سلسلة المحادثات 1 سلسلة المحادثات 2
if (A) B = true if (B) A = true

بما أنّه لا تتم إعادة ترتيب العمليات، سيتم تقييم كلا الشرطين على أنّهما خطأ، ولن يتم تعديل أي من المتغيرين. وبالتالي، لا يمكن أن يحدث سباق بيانات. لا داعي للتفكير في ما قد يحدث إذا تمت إعادة ترتيب عملية التحميل من A والتخزين في B في السلسلة 1 بطريقة ما. لا يُسمح لبرنامج التجميع بإعادة ترتيب السلسلة 1 من خلال إعادة كتابتها على النحو التالي: "B = true; if (!A) B = false". سيكون ذلك مثل صنع النقانق في وسط المدينة في وضح النهار.

يتم تعريف سباقات البيانات رسميًا على الأنواع الأساسية المضمنة مثل الأعداد الصحيحة والمراجع أو المؤشرات. إنّ تعيين قيمة لمتغيّر int أثناء قراءته في سلسلة محادثات أخرى في الوقت نفسه هو بالتأكيد سباق بيانات. ولكن تمت كتابة كل من مكتبة C++‎ العادية ومكتبات Java Collections للسماح لك أيضًا بالتفكير في سباقات البيانات على مستوى المكتبة. وتضمن عدم حدوث سباق بيانات ما لم تكن هناك عمليات وصول متزامنة إلى الحاوية نفسها، على الأقل إحداها تقوم بتحديثها. يؤدي تعديل set<T> في سلسلة محادثات واحدة أثناء قراءتها في الوقت نفسه في سلسلة محادثات أخرى إلى السماح للمكتبة بتقديم سباق بيانات، وبالتالي يمكن اعتباره بشكل غير رسمي "سباق بيانات على مستوى المكتبة". في المقابل، لا يؤدي تعديل set<T> واحد في سلسلة محادثات واحدة أثناء قراءة سلسلة محادثات مختلفة في سلسلة محادثات أخرى إلى حدوث سباق بيانات، لأنّ المكتبة تَعِد بعدم تقديم سباق بيانات (منخفض المستوى) في هذه الحالة.

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

تجنُّب حالات السباق

توفّر لغات البرمجة الحديثة عددًا من آليات المزامنة لتجنُّب حالات السباق. الأدوات الأساسية هي:

الأقفال أو الاستبعادات المتبادلة
يمكن استخدام الاستبعادات المتبادلة (C++11 std::mutex أو pthread_mutex_t) أو synchronized كتل في Java لضمان عدم تشغيل قسم معين من الرمز بشكل متزامن مع أقسام أخرى من الرمز تصل إلى البيانات نفسها. سنشير إلى هذه الميزات وغيرها من الميزات المشابهة بشكل عام باسم "الأقفال". يؤدي الحصول على قفل محدّد باستمرار قبل الوصول إلى بنية بيانات مشتركة وتحريره بعد ذلك إلى منع حدوث سباقات البيانات عند الوصول إلى بنية البيانات. ويضمن أيضًا أن تكون التعديلات وعمليات الوصول ذرية، أي لا يمكن تشغيل أي تعديل آخر على بنية البيانات في المنتصف. هذه هي الأداة الأكثر شيوعًا لمنع سباقات البيانات. يضمن استخدام كتل synchronized في Java أو lock_guard أو unique_lock في C++‎ تحرير الأقفال بشكل صحيح في حال حدوث استثناء.
المتغيّرات المتقلبة/الذرية
توفّر لغة Java حقول volatile تتيح الوصول المتزامن بدون حدوث تضارب في البيانات. منذ عام 2011، تتيح لغتا C و++C استخدام متغيرات وحقول atomic ذات دلالات مشابهة. وعادةً ما يكون استخدامها أكثر صعوبة من الأقفال، لأنها تضمن فقط أن الوصول الفردي إلى متغير واحد يكون ذريًا. (في C++‎، يمتد هذا عادةً إلى عمليات القراءة والتعديل والكتابة البسيطة، مثل الزيادات. تتطلب Java طلبات إجراء خاصة لذلك.) على عكس الأقفال، لا يمكن استخدام المتغيّرات volatile أو atomic مباشرةً لمنع سلاسل التعليمات الأخرى من التداخل مع تسلسلات الرموز الأطول.

من المهم ملاحظة أنّ volatile له معانٍ مختلفة جدًا في C++‎ وJava. في لغة C++‎، لا يمنع volatile سباقات البيانات، على الرغم من أنّ الرموز البرمجية القديمة غالبًا ما تستخدمه كحل بديل لعدم توفّر عناصر atomic. لم يعُد يُنصح باستخدام هذا الخيار، ففي لغة C++‎، يمكنك استخدام atomic<T> للمتغيّرات التي يمكن الوصول إليها بشكل متزامن من خلال سلاسل محادثات متعددة. تم تصميم C++ volatile لسجلات الأجهزة وما شابه ذلك.

يمكن استخدام متغيرات C/C++ atomic أو متغيرات Java volatile لمنع سباقات البيانات على المتغيرات الأخرى. إذا تم الإعلان عن أنّ flag من النوع atomic<bool> أو atomic_bool (C/C++) أو volatile boolean (Java)، وكان في البداية خطأ، يكون المقتطف التالي خاليًا من سباق البيانات:

سلسلة المحادثات 1 سلسلة المحادثات 2
A = ...
  flag = true
while (!flag) {}
... = A

بما أنّ سلسلة المحادثات 2 تنتظر ضبط flag، يجب أن يتم الوصول إلى A في سلسلة المحادثات 2 بعد، وليس بالتزامن مع، التعيين إلى A في سلسلة المحادثات 1. وبالتالي، لا يوجد سباق بيانات في A. لا يُعدّ السباق على flag سباق بيانات، لأنّ عمليات الوصول المتقلبة/الذرية ليست "عمليات وصول عادية إلى الذاكرة".

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

على الرغم من أنّ المثال السابق خالٍ من سباق البيانات، فإنّ الأقفال مع Object.wait() في Java أو متغيّرات الشروط في C/C++ عادةً ما توفّر حلاً أفضل لا يتضمّن الانتظار في حلقة أثناء استنزاف طاقة البطارية.

متى تصبح ميزة إعادة ترتيب الذكريات مرئية؟

عادةً ما توفر لنا البرمجة الخالية من سباق البيانات من الاضطرار إلى التعامل بشكل صريح مع مشكلات إعادة ترتيب الوصول إلى الذاكرة. ومع ذلك، هناك عدة حالات تظهر فيها إعادة الترتيب:
  1. إذا كان برنامجك يحتوي على خطأ برمجي يؤدي إلى حدوث سباق بيانات غير مقصود، يمكن أن تصبح تحويلات برنامج التجميع والأجهزة مرئية، وقد يكون سلوك برنامجك مفاجئًا. على سبيل المثال، إذا نسينا تعريف flag كمتغيّر في المثال السابق، قد يرى Thread 2 A غير مهيأ. أو قد يقرر برنامج التجميع أن العلامة لا يمكن أن تتغير أثناء حلقة Thread 2 ويحول البرنامج إلى
    سلسلة المحادثات 1 سلسلة المحادثات 2
    A = ...
      flag = true
    reg0 = flag; while (!reg0) {}
    ... = A
    عند تصحيح الأخطاء، قد تلاحظ استمرار الحلقة إلى الأبد على الرغم من أنّ flag صحيح.
  2. توفّر لغة C++‎ تسهيلات لتخفيف الاتساق التسلسلي بشكل صريح حتى في حال عدم وجود حالات سباق. يمكن أن تتلقّى العمليات البسيطة وسيطات memory_order_... صريحة. وبالمثل، توفّر حزمة java.util.concurrent.atomic مجموعة أكثر تقييدًا من الميزات المشابهة، لا سيما lazySet(). ويستخدم مبرمجو Java أحيانًا سباقات البيانات المقصودة للحصول على تأثير مماثل. كل هذه توفر تحسينات في الأداء بتكلفة كبيرة في تعقيد البرمجة. ونناقشها بإيجاز أدناه.
  3. تتم كتابة بعض رموز C وC++‎ بأسلوب قديم، ولا يتوافق تمامًا مع معايير اللغة الحالية، حيث يتم استخدام متغيرات volatile بدلاً من atomic، ويتم منع ترتيب الذاكرة صراحةً عن طريق إدراج ما يسمى fences أو barriers. يتطلب ذلك استدلالاً صريحًا حول إعادة ترتيب الوصول وفهم نماذج ذاكرة الأجهزة. لا يزال أسلوب الترميز على هذا النحو مستخدمًا في نواة Linux. ولا ينبغي استخدامه في تطبيقات Android الجديدة، كما أنه لم تتم مناقشته هنا.

التمرّن

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

ما يجب تجنّبه في C

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

‫C/C++‎ و "volatile"

تُعدّ تعريفات volatile في لغتَي C وC++‎ أداة ذات غرض خاص جدًا. وهي تمنع برنامج التجميع من إعادة ترتيب عمليات الوصول المتقلبة أو إزالتها. يمكن أن يكون ذلك مفيدًا للرمز البرمجي الذي يصل إلى سجلات الأجهزة، أو الذاكرة التي تم ربطها بأكثر من موقع جغرافي، أو في ما يتعلق بـ setjmp. ولكن لغتَي C وC++‎ volatile، على عكس Javavolatile، غير مصمَّمتَين للتواصل بين سلاسل التعليمات.

في لغتَي C و++C، قد تتم إعادة ترتيب عمليات الوصول إلى بيانات volatile مع عمليات الوصول إلى البيانات غير المتغيّرة، ولا توجد ضمانات للذريّة. وبالتالي، لا يمكن استخدام volatile لمشاركة البيانات بين سلاسل المحادثات في الرمز البرمجي المحمول، حتى على معالج أحادي. لا يمنع C volatile عادةً إعادة ترتيب الوصول بواسطة الأجهزة، لذا فهو في حد ذاته أقل فائدة في بيئات SMP متعددة السلاسل. هذا هو السبب في أنّ C11 وC++11 يتيحان استخدام عناصر atomic. لذا، عليك استخدامها بدلاً من ذلك.

لا يزال الكثير من الرموز البرمجية القديمة بلغتَي C وC++‎ تسيء استخدام volatile للتواصل بين سلاسل المحادثات. غالبًا ما يعمل هذا بشكل صحيح مع البيانات التي تتناسب مع سجل الجهاز، بشرط استخدامها مع حواجز صريحة أو في الحالات التي لا يكون فيها ترتيب الذاكرة مهمًا. ولكن لا يمكن ضمان عمله بشكل صحيح مع برامج التجميع المستقبلية.

أمثلة

في معظم الحالات، من الأفضل استخدام قفل (مثل pthread_mutex_t أو C++11 std::mutex) بدلاً من عملية ذرية، ولكننا سنستخدم الأخير لتوضيح كيفية استخدامه في موقف عملي.

MyThing* gGlobalThing = NULL;  // Wrong!  See below.
void initGlobalThing()    // runs in Thread 1
{
    MyStruct* thing = malloc(sizeof(*thing));
    memset(thing, 0, sizeof(*thing));
    thing->x = 5;
    thing->y = 10;
    /* initialization complete, publish */
    gGlobalThing = thing;
}
void useGlobalThing()    // runs in Thread 2
{
    if (gGlobalThing != NULL) {
        int i = gGlobalThing->x;    // could be 5, 0, or uninitialized data
        ...
    }
}

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

تكمن المشكلة في أنّه يمكن ملاحظة عملية التخزين في gGlobalThing قبل إعداد الحقول، وذلك عادةً لأنّ برنامج التجميع أو المعالج أعاد ترتيب عمليات التخزين في gGlobalThing وthing->x. قد يرى مؤشر ترابط آخر يقرأ من thing->x 5 أو 0 أو حتى بيانات غير مهيأة.

تكمن المشكلة الأساسية هنا في حدوث تضارب في البيانات في gGlobalThing. إذا استدعى Thread 1 الدالة initGlobalThing() بينما يستدعي Thread 2 الدالة useGlobalThing()، يمكن قراءة gGlobalThing أثناء كتابتها.

يمكن حلّ هذه المشكلة من خلال تحديد gGlobalThing كعنصر أساسي. في C++11:

atomic<MyThing*> gGlobalThing(NULL);

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

ما يجب تجنّبه في Java

لم نناقش بعض ميزات لغة Java ذات الصلة، لذا سنلقي نظرة سريعة عليها أولاً.

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

في الوقت الحالي، سنلتزم بالنموذج الخالي من سباق البيانات، والذي توفّر له Java الضمانات نفسها التي توفّرها لغتا C وC++. مرة أخرى، توفّر اللغة بعض العناصر الأساسية التي تخفّف بشكل صريح من الاتساق التسلسلي، لا سيما استدعاءات lazySet() وweakCompareAndSet() في java.util.concurrent.atomic. كما هو الحال مع C وC++، سنتجاهل هذه في الوقت الحالي.

الكلمات الرئيسية "synchronized" و"volatile" في Java

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

كما ذكرنا أعلاه، فإنّ volatile T في Java هو نظير atomic<T> في C++11. يُسمح بالوصول المتزامن إلى حقول volatile، ولا يؤدي ذلك إلى حدوث سباقات بيانات. Ignoring lazySet() et al. and data races, it is the Java VM's job to make sure that the result still appears sequentially consistent.

على وجه الخصوص، إذا كتب سلسلة التعليمات 1 في حقل volatile، وقرأ سلسلة التعليمات 2 لاحقًا من نفس الحقل ورأى القيمة المكتوبة حديثًا، فمن المضمون أيضًا أن يرى سلسلة التعليمات 2 جميع عمليات الكتابة التي تم إجراؤها مسبقًا بواسطة سلسلة التعليمات 1. In terms of memory effect, writing to a volatile is analogous to a monitor release, and reading from a volatile is like a monitor acquire.

هناك اختلاف واحد ملحوظ عن atomic في C++‎: إذا كتبنا volatile int x; في Java، فإنّ x++ هو نفسه x = x + 1، فهو يؤدي عملية تحميل ذرية، ويزيد النتيجة، ثم يؤدي عملية تخزين ذرية. على عكس C++‎، لا تكون الزيادة ككل ذرية. بدلاً من ذلك، يتم توفير عمليات الزيادة الذرية من خلال java.util.concurrent.atomic.

أمثلة

في ما يلي مثال بسيط على تنفيذ غير صحيح لعدّاد أحادي: (Java theory and practice: Managing volatility).

class Counter {
    private int mValue;
    public int get() {
        return mValue;
    }
    public void incr() {
        mValue++;
    }
}

لنفترض أنّه يتم استدعاء get() وincr() من سلاسل محادثات متعددة، ونريد التأكّد من أنّ كل سلسلة محادثات ترى العدد الحالي عند استدعاء get(). المشكلة الأكثر وضوحًا هي أنّ mValue++ يتضمّن في الواقع ثلاث عمليات:

  1. reg = mValue
  2. reg = reg + 1
  3. mValue = reg

إذا تم تنفيذ سلسلتَي محادثات في incr() في الوقت نفسه، قد يتم فقدان أحد التحديثات. لجعل الزيادة ذرية، نحتاج إلى الإعلان عن incr() "متزامن".

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

للأسف، لقد قدّمنا إمكانية حدوث تعارض في القفل، ما قد يعيق الأداء. بدلاً من تعريف get() على أنّه متزامن، يمكننا تعريف mValue على أنّه "متقلب". (يُرجى العِلم أنّ incr() يجب أن يظل يستخدم synchronize لأنّ mValue++ ليس عملية ذرية واحدة). يؤدي ذلك أيضًا إلى تجنُّب جميع حالات السباق، وبالتالي يتم الحفاظ على الاتساق التسلسلي. سيكون incr() أبطأ إلى حد ما، لأنه يتكبد كلاً من النفقات العامة للدخول/الخروج من الشاشة، والنفقات العامة المرتبطة بمتجر متقلب، ولكن get() سيكون أسرع، لذلك حتى في حالة عدم وجود خلاف، يعد هذا فوزًا إذا كان عدد القراءات يفوق عدد الكتابات بشكل كبير. (راجِع أيضًا AtomicInteger لمعرفة طريقة إزالة الكتلة المتزامنة بالكامل).

إليك مثال آخر، مشابه في الشكل لأمثلة C السابقة:

class MyGoodies {
    public int x, y;
}
class MyClass {
    static MyGoodies sGoodies;
    void initGoodies() {    // runs in thread 1
        MyGoodies goods = new MyGoodies();
        goods.x = 5;
        goods.y = 10;
        sGoodies = goods;
    }
    void useGoodies() {    // runs in thread 2
        if (sGoodies != null) {
            int i = sGoodies.x;    // could be 5 or 0
            ....
        }
    }
}

يواجه هذا الرمز المشكلة نفسها التي يواجهها رمز C، أي أنّ هناك سباق بيانات على sGoodies. وبالتالي، قد تتم ملاحظة عملية الإسناد sGoodies = goods قبل إعداد الحقول في goods. إذا أعلنت عن sGoodies باستخدام الكلمة الرئيسية volatile، ستتم استعادة الاتساق التسلسلي، وستعمل الأمور على النحو المتوقع.

يُرجى العِلم أنّ مرجع sGoodies نفسه فقط هو المتغيّر. أما عمليات الوصول إلى الحقول الموجودة بداخله، فلا يتم تتبُّعها. بعد أن يصبح sGoodies volatile، ويتم الحفاظ على ترتيب الذاكرة بشكل صحيح، لا يمكن الوصول إلى الحقول بشكل متزامن. ستؤدي العبارة z = sGoodies.x إلى تحميل متغيّر لـ MyClass.sGoodies يليه تحميل غير متغيّر لـ sGoodies.x. إذا أنشأت مرجعًا محليًا MyGoodies localGoods = sGoodies، لن ينفّذ أي z = localGoods.x لاحق أي عمليات تحميل متغيّرة.

من الأساليب الأكثر شيوعًا في برمجة Java هو أسلوب "القفل المزدوج" (double-checked locking):

class MyClass {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized (this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }
}

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

يؤدي ذلك إلى حدوث سباق بيانات في الحقل helper. ويمكن ضبطه بالتزامن مع helper == null في سلسلة محادثات أخرى.

لمعرفة كيف يمكن أن يفشل ذلك، لنلقِ نظرة على الرمز نفسه بعد إعادة كتابته بشكل بسيط، كما لو تم تجميعه بلغة شبيهة بلغة C (لقد أضفتُ حقلين من الأعداد الصحيحة لتمثيل نشاط الدالة الإنشائية Helper’s):

if (helper == null) {
    synchronized() {
        if (helper == null) {
            newHelper = malloc(sizeof(Helper));
            newHelper->x = 5;
            newHelper->y = 10;
            helper = newHelper;
        }
    }
    return helper;
}

ما مِن شيء يمنع الجهاز أو برنامج التجميع من إعادة ترتيب عملية التخزين في helper مع عمليات التخزين في الحقلَين x/y. قد يعثر مؤشر ترابط آخر على helper غير فارغ ولكن لم يتم ضبط حقوله بعد وجاهزة للاستخدام. لمزيد من التفاصيل والمزيد من أوضاع الفشل، يُرجى الاطّلاع على رابط "إعلان "تعطُّل القفل المزدوج"" في الملحق لمزيد من التفاصيل، أو العنصر 71 ("استخدام التهيئة الكسولة بحكمة") في كتاب Effective Java, 2nd Edition من تأليف "جوش بلوخ".

هناك طريقتان لحلّ هذه المشكلة:

  1. قم بالشيء البسيط واحذف التحقق الخارجي. يضمن ذلك عدم فحص قيمة helper خارج كتلة متزامنة.
  2. يجب الإعلان عن أنّ helper متغيّر. بإجراء هذا التغيير البسيط، سيعمل الرمز في المثال J-3 بشكل صحيح على Java 1.5 والإصدارات الأحدث. (قد ترغب في تخصيص دقيقة من وقتك لإقناع نفسك بأنّ هذا صحيح).

في ما يلي مثال آخر على سلوك volatile:

class MyClass {
    int data1, data2;
    volatile int vol1, vol2;
    void setValues() {    // runs in Thread 1
        data1 = 1;
        vol1 = 2;
        data2 = 3;
    }
    void useValues() {    // runs in Thread 2
        if (vol1 == 2) {
            int l1 = data1;    // okay
            int l2 = data2;    // wrong
        }
    }
}

بالنظر إلى useValues()، إذا لم يلاحظ Thread 2 بعد التحديث إلى vol1، فلن يتمكن من معرفة ما إذا كان قد تم تعيين data1 أو data2 بعد. وعندما يرى التعديل على vol1، يعرف أنّه يمكن الوصول إلى data1 بأمان وقراءته بشكل صحيح بدون حدوث سباق بيانات. ومع ذلك، لا يمكنه وضع أي افتراضات حول data2، لأنّ عملية التخزين هذه تمّت بعد عملية التخزين المتقلبة.

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

الإجراءات المطلوبة

في C/C++‎، يُفضَّل استخدام فئات المزامنة C++11، مثل std::mutex. وإذا لم يكن الأمر كذلك، استخدِم عمليات pthread المقابلة. وتشمل هذه الميزات حواجز الذاكرة المناسبة، ما يوفّر سلوكًا صحيحًا (متسقًا بشكل تسلسلي ما لم يُذكر خلاف ذلك) وفعّالاً على جميع إصدارات نظام Android الأساسي. احرص على استخدامها بشكل صحيح. على سبيل المثال، تذكَّر أنّ عمليات انتظار متغيّر الشرط قد تعود بشكل زائف بدون إشارة، وبالتالي يجب أن تظهر في حلقة.

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

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

تجنَّب استخدام volatile للتواصل بين سلاسل المحادثات في C/C++.

في Java، غالبًا ما يتم حلّ مشاكل التزامن بشكل أفضل باستخدام فئة خدمات مساعدة مناسبة من حزمة java.util.concurrent. الرمز مكتوب بشكل جيد وتم اختباره بشكل جيد على SMP.

ربما يكون الشيء الأكثر أمانًا الذي يمكنك القيام به هو جعل الكائنات غير قابلة للتغيير. تحتفظ الكائنات من فئات مثل String وInteger في Java بالبيانات التي لا يمكن تغييرها بمجرد إنشاء الكائن، مما يتجنب كل احتمالات سباقات البيانات على تلك الكائنات. يتضمّن كتاب Effective Java, 2nd Ed. تعليمات محدّدة في "Item 15: Minimize Mutability". يُرجى ملاحظة مدى أهمية تعريف حقول Java على أنّها "نهائية" (Bloch).

حتى إذا كان الكائن غير قابل للتغيير، تذكَّر أنّ نقله إلى سلسلة محادثات أخرى بدون أي نوع من المزامنة هو سباق بيانات. قد يكون هذا مقبولاً في بعض الأحيان في Java (انظر أدناه)، ولكنه يتطلب عناية كبيرة، ومن المرجح أن يؤدي إلى رمز هش. إذا لم يكن الأداء بالغ الأهمية، أضِف بيان volatile. في لغة C++‎، يُعدّ إرسال مؤشر أو مرجع إلى كائن غير قابل للتغيير بدون مزامنة مناسبة خطأً، مثل أي سباق بيانات. In this case, it is reasonably likely to result in intermittent crashes since, for example, the receiving thread may see an uninitialized method table pointer due to store reordering.

إذا لم تكن فئة المكتبة الحالية أو الفئة غير القابلة للتغيير مناسبة، يجب استخدام عبارة synchronized في Java أو lock_guard / unique_lock في C++‎ لحماية الوصول إلى أي حقل يمكن الوصول إليه من خلال أكثر من سلسلة محادثات واحدة. إذا لم تكن الاستبعادات المتبادلة مناسبة لحالتك، عليك تعريف الحقول المشتركة على أنّها volatile أو atomic، ولكن يجب الانتباه جيدًا لفهم التفاعلات بين سلاسل التعليمات. لن تحميك هذه التعريفات من أخطاء البرمجة المتزامنة الشائعة، ولكنها ستساعدك في تجنب حالات الفشل الغامضة المرتبطة بتحسين برامج التجميع وأخطاء SMP.

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

مزيد من المعلومات حول ترتيبات الذاكرة الضعيفة

يوفّر الإصدار C++11 والإصدارات الأحدث آليات صريحة لتخفيف ضمانات الاتساق التسلسلي للبرامج الخالية من سباق البيانات. إنّ الوسيطات الصريحة memory_order_relaxed وmemory_order_acquire (للتحميل فقط) وmemory_order_release (للتخزين فقط) للعمليات الذرية توفّر ضمانات أضعف بكثير من memory_order_seq_cst التلقائية والضمنية عادةً. يوفّر memory_order_acq_rel ضمانات memory_order_acquire وmemory_order_release لعمليات القراءة والتعديل والكتابة الذرية. لم يتم تحديد memory_order_consume أو تنفيذه بشكل كافٍ ليكون مفيدًا، ويجب تجاهله في الوقت الحالي.

إنّ طرق lazySet في Java.util.concurrent.atomic تشبه مخازن memory_order_release في C++‎. تُستخدم أحيانًا متغيرات Java العادية كبديل لعمليات الوصول إلى memory_order_relaxed، على الرغم من أنّها في الواقع أضعف. على عكس لغة C++‎، لا توجد آلية حقيقية للوصول غير المنظّم إلى المتغيرات التي تم تعريفها على أنّها volatile.

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

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

  • يمكن لبرنامج التجميع أو الجهاز نقل عمليات الوصول إلى memory_order_relaxed إلى قسم مهم (ولكن ليس خارجه) يحده الحصول على قفل وإطلاقه. وهذا يعني أنّ عمليتَي تخزين memory_order_relaxed قد تظهران بترتيب غير صحيح، حتى لو كان يفصل بينهما قسم مهم.
  • قد يظهر متغيّر Java عادي، عند إساءة استخدامه كعدّاد مشترك، لمؤشر ترابط آخر على أنّه يتناقص، على الرغم من أنّه تتم زيادته فقط من خلال مؤشر ترابط آخر واحد. ولكن هذا ليس صحيحًا بالنسبة إلى C++ atomic memory_order_relaxed.

With that as a warning, here we give a small number of idioms that seem to cover many of the use cases for weakly ordered atomics. ينطبق العديد من هذه القواعد على لغة C++‎ فقط.

ألعاب غير سباقات

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

لا يوجد نظير حقيقي لهذا في Java.

لا يتم الاعتماد على النتيجة للتأكّد من صحتها

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

ومن الأمثلة الشائعة على ذلك استخدام compare_exchange في لغة C++‎ لاستبدال x بـ f(x) بشكل ذري. لا يلزم أن يكون التحميل الأولي لـ xموثوقًا به لحساب f(x). إذا أخطأنا، سيتعذّر تنفيذ compare_exchange وسنعيد المحاولة. لا بأس في أن يستخدم التحميل الأولي لـ x وسيطة memory_order_relaxed، فترتيب الذاكرة لـ compare_exchange الفعلي هو المهم فقط.

بيانات تم تعديلها بشكل ذري ولكن لم تتم قراءتها

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

في هذه الحالة، لا توجد طريقة لمعرفة ما إذا كان الوصول إلى هذه البيانات قد تمت إعادة ترتيبه، وبالتالي قد يستخدم رمز C++‎ وسيطة memory_order_relaxed.

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

  • يؤدي استخدام memory_order_relaxed إلى تحسين الأداء، ولكن قد لا يعالج أهم مشكلة في الأداء: يتطلب كل تحديث وصولاً حصريًا إلى سطر ذاكرة التخزين المؤقت الذي يحتوي على العداد. يؤدي هذا إلى عدم توفر البيانات في ذاكرة التخزين المؤقت في كل مرة يصل فيها مؤشر ترابط جديد إلى العداد. إذا كانت التعديلات متكررة وتتبادل بين سلاسل المحادثات، فمن الأسرع بكثير تجنب تحديث العداد المشترك في كل مرة، على سبيل المثال، باستخدام عدادات محلية لسلسلة المحادثات وجمعها في النهاية.
  • يمكن دمج هذه الطريقة مع القسم السابق: من الممكن قراءة القيم التقريبية وغير الموثوقة في الوقت نفسه أثناء تعديلها، مع استخدام جميع العمليات memory_order_relaxed. ولكن من المهم التعامل مع القيم الناتجة على أنها غير موثوقة تمامًا. Just because the count appears to have been incremented once does not mean another thread can be counted on to have reached the point at which the increment has been performed. بدلاً من ذلك، قد تكون الزيادة قد تمت إعادة ترتيبها باستخدام رمز سابق. (As for the similar case we mentioned earlier, C++ does guarantee that a second load of such a counter will not return a value less than an earlier load in the same thread. ما لم يحدث بالطبع تجاوز في العداد.)
  • من الشائع العثور على رمز يحاول حساب قيم العدادات التقريبية عن طريق إجراء عمليات قراءة وكتابة ذرية (أو غير ذرية) فردية، ولكن دون جعل الزيادة ككل ذرية. الحجة المعتادة هي أن هذا "قريب بما فيه الكفاية" لعدادات الأداء أو ما شابه. عادةً لا. عندما تكون التحديثات متكررة بشكل كافٍ (وهي حالة قد تهمك)، عادةً ما يتم فقدان جزء كبير من الأعداد. على جهاز رباعي النواة، قد يتم فقدان أكثر من نصف عدد المرات بشكل شائع. (تمرين سهل: أنشئ سيناريو من سلسلتين يتم فيه تحديث العداد مليون مرة، ولكن قيمة العداد النهائية هي واحد.)

التواصل البسيط باستخدام الأعلام

تضمن عملية تخزين memory_order_release (أو عملية قراءة-تعديل-كتابة) أنّه إذا قرأت عملية تحميل memory_order_acquire (أو عملية قراءة-تعديل-كتابة) القيمة المكتوبة لاحقًا، ستلاحظ أيضًا أي عمليات تخزين (عادية أو ذرية) سبقت عملية تخزين memory_order_release. في المقابل، لن تلاحظ أي عمليات تحميل تسبق memory_order_release أي عمليات تخزين تلي عملية تحميل memory_order_acquire. على عكس memory_order_relaxed، يسمح هذا الإجراء باستخدام هذه العمليات الذرية لإبلاغ سلسلة محادثات أخرى بمدى التقدّم.

على سبيل المثال، يمكننا إعادة كتابة مثال القفل المزدوج من الأعلى في C++ على النحو التالي

class MyClass {
  private:
    atomic<Helper*> helper {nullptr};
    mutex mtx;
  public:
    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper == nullptr) {
        lock_guard<mutex> lg(mtx);
        myHelper = helper.load(memory_order_relaxed);
        if (myHelper == nullptr) {
          myHelper = new Helper();
          helper.store(myHelper, memory_order_release);
        }
      }
      return myHelper;
    }
};

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

يمكن لمبرمج Java تمثيل helper على أنّه java.util.concurrent.atomic.AtomicReference<Helper> واستخدام lazySet() كمتجر الإصدار. وستستمر عمليات التحميل في استخدام طلبات get() العادية.

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

    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper != nullptr) {
        return myHelper;
      }
      lock_guard&ltmutex> lg(mtx);
      if (helper == nullptr) {
        helper = new Helper();
      }
      return helper;
    }

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

حتى في هذه الحالة، من المرجّح أن ينشئ helper.load(memory_order_acquire) الرمز نفسه على البُنى الحالية المتوافقة مع Android كمرجع عادي (متسق تسلسليًا) لـ helper. قد يكون التحسين الأكثر فائدة هنا هو إدخال myHelper لإلغاء التحميل الثاني، على الرغم من أنّ برنامج التجميع المستقبلي قد يفعل ذلك تلقائيًا.

Acquire/release ordering does not prevent stores from getting visibly delayed, and does not ensure that stores become visible to other threads in a consistent order. نتيجةً لذلك، لا يتيح استخدام نمط ترميز معقّد ولكنه شائع إلى حد ما، مثل خوارزمية الاستبعاد المتبادل التي وضعها ديكر: تضبط جميع سلاسل التعليمات أولاً علامة تشير إلى أنها تريد تنفيذ إجراء معيّن، وإذا لاحظت سلسلة التعليمات t بعد ذلك أنّه لا توجد سلسلة تعليمات أخرى تحاول تنفيذ إجراء معيّن، يمكنها المتابعة بأمان، مع العلم أنّه لن يكون هناك أي تداخل. لن يتمكّن أي مؤشر ترابط آخر من المتابعة، لأنّ علامة t لا تزال مضبوطة. ويفشل هذا إذا تم الوصول إلى العلامة باستخدام ترتيب الحصول/الإصدار، لأنّ ذلك لا يمنع جعل علامة سلسلة المحادثات مرئية للآخرين في وقت متأخر، بعد أن يكونوا قد تابعوا بشكل خاطئ. يمنع الإعداد التلقائي memory_order_seq_cst ذلك.

الحقول غير القابلة للتغيير

إذا تمّت تهيئة حقل عنصر عند الاستخدام الأول ولم يتم تغييره بعد ذلك، قد يكون من الممكن تهيئته وقراءته لاحقًا باستخدام عمليات وصول ضعيفة الترتيب. في لغة C++‎، يمكن الإعلان عنها كـ atomic والوصول إليها باستخدام memory_order_relaxed أو في Java، يمكن الإعلان عنها بدون volatile والوصول إليها بدون إجراءات خاصة. يتطلّب ذلك استيفاء جميع الشروط التالية:

  • It should be possible to tell from the value of the field itself whether it has already been initialized. للوصول إلى الحقل، يجب أن تقرأ قيمة الاختبار والإرجاع للمسار السريع الحقل مرة واحدة فقط. في Java، يُعدّ الأخير ضروريًا. حتى إذا تم اختبار الحقل على أنّه تم إعداده، قد يقرأ التحميل الثاني القيمة السابقة التي لم يتم إعدادها. في لغة C++‎، تُعدّ قاعدة "القراءة لمرة واحدة" ممارسة جيدة.
  • يجب أن تكون كل من عمليات الإعداد والتحميل اللاحقة ذرية، بمعنى أنه لا ينبغي أن تكون التحديثات الجزئية مرئية. بالنسبة إلى Java، يجب ألا يكون الحقل long أو double. بالنسبة إلى لغة C++‎، يجب استخدام عملية إسناد ذرية، ولن ينجح إنشاؤها في مكانها لأنّ إنشاء atomic ليس ذريًا.
  • يجب أن تكون عمليات الإعداد المتكررة آمنة، لأنّ سلاسل التعليمات المتعددة قد تقرأ القيمة غير المُعدّة في الوقت نفسه. في لغة C++‎، ينبع هذا بشكل عام من شرط "قابلية النسخ البسيطة" المفروض على جميع الأنواع الذرية، فالأنواع التي تحتوي على مؤشرات مملوكة متداخلة تتطلب إلغاء التخصيص في دالة الإنشاء بالنسخ، ولن تكون قابلة للنسخ بشكل بسيط. بالنسبة إلى Java، تُعدّ بعض أنواع المراجع مقبولة:
  • تقتصر مراجع Java على الأنواع غير القابلة للتغيير التي تحتوي على حقول نهائية فقط. يجب ألا ينشر مُنشئ النوع غير القابل للتغيير مرجعًا للكائن. في هذه الحالة، تضمن قواعد حقل Java النهائي أنه إذا رأى القارئ المرجع، فسيرى أيضًا الحقول النهائية التي تمت تهيئتها. لا تتضمّن لغة C++‎ أي قواعد مشابهة لهذه القواعد، كما أنّ المؤشرات إلى العناصر المملوكة غير مقبولة لهذا السبب أيضًا (بالإضافة إلى انتهاك متطلبات "النسخ البسيط").

ملاحظات الإغلاق

While this document does more than merely scratch the surface, it doesn’t manage more than a shallow gouge. هذا موضوع واسع وعميق للغاية. بعض المجالات لمزيد من الاستكشاف:

  • يتم التعبير عن نماذج الذاكرة الفعلية في Java وC++‎ من خلال علاقة يحدث قبل التي تحدّد متى يتم ضمان حدوث إجراءَين بترتيب معيّن. عندما عرّفنا سباق البيانات، تحدثنا بشكل غير رسمي عن عمليتَي وصول إلى الذاكرة تحدثان "في الوقت نفسه". رسميًا، يتم تعريف ذلك على أنه لا يحدث أي منهما قبل الآخر. من المفيد معرفة التعريفات الفعلية لـ يحدث قبل ويتزامن مع في نموذج ذاكرة Java أو C++. على الرغم من أنّ المفهوم البديهي لـ "في الوقت نفسه" يُعدّ كافيًا بشكل عام، فإنّ هذه التعريفات مفيدة، خاصةً إذا كنت تفكّر في استخدام عمليات ذرية ضعيفة الترتيب في لغة C++. (يحدّد مواصفات Java الحالية lazySet() بشكل غير رسمي للغاية).
  • تعرَّف على ما يُسمح لبرامج التجميع بتنفيذه وما لا يُسمح به عند إعادة ترتيب الرموز البرمجية. (The JSR-133 spec has some great examples of legal transformations that lead to unexpected results.)
  • تعرَّف على كيفية كتابة فئات غير قابلة للتغيير في Java وC++‎ (هناك ما هو أكثر من مجرد "عدم تغيير أي شيء بعد الإنشاء").
  • استوعِب الاقتراحات الواردة في قسم التزامن في Effective Java, 2nd Edition. (على سبيل المثال، يجب تجنب استدعاء الطرق التي من المفترض أن يتم تجاوزها أثناء وجودها داخل كتلة متزامنة.)
  • اطّلِع على واجهتَي برمجة التطبيقات java.util.concurrent وjava.util.concurrent.atomic لمعرفة الميزات المتاحة. ننصحك باستخدام تعليقات توضيحية للتزامن، مثل @ThreadSafe و@GuardedBy (من net.jcip.annotations).

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

الملحق

تنفيذ مخازن المزامنة

(هذا ليس شيئًا سيجد معظم المبرمجين أنفسهم ينفذونه، لكن المناقشة مفيدة.)

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

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

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

يتم فرض ترتيب الذاكرة على ARMv7 وx86 وMIPS باستخدام تعليمات "fence" التي تمنع تقريبًا ظهور التعليمات التي تلي "fence" قبل التعليمات التي تسبق "fence". (تُعرف هذه أيضًا باسم تعليمات "الحاجز"، ولكن قد يؤدي ذلك إلى حدوث التباس مع الحواجز التي تستخدم أسلوب pthread_barrier، والتي تؤدي أكثر من ذلك بكثير). إنّ المعنى الدقيق لتعليمات السياج هو موضوع معقّد إلى حدّ ما يجب أن يتناول الطريقة التي تتفاعل بها الضمانات التي توفّرها أنواع مختلفة من السياج، وكيفية دمجها مع ضمانات الترتيب الأخرى التي توفّرها الأجهزة عادةً. هذه نظرة عامة عالية المستوى، لذلك سنتجاوز هذه التفاصيل.

إنّ أبسط أنواع ضمانات الترتيب هي تلك التي توفّرها العمليات الذرية في لغتَي C++‎ memory_order_acquire وmemory_order_release: يجب أن تكون عمليات الذاكرة التي تسبق عملية تخزين الإصدار مرئية بعد عملية تحميل الاستحواذ. على أجهزة ARMv7، يتم فرض ذلك من خلال:

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

وهما معًا يكفيان لترتيب الحصول على/إصدار C++. وهي ضرورية، ولكنها غير كافية، لـ Java volatile أو C++‎ المتسقة بشكل تسلسلي atomic.

لمعرفة ما نحتاجه أيضًا، لنلقِ نظرة على جزء من خوارزمية ديكر التي ذكرناها بإيجاز سابقًا. ‫flag1 وflag2 هما متغيّران من نوع atomic في لغة C++‎ أو volatile في لغة Java، وكلاهما يحملان القيمة false في البداية.

سلسلة المحادثات 1 سلسلة المحادثات 2
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

يعني الاتساق التسلسلي أنّه يجب تنفيذ أحد التعيينات إلى flagn أولاً، وأن يظهر في الاختبار في سلسلة التعليمات الأخرى. وبالتالي، لن نرى أبدًا هذه السلاسل تنفّذ "critical-stuff" في الوقت نفسه.

But the fencing required for acquire-release ordering only adds fences at the beginning and end of each thread, which doesn't help here. نحتاج أيضًا إلى التأكد من أنه إذا تم اتباع مخزن volatile/atomic بحمولة volatile/atomic، فلن تتم إعادة ترتيب الاثنين. يتم فرض ذلك عادةً عن طريق إضافة حاجز ليس فقط قبل متجر متسق بشكل تسلسلي، ولكن أيضًا بعده. (هذا أقوى بكثير مما هو مطلوب، لأن هذا الحاجز عادة ما يطلب جميع عمليات الوصول السابقة إلى الذاكرة فيما يتعلق بجميع العمليات اللاحقة.)

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

كما رأينا في قسم سابق، نحتاج إلى إدراج حاجز تخزين/تحميل بين العمليتين. سيبدو الرمز البرمجي الذي تم تنفيذه في الجهاز الافتراضي للوصول المتقلب على النحو التالي:

حِمل متقلّب مساحة تخزين متغيّرة
reg = A
fence for "acquire" (1)
fence for "release" (2)
A = reg
fence for later atomic load (3)

Real machine architectures commonly provide multiple types of fences, which order different types of accesses and may have different cost. The choice between these is subtle, and influenced by the need to ensure that stores are made visible to other cores in a consistent order, and that the memory ordering imposed by the combination of multiple fences composes correctly. لمزيد من التفاصيل، يُرجى الاطّلاع على صفحة جامعة كامبريدج التي تتضمّن عمليات الربط المجمّعة للعمليات الذرية بالمعالجات الفعلية.

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

توفّر بنية ARMv8 تعليمات LDAR وSTLR التي تفرض مباشرةً متطلبات عمليات التحميل والتخزين المتسلسلة والمتسقة في لغة C++‎ أو المتغيّرة في لغة Java. تتجنب هذه القيود غير الضرورية لإعادة الترتيب التي ذكرناها أعلاه. يستخدم رمز Android ‏64 بت على ARM هذه؛ اخترنا التركيز على وضع سياج ARMv7 هنا لأنه يلقي مزيدًا من الضوء على المتطلبات الفعلية.

محتوى إضافي للقراءة

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

Shared Memory Consistency Models: A Tutorial
هذه الورقة البحثية التي كتبها "أدفي" و"غاراتشورلو" في عام 1995 هي مرجع جيد للبدء إذا أردت التعمّق أكثر في نماذج اتساق الذاكرة.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
حواجز الذاكرة
إليك مقالة قصيرة ومفيدة تلخّص المشاكل.
https://en.wikipedia.org/wiki/Memory_barrier
أساسيات Threads
مقدمة عن البرمجة المتعددة السلاسل في C++‎ وJava، بقلم هانز بوهم. مناقشة حول سباقات البيانات وطرق المزامنة الأساسية.
http://www.hboehm.info/c++mm/threadsintro.html
Java Concurrency In Practice
نُشر هذا الكتاب في عام 2006، وهو يغطي مجموعة واسعة من المواضيع بتفصيل كبير. ننصح به بشدة لأي شخص يكتب رمزًا متعدد سلاسل المحادثات بلغة Java.
http://www.javaconcurrencyinpractice.com
الأسئلة الشائعة حول JSR-133 (نموذج ذاكرة Java)
مقدمة بسيطة عن نموذج ذاكرة Java، بما في ذلك شرح للمزامنة والمتغيرات المتقلبة وإنشاء الحقول النهائية. (قديم بعض الشيء، خاصةً عند مناقشة لغات أخرى).
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
Validity of Program Transformations in the Java Memory Model
شرح تقني إلى حد ما للمشاكل المتبقية في نموذج ذاكرة Java. لا تنطبق هذه المشاكل على البرامج الخالية من سباق البيانات.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf
نظرة عامة على الحزمة java.util.concurrent
مستندات حزمة java.util.concurrent بالقرب من أسفل الصفحة، يوجد قسم بعنوان "خصائص اتساق الذاكرة" يشرح الضمانات التي تقدمها الفئات المختلفة.
java.util.concurrent ملخّص الحزمة
Java Theory and Practice: Safe Construction Techniques in Java
تتناول هذه المقالة بالتفصيل مخاطر تسرُّب المراجع أثناء إنشاء العناصر، وتقدّم إرشادات حول الدوال الإنشائية الآمنة لسلاسل المحادثات.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html
نظرية Java وممارستها: إدارة التغيُّر
مقالة رائعة تصف ما يمكنك وما لا يمكنك تحقيقه باستخدام الحقول المتقلبة في Java.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html
The “Double-Checked Locking is Broken” Declaration
شرح مفصّل من "بيل بوغ" للطرق المختلفة التي يتم من خلالها كسر القفل المزدوج بدون volatile أو atomic. ويشمل ذلك لغتَي C/C++‎ وJava.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[ARM] اختبارات الحاجز والكتيّب
مناقشة حول مشاكل ARM SMP، مع توضيحها بمقتطفات قصيرة من رمز ARM. إذا وجدت الأمثلة في هذه الصفحة غير محددة للغاية، أو كنت تريد قراءة الوصف الرسمي لتعليمات DMB، فاقرأ هذا. وتوضّح أيضًا التعليمات المستخدَمة لحواجز الذاكرة في الرمز البرمجي القابل للتنفيذ (قد تكون مفيدة إذا كنت تنشئ رمزًا برمجيًا بشكل فوري). يُرجى العِلم أنّ هذا يسبق ARMv8، الذي يتيح أيضًا استخدام تعليمات إضافية لترتيب الذاكرة وتم نقله إلى نموذج ذاكرة أقوى إلى حدّ ما. (يُرجى الاطّلاع على "دليل مرجع بنية ARM®‎ ARMv8، لملف تعريف بنية ARMv8-A" للحصول على التفاصيل).
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf
حواجز الذاكرة في نواة Linux
مستندات حول حواجز الذاكرة في نواة Linux يتضمّن بعض الأمثلة المفيدة وفن ASCII.
http://www.kernel.org/doc/Documentation/memory-barriers.txt
ISO/IEC JTC1 SC22 WG21 (معايير C++‎) 14882 (لغة البرمجة C++‎)، القسم 1.10 والفقرة 29 ("مكتبة العمليات الذرية")
مسودة معيار لميزات العمليات الذرية في C++‎ هذا الإصدار قريب من معيار C++14، والذي يتضمن تغييرات طفيفة في هذا المجال من C++11.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(مقدمة: http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf)
الفصل 7.16 ("Atomics <stdatomic.h>‎") من معيار ISO/IEC JTC1 SC22 WG14 (معايير C) 9899 (لغة البرمجة C)
مسودة معيار لميزات العمليات الذرية ISO/IEC 9899-201x C. لمعرفة التفاصيل، يُرجى أيضًا الاطّلاع على تقارير العيوب اللاحقة.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
عمليات ربط C/C++11 بالمعالجات (جامعة كامبريدج)
مجموعة ترجمات "ياروسلاف سيفسيك" و"بيتر سيويل" لعمليات C++‎ الذرية إلى مجموعات تعليمات المعالجات الشائعة المختلفة
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html
خوارزمية ديكر
"أول حل صحيح معروف لمشكلة الاستبعاد المتبادل في البرمجة المتزامنة". تحتوي مقالة ويكيبيديا على الخوارزمية الكاملة، مع مناقشة حول كيفية تحديثها للعمل مع برامج التجميع المحسّنة الحديثة وأجهزة SMP.
https://en.wikipedia.org/wiki/Dekker's_algorithm
التعليقات على ARM مقابل Alpha ومعالجة التبعيات
رسالة إلكترونية من "كاتالين ماريناس" في القائمة البريدية لنواة ARM يتضمّن ملخّصًا جيدًا لتبعية العنوان والتحكّم.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
What Every Programmer Should Know About Memory
مقالة طويلة ومفصّلة جدًا حول أنواع الذاكرة المختلفة، لا سيما ذاكرة التخزين المؤقت لوحدة المعالجة المركزية، من تأليف &quot;أولريش دريبر&quot;.
http://www.akkadia.org/drepper/cpumemory.pdf
الاستدلال على نموذج الذاكرة المتسق بشكل ضعيف في ARM
هذه الورقة من تأليف "تشونغ" و"اشتياق" من شركة ARM, Ltd. وهي تحاول وصف نموذج ذاكرة ARM SMP بطريقة دقيقة ولكن سهلة الفهم. تعريف "إمكانية تتبّع البيانات" المستخدَم هنا مأخوذ من هذه الورقة البحثية. مرة أخرى، يسبق هذا ARMv8.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711
دليل JSR-133 لمؤلفي برامج التجميع
كتب "دوغ ليا" هذا الدليل ليكون مكمّلاً لمستندات JSR-133 (نموذج ذاكرة Java). يحتوي على المجموعة الأولية من إرشادات التنفيذ لنموذج ذاكرة Java الذي استخدمه العديد من مؤلفي برامج التجميع، ولا يزال يتم الاستشهاد به على نطاق واسع ومن المرجح أن يقدم نظرة ثاقبة. للأسف، لا تتوافق أنواع الحواجز الأربعة التي تمت مناقشتها هنا مع البُنى المتوافقة مع Android، وتُعدّ تعيينات C++11 المذكورة أعلاه الآن مصدرًا أفضل للوصفات الدقيقة، حتى بالنسبة إلى Java.
http://g.oswego.edu/dl/jmm/cookbook.html
‫x86-TSO: نموذج مبرمج دقيق وقابل للاستخدام للمعالجات المتعددة x86
وصف دقيق لنموذج الذاكرة x86 للأسف، إنّ الأوصاف الدقيقة لنموذج ذاكرة ARM أكثر تعقيدًا بكثير.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf