برمجة غير متزامنة في .NET: أفضل الممارسات

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



دميتري إيفانوف - فريق تحليل البرمجيات ، في هواوي ، وهي تقنية TechBide و JetBrains Rider سابقة لـ ReSharper core: هياكل البيانات ، وذاكرة التخزين المؤقت ، والمقالات المتعددة ، والمتحدث العادي في مؤتمر DotNext .

تحت المشهد - تسجيل الفيديو ونسخ النص لتقرير ديمتري من مؤتمر DotNext 2019 Piter.



مزيد من السرد نيابة عن المتحدث.

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

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

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

خطة


  • مشاكل في الأساليب التي يتم حلها باستخدام غير متزامن / انتظار.
  • أمثلة على التصميم المثير للجدل.
  • مهمة من الحياة الواقعية سنقوم بحلها بشكل غير متزامن.


غير متزامن / في انتظار حل المشاكل




لماذا نحتاج إلى مزامنة / انتظار؟ لنفترض أن لدينا كودًا يعمل مع الذاكرة المشتركة المشتركة.

في بداية العمل ، قرأنا الطلب ، في هذه الحالة ، الملف من قائمة انتظار الحظر (على سبيل المثال ، من الإنترنت أو من القرص) ، باستخدام طلب حظر Dequeue (سيتم وضع علامة على طلبات الحظر باللون الأحمر في الصور مع أمثلة).

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

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

لقد سمع الكثير منكم أن التوازي ليس هو نفس التزامن. في هذه الحالة ، يطرح السؤال: هل يمكن للتزامن أن يساعد في كتابة كود متوازي أكثر إحكاما وجمالا وأسرع؟

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

الطريق إلى التزامن / انتظار


دعونا نلقي نظرة على تطور البرمجة غير المتزامنة بشكل عام في العالم وفي .NET.

أتصل مرة أخرى


Void Foo(params, Action callback) {…}
 

Void OurMethod() {//synchronous code
 
    Foo(params,() =>{//asynchronous code;continuation
    });
}

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

المزيد من الاسترجاعات


void Foo(params, Action callback) {...} 
void Bar(Action callback) {...}
void Baz(Action callback) {...}

void OurMethod() {
    ... //synchronous code
    
    Foo(params, () => { 
      ... //continuation 1 
      Bar(() => {
        //continuation 2
        Baz(() => {
          //continuation 3
        }); 
      });
    });
}

وهكذا ، من رد اتصال واحد يمكنك تسجيل رد اتصال آخر ، يمكنك من خلاله تسجيل رد اتصال ثالث ، وفي النهاية يتحول كل شيء إلى رد اتصال Call Hell .



رد الاتصال: استثناءات



void Foo(params, Action onSuccess, Action onFailure) {...}


void OurMethod() {
    ... //synchronous code 
    Foo(params, () => {
      ... //asynchronous code on success 
    },
    () => {
        ... //asynchronous code on failure
    }); 
}

كيف تعمل مع الاستثناءات؟ على سبيل المثال ، ReSharper ، عند الرد بشكل منفصل على الاستثناءات والتنفيذ الجيد ، لا يُظهر أجمل أجزاء التعليمات البرمجية - هناك استدعاءات منفصلة لحالة استثنائية واستمرار ناجح. والنتيجة هي مجرد رد جحيم ، ولكن ليس خطيًا ، ولكنه يشبه الشجرة ، والذي يمكن أن يكون مربكًا تمامًا.



في .NET ، يُسمى أسلوب رد الاتصال الأول بنموذج البرمجة غير المتزامن (APM). سيتم استدعاء الطريقة AsyncCallback، وهي في الأساس نفس الطريقة Action، ولكن النهج يحتوي على بعض الميزات. بادئ ذي بدء ، يجب أن تبدأ الأساليب بكلمة "Begin" (القراءة من ملف هي BeginRead) ، والتي تُرجع بعضها AsyncResult. نفسهAsyncResult- هذا معالج يعرف أن العملية قد اكتملت ولديه آلية WaitHandle. يمكنك WaitHandleالانتظار ، في انتظار اكتمال العملية بشكل غير متزامن. من ناحية أخرى ، يمكنك الاتصال EndOperation، أي إجراء EndReadوتعليق بشكل متزامن (والذي يشبه إلى حد بعيد خاصية Task.Result).

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



