عدم التزامن غير المتزامن: مضادات في العمل مع المزامنة / تنتظر في .NET

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




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

الجمود غير متزامن الكلاسيكية


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


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


في مكالمة غير متزامنة ، نقوم بتقسيم تدفق تنفيذ الأوامر على "ما قبل" و "بعد" العملية غير المتزامنة ، وفي .NET لا توجد ضمانات بأن التعليمات البرمجية التي تكمن بعد الانتظار سيتم تنفيذها في نفس مؤشر الترابط كما في الكود قبل الانتظار. في معظم الحالات ، هذا ليس ضروريًا ، ولكن ماذا تفعل عندما يكون هذا السلوك ضروريًا لتشغيل البرنامج؟ تحتاج إلى استخدام SynchronizationContext . هذه آلية تسمح لك بفرض قيود معينة على مؤشرات الترابط التي يتم فيها تنفيذ التعليمات البرمجية. بعد ذلك ، سوف نتعامل مع سياقي التزامن ( WindowsFormsSynchronizationContext و AspNetSynchronizationContext ) ، لكن أليكس ديفيس كتب في كتابه أن هناك حوالي عشرة منهم في .NET. حول SynchronizationContext مكتوب بشكل جيد هنا ، وهنا ، وقد نفذ المؤلف هنا بنفسه ، والذي يحظى باحترام كبير.


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


ولكن ماذا لو كان ، في هذه الحالة بالذات ، من المهم بالنسبة لنا أن يتم تنفيذ التعليمات البرمجية بعد الانتظار في أي مؤشر ترابط مجاني من تجمع مؤشرات الترابط؟ تحتاج إلى استخدام تعويذة ConfigureAwait(false) . تخبر القيمة الخاطئة التي تم تمريرها إلى المعلمة continueOnCapturedContext النظام أنه يمكن استخدام أي مؤشر ترابط من التجمع. وما يحدث إذا لم يكن هناك سياق التزامن على الإطلاق في وقت تنفيذ الأسلوب ( SynchronizationContext.Current == null ) ، على سبيل المثال في تطبيق وحدة التحكم. في هذه الحالة ، ليست لدينا قيود على سلسلة الرسائل التي يجب أن يتم تنفيذ التعليمات البرمجية بعد انتظارها وسيأخذ النظام سلسلة الرسائل الأولى من المجموعة ، كما في حالة ConfigureAwait(false) .


إذن ما هو الجمود غير المتزامن؟


حالة توقف تام في WPF و WinForms


الفرق بين تطبيقات WPF و WinForms هو سياق التزامن للغاية. يحتوي سياق التزامن WPF و WinForms على مؤشر ترابط خاص - مؤشر ترابط واجهة المستخدم. يوجد مؤشر ترابط UI واحد لكل SynchronizationContext وفقط من هذا الخيط يمكنه التفاعل مع عناصر واجهة المستخدم. بشكل افتراضي ، تستأنف التعليمة البرمجية التي بدأت العمل في مؤشر ترابط واجهة المستخدم العملية بعد عملية غير متزامنة فيها.


الآن دعونا نلقي نظرة على مثال:

 private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the instruction following await"; } 

ماذا يحدث عند استدعاء StartWork().Wait() :

  1. سينتقل مؤشر ترابط الاستدعاء (وهذا هو مؤشر ترابط واجهة المستخدم) إلى أسلوب await Task.Delay(100) StartWork إلى await Task.Delay(100) .
  2. سيبدأ مؤشر ترابط واجهة المستخدم عملية Task.Delay(100) غير المتزامنة ، وسيعود التحكم إلى أسلوب Button_Click ، وهناك طريقة Wait() لفئة Task انتظار ذلك. عندما يتم استدعاء أسلوب Wait() ، سيتم حظر مؤشر ترابط واجهة المستخدم حتى نهاية العملية غير المتزامنة ، ونتوقع أنه بمجرد اكتماله ، سيقوم مؤشر ترابط واجهة المستخدم بالتقاط التطبيق على الفور والانتقال إلى أبعد من التعليمات البرمجية ، ومع ذلك ، سيكون كل شيء خاطئًا.
  3. بمجرد Task.Delay(100) ، سيتعين على مؤشر ترابط واجهة المستخدم أولاً متابعة تنفيذ الأسلوب StartWork() ولهذا فهو يحتاج إلى مؤشر الترابط الذي بدأ فيه التنفيذ تمامًا. لكن مؤشر ترابط UI ينتظر الآن نتيجة العملية.
  4. StartWork() Button_Click لا يمكن لـ Button_Click StartWork() متابعة التنفيذ وإرجاع النتيجة ، Button_Click نفس النتيجة ، وبسبب حقيقة أن التنفيذ بدأ في مؤشر ترابط واجهة المستخدم ، فإن التطبيق يتعطل ببساطة دون فرصة لمواصلة العمل.

