C #: حالة استخدام واحدة لأي مهمة

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



المقالات السابقة ذات الصلة


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



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

سياق المزامنة


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

private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running.."; // 1 -- UI Thread var result = await _stockPrices.GetStockPricesForAsync("MSFT"); // 2 -- Usually non-UI Thread textBox.Text = "Result is: " + result; //3 -- Should be UI Thread } 

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

توفر كل واجهة مستخدم أدوات مساعدة خاصة لتنظيم المهام في واحد أو أكثر من سلاسل عمليات واجهة المستخدم المتخصصة. تستخدم نماذج Windows طريقة Control.Invoke ، Control.Invoke WPF أسلوب Dispatcher.Invoke ، ويمكن للأنظمة الأخرى الوصول إلى طرق أخرى. المخططات المستخدمة في كل هذه الحالات متشابهة إلى حد كبير ، ولكنها تختلف في التفاصيل. يسمح لك سياق المزامنة بالتجريد من الاختلافات من خلال توفير واجهة برمجة تطبيقات لتشغيل التعليمات البرمجية في سياق "خاص" يوفر معالجة التفاصيل الثانوية بواسطة أنواع مشتقة مثل WindowsFormsSynchronizationContext و DispatcherSynchronizationContext ، إلخ.

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

جمود


دعونا نلقي نظرة على رمز صغير وبسيط نسبيًا. هل هناك مشاكل هنا؟

 // UI code private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public Task<decimal> GetStockPricesForAsync(string symbol) { await Task.Yield(); return 42; } 

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

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

 // UI code private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public Task<decimal> GetStockPricesForAsync(string symbol) { // We know that the initialization step is very fast, // and completes synchronously in most cases, // let's wait for the result synchronously for "performance reasons". InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } // StockPrices.dll private async Task InitializeIfNeededAsync() => await Task.Delay(1); 

يؤدي هذا الرمز مرة أخرى إلى طريق مسدود. كيفية حلها:

  • يجب عدم حظر التعليمات البرمجية غير المتزامنة باستخدام Task.Wait() أو Task.Result و
  • استخدم ConfigureAwait(false) في كود المكتبة.

معنى التوصية الأولى واضح ، والثانية التي سنوضحها أدناه.

تكوين البيانات المنتظرة


هناك سببان Task.Wait() حالة توقف تام في المثال الأخير: Task.Wait() في Task.Wait() والاستخدام غير المباشر لسياق المزامنة في الخطوات اللاحقة في InitializeIfNeededAsync. على الرغم من أن مبرمجي C # لا يوصون بحظر المكالمات بالطرق غير المتزامنة ، فمن الواضح أنه في معظم الحالات لا يزال هذا الحظر قيد الاستخدام. يقدم مبرمجو C # الحل التالي لمشكلة الجمود: Task.ConfigureAwait(continueOnCapturedContext:false) .

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

 public Task<decimal> GetStockPricesForAsync(string symbol) { InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } private async Task InitializeIfNeededAsync() => await Task.Delay(1).ConfigureAwait(false); 