النموذج الثاني يسمى النمط غير المتزامن القائم على الأحداث. هذا هو نهج رد الفعل على رد الفعل. فكرة الطريقة هي أننا نمرر إلى الأسلوب OperationNameAsyncبعض الكائنات التي تحتوي على حدث مكتمل والاشتراك في هذا الحدث. كما لاحظت ، BeginOperationNameتغييرات في OperationNameAsync. يمكن أن يحدث الارتباك عندما تذهب إلى فئة Socket ، حيث يتم مزج نمطين: ConnectAsyncو BeginConnect.

يرجى ملاحظة أنه يجب عليك الاتصال للإلغاء OperationNameAsyncCancel. نظرًا لأنه في .NET لم يتم العثور على هذا في أي مكان آخر ، عادةً ما يرسل الجميع s CancellationToken . وبالتالي ، إذا واجهت عن طريق الخطأ طريقة في المكتبة تنتهي بـ Async، فأنت بحاجة إلى فهم أنها لا تعود بالضرورة Task، ولكن يمكنك إرجاع بنية مماثلة.



خذ بعين الاعتبار نموذج معروف في Java باسمالعقود الآجلة ، في JavaScript ، كوعود ، وفي .NET ، كنماذج مهمة غير متزامنة ، وبعبارة أخرى ، "مهام". تفترض هذه الطريقة أن لديك بعض كائن الحساب ، ويمكنك رؤية حالة هذا الكائن (قيد التشغيل أو منتهي). في .NET ، هناك ما يسمى RnToCompletionالفصل المريح لحالتين: بداية المهمة وإكمال المهمة. يحدث خطأ شائع عندما يتم استدعاء طريقة في مهمة IsCompletedلا تؤدي إلى استمرار النجاح ، ولكن RnToCompletion، Canceledو Faulted. وبالتالي ، يجب أن تختلف نتيجة النقر على "إلغاء" في تطبيق واجهة المستخدم عن عودة الاستثناءات (عمليات التنفيذ). في .NET ، تم التمييز: إذا كان التنفيذ هو خطأك الذي تريد تأمينه ، ثم إلغاء- العملية القسرية.

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

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

الجمع بين الاستمرارية


Task ourMethod() {
  return Task.RunSynchronously(() =>{
    ... //synchronous code
  })
  .ContinueWith(_ =>{
    Foo(); //continuation 1
  })
  .ContinueWith(_ =>{
    Bar(); //continuation 2
  })
  .ContinueWith(_ =>{
    Baz(); //continuation 3
  })
}

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

بدء ومتابعة المهام


Task.Factory.StartNew(Action, 
  TaskCreationOptions, 
  TaskScheduler, 
  CancellationToken
)
Task.ContinueWith(Action<Task>, 
  TaskContinuationOptions, 
  TaskScheduler, 
  CancellationToken
)

دعونا ننتقل إلى ثلاث معلمات أثناء إطلاق المهمة القياسية: الأولى هي خيارات بدء المهمة ، والثانية هي schedulerالتي يتم تشغيل المهمة عليها ، والثالثة - CancellationToken.



يخبرك TaskScheduler من أين تبدأ المهمة وهو كائن يمكنك تجاوزه بشكل مستقل. على سبيل المثال ، يمكنك تجاوز طريقة Queue. إذا قمت بذلك TaskSchedulerل thread pool، الأسلوب Queueيأخذ موضوع من thread poolويرسل مهمتك هناك.

إذا استولت schedulerعلى الخيط الرئيسي ، فإنه يضع كل شيء في قائمة انتظار واحدة ، ويتم تنفيذ المهام بالتسلسل على الخيط الرئيسي. ومع ذلك ، فإن المشكلة هي أنه في .NET يمكنك تنفيذ المهمة دون المرور TaskScheduler. السؤال الذي يطرح نفسه: كيف تحسب NET ثم المهمة التي تم تمريرها إليها؟ عندما تبدأ المهمة من StartNewالداخلAction، ThreadStatic. Currentعرضت في تلك TaskSchedulerالتي قدمناها لها.

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

