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

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

المقدّمة

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

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

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

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

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

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

راجِع قراءة المزيد في نهاية المستند للحصول على مؤشّرات إلى علاجات أكثر شمولاً.

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

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

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

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

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

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

إليك مثال بسيط على تشغيل التعليمة البرمجية على سلسلتَي محادثات:

سلسلة التعليمات 1 سلسلة التعليمات 2
A = 3
B = 5
reg0 = B
reg1 = A

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

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

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

السجلات (Register) الولايات
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 بشكل عام على زيادة احتمال ملاحظة المشاكل التي كانت تظهر طوال الوقت.

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

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

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

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

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

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

سلسلة التعليمات 1 سلسلة التعليمات 2
if (A) B = true if (B) A = true

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

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

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

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

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

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

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

يمكن استخدام متغيّرات C/C++ atomic أو متغيّرات JavaScript 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 في المثال السابق، قد يظهر في سلسلة المحادثات 2 رمز A غير مهيأ. أو قد يقرر المحول البرمجي أنه لا يمكن أن تتغير العلامة أثناء تكرار حلقة Thread 2 وتحويل البرنامج إلى
    سلسلة التعليمات 1 سلسلة التعليمات 2
    A = ...
      flag = true
    reg0 = العلم; بينما (!reg0) {}
    ... = أ
    عند تصحيح الأخطاء، قد ترى أنّ التكرار يستمر إلى الأبد على الرغم من أنّ flag صحيح.
  2. توفر C++ مرافق للاسترخاء التسلسلي بشكل صريح حتى لو لم تكن هناك سباقات. قد تحتاج العمليات البسيطة إلى وسيطات memory_order_... صريحة. وبالمثل، توفّر حزمة java.util.concurrent.atomic مجموعة أكثر تقييدًا من المرافق المشابهة، ولا سيما lazySet(). ويستخدم مبرمجو Java أحيانًا سباقات البيانات المقصودة لتأثير مماثل. توفر كل هذه الأدوات تحسينات في الأداء مقابل تكلفة كبيرة في تعقيد البرمجة. ونحن نناقشها بإيجاز فقط أدناه.
  3. تتم كتابة بعض الرموز البرمجية C وC++ بأسلوب قديم، بشكل غير متوافق تمامًا مع معايير اللغة الحالية، حيث يتم استخدام متغيرات volatile بدلاً من متغيرات atomic، ويُحظر ترتيب الذاكرة صراحةً من خلال إدخال ما يُعرف باسم الأسوار أو الحواجز. وهذا يتطلب أسبابًا صريحة حول إعادة ترتيب الوصول وفهم نماذج ذاكرة الأجهزة. ولا يزال يتم استخدام نمط الترميز على طول هذه السطور في نواة Linux. ويجب عدم استخدامه في تطبيقات Android الجديدة، ولن يتم تناول المزيد من التفاصيل هنا أيضًا.

التدرّب

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

ما الذي لا يجب فعله في C

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

C/C++ و "متقلب"

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

في 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 بواسطة المحول البرمجي أو المعالج. وقد يظهر 5 أو 0 أو حتى بيانات غير مهيأة لقراءة سلسلة تعليمات أخرى من thing->x.

المشكلة الأساسية هنا هي سباق البيانات في gGlobalThing. إذا استدعت سلسلة 1 سلسلة التعليمات initGlobalThing() بينما استدعت سلسلة 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++، سنتجاهل هذه في الوقت الحالي.

الكلمات الرئيسية "المتزامنة" و"المتغيرة" في Java

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

كما ذكرنا أعلاه، فإن volatile T لـ Java هو تناظري لـ atomic<T> لـ C++11. يُسمح بالوصول المتزامن إلى volatile حقلاً ولا يؤدي إلى حدوث سباقات للبيانات. بغض النظر عن lazySet() وآخرون وسباقات البيانات، يكون على جهاز Java VM التأكّد من أنّ النتيجة لا تزال تظهر بشكل تسلسلي متسق.

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

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

أمثلة

