
دعونا نرى كيف تعمل البرمجة المتزامنة والمتوازية في .Net ، باستخدام مشكلة الفلاسفة في تناول الطعام كمثال. هذه الخطة ، من مزامنة الخيوط / العمليات ، إلى نموذج الجهات الفاعلة (في الأجزاء التالية). قد تكون المقالة مفيدة لأحد معارفه الأولين أو لتحديث معلوماتك.
لماذا تكون قادرة على القيام بذلك؟ يصل الترانزستورات إلى الحد الأدنى لحجمها ، ويستند قانون مور إلى الحد من سرعة الضوء ، وبالتالي يلاحظ النمو في الكمية ، ويمكن إجراء المزيد من الترانزستورات. في الوقت نفسه ، يتزايد حجم البيانات ، ويتوقع المستخدمون رد فعل فوري للأنظمة. في مثل هذه الحالة ، فإن البرمجة "العادية" ، عندما يكون لدينا خيط تنفيذي واحد ، لم تعد فعالة. من الضروري حل مشكلة التنفيذ المتزامن أو التنافسي بطريقة أو بأخرى. علاوة على ذلك ، توجد هذه المشكلة على مستويات مختلفة: على مستوى التدفقات ، على مستوى العمليات ، على مستوى الأجهزة في الشبكة (الأنظمة الموزعة). يحتوي .NET على تقنيات عالية الجودة ومختبرة زمنياً لحل مثل هذه المشكلات بسرعة وفعالية.
مهمة
طرح Edsger Dijkstra هذه المشكلة لطلابه في وقت مبكر من عام 1965. والصياغة الثابتة هي هذه. هناك بعض (عادة خمسة) عدد من الفلاسفة والعديد من الشوك. إنهم يجلسون على المائدة المستديرة ، والشوك بينهما. يمكن للفلاسفة أن يأكلوا من أطباقهم مع طعام لا نهاية له أو التفكير أو الانتظار. لأكل الفيلسوف ، يجب أن تأخذ شوكة (الأخير يشارك الشوكة مع الأول). لاتخاذ ووضع شوكة - إجراءين منفصلين. جميع الفلاسفة صامتون. تتمثل المهمة في إيجاد مثل هذه الخوارزمية التي يفكرون فيها جميعًا ويملون حتى بعد 54 عامًا.
أولاً ، دعونا نحاول حل هذه المشكلة عن طريق استخدام المساحة المشتركة. الشوكات على الطاولة والفلاسفة يأخذونها عندما يكونون ويعيدونها. هناك مشاكل مع التزامن ، عندما بالضبط لاتخاذ المقابس؟ ماذا تفعل إذا لم يكن هناك المكونات؟ وغيرها ، ولكن أولاً ، دعونا نطلق الفلاسفة.
لبدء مؤشرات الترابط ، استخدم تجمع Task.Run
الترابط من خلال الأسلوب Task.Run
:
var cancelTokenSource = new CancellationTokenSource(); Action<int> create = (i) => RunPhilosopher(i, cancelTokenSource.Token); for (int i = 0; i < philosophersAmount; i++) { int icopy = i;
تجمع مؤشر الترابط الذي تم إنشاؤه لتحسين إنشاء مؤشر الترابط وحذفه. يحتوي هذا التجمع على قائمة انتظار مهمة ويقوم CLR بإنشاء أو حذف مؤشرات الترابط حسب عدد هذه المهام. تجمع واحد لجميع AppDomains. يجب استخدام هذا التجمع دائمًا تقريبًا ، لأنه لا تحتاج إلى عناء إنشاء وحذف سلاسل الرسائل وقوائم الانتظار الخاصة بهم ، وما إلى ذلك. هذا ممكن بدون تجمع ، ولكن بعد ذلك يجب عليك استخدام مؤشر الترابط مباشرةً ، يُنصح بالحالات التي تحتاج فيها إلى تغيير أولوية سلسلة الرسائل ، أو عندما يكون لدينا عملية طويلة ، أو مقدمة سلسلة الرسائل ، إلخ.
الفئة System.Threading.Tasks.Task
تجعل من السهل العمل مع تجمع مؤشرات الترابط هذا (أو حتى الاستغناء عنه). إنها عملية غير متزامنة. بمعنى تقريبي ، هذا هو نفس Thread
، ولكن مع كل أنواع وسائل الراحة: القدرة على بدء المهام بعد مجموعة من المهام الأخرى ، وإعادتها من الوظائف ، ومن المريح مقاطعتها ، والكثير غيرها. وما إلى ذلك ، فهي ضرورية لدعم بنيات غير متزامنة / في انتظار (نمط غير متزامن قائم على المهام ، والسكر النحوي لانتظار تشغيل IO). سنتحدث عن هذا مرة أخرى.
هناك حاجة إلى CancelationTokenSource
هنا بحيث يمكن إنهاء مؤشر الترابط نفسه بإشارة مؤشر ترابط الاستدعاء.
مشكلات المزامنة
منعت الفلاسفة
حسنًا ، يمكننا إنشاء سلاسل رسائل ، دعونا نحاول تناول الغداء:
هنا نحاول أولاً أخذ اليسار ثم الشوك الأيمن ، وإذا نجح ، فإننا نأكل ونعيده. أخذ شوكة واحدة هو ذري ، أي لا يمكن أن يأخذ دفقان واحدًا في نفس الوقت (بشكل غير صحيح: يقرأ الأول أن القابس مجاني ، والثاني أيضًا ، الأول يأخذ ، والثاني يأخذ). للقيام بذلك ، Interlocked.CompareExchange
، والذي يجب تنفيذه باستخدام تعليمة المعالج ( TSL
، XCHG
) ، الذي يحظر قطعة من الذاكرة للقراءة والكتابة المتسلسلة الذرية. و SpinWait يكافئ إنشاء while(true)
مع القليل من "السحر" - يأخذ مؤشر الترابط المعالج ( Thread.SpinWait
) ، ولكن في بعض الأحيان ينقل التحكم إلى مؤشر ترابط آخر ( Thread.Yeild
) أو يسقط نائماً ( Thread.Sleep
).
لكن هذا الحل لا يعمل ، لأن سيتم حظر التدفقات قريبًا (بالنسبة لي في غضون ثانية): يأخذ جميع الفلاسفة شوكةهم اليسرى ، ولكن ليس الشق الأيمن. صفيف الشوك ثم قيم: 1 2 3 4 5.

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

هنا يمكنك أن ترى أن الخيوط تستيقظ أحيانًا وتحاول الحصول على مورد. اثنان من النوى الأربعة لا يفعلان شيئًا (الرسم البياني الأخضر في الأعلى).
وفاة الفيلسوف
حسنًا ، هناك مشكلة أخرى يمكن أن تقطع عشاء الفلاسفة المجيدين وهي وفاة أحدهم فجأة مع وجود شوكة في يديه (وسوف يدفنونه هكذا). ثم سيترك الجيران دون الغداء. يمكنك NullReferenceException
نموذج رمز لهذه الحالة بنفسك ، على سبيل المثال ، NullReferenceException
طرح NullReferenceException
بعد أن يأخذ الفيلسوف الشوك. وبالمناسبة ، لن تتم معالجة الاستثناء ولن يقوم رمز الاتصال ببساطة بالقبض عليه (لهذا ، AppDomain.CurrentDomain.UnhandledException
، وما إلى ذلك). لذلك ، تكون معالجات الأخطاء ضرورية في مؤشرات الترابط نفسها مع الإنهاء الصحيح.
النادل
حسنًا ، كيف يمكننا حل هذه المشكلة مع الجمود والموت والموت؟ سنسمح بفيلسوف واحد فقط على الشوك ، ونضيف الاستبعاد المتبادل للتدفقات لهذا المكان. كيف نفعل ذلك؟ لنفترض أن النادل يقف بجانب الفلاسفة الذين يمنحون الإذن لفيلسوف واحد لأخذ الشوك. كيف نجعل هذا النادل وكيف طرحه الفلاسفة أسئلة شيقة.
إن أبسط طريقة هي عندما يسأل الفلاسفة ببساطة النادل للوصول إلى الشوك. أي الآن لن ينتظر الفلاسفة قابسًا قريبًا ، لكنهم ينتظرون أو يسألون النادل. أولاً ، نستخدم مساحة المستخدم فقط لهذا ، حيث لا نستخدم المقاطعات لاستدعاء أي إجراءات من النواة (حولها أدناه).
حلول مساحة المستخدم
هنا سنفعل نفس الشيء كما اعتدنا أن نفعل بشوكة واحدة وفيلسوفين ، سوف ندور في حلقة وننتظر. ولكن الآن سيكون جميع الفلاسفة وكأن شوكة واحدة فقط ، أي يمكننا القول أنه لن يكون هناك سوى الفيلسوف الذي أخذ هذا "الشوكة الذهبية" من النادل. لهذا نستخدم SpinLock.
private static SpinLock spinLock = new SpinLock();
SpinLock
هو مانع ، مع ، تقريبا ، نفس while(true) { if (!lock) break; }
while(true) { if (!lock) break; }
، ولكن مع "سحر" أكثر مما هو SpinWait
في SpinWait
(والذي يستخدم هناك). الآن يعرف كيفية حساب من ينتظرون ، ووضعهم في النوم قليلاً ، وأكثر من ذلك. بشكل عام ، يفعل كل شيء ممكن لتحسين. ولكن يجب علينا أن نتذكر أن هذه الدورة لا تزال هي نفس الدورة النشطة التي تأكل موارد المعالج وتحافظ على سلسلة رسائل يمكن أن تؤدي إلى الجوع إذا أصبح أحد الفلاسفة أولوية على الآخرين ، لكن ليس لديه شوكة ذهبية (مشكلة انعكاس الأولوية). لذلك ، نحن نستخدمها فقط لإجراء تغييرات قصيرة جدًا في الذاكرة المشتركة ، دون أي مكالمات من أطراف ثالثة أو أقفال متداخلة أو مفاجآت إلخ.

الرقم ل SpinLock
. تيارات باستمرار "القتال" من أجل الشوكة الذهبية. يحدث فشل - في الشكل ، المنطقة المحددة. لا يتم استخدام النوى بالكامل: فقط حوالي 2/3 من هذه المواضيع الأربعة.
الحل الآخر هنا هو استخدام Interlocked.CompareExchange
بنفس التوقع النشط ، كما هو موضح في الكود أعلاه (في الفلاسفة الجائعين) ، ولكن هذا ، كما ذكرنا سابقًا ، يمكن أن يؤدي نظريًا إلى الحجب.
حول Interlocked
، تجدر الإشارة إلى أنه لا يوجد فقط CompareExchange
، ولكن أيضًا طرق أخرى للقراءة والكتابة الذرية. ومن خلال تكرار التغييرات في حالة تمكن مؤشر ترابط آخر من إجراء تغييراته (قراءة 1 ، قراءة 2 ، كتابة 2 ، الكتابة 1 سيئة) ، يمكن استخدامه لإجراء تغييرات معقدة بقيمة واحدة (نمط أي شيء متشابك).
حلول وضع Kernel
لتجنب فقدان الموارد في حلقة ، دعنا نرى كيف يمكنك حظر الدفق. بمعنى آخر ، واستمرارًا لمثالنا ، سنرى كيف يضع النادل الفيلسوف لينام ويستيقظه فقط عند الضرورة. أولاً ، لنرى كيفية القيام بذلك من خلال وضع kernel لنظام التشغيل. جميع الهياكل هناك غالبًا ما تكون أبطأ من تلك الموجودة في مساحة المستخدم. عدة مرات أبطأ ، على سبيل المثال AutoResetEvent
يمكن أن يكون أبطأ 53 مرة من SpinLock
[Richter]. ولكن مع مساعدتهم ، يمكنك مزامنة العمليات في جميع أنحاء النظام ، سواء تمت إدارتها أم لا.
البناء الرئيسي هنا هو الإشارة التي اقترحها Dijkstroy منذ أكثر من نصف قرن. الإشارة المصغرة ، بعبارات بسيطة ، هي عدد صحيح موجب يتم التحكم فيه بواسطة نظام ، عمليتان عليه - الزيادة والنقصان. إذا لم يعمل التخفيض ، صفر ، فسيتم حظر مؤشر ترابط الاتصال. عندما يتم زيادة الرقم بواسطة بعض الخيط / العملية النشطة الأخرى ، يتم تخطي الخيوط ، وينخفض الإشارة مرة أخرى بعدد الخانات التي تم تمريرها. يمكنك أن تتخيل القطارات في عنق الزجاجة مع إشارة. يوفر .NET العديد من التصميمات بميزات مشابهة: AutoResetEvent
و AutoResetEvent
و Mutex
و Semaphore
نفسه. سنستخدم AutoResetEvent
، وهذا هو أبسط هذه الإنشاءات: قيمتان فقط هما 0 و 1 (خطأ ، صحيح). WaitOne()
طريقة WaitOne()
بحظر مؤشر ترابط الاستدعاء إذا كانت القيمة 0 ، وإذا كانت القيمة 1 ، ثم تنخفض إلى 0 وتتخطاها. ويزيد الأسلوب Set()
إلى 1 ويتخطى انتظارًا واحدًا ، والذي ينخفض مرة أخرى إلى 0. وهو يتصرف مثل الباب الدوار في المترو.
سوف نعقد الحل وسنستخدم القفل لكل فيلسوف ، وليس للجميع في آن واحد. أي الآن يمكن أن يكون هناك العديد من الفلاسفة في وقت واحد ، وليس واحد. لكن مرة أخرى نمنع الوصول إلى الطاولة من أجل أن نأخذ الشوك بشكل صحيح ، ونتجنب السباقات (ظروف السباق).
لفهم ما يحدث هنا ، ضع في اعتبارك الحالة التي فشل فيها الفيلسوف في أخذ الشوك ، ثم تصرفاته ستكون هكذا. إنه ينتظر الوصول إلى الطاولة. بعد استلامها ، يحاول أخذ الشوك. لم ينجح الأمر. انه يتيح الوصول إلى الجدول (الاستبعاد المتبادل). ويمر "الباب الدوار" ( AutoResetEvent
) (في البداية كانت مفتوحة). يدخل الدورة مرة أخرى ، لأنه ليس لديه شوك. يحاول أن يأخذهم ويتوقف عند الباب الدوار. بعض الجيران الأكثر حظًا على اليمين أو اليسار ، بعد الانتهاء من تناول الطعام ، يفتح فيلسوفنا ، "يفتح الباب الدوار". فيلسوفنا يمر به (ويغلق خلفه) مرة ثانية. يحاول للمرة الثالثة أخذ الشوك. حظا سعيدا ويمر الباب الدوار له لتناول الطعام.
عندما تكون هناك أخطاء عشوائية في هذه التعليمة البرمجية (موجودة دائمًا) ، على سبيل المثال ، يتم تحديد جار بشكل غير صحيح أو يتم AutoResetEvent
كائن AutoResetEvent
نفسه للجميع ( Enumerable.Repeat
) ، ثم سينتظر الفلاسفة المطورين ، لأن العثور على أخطاء في هذا الرمز هو مهمة صعبة إلى حد ما. مشكلة أخرى في هذا الحل هي أنه لا يضمن عدم تجويع أي فيلسوف.
حلول هجينة
درسنا طريقتين للمزامنة عندما نبقى في وضع المستخدم وتدور في حلقة وعندما نحظر سلسلة من خلال النواة. الطريقة الأولى جيدة للأقفال القصيرة والثانية للأقفال الطويلة. غالبًا ما تحتاج إلى الانتظار لفترة وجيزة حتى يتم تغيير المتغير في الحلقة ، ثم حظر الخيط عندما يكون الانتظار طويلًا. يتم تنفيذ هذا النهج في ما يسمى تصاميم هجينة. يوجد هنا نفس التركيبات التي كانت مخصصة لوضع kernel ، ولكن الآن مع حلقة في وضع المستخدم: SemaphorSlim
، ManualResetEventSlim
، إلخ. البناء الأكثر شيوعًا هنا هو Monitor
، لأنه يحتوي C # على بنية lock
معروفة. Monitor
هي نفسها إشارة ذات قيمة قصوى تبلغ 1 (mutex) ، ولكن مع دعم للانتظار في حلقة ، تكرار ، نمط شرط متغير (حوله أدناه) ، وما إلى ذلك. دعونا ننظر إلى حل معها.
هنا نقوم مرة أخرى بإغلاق الجدول بأكمله للوصول إلى الشوك ، لكننا الآن نفتح جميع التدفقات في وقت واحد ، وليس الجيران عندما ينتهي شخص ما من الأكل. أي أولاً ، يأكل أحدهم ويعرقل الجيران ، وعندما ينتهي هذا الشخص ، لكنه يريد أن يأكل مرة أخرى على الفور ، يذهب إلى القفل ويوقظ جيرانه ، لأن وقت انتظاره أقصر.
لذلك نتجنب الجمود وتجويع بعض الفيلسوف. نستخدم حلقة لفترة قصيرة ونمنع التدفق لفترة طويلة. يعمل إلغاء قفل الكل في وقت واحد بشكل أبطأ مما لو تم إلغاء قفل الجار فقط ، كما هو الحال في حل AutoResetEvent
، لكن يجب ألا يكون الفرق كبيرًا ، لأن يجب أن تظل مؤشرات الترابط في وضع المستخدم أولاً.
lock
بناء الجملة لديه مفاجآت غير سارة. يوصون باستخدام Monitor
مباشرة [Richter] [Eric Lippert]. أحدها هو أن lock
يخرج دائمًا من Monitor
، حتى لو كان هناك استثناء ، ومن ثم يمكن لمؤشر ترابط آخر تغيير حالة الذاكرة المشتركة. في مثل هذه الحالات ، يكون من الأفضل دائمًا الذهاب إلى طريق مسدود أو إكمال البرنامج بطريقة آمنة. المفاجأة الأخرى هي أن الشاشة تستخدم كتل التزامن ( SyncBlock
) ، والتي هي في كل الكائنات. لذلك ، إذا قمت بتحديد الكائن الخطأ ، فيمكنك بسهولة الحصول على حالة توقف تام (على سبيل المثال ، إذا قمت بإجراء تأمين على السلسلة interned). نحن دائما نستخدم وجوه خفية لهذا الغرض.
يسمح لك نمط متغير الحالة بتنفيذ توقعات الحالة المعقدة بشكل أكثر دقة. في .NET ، فهو غير مكتمل ، في رأيي ، لأنه من الناحية النظرية ، يجب أن يكون هناك العديد من قوائم الانتظار على العديد من المتغيرات (كما هو الحال في خيوط Posix) ، وليس على قفل واحد. ثم يمكن للمرء أن يجعلها لجميع الفلاسفة. ولكن حتى في هذا النموذج ، يسمح لك بتقليل الرمز.
العديد من الفلاسفة أو async
/ await
حسنًا ، الآن يمكننا حظر المواضيع بفعالية. لكن ، ماذا لو حصلنا على الكثير من الفلاسفة؟ 100؟ 10000؟ على سبيل المثال ، تلقينا 100000 طلب إلى خادم ويب. سيتم إنشاء دفق لكل طلب ، لأنه لن يتم تنفيذ العديد من مؤشرات الترابط بالتوازي. فقط كما سيتم تنفيذ العديد من النوى المنطقية (لدي 4). والجميع سوف يأخذ ببساطة الموارد. أحد الحلول لهذه المشكلة هو نمط المزامنة / الانتظار. فكرتها هي أن الوظيفة لا تحتوي على دفق ، إذا كنت بحاجة إلى الانتظار حتى تستمر. وعندما تفعل هذا يحدث شيء ما ، تستأنف تنفيذها (ولكن ليس بالضرورة في نفس الموضوع!). في حالتنا ، سوف ننتظر المكونات.
لدى WaitAsync()
طريقة WaitAsync()
لهذا الغرض. هنا تطبيق باستخدام هذا النمط.
async
/ await
, Task
. , , Task. , , . , , , , . . async
/ await
.
. 100 4 , 8 . Monitor 4 , . 4 2. async / await 100, 6.8 . , 6 . Monitor .
استنتاج
, .NET . , , , . . , , , TPL Dataflow, Reactive , Software Transaction .
مصادر