في هذه الحالة ، يتم Task.Delay(1 لاستمرار مهمة Task.Delay(1 ) (هنا عبارة فارغة) في مؤشر الترابط من تجمع مؤشر الترابط ، وليس في مؤشر ترابط واجهة المستخدم ، مما يلغي حالة الجمود.

تعطيل سياق المزامنة


أعلم أن ConfigureAwait يحل هذه المشكلة بالفعل ، ولكنه يفرز أكثر من ذلك بكثير. هنا مثال صغير:

 public Task<decimal> GetStockPricesForAsync(string symbol) { InitializeIfNeededAsync().Wait(); return Task.FromResult((decimal)42); } private async Task InitializeIfNeededAsync() { // Initialize the cache field first await _cache.InitializeAsync().ConfigureAwait(false); // Do some work await Task.Delay(1); } 

هل ترى المشكلة؟ استخدمنا ConfigureAwait(false) ، لذا يجب أن يكون كل شيء على ما يرام. لكن ليس حقيقة.

ConfigureAwait(false) بإرجاع كائن ConfiguredTaskAwaitable مخصص للانتظار ، ونعلم أنه يستخدم فقط إذا لم تكتمل المهمة بشكل متزامن. بمعنى أنه في حالة انتهاء _cache.InitializeAsync() بشكل متزامن ، فإن حالة توقف تام ما زالت ممكنة.

للتخلص من حالات الجمود ، يجب "تزيين" جميع المهام التي تنتظر الانتهاء باستدعاء الأسلوب ConfigureAwait(false) . كل هذا يزعج ويولد أخطاء.

بدلاً من ذلك ، يمكنك استخدام الكائن المخصص للانتظار في جميع الطرق العامة لتعطيل سياق المزامنة في الطريقة غير المتزامنة:

 private void buttonOk_Click(object sender, EventArgs args) { textBox.Text = "Running.."; var result = _stockPrices.GetStockPricesForAsync("MSFT").Result; textBox.Text = "Result is: " + result; } // StockPrices.dll public async Task<decimal> GetStockPricesForAsync(string symbol) { // The rest of the method is guarantee won't have a current sync context. await Awaiters.DetachCurrentSyncContext(); // We can wait synchronously here and we won't have a deadlock. InitializeIfNeededAsync().Wait(); return 42; } 

Awaiters.DetachCurrentSyncContext بإرجاع كائن الانتظار المخصص التالي:

 public struct DetachSynchronizationContextAwaiter : ICriticalNotifyCompletion { /// <summary> /// Returns true if a current synchronization context is null. /// It means that the continuation is called only when a current context /// is presented. /// </summary> public bool IsCompleted => SynchronizationContext.Current == null; public void OnCompleted(Action continuation) { ThreadPool.QueueUserWorkItem(state => continuation()); } public void UnsafeOnCompleted(Action continuation) { ThreadPool.UnsafeQueueUserWorkItem(state => continuation(), null); } public void GetResult() { } public DetachSynchronizationContextAwaiter GetAwaiter() => this; } public static class Awaiters { public static DetachSynchronizationContextAwaiter DetachCurrentSyncContext() { return new DetachSynchronizationContextAwaiter(); } } 

يقوم DetachSynchronizationContextAwaiter بما يلي: يعمل أسلوب المزامنة مع سياق مزامنة غير صفري. ولكن إذا كانت الطريقة المتزامنة تعمل بدون سياق التزامن ، فإن الخاصية IsCompleted ترجع إلى true ، ويتم تنفيذ الأسلوب بشكل متزامن.

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

تم سرد الفوائد الأخرى لهذا النهج أدناه.

  • يتم تقليل احتمال الخطأ. ConfigureAwait(false) فقط إذا تم تطبيقه على جميع المهام التي تنتظر الانتهاء. يجدر نسيان شيء واحد على الأقل - وقد يحدث طريق مسدود. في حالة وجود كائن انتظار مخصص ، تذكر أن جميع طرق المكتبة العامة يجب أن تبدأ بـ Awaiters.DetachCurrentSyncContext() . الأخطاء ممكنة هنا ، ولكن احتمالها أقل بكثير.
  • الكود الناتج هو أكثر وضوحا وواضحا. تبدو طريقة ConfigureAwait مع العديد من المكالمات أقل قراءة بالنسبة لي (بسبب عناصر إضافية) وليست مفيدة بما يكفي للمبتدئين.

معالجة الاستثناء


ما الفرق بين هذين الخيارين:

Task mayFail = Task.FromException (new ArgumentNullException ())؛

 // Case 1 try { await mayFail; } catch (ArgumentException e) { // Handle the error } // Case 2 try { mayFail.Wait(); } catch (ArgumentException e) { // Handle the error } 

في الحالة الأولى ، كل شيء يلبي التوقعات - يتم تنفيذ معالجة الخطأ ، ولكن في الحالة الثانية لا يحدث هذا. تم تصميم مكتبة المهام المتوازية TPL للبرمجة غير المتزامنة والمتوازية ، ويمكن أن تمثل المهمة / المهمة نتيجة عدة عمليات. هذا هو السبب في قيام Task.Result و Task.Wait() دائمًا بإلقاء AggregateException ، والتي قد تحتوي على العديد من الأخطاء.

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

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

 try { Task<int> task1 = Task.FromException<int>(new ArgumentNullException()); Task<int> task2 = Task.FromException<int>(new InvalidOperationException()); // await will rethrow the first exception await Task.WhenAll(task1, task2); } catch (Exception e) { // ArgumentNullException. The second error is lost! Console.WriteLine(e.GetType()); } 

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

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

  1. عرض المهام يدويًا إذا كان لديهم حق الوصول ، أو
  2. تكوين مكتبة TPL لفرض التفاف الاستثناء في AggregateException آخر.

 try { Task<int> task1 = Task.FromException<int>(new ArgumentNullException()); Task<int> task2 = Task.FromException<int>(new InvalidOperationException()); // t.Result forces TPL to wrap the exception into AggregateException await Task.WhenAll(task1, task2).ContinueWith(t => t.Result); } catch(Exception e) { // AggregateException Console.WriteLine(e.GetType()); } 

طريقة فراغ غير متزامن


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

 private async void buttonOk_ClickAsync(object sender, EventArgs args) { textBox.Text = "Running.."; var result = await _stockPrices.GetStockPricesForAsync("MSFT"); textBox.Text = "Result is: " + result; } 

ولكن ماذا لو تسبب GetStockPricesForAsync حدوث خطأ؟ يتم تنظيم استثناء أسلوب عدم التزامن غير متزامن في سياق المزامنة الحالي ، مما يؤدي إلى نفس السلوك المتبع في التعليمات البرمجية المتزامنة (لمزيد من المعلومات ، راجع طريقة ThrowAsync على صفحة الويب AsyncMethodBuilder.cs ). في نماذج Windows ، يؤدي استثناء غير معالج في معالج الأحداث إلى إطلاق حدث Application.ThreadException ، بالنسبة إلى WPF ، وحدث حدث Application.DispatcherUnhandledException ، وما إلى ذلك.

ماذا لو لم تحصل طريقة عدم التزامن على سياق المزامنة؟ في هذه الحالة ، يتسبب الاستثناء غير المعالج في حدوث عطل فادح في التطبيق. لن يتم إطلاق حدث [ TaskScheduler.UnobservedTaskException ] الذي يتم استعادته ، ولكن سيتم إطلاق حدث AppDomain.UnhandledException الذي لن يتم استعادته ثم إغلاق التطبيق. يحدث هذا عن قصد ، وهذه هي النتيجة التي نحتاجها بالضبط.

لنلقِ نظرة الآن على طريقة أخرى معروفة: استخدام طرق الفراغ غير المتزامنة فقط لمعالجات أحداث واجهة المستخدم.

لسوء الحظ ، من السهل استدعاء طريقة الفراغ غير المتزامن عن طريق الصدفة.

 public static Task<T> ActionWithRetry<T>(Func<Task<T>> provider, Action<Exception> onError) { // Calls 'provider' N times and calls 'onError' in case of an error. } public async Task<string> AccidentalAsyncVoid(string fileName) { return await ActionWithRetry( provider: () => { return File.ReadAllTextAsync(fileName); }, // Can you spot the issue? onError: async e => { await File.WriteAllTextAsync(errorLogFile, e.ToString()); }); } 

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

الخلاصة


تأثرت العديد من جوانب البرمجة غير المتزامنة في C # بسيناريو مستخدم واحد - ببساطة تحويل الشفرة المتزامنة لتطبيق واجهة مستخدم موجود إلى غير متزامن:

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

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

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


All Articles