في ما يلي طريقة بسيطة وغير صحيحة للعدّاد الأحادي: (نظرية وممارسة Java: إدارة التقلبات).

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 وهو "القفل المزدوج الذي تم التحقق منه" الشهير:

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() مخصّصة. لتجنُّب سباق إنشاء سلسلتَين من المثيل، نحتاج إلى مزامنة إنشاء الكائن. لا نريد دفع التكاليف الإضافية مقابل الجزء "المتزامن" في كل مكالمة، لذلك لا ننفّذ هذا الجزء إلا إذا كان 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 قيمة غير فارغة، إلّا أنّ حقولها لم يتم ضبطها بعد وجاهزة للاستخدام. لمعرفة مزيد من التفاصيل وحالات التعطُّل، يُرجى الاطّلاع على الرابط "Double Checked Locking is Broken’ Declaration" (البيان المعطّل للفحص المزدوج) في الملحق للحصول على مزيد من التفاصيل، أو العنصر 71 ("استخدام الإعداد الكسول بحكمة") في الإصدار الثاني من لغة Java الفعّالة لـ Josh Bloch.

هناك طريقتان لإصلاح ذلك:

  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 وفتح قفلها إجراء عملية صغيرة واحدة لكل منها، وغالبًا ما تكلف ذاكرة التخزين المؤقت واحدة أقل من مرة واحدة فقط، إذا لم يكن هناك تنافر، لذا لن تنقَّل كثيرًا من خلال استبدال استدعاءات دالة الاستبعاد المتبادل بالعمليات الذرية. تتطلب التصميمات الخالية من الأقفال لهياكل البيانات غير التافهة مزيدًا من العناية لضمان أن العمليات ذات المستوى الأعلى في بنية البيانات تبدو ذرية (بشكل عام، وليس فقط قطعها الذرية بشكل صريح).

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

تجنَّب استخدام volatile للتواصل بشأن سلاسل المحادثات باستخدام لغة C/C++.

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

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

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

إذا لم تكن هناك فئة مكتبة حالية أو فئة غير قابلة للتغيير مناسبة، يجب استخدام عبارة synchronized في لغة Java أو لغة C++lock_guard / unique_lock لحماية إمكانية الوصول إلى أي حقل يمكن الوصول إليه من خلال أكثر من سلسلة محادثات واحدة. إذا لم تكن دالة الاستبعاد المتبادل مناسبة لموقفك، عليك الإفصاح عن الحقول المشتركة 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 مشابهة لمتاجر C++ memory_order_release. ويتم أحيانًا استخدام المتغيرات العادية لـ Java كبديل لعمليات الوصول إلى memory_order_relaxed، على الرغم من أنّها في الواقع أضعف. على عكس لغة C++ ، ما مِن آلية حقيقية للوصول غير المرتّب إلى المتغيّرات التي تم تعريفها على أنّها volatile.

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

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

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

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

مرات الدخول إلى غير ألعاب السباقات

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

لا يوجد تشبيه حقيقي لهذا في Java.

لا يتم الاعتماد على النتيجة في صحتها

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

ومن الأمثلة الشائعة على ذلك استخدام لغة C++ compare_exchange لاستبدال 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. لكن من المهم التعامل مع القيم الناتجة على أنها غير موثوقة تمامًا. لا يعني مجرد زيادة العدد مرة واحدة أنه يمكن الاعتماد على سلسلة محادثات أخرى للوصول إلى النقطة التي تم إجراء الزيادة فيها. ربما تمت إعادة ترتيب الجزء بدلاً من ذلك باستخدام تعليمة برمجية سابقة. (بالنسبة إلى الحالة المماثلة التي ذكرناها سابقًا، تضمن C++ أن التحميل الثاني لمثل هذا العدّاد لن يُرجع قيمة أقل من تحميل سابق في نفس سلسلة التعليمات. ما لم يكن العدّاد بالكامل).
  • من الشائع العثور على تعليمة برمجية تحاول حساب القيم المضادة التقريبية عن طريق إجراء عمليات قراءة وكتابة فردية (أو لا) بشكل ذري (أو لا)، لكن بدون إجراء الزيادة كعدد ذري كامل. الوسيطة المعتادة هي أن هذا "قريب بما يكفي" لعدادات الأداء أو ما شابه ذلك. لا يكون الأمر عادةً كذلك. عندما تكون التحديثات متكررة بما فيه الكفاية (حالة ربما تهمك)، يتم عادةً فقدان جزء كبير من الأعداد. على الأجهزة رباعية النواة، من المحتمل أن يتم فقدان أكثر من نصف عدد هذه الأعداد. (تمرين سهل: أنشئ سيناريو من سلسلتين يتم فيه تحديث العدّاد مليون مرة، لكن قيمة العدّاد النهائية هي واحد).

