موضوع أريادن: كيفية الوقوع في الحب مع JSR-133. تقرير ياندكس

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


- اسمي سيفا مينكوف. أنا أعمل في قسم البنية التحتية السحابية في قسم البحث. أنا أساسا التعامل مع الخلفية. أنا أكتب بلغات مختلفة ، ولكن في أغلب الأحيان يكون Java ولغات تعمل على Java Virtual Machine (JVM).

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

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

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



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

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



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

قد يبدو تطبيق Java البسيط شيئًا كهذا.



سنقوم بكتابة فئة ReadWriteTest ، وسيكون لها متغيرين ثابتين X و Y. مباشرة في الطريقة الرئيسية ، نقوم ببناء موضوعين Thread1 و Thread2 ، ونمنح كل منهما مدخلات بعض وظيفة lambda التي سيتم تنفيذها في وقت تنفيذ مؤشر الترابط. ضع الكود من الشريحة السابقة هناك وابدأ موضوعين.

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


رابط من الشريحة

من أجل عدم إعادة اختراع العجلة ، يمكننا استخدام أداة جاهزة. يسمى هذا jcstress ، الأداة المساعدة Java Concurrency Stress باختبار جزء من مشروع OpenJDK.

توفر هذه الأداة المساعدة بعض الإطار لكتابة اختبارات الإجهاد. في هذه الحالة ، يمكن بسهولة إعادة كتابة الكود من الشريحة السابقة. بادئ ذي بدء ، سنعلق تعليق اختبار jcstress على الفصل ، مما يجعل البرامج النصية للاختبار مرئية للأداة. نقوم أيضًا بوضع علامة عليها مع فئة الحالة ، والتي تقول أن الفصل يحتوي على بيانات يمكن تغييرها: يتم تعديلها وقراءتها من تدفقات مختلفة. نعلن عن طريقتين ، thread1 و thread2 ، ووضع علامة عليها مع شرح الممثل. التعليق التوضيحي للممثل يعني أنه يجب تنفيذ الطريقة في سلسلة رسائل منفصلة. تضمن jcstress أنه سيتم تنفيذ كل طريقة في خيط منفصل على مثيل واحد من فئة الولاية. لم يتم تحديد الترتيب الذي سيتم إطلاقها به على وجه التحديد. وستتم كتابة النتيجة على كائن II_Result يظهر على الشريحة. يمكننا أن نفترض أن هذه مجموعة من قيمتين عدديتين ، يتم تقديمهما فقط من خلال طريقة Dependency Injection ، التي تحدث عنها Cyril في تقرير سابق.

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



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



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

الجواب الأول هو صفر واحد.



البديل الثاني لتطوير الأحداث هو عكس ذلك تمامًا: تم تنفيذ الدفق الثاني قبل الدفق الأول.



تبعا لذلك ، نحصل على نتيجة مرآة لصفر واحد.



هناك حوالي أربعة خيارات أخرى تعطي نفس النتيجة عندما يكون تنفيذ مؤشرات الترابط مشوشًا تمامًا. على سبيل المثال ، سجلنا وحدة في تيار واحد في X ، وفي الثانية تمكنا من الحصول على وحدة في Y ، وحسابنا واحد. يمكنك بعد ذلك معرفة الخيارات الأخرى المتوفرة كتمرين منزلي.



يبدو أننا تجاوزنا جميع الخيارات الممكنة ، لم يعد هناك شيء آخر. لنشغل الأداة ونرى ما الذي توصل إليه.


رابط من الشريحة

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

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

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



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

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

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

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

هذا يجعل الأمور معقدة للغاية: من الصعب فهم ما يقوم به البرنامج ، ومن الصعب اختباره.

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



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



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

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

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

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



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

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

لماذا هو صعب جدا؟ لقد ذهبت بطريقة خاطئة وحذرت منك أن تتصرف مثلي.

أخذتها ، فتشت على شبكة الإنترنت ما هو نموذج الذاكرة. العثور على كتاب يسمى JSR-133 Cookbook for Compiler Writers. إنها تصف كيف يمكن لمطور برنامج التحويل البرمجي تطبيق نموذج الذاكرة هذا بطريقة بسيطة. تكمن المشكلة في أن هذا تطبيق معين ، ولا يمكن استخدامه للحكم على طراز الذاكرة بالكامل بشكل عام.

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



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

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

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



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

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

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



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

سنرتب يحدث قبل في موضوع واحد. الكتابة إلى X يحدث قبل القراءة من Y وفي الخيط الثاني. الكتابة إلى Y يحدث قبل القراءة من X.

ثم لدينا أربعة إجراءات التزامن: الكتابة إلى X ، الكتابة إلى Y ، القراءة من X ، القراءة من Y. يمكن أن تظهر بترتيب ما ، ويمكن أن يحدث الزوج في حالتين.



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



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



يمكنك التحقق من ذلك في الممارسة العملية. خذ وأضف الكلمة المتقلبة في اختبارنا. ركض وانظر ، في الواقع ، لن يتم إعادة إنتاج هذه القيمة في بلدنا. happens-before — . .



, . volatile Z volatile, . , Z; , , , Z. happens-before . , Z , . .

, , — put value. — get value . happens-before , , put value happens-before get value. , happens-before , volatile, . , , — put happens-before get.



, . -, . , , . , . , , . , . , , , , .

-, , jcstress. : , JVM . , .

, . — «The Art of Multiprocessor Programming» . , happens-before, , . . — «Java Concurrency in Practice» . , . , , . . . , performance- Oracle, Red Hat. , Java- , . JMM.

. , -, . , , YouTube. , , . .

Source: https://habr.com/ru/post/ar477074/


All Articles