كل شيء هو نفسه مع استمرار. السؤال الذي يطرح نفسه: من أين يأتي TaskSchedulerللاستمرار؟ بادئ ذي بدء ، يتم أخذها في الطريقة التي بدأت بها Continuation. كما أنها TaskSchedulerمأخوذة من ThreadStatic. من المهم أن تعمل الاستمرارات بشكل مختلف تمامًا في حالة عدم المزامنة / الانتظار .



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



على سبيل المثال، المعلمات ExecuteSynchronouslyو RunContinuationsAsynchronouslyتمثل خيارين تطبيق ممكنة، ولكن ما إذا كان سيتم إطلاق استمرار متزامن أو غير متزامن يعتمد على الكثير من الأشياء التي سوف لا تعرف.



مثال آخر: أطلقنا المهمة ، أطلقنا الاستمرارية وأعطينا معلمتين في وقت واحدTaskContinuations.ExecuteSynchronouslyوبعد ذلك بدأوا الاستمرارية بشكل غير متزامن. هل سيتم تنفيذه في المكدس نفسه حيث تنتهي المهمة السابقة ، أم سيتم نقلها إليه thread pool؟ في هذه الحالة ، سيكون هناك خيار ثالث: يعتمد.



TaskCompletionSource


تأمل TaskCompletionSource. عند إنشاء مهمة ، يمكنك تعيين SetResultنتيجتها لتكييف الأنماط السابقة غير المتزامنة مع عالم المهام. يمكنك TaskCompletionSourceطلب tcs.Task، وستنتقل هذه المهمة إلى حالة finishعند الاتصال tcs.SetResult. ومع ذلك ، إذا قمت بتشغيل هذا على تجمع مؤشر الترابط ، فسوف تحصل على طريق مسدود . السؤال هو ، لماذا إذا لم نكتب أي شيء حتى بشكل متزامن؟



نحن ننشئ TaskCompletionSource، نبدأ مهمة جديدة ، ولدينا خيط ثانٍ يبدأ شيئًا ما في هذه المهمة. يذهب أكثر من وتوقع لمائة مللي ثانية. ثم خيطنا الرئيسي - أخضر - يذهب إلى الانتظار وهذا كل شيء. يطلق المكدس ، المكدس معلقة ، في انتظار أن يتم استدعاؤه باستمرارtask.Waitعندما tcsيتعرض.

في الخيط الأزرق نصل إلى tcs، ثم الأكثر إثارة للاهتمام. استنادًا إلى الاعتبارات الداخلية لـ .NET ، TaskCompletionSourceيعتقد أن استمرار ذلك tcsيمكن إجراؤه بشكل متزامن ، أي مباشرة على نفس المكدس ، ثم task.Waitيتم تنفيذ ذلك بشكل متزامن على نفس المكدس. هذا غريب للغاية ، على الرغم من حقيقة أننا لم نكتب حتى في أي مكان ExecuteSynchronously. ربما تكون هذه هي مشكلة خلط الكود المتزامن وغير المتزامن.



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

var  tcs  =  new   TaskCompletionSource<int>(
       TaskContinuationsOptions.RunContinuationsAsynchronously  
) ;
lock(mylock)
{  
    tcs.SetResult(O); 
});

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



لماذا يجب أن يتم الاستمرار بشكل متزامن؟ لأنه RunContinuationsAsynchronouslyيشير إلى ما يلي ContinueWithوليس إلى ما نملك. من أجل الارتباط بمعاييرنا ، تحتاج إلى كتابة ما يلي:



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

التسلسل الهرمي للوالدين والطفل


Task.Factory.StartNew(() => 
{
  //... some parent activity

   Task.Factory.StartNew(() => {
      //... some child activity
   })

})
.ContinueWith(...) // don’t wait for child

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



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

Task.Factory.StartNew(() => 
{
  //... some parent activity
  Foo(); 

})
.ContinueWith(...) // still wait for child

void Foo() { 
   Task.Factory.StartNew(() => {
      //... parent task to attach is in ThreadStatic
   }, TaskCreationOptions.AttachedToParent); 
}

قد تكون هناك مشكلة في نقل المهمة في مكان ما ThreadStatic، ثم تتم إضافة كل شيء بدأت به AttachedToParentإلى هذه المهمة الرئيسية ، وهي جرس إنذار.

Task.Factory.StartNew(() => 
{
  //... some parent activity

  Foo();
}, TaskCreationOptions.DenyChildAttach)
.ContinueWith(...) // don’t wait for child

void Foo() { 
   Task.Factory.StartNew(() => {
      //... some child activity
   }, TaskCreationOptions.AttachedToParent); 
}

من ناحية أخرى ، هناك خيار يلغي الخيار السابق DenyChildAttach. يحدث مثل هذا التطبيق في كثير من الأحيان.

Task.Run(() => 
{
  //... some parent activity

  Foo(); 

})
.ContinueWith(...) //don’t wait for child

void Foo() { 
   Task.Factory.StartNew(() => {
      //... some child activity
    }, TaskCreationOptions.AttachedToParent); 
}

تجدر الإشارة إلى أن Task.Runهذه هي الطريقة القياسية للبدء ، والتي تتضمن افتراضيًا DenyChildAttach. يضيف

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



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

  • سيتم إكمال المتابعة العامة بنجاح ؛ سيحدث RunToCompletion ؛
  • ستكون المهمة في خطأ ؛
  • سيحدث الإلغاء ؛
  • المهمة لن تصل إلى الاكتمال على الإطلاق ، وسوف تكون في نوع من النسيان ؛
  • الخيار - "يعتمد".



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

إلغاء



Task.Factory.StartNew(() => 
{
    throw new OperationCanceledException(); 
});

                                                      Failed

المعلمة الثالثة في بداية المهمة هي kancellation. تكتب OperationCanceledException، أي إجراء خاص يضع المهمة في حالة "ملغاة". في هذه الحالة ، ستكون المهمة في الحالة "فاشلة" ، لأن الجميع OperationCanceledExceptionليسوا متساوين.

Task.Factory.StartNew(() => 
{
    throw new OperationCanceledException(cancellationToken); 
}, cancellationToken);

                                                      Canceled

لكي تتمكن من القيام بالمهمة Canceled، تحتاج إلى رميها OperationCanceledExceptionمع CancellationToken. في الواقع ، لا تفعل هذا صراحة أبدًا ، ولكن افعل ذلك بهذه الطريقة:

Task.Factory.StartNew(() => 
{
    cancellationToken.ThrowIfCancellationRequested(); 
}, cancellationToken);
                                                       Canceled

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

كومة العميق


Task.Factory.StartNew(() => 
{
    Foo();
}, cancellationToken);

  void Foo() { 
     Bar() {
       ...
          Baz() {
             //how to get cancellation token?
          } 
    }
}

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

static AsyncLocal<Cancelation> asyncLocalCancellation;

Task.Factory.StartNew(() => 
{
     asyncLocalCancellation.Set(cancellationToken) 
    Foo();
}, cancellationToken); // use AsyncLocal to put cancellation int

  void Foo() { 
     async Bar() {
      ...
         Baz() {
             asyncLocalCancellation.Value.CheckForInterrupt(); 
         }
   } 
}

هذا هو نفسه ، ThreadStaticفقط الخاص ThreadLocalالذي ينجو من رحلات كود غير متزامن / في انتظار. نظرًا لأن شفرتك غير متزامنة ، ولديك هذا الإلغاء ، يمكنك وضعها AsyncLocal، وفي مكان ما على مستوى عميق يمكنك أن تقول " CheckForInterrupt Throw If Cancellation Requested". مرة أخرى ، هذه هي المعلمة الوحيدة CancellationTokenالتي تحتاج إلى تشويه الرمز بالكامل ، ولكن ، في رأيي ، بالنسبة لمعظم المهام ، تحتاج فقط إلى معرفة ما حدث OperationCanceledException، ومن هذا استخلاص استنتاج ينص على: تم الإلغاء أو الفشل.

التعقيد المعرفي


Task.Factory.StartNew(Action, 
    TaskCreationOptions, 
    TaskScheduler, 
    CancellationToken
)
                                                   JetBrains.Lifetimes

lifetime.Start(TaskScheduler, Action) //puts lifetime in AsyncLocal

lifetime.StartMainRead(Action) 
lifetime.StartMainWrite(TaskScheduler, Action) 
lifetime.StartBackgroundRead(TaskScheduler, Action)

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

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



دعونا نرى كيف يحل التزامن / انتظار حل هذه المشاكل ، وما هي المشاكل التي تقدمها .

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

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

async Task MyFuncAsync() { 
  synchronousCode();
   await InnerAsync();
   await Task.Yield(); //guaranteed !IsCompleted 
   continuationCode();
}

هناك خدعة واحدة Task.Yield- هذه مهمة خاصة تضمن أن صاحبها لن يعود إليك دائمًا IsCompleted. وفقًا لذلك ، continuationلن يتم استدعاؤه بشكل متزامن في هذا المكان. بالنسبة لمؤشر ترابط واجهة المستخدم ، يمكن أن يكون هذا مهمًا لأنك لا تأخذ هذا الموضوع لفترة طويلة من الوقت.



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

آلية المهمة قيد الانتظار هي كما يلي: يتم أخذها static، يطلق عليهاSynchronizationContextومنه يتم إنشاؤه TaskScheduler. SynchronizationContext هو شيء مع طريقة النشر ، والتي تشبه إلى حد كبير الأسلوب Queue. في الواقع TaskScheduler، الذي كان في وقت سابق ، فإنه يأخذ ببساطة SynchronizationContextومن خلال Post يؤدي مهمته عليه.

async Task MyFuncAsync() { 
  synchronousCode();

    await InnerAsync().ConfigureAwait(false);
    continuationCode(); 
}

هناك طريقة لتغيير هذا السلوك باستخدام معلمة ContinueOnCapturedContext. تسمى واجهة برمجة التطبيقات الأكثر إثارة للاشمئزاز الموجودة في .NET ConfigureAwait. في هذه الحالة ، تقوم واجهة برمجة التطبيقات (API) بإنشاء مُعلِّق خاص ، يختلف عن TaskAwaiterذلك الذي يغير الاستمرار ، ويتم تشغيله على نفس مؤشر الترابط ، وفي نفس السياق الذي انتهت InnerAsync فيه الطريقة وحيث انتهت المهمة.

async Task MyFuncAsync() { 
  synchronousCode();

    await InnerAsync().ConfigureAwait(continueOnCapturedContext: false); 
    continuationCode(); //code must be absolutely context-agnostic
}

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

جمود


async Task MyFuncAsync() { //UI thread 
  synchronousCode();

    await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false); 
    continuationCode();
}
myFuncAsync().Wait() //on UI thread

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

async Task OnBluttionClick() { //UI thread 
  int v = Button.Text.ParseInt();

    await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false); 
  Button.Text.Set((v+1).ToString());
}
myFuncAsync().Wait() //on UI thread

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

async Task MyFuncAsync() { //UI thread 
  synchronousCode();

    await Task.Delay(10).ConfigureAwait(continueOnCapturedContext: false); 
    continuationCode(); //The same UI context
}
PumpUntil(() => task.IsCompleted);
//VS synchronization contexts always pump on any Wait

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

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

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

تغيير السياق


async Task MyFuncAsync() { 
  synchronousCode(); // on initial context

    await myTaskScheduler;
    continuationCode(); //on scheduler context 
}

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

التزامن المنظم


async Task MyFuncAsync() { 
  synchronousCode(); // on initial context

    await Task.Factory.StartNew(() => {...}, myTaskScheduler);
    continuationCode(); //on initial context 
}

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

سلوك متسلسل


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

async Task MyAsync() {

  var task1 = StartTask1Async();
  await task1;

  var task2 = StartTask2Async();
  await task2; 
}

السلوك المتزامن


async Task MyAsync() {
  var task1 = StartTask1Async();
  var task2 = StartTask2Async();

  await task1;
  await task2; 
}