التواصل البسيط للإبلاغ

يضمن متجر 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 لتقليل أي حمل ثان، على الرغم من أنّ برنامج التجميع المستقبلي قد يجري ذلك تلقائيًا.

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

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

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

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

الملاحظات الختامية

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

  • يتم التعبير عن نماذج ذاكرة Java وC++ الفعلية من خلال علاقة تحدث قبل التي تحدد وقت ضمان حدوث إجراءين بترتيب معين. عندما حددنا سباق البيانات، تحدثنا بشكل غير رسمي عن وصولين إلى الذاكرة يحدثان "في الوقت نفسه". ويُقصد بذلك رسميًا عدم حدوث أي منهما قبل الآخر. ومن المفيد معرفة التعريفات الفعلية لمصطلح يحدث قبل ويتزامن مع في نموذج ذاكرة جافا أو C++. على الرغم من أنّ المفهوم البسيط "في الوقت نفسه" مناسب عمومًا، إلا أنّ هذه التعريفات هي مفيدة، خاصةً إذا كنت تفكر في استخدام العمليات الذرية ذات الترتيب الضعيف في لغة C++ . (تحدّد مواصفات Java الحالية lazySet() فقط بشكل غير رسمي للغاية).
  • تعرّف على برامج التحويل البرمجي وتلك التي لا يُسمح لها بتنفيذها عند إعادة ترتيب الرموز البرمجية. (تحتوي مواصفات JSR-133 على بعض الأمثلة الرائعة على التحويلات القانونية التي تؤدي إلى نتائج غير متوقعة).
  • تعرف على كيفية كتابة الفئات غير القابلة للتغيير في Java وC++. (هناك أكثر من مجرد "لا تغير أي شيء بعد البناء").
  • يمكنك دمج الاقتراحات في قسم "التزامن" في فعالة Java، الإصدار الثاني. (على سبيل المثال، يجب أن تتجنب استدعاء طرق الاستدعاء التي يُفترض أن يتم تجاوزها أثناء داخل كتلة متزامنة).
  • يُرجى الاطّلاع على واجهات برمجة التطبيقات java.util.concurrent وjava.util.concurrent.atomic لمعرفة ما هو متوفّر. يمكنك استخدام التعليقات التوضيحية للتزامن، مثل @ThreadSafe و@GuardedBy (من net.jcip.annotations).

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

الملحق

تنفيذ متاجر المزامنة

(قد لا يجد معظم المبرمجين أنفسهم ينفّذون هذا الإجراء، إلا أنّ النقاشات تُضفي المزيد من التفاصيل).

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

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

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

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

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

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

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

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

سلسلة التعليمات 1 سلسلة التعليمات 2
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

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

لكن التسييج المطلوب لطلب إلغاء الاستحواذ يضيف فقط الأسوار في بداية ونهاية كل سلسلة محادثات، وهو أمر لا يساعد هنا. بالإضافة إلى ذلك، يجب الحرص على عدم إعادة ترتيب المتجرَين، أي إذا كان تحميل volatile/atomic ويليه حِمل بنسبة volatile/atomic. يتم فرض ذلك عادةً من خلال إضافة سياج ليس فقط قبل متجر ذي اتساق تسلسلي، ولكن أيضًا بعده. (يكون هذا مجددًا أقوى بكثير مما هو مطلوب، لأن هذا السياج يطلب عادةً جميع عمليات الوصول السابقة إلى الذاكرة فيما يتعلق بجميع عمليات الوصول اللاحقة).

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

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

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

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

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

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

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

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

