عدم التزامن في C # و F #. مخاطر عدم التزامن في C #

مرحبا هبر! أقدم انتباهكم إلى ترجمة المقال "Async in C # and F # Asyncchronous gotchas in C #" من تأليف توماس بيتريشك.



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



صورة



مناقشة واحدة (ليست ضمن NDA) كانت تتحدث Async Clinic عن الكلمات الرئيسية الجديدة في C # 5.0 - غير متزامن وانتظر. تحدث لوسيان وستيفن عن المشكلات الشائعة التي يواجهها مطورو C # عند كتابة برامج غير متزامنة. في هذا المنشور ، سألقي نظرة على بعض المشاكل من منظور F #. كانت المحادثة نشطة للغاية ، ووصف أحدهم رد فعل جمهور F # على النحو التالي:



صورة

(عندما يكتب MVPs في F # انظر أمثلة رمز C # ، فإنهم يضحكون مثل الفتيات)



لماذا يحدث هذا؟ اتضح أن العديد من الأخطاء الشائعة مستحيلة (أو أقل احتمالًا) عند استخدام النموذج غير المتزامن F # (الذي ظهر في F # 1.9.2.7 ، والذي تم إصداره في عام 2007 وتم شحنه مع Visual Studio 2008).



الوقوع في الخطأ رقم 1: عدم التزامن لا يعمل بشكل غير متزامن



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



  async Task WorkThenWait()
  {
      Thread.Sleep(1000);
      Console.WriteLine("work");
      await Task.Delay(1000);
  }
 
  void Demo() 
  {
      var child = WorkThenWait();
      Console.WriteLine("started");
      child.Wait();
      Console.WriteLine("completed");
  }


إذا كنت تعتقد أنه سيتم طباعة "بدأ" و "العمل" و "مكتمل" ، فأنت مخطئ. يقوم الكود بطباعة "عمل" و "بدأ" و "مكتمل" ، جربه بنفسك! أراد المؤلف بدء العمل (عن طريق الاتصال بـ WorkThenWait) ثم الانتظار حتى تكتمل المهمة. تكمن المشكلة في أن WorkThenWait يبدأ بإجراء بعض العمليات الحسابية الثقيلة (هنا Thread.Sleep) وبعد ذلك فقط يستخدم الانتظار.



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



المقابلة كود F #



في F # ، هذه ليست مشكلة. عند كتابة تعليمات برمجية غير متزامنة في F # ، يتم تأجيل كافة التعليمات البرمجية الموجودة داخل الكتلة غير المتزامنة {…} وتشغيلها لاحقًا (عند تشغيلها صراحة). يتوافق رمز C # أعلاه مع ما يلي في F #:



let workThenWait() = 
    Thread.Sleep(1000)
    printfn "work done"
    async { do! Async.Sleep(1000) }
 
let demo() = 
    let work = workThenWait() |> Async.StartAsTask
    printfn "started"
    work.Wait()
    printfn "completed"
  


من الواضح أن وظيفة workThenWait لا تؤدي العمل (Thread.Sleep) كجزء من الحساب غير المتزامن ، وسيتم تنفيذها عند استدعاء الوظيفة (وليس عند بدء سير العمل غير المتزامن). النمط الشائع في F # هو التفاف جسم الوظيفة بالكامل في صيغة غير متزامنة. في F # ، ستكتب ما يلي ، والذي يعمل كما هو متوقع:



let workThenWait() = async
{ 
    Thread.Sleep(1000)
    printfn "work done"
    do! Async.Sleep(1000) 
}
  


الوقوع الثاني: تجاهل النتائج



إليك مشكلة أخرى في نموذج البرمجة غير المتزامن C # (هذه المقالة مأخوذة مباشرة من شرائح Lucian). خمن ما يحدث عند تشغيل الطريقة غير المتزامنة التالية:



async Task Handler() 
{
   Console.WriteLine("Before");
   Task.Delay(1000);
   Console.WriteLine("After");
}
 


هل تتوقع طباعة "قبل" ، وانتظر ثانية واحدة ، ثم طباعة "بعد"؟ خطأ! ستتم طباعة كلتا الرسالتين في وقت واحد ، دون تأخير متوسط. تكمن المشكلة في أن Task.Delay يعيد مهمة ، وقد نسينا الانتظار حتى يكتمل (باستخدام انتظار).



المقابلة كود F #



مرة أخرى ، ربما لم تكن قد واجهت هذا في F #. يمكنك كتابة كود يستدعي Async.Sleep ويتجاهل Async المرتجع:



let handler() = async
{
    printfn "Before"
    Async.Sleep(1000)
    printfn "After" 
}
 


إذا قمت بلصق هذا الرمز في Visual Studio أو MonoDevelop أو Try F # ، فستتلقى تحذيرًا على الفور:



تحذير FS0020: يجب أن يحتوي هذا التعبير على نوع وحدة ، ولكن من النوع Async ‹unit ... استخدم التجاهل لتجاهل نتيجة التعبير ، أو السماح لربط النتيجة باسم.


تحذير FS0020: يجب أن يكون هذا التعبير من نوع وحدة ولكنه من النوع Async ‹unit ... استخدم التجاهل لتجاهل نتيجة التعبير ، أو للسماح بربط النتيجة بالاسم.




لا يزال بإمكانك تجميع الكود وتشغيله ، ولكن إذا قرأت التحذير ، فسترى أن التعبير يعيد Async وتحتاج إلى انتظار نتيجته باستخدام do!:



let handler() = async 
{
   printfn "Before"
   do! Async.Sleep(1000)
   printfn "After" 
}
 


الخطأ رقم 3: الطرق غير المتزامنة التي ترجع الفراغ



تم تخصيص جزء كبير من المحادثة لأساليب الفراغ غير المتزامن. إذا كتبت Foo () void غير متزامن {…} ، فإن المحول البرمجي C # يولد طريقة ترجع void. ولكن تحت غطاء محرك السيارة ، فإنه ينشئ مهمة ويديرها. هذا يعني أنه لا يمكنك التنبؤ بموعد إنجاز العمل بالفعل.



في الخطاب ، تم تقديم التوصية التالية بشأن استخدام نمط الفراغ غير المتزامن:



صورة

(من أجل الجنة ، توقف عن استخدام الفراغ غير المتزامن!)



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



دعني أوضح المشكلة مع مقتطف من مقالة مجلة MSDN حول البرمجة غير المتزامنة في C #:



async void ThrowExceptionAsync() 
{
    throw new InvalidOperationException();
}

public void CallThrowExceptionAsync() 
{
    try 
    {
        ThrowExceptionAsync();
    } 
    catch (Exception) 
    {
        Console.WriteLine("Failed");
    }
}
 


هل تعتقد أن هذا الرمز سيطبع "فشل"؟ أتمنى أن تكون قد فهمت بالفعل أسلوب هذه المقالة ... في

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



المقابلة كود F #



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



يمكنك كتابة رمز يطابق كود C # أعلاه باستخدام Async.



let throwExceptionAsync() = async {
    raise <| new InvalidOperationException()  }

let callThrowExceptionAsync() = 
  try
     throwExceptionAsync()
     |> Async.Start
   with e ->
     printfn "Failed"


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



الخطأ رقم 4: دوال لامدا غير المتزامنة التي ترجع الفراغ



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



هذه عينة من شرائح لوسيان. متى تنتهي الكود التالي (الصحيح تمامًا) - ثانية واحدة (بعد انتهاء جميع المهام من الانتظار) أو فورًا؟



Parallel.For(0, 10, async i => 
{
    await Task.Delay(1000);
});


لن تتمكن من الإجابة على هذا السؤال إلا إذا كنت تعلم أنه لا يوجد سوى زيادة في التحميل على مندوبي الإجراء لقبولهم - وبالتالي فإن lambda ستترجم دائمًا كفراغ غير متزامن. وهذا يعني أيضًا أن إضافة بعض (ربما الحمولة) ستكون بمثابة تغيير جذري.



المقابلة كود F #



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



Parallel.For(0, 10, fun i -> async {
  do! Async.Sleep(1000) 
})


تقول رسالة الخطأ ببساطة أن نوع الوظيفة int -> Async غير متوافق مع مفوض الإجراء (في F # يجب أن يكون int -> unit):



خطأ FS0041: لا توجد أحمال زائدة مطابقة للطريقة لـ. يتم عرض الأحمال الزائدة المتاحة أدناه (أو في نافذة قائمة الأخطاء).


خطأ FS0041: لم يتم العثور على أحمال زائدة للأسلوب For. تظهر الأحمال الزائدة المتاحة أدناه (أو في مربع قائمة الأخطاء).




للحصول على نفس السلوك كما في كود C # أعلاه ، يجب أن نبدأ صراحة. إذا كنت ترغب في تشغيل تسلسل غير متزامن في الخلفية ، فيمكن القيام بذلك بسهولة باستخدام Async.Start (الذي يأخذ حسابًا غير متزامن يقوم بإرجاع وحدة ، وجدولتها ، وإرجاع وحدة):



Parallel.For(0, 10, fun i -> Async.Start(async {
  do! Async.Sleep(1000) 
}))


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



الخطأ رقم 5: مهام التداخل



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



Console.WriteLine("Before");
await Task.Factory.StartNew(
    async () => { await Task.Delay(1000); });
Console.WriteLine("After");


بشكل غير متوقع تمامًا ، لا يوجد تأخير بين هذه الاستنتاجات. كيف يكون هذا ممكنا؟ يأخذ أسلوب StartNew مفوضًا ويعيد المهمة حيث T هو النوع الذي يتم إرجاعه بواسطة المفوض. في حالتنا ، يقوم المفوض بإرجاع Task ، لذلك نحصل على Task نتيجة لذلك. الانتظار ينتظر فقط حتى تكتمل المهمة الخارجية (التي تُرجع المهمة الداخلية فورًا) ، بينما يتم تجاهل المهمة الداخلية.



في C # ، يمكن إصلاح ذلك باستخدام Task.Run بدلاً من StartNew (أو عن طريق إزالة async / wait في وظيفة lambda).



هل يمكنك كتابة شيء مثل هذا في F #؟ يمكننا إنشاء مهمة ستعيد Async باستخدام دالة Task.Factory.StartNew ودالة lambda التي تُرجع كتلة غير متزامنة. لانتظار اكتمال المهمة ، سنحتاج إلى تحويلها إلى تنفيذ غير متزامن باستخدام Async.AwaitTask. هذا يعني أننا نحصل على Async <Async>:



async {
  do! Task.Factory.StartNew(fun () -> async { 
    do! Async.Sleep(1000) }) |> Async.AwaitTask }


مرة أخرى ، لا يتم ترجمة هذا الرمز. المشكلة هي أن تفعل! يتطلب Async على اليمين ، ولكنه يتلقى في الواقع Async <Async>. بمعنى آخر ، لا يمكننا تجاهل النتيجة فقط. نحتاج إلى القيام بشيء حيال هذا بشكل صريح

(يمكنك استخدام Async. تجاهل لإعادة إنتاج سلوك C #). قد لا تكون رسالة الخطأ واضحة مثل الرسائل السابقة ، ولكنها تعطي فكرة عامة:



خطأ FS0001: كان من المتوقع أن يحتوي هذا التعبير على النوع Async ‹unit ... ولكن هنا يحتوي على وحدة النوع


خطأ FS0001: توقع تعبير غير متزامن "الوحدة" ، نوع الوحدة موجود


الخطأ رقم 6: عدم التزامن لا يعمل



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



async Task FooAsync() 
{
    await Task.Delay(1000);
}
void Handler() 
{
    FooAsync().Wait();
}


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



يمكنك كتابة نفس الكود في F # ، ولكن لا تستخدم مهام سير العمل غير المتزامنة مهام .NET (المصممة في الأصل لحساب ربط وحدة المعالجة المركزية) ، ولكن بدلاً من ذلك استخدم النوع F # Async ، والذي لا يتم تجميعه مع Wait. هذا يعني أنه عليك أن تكتب:



let fooAsync() = async {
    do! Async.Sleep(1000) }
let handler() = 
    fooAsync() |> Async.RunSynchronously


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



ملخص



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



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



أخيرًا ، لا ينبغي فهم هذه المقالة على أنها نقد مدمر لعدم التزامن في C # :-). أفهم تمامًا سبب اتباع تصميم C # للمبادئ التي يتبعها - بالنسبة لـ C # ، من المنطقي استخدام Task (بدلاً من Async المنفصل) ، والتي لها عدد من النتائج. ويمكنني فهم أسباب القرارات الأخرى - ربما تكون هذه هي أفضل طريقة لدمج البرمجة غير المتزامنة في C #. لكن في الوقت نفسه ، أعتقد أن F # يقوم بعمل أفضل - جزئيًا بسبب قدرته على التركيب ، ولكن الأهم من ذلك بسبب الوظائف الإضافية الرائعة مثل وكلاء F # . بالإضافة إلى ذلك ، فإن عدم التزامن في F # له مشاكله أيضًا (الخطأ الأكثر شيوعًا هو أنه يجب استخدام الدوال التكرارية الذيل! بدلاً من do! ، لتجنب التسريبات) ، ولكن هذا موضوع لمدونة منفصلة.



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



All Articles