هنا تبدأ المهام بالتوازي. من الواضح أن الأساليب يمكن أن ترجع المهمة على الفور في حالة تشغيل ، فلن يكون هناك موازية. دعنا نقول أن كلتا المهمتين تقومان بإعدام. وانتظرت المهمة الأولى ، ثم في أول انتظار أقلعت. أي بمجرد أن تكتب await task1أنت تقلع ولم تعالج exception task2. من المثير للاهتمام ، هذا رمز صالح تمامًا. وهذا الرمز هو الذي قاد .NET إلى حقيقة أن سلوك العمل مع عمليات التنفيذ قد تغير في الإصدار 4.5.

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


async Task MyAsync() {
  var task1 = StartTask1Async();
  var task2 = StartTask2Async(); 

  await task1;
  await task2;

  // if task1 throws exception and task2 throws exception we only throw and
  // handle task1’s exception

  //4.0 -> 4.5 framework: unhandled exceptions now don’t crush process
  //still visible in UnobservedExceptionHandler
}

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

async  Task  MyAsync(CancellationToken cancellationToken)  {  

  await  SomeTask1  Async(cancellationToken); 
 
  await  Some Task2Async( cancellation  Token); 
  //you should always pass use async API with cancelationToken  if possible 
} 
  
try { 
    await  MyAsync( cancellation  Token); 
} catch (OperationException e) { // do nothing: OCE happened
} catch (Exception e) { 
    log.Error(e);
}

انظر كيف تسير عملية التنفيذ. يجب إرسال CancellationToken-s ، فمن الضروري "تشويه" CancellationToken-s جميع التعليمات البرمجية. السلوك الطبيعي غير المتزامن هو أنك لا تحقق في أي مكان Task.Status ancellationToken، فأنت تعمل برمز غير متزامن بنفس الطريقة المتبعة مع المتزامن. أي أنه في حالة الإلغاء ، ستحصل على تنفيذ ، وفي هذه الحالة ، لا تفعل شيئًا عند استلامه OperationCanceledException.

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

في التمرين


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


DataTable<File, ProcessedFile> sharedMemory;

// in any thread
void SynchronousWorker(...) {
  File f = blockingQueue.Dequeue(); 
  ProcessedFile p = ProcessInParallel(f);

  lock (_lock) { 
    sharedMemory.add(f, p);
  } 
}

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

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


عند إعادة كتابة الرمز إلى غير متزامن ، سنقوم أولاً باستبداله voidبـ async Task. تأكد من كتابة كلمة "Async" في النهاية. يجب أن تنتهي جميع الطرق غير المتزامنة بـ Async - هذه اصطلاح.

DataTable<File, ProcessedFile> sharedMemory;
// in any thread
async Task WorkerAsync(...) {

  File f = blockingQueue.Dequeue(); 

  ProcessedFile p = ProcessInParallel(f);

  lock (_lock) { 
    sharedMemory.add(f, p);
  } 
}

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



تسمى هذه البدائية القناة: القنوات التي تعيش في الحزمة System.Threading.Channels. يمكنك إنشاء قنوات وقوائم انتظار ، محدودة وغير محدودة ، يمكنك الانتظار بشكل غير متزامن. علاوة على ذلك ، يمكنك إنشاء قناة بقيمة "صفر" ، أي أنها لن تحتوي على مخزن مؤقت على الإطلاق. تسمى هذه القنوات قنوات الالتقاء ويتم الترويج لها بنشاط في Go و Kotlin. ومن حيث المبدأ ، إذا كان من الممكن استخدام القنوات في رمز غير متزامن ، فهذا نمط جيد جدًا. أي أننا نغير قائمة الانتظار إلى القناة حيث توجد طرق ReadAsyncو WriteAsync.

ProcessInParallel هي مجموعة من التعليمات البرمجية المتوازية التي تقوم بمعالجة ملف وتحويله إلىProcessedFile. هل يمكن أن يساعدنا المزامنة في الكتابة ليس رمزًا غير متزامن ، ولكن بشكل متوازي أكثر تعقيدًا؟

تبسيط الكود الموازي


يمكن إعادة كتابة الرمز بهذه الطريقة:

DataTable<File, ProcessedFile> sharedMemory;