يمكن التعامل مع هذا الموقف بكل بساطة عن طريق تغيير الدعوة إلى Task.Delay(100) إلى Task.Delay(100).ConfigureAwait(false) :

 private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100).ConfigureAwait(false); var s = "Just to illustrate the instruction following await"; } 

ستعمل هذه التعليمة البرمجية دون توقف تام ، حيث يمكن الآن استخدام مؤشر ترابط من التجمع لإكمال أسلوب StartWork() بدلاً من مؤشر ترابط UI محظور. يوصي ستيفن كلاري باستخدام ConfigureAwait(false) في جميع "أساليب المكتبة" في مدونته ، لكنه يؤكد بشكل خاص على أن استخدام ConfigureAwait(false) لعلاج حالة توقف تام ليست ممارسة جيدة. بدلاً من ذلك ، ينصح بعدم استخدام أساليب الحظر مثل Wait() ، Result ، GetAwaiter().GetResult() جميع الطرق لاستخدام async / await ، إن أمكن (ما يسمى Async all the way principle).


حالة توقف تام في ASP.NET


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


مثال:

 public class HomeController : Controller { public ActionResult Deadlock() { StartWork().Wait(); return View(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the code following await"; } } 

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


تم إصلاح كل ذلك بواسطة نفس ConfigureAwait(false) .


حالة توقف تام في ASP.NET Core (في الواقع لا)


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




تذكر أن استخدام حظر المكالمات يلغي جميع مزايا البرمجة غير المتزامنة التي تحولها إلى متزامن . نعم ، في بعض الأحيان دون استخدام Wait() لن يعمل لكتابة برنامج ، ولكن يجب أن يكون السبب جادًا.

استخدام خاطئ من Task.Run ()


تم إنشاء الأسلوب Task.Run() لبدء العمليات في مؤشر ترابط جديد. كما يلائم طريقة مكتوبة في نقش TAP ، فإنه يُرجع Task أو Task<T> والأشخاص الذين يواجهون المزامنة / تنتظر لأول مرة لديهم رغبة كبيرة في التفاف تعليمة برمجية متزامنة في Task.Run() والتخلص من نتائج هذه الطريقة. يبدو أن الكود أصبح غير متزامن ، لكن في الواقع ، لم يتغير شيء. دعونا نرى ما يحدث مع هذا الاستخدام من Task.Run() .


مثال:

 private static async Task ExecuteOperation() { Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}"); await Task.Run(() => { Console.WriteLine($"Inside before sleep: {Thread.CurrentThread.ManagedThreadId}"); Thread.Sleep(1000); Console.WriteLine($"Inside after sleep: {Thread.CurrentThread.ManagedThreadId}"); }); Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}"); } 

ستكون نتيجة هذا الرمز:

 Before: 1 Inside before sleep: 3 Inside after sleep: 3 After: 3 

هنا Thread.Sleep(1000) هو نوع من العمليات المتزامنة التي تتطلب إكمال مؤشر ترابط. افترض أننا نريد أن نجعل حلنا غير متزامن وحتى يمكن Task.Run() هذه العملية ، قمنا Task.Run() في Task.Run() .


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


يجب أن يكون مفهوما أنه على الرغم من حقيقة أنه في السطور من Inside after sleep: 3 و After: 3 نرى نفس معرف الدفق ، فإن سياق التنفيذ مختلف تمامًا في هذه الأماكن. ASP.NET هو ببساطة أذكى منا ويحاول حفظ الموارد عند تبديل السياق من الكود داخل Task.Run() إلى الكود الخارجي. هنا قرر عدم تغيير ما لا يقل عن تدفق التنفيذ.


في مثل هذه الحالات ، لا معنى لاستخدام Task.Run() . بدلاً من ذلك ، ينصح Clary بإجراء جميع العمليات غير المتزامنة ، أي في حالتنا ، استبدال Thread.Sleep(1000) بـ Task.Delay(1000) ، لكن هذا بالطبع غير ممكن دائمًا. ما الذي يجب فعله في الحالات التي نستخدم فيها مكتبات الجهات الخارجية التي لا نستطيع أو لا نريد إعادة كتابتها وجعلها غير متزامنة حتى النهاية ، لكن لسبب أو لآخر نحتاج إلى طريقة المزامنة؟ من الأفضل استخدام Task.FromResult() للالتفاف على نتيجة أساليب البائع في المهام. هذا ، بطبيعة الحال ، لن يجعل الشفرة غير متزامنة ، لكننا سنوفر على الأقل التبديل في السياق.


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

سوء استخدام الفراغ غير المتزامن


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

  1. لا يمكنك انتظار النتيجة.
  2. معالجة الاستثناء من خلال try-catch غير مدعومة.
  3. من المستحيل دمج المكالمات من خلال Task.WhenAll() و Task.WhenAny() وغيرها من الطرق المشابهة.

من بين كل هذه الأسباب ، فإن النقطة الأكثر إثارة للاهتمام هي معالجة الاستثناءات. الحقيقة هي أنه في أساليب المزامنة التي تُرجع Task أو Task<T> ، يتم اكتشاف الاستثناءات وملفها في كائن Task ، والتي سيتم بعد ذلك تمريرها إلى طريقة الاستدعاء. في مقالتها في MSDN ، كتبت كلاري أنه نظرًا لعدم وجود قيمة معادة في أساليب الفراغ غير المتزامن ، فلا يوجد شيء لربط الاستثناءات فيه ويتم طرحها مباشرة في سياق التزامن. والنتيجة هي استثناء غير معالَج بسبب تعطل العملية ، مع إتاحة الوقت لخطأ ربما إلى وحدة التحكم. يمكنك الحصول على هذه الاستثناءات وحفظها من خلال الاشتراك في حدث AppDomain.UnhandledException ، ولكن لن تتمكن من إيقاف تعطل العملية حتى في معالج هذا الحدث. هذا السلوك نموذجي فقط لمعالج الأحداث ، ولكن ليس للطريقة المعتادة ، التي نتوقع منها إمكانية معالجة استثناء قياسي من خلال try-catch.


على سبيل المثال ، إذا كتبت مثل هذا في تطبيق ASP.NET Core ، فسيتم ضمان سقوط العملية:

 public IActionResult ThrowInAsyncVoid() { ThrowAsynchronously(); return View(); } private async void ThrowAsynchronously() { throw new Exception("Obviously, something happened"); } 

لكن الأمر يستحق تغيير نوع ThrowAsynchronously للأسلوب ThrowAsynchronously إلى Task (حتى دون إضافة الكلمة الرئيسية التي تنتظر الانتظار) وسيتم استثناء الاستثناء بواسطة معالج خطأ ASP.NET Core القياسي ، وستستمر العملية على الرغم من التنفيذ.


كن حذرًا مع الأساليب غير المتجانسة - يمكن أن تضعك في هذه العملية.

ننتظر في طريقة سطر واحد


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


بدلاً من هذا الرمز:

 public async Task MyMethodAsync() { await Task.Delay(1000); } 

سيكون من الممكن (ويفضل) أن تكتب:
 public Task MyMethodAsync() { return Task.Delay(1000); } 

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


بمعنى آخر ، تكون نتيجة الإصدار الأول من الطريقة هي Task ، والتي ستصبح Completed بمجرد انتهاء انتظار Task.Delay(1000) ، Task.Delay(1000) نتيجة الإصدار الثاني من الطريقة هي Task ، والتي يتم إرجاعها بواسطة Task.Delay(1000) ، والتي سوف تصبح Completed بمجرد مرور 1000 ميلي ثانية .


كما ترون ، كلا الإصدارين متكافئين ، ولكن في الوقت نفسه ، يتطلب الأول موارد أكثر بكثير لإنشاء "مجموعة أدوات" غير متزامنة.


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


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

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


All Articles