نماذج تناسق الذاكرة المشتركة: دليل تعليمي
يكتب "أدفي" و"غاراتشورلو" في عام 1995، وهذا هو المكان الذي يمكن أن تبدأ فيه إذا كنت ترغب في التعمق أكثر في نماذج اتساق الذاكرة.
http://www.hpl.hp.com/techreports/Compaq-ديسمبر/WRL-95-7.pdf
حواجز الذاكرة
مقالة بسيطة تلخص المشاكل.
https://en.wikipedia.org/wiki/Memory_barrier
أساسيات سلاسل المحادثات
مقدمة عن البرمجة متعددة السلاسل في لغة C++ ولغة Java، كتبها "هانز بوهم" مناقشة سباقات البيانات وطرق المزامنة الأساسية.
http://www.hboehm.info/c++mm/threadsintro.html
تطبيق تزامن Java
يتناول هذا الكتاب، الذي نُشر في عام 2006، مجموعة كبيرة من المواضيع بمزيد من التفصيل. يُنصح به بشدة لأي شخص يكتب تعليمات برمجية متعددة السلاسل في Java.
http://www.javaconcurrencyinpractice.com
الأسئلة الشائعة حول JSR-133 (نموذج ذاكرة Java)
مقدمة بسيطة حول نموذج ذاكرة Java، بما في ذلك شرح للمزامنة والمتغيّرات المتغيّرة وإنشاء الحقول النهائية (قد يكون بعض الشيء، لا سيما عندما يناقش لغات أخرى.)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
صلاحية عمليات تحويل البرامج في نموذج ذاكرة Java
شرح تقني إلى حدٍّ ما للمشاكل المتبقية في نموذج ذاكرة Java. ولا تنطبق هذه المشاكل على البرامج الخالية من سباقات البيانات.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf
نظرة عامة على package java.util.concurrent
مستندات حزمة java.util.concurrent وبالقرب من أسفل الصفحة، يوجد قسم بعنوان "خصائص تناسق الذاكرة" يشرح الضمانات التي وفرتها الفئات المختلفة. ملخّص الحزمة
java.util.concurrent
نظرية وممارسة Java: تقنيات إنشاء آمنة في Java
تتناول هذه المقالة بالتفصيل مخاطر اختفاء المراجع أثناء إنشاء العناصر، كما تقدم إرشادات حول أدوات الإنشاء الآمنة للسلاسل.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html
نظرية Java والتطبيق: إدارة التقلبات
مقالة رائعة تصف ما يمكنك وما لا يمكنك إنجازه باستخدام الحقول المتغيرة في Java.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html
بيان "التحقق من القفل المزدوج هو كسر"
شرح "بيل بوغ" المفصّل للطرق المختلفة التي يتم من خلالها كسر دالة الاستبعاد المتبادل التي تم التحقق منها باستخدام volatile أو atomic. ويتضمن لغة C/C++ وJava.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[ARM] اختبارات Barrier Litmus وكتاب الطبخ
مناقشة حول مشاكل 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
مستندات حول عوائق ذاكرة kernel على نظام التشغيل 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
(intro: http://www.hpl.hp.com/techreports/20202020312-31282721)
الفصل 7.16 من ISO/IEC JTC1 SC22 WG14 (معايير C) 9899 (لغة البرمجة C) ("Atomics <stdatomic.h>")
مسودة معيار لميزات التشغيل الذري لمعيار 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
خوارزمية ديكر
"أول حل صحيح معروف لمشكلة الاستبعاد المتبادل في البرمجة المتزامنة". تحتوي مقالة wikipedia على الخوارزمية الكاملة، مع مناقشة حول الحاجة إلى تحديثها للعمل باستخدام برامج التجميع الحديثة وأجهزة SMP.
https://en.wikipedia.org/wiki/Dekker's_algorithm
التعليقات على ARM مقابل ألفا ومعالجة التبعيات
رسالة إلكترونية حول القائمة البريدية لمجموعة الذراع من Catalin Marinas يتضمن ملخصًا رائعًا للعنوان وتبعيات التحكم.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
المعلومات التي يجب أن يعرفها كل مبرمج عن الذاكرة
مقالة طويلة للغاية ومفصّلة عن أنواع الذاكرة المختلفة، لا سيّما ذاكرات التخزين المؤقت لوحدة المعالجة المركزية (CPU)، كتبها "أولريش دريبر"
http://www.akkadia.org/drepper/cpumemory.pdf
التفكير في نموذج الذاكرة المتسق بشكل ضعيف للغاية ARM
تمت كتابة هذه المقالة من قِبل Chong & Ishtiaq من ARM, Ltd. وهي تحاول وصف نموذج الذاكرة الذي تم إنشاؤه باستخدام ARM SMP بطريقة صارمة، إلا أنّه سهل الوصول إليها. تعريف "الملاحظة" المستخدم هنا يأتي من هذه المقالة. مرة أخرى، يسبق هذا الإصدار ARMv8.
http://مدخل.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