// in any thread
async Task WorkerAsync(...) {

  File f = await channel.ReadAsync();

  ProcessedFile p = await ProcessInParallelAsync(f);

  lock (_lock) { 
    sharedMemory.add(f, p);
  } 
}



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



نحدد الرسم البياني للمهمة على النحو التالي: دعنا نقول أن كل مهمة لها مهام أخرى تعتمد عليها ، ثم باستخدام قاموس ExecuteBefore نكتب الهيكل العظمي لطريقتنا.

حلول الهيكل العظمي


Dictionary<Action<ProcessedFile>, Action<ProcessedFile>[]> ExecuteBefore; async Task<ProcessedFile> ProcessInParallelAsync() {
  var res = new ProcessedFile();


  // lots of work with toposort, locks, etc.

  return res; 
}

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

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


Dictionary<Action<ProcessedFile>, Action<ProcessedFile>[]> ExecuteBefore;
async Task<ProcessedFile> ProcessInParallelAsync() {
  var res = new ProcessedFile();
  var lazy = new Dictionary<Action<ProcessedFile>, Lazy<Task>>(); 
  foreach ((action, beforeList) in ExecuteBefore)
    lazy[action] = new Lazy<Task>(async () => 
    {
      await Task.WhenAll(beforeList.Select(b => lazy[b].Value)) 
      await Task.Yield();
      action(res);
}
  await Task.WhenAll(lazy.Values.Select(l => l.Value)) 
  return res;
}

هناك نمط يسمى Async Lazy. نقوم بإنشاء ProcessedFileأعمالنا التي يجب تنفيذ الإجراءات المختلفة عليها. دعنا ننشئ قاموسًا: سنقوم بتنسيق كل مرحلة من مراحلنا (Action ProcessedFile) في بعض المهام ، أو بالأحرى ، إلى Lazy من Task ونعمل على طول الرسم البياني الأصلي. actionسيكون للمتغير الإجراء نفسه ، وفي قائمة قبل - تلك الإجراءات التي يجب القيام بها قبل عملنا. ثم قم بإنشاء Lazyمن action. نكتب في المهمة await. لذلك نحن ننتظر كل المهام التي يجب انجازها قبلها. في قائمة قبل ، حدد القائمة Lazyالموجودة في هذا القاموس.

يرجى ملاحظة أنه هنا لن يتم تنفيذ أي شيء بشكل متزامن ، لذلك لن يقع هذا الرمز ItemNotFoundException in Dictionary. نقوم بتنفيذ جميع المهام التي كانت قبلنا ، ونقوم بالبحث عن طريق العملLazy Task. ثم نقوم بتنفيذ عملنا. في النهاية ، ما عليك سوى أن تطلب من كل مهمة أن تبدأ ، وإلا فلن تعرف أبدًا ما إذا كان هناك شيء لم يبدأ. في هذه الحالة ، لم يبدأ شيء. هذا هو الحل. تتم كتابة هذه الطريقة في 10 دقائق ، وهي واضحة تمامًا.

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

تخلص من الأقفال


DataTable<File, ProcessedFile> sharedMemory;

// in any thread
async Task WorkerAsync(...) {

  File f = await channel.ReadAsync();

  ProcessedFile p = await ProcessInParallelAsync(f);

    lock (_lock) {
      sharedMemory.add(f, p);
   }
 }

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

Channel<Pair<File, ProcessedFile>> output;
// in any thread
async Task WorkerAsync(...) {

  File f = await channel.ReadAsync();

  ProcessedFile p = await ProcessInParallelAsync(f);
  
  await output.WriteAsync(); 
}

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



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



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

ملخص


  • تجنب المزامنة في التعليمات البرمجية غير المتزامنة.
  • الرقم التسلسلي أبسط من التوازي.
  • يمكن أن يكون الرمز غير المتزامن بسيطًا ويستخدم الحد الأدنى من المعلمات وسياقًا ضمنيًا يغير سلوكه.

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

الرقم التسلسلي أبسط من التوازي. إذا كان بإمكانك كتابة بنيتك بحيث تبدو متسلسلة ، دون تشغيل رمز موازٍ وقفل ، فقم بكتابة البنية بالتسلسل.

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

ماذا تقرأ



-10 . DotNext .

All Articles