.NET: أدوات للعمل مع تعدد مؤشرات الترابط وعدم التزامن. الجزء 1

أقوم بنشر المقال الأصلي على حبر، والترجمة منشورة في الشركة блоге.

إن الحاجة إلى القيام بشيء ما بشكل غير متزامن، دون انتظار النتيجة هنا والآن، أو تقسيم العمل الكبير بين عدة وحدات تؤديه، كانت موجودة قبل ظهور أجهزة الكمبيوتر. ومع مجيئهم، أصبحت هذه الحاجة ملموسة للغاية. الآن، في عام 2019، أكتب هذه المقالة على جهاز كمبيوتر محمول مزود بمعالج Intel Core ثماني النواة، والذي يعمل عليه أكثر من مائة عملية بالتوازي، وحتى المزيد من الخيوط. يوجد بالجوار هاتف متهالك قليلاً تم شراؤه منذ عامين ويحتوي على معالج 8 نواة. الموارد المواضيعية مليئة بالمقالات ومقاطع الفيديو حيث يعجب مؤلفوها بالهواتف الذكية الرائدة لهذا العام والتي تتميز بمعالجات ذات 8 نواة. يوفر MS Azure جهازًا افتراضيًا مزودًا بمعالج 16 نواة وذاكرة وصول عشوائي سعة 20 تيرابايت مقابل أقل من 128 دولارًا في الساعة. لسوء الحظ، من المستحيل استخراج الحد الأقصى وتسخير هذه القوة دون القدرة على إدارة تفاعل الخيوط.

مفردات اللغة

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

استعارة

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

أثناء تحضير الإفطار في الصباح (وحدة المعالجة المركزية‏:) أتيت إلى المطبخ (Компьютер). لدي يدين (الألوان). يوجد عدد من الأجهزة في المطبخ (IO): الفرن، غلاية، محمصة الخبز، ثلاجة. أشعل الغاز وأضع عليه مقلاة وأسكب الزيت فيها دون أن أنتظر حتى يسخن (بشكل غير متزامن، بدون حظر، IO-الانتظار)، أخرج البيض من الثلاجة وأكسره في طبق، ثم أضربه بيد واحدة (الموضوع رقم 1)، والثانية (الموضوع رقم 2) ممسكًا باللوحة (الموارد المشتركة). الآن أرغب في تشغيل الغلاية، لكن ليس لدي ما يكفي من الأيدي (تجويع الموضوع) خلال هذا الوقت، تسخن المقلاة (معالجة النتيجة) التي أسكب فيها ما قمت بجلده. وصلت إلى الغلاية وأشعلتها وأشاهد بغباء الماء يغلي فيها (حظر-IO-الانتظار)، على الرغم من أنه خلال هذا الوقت كان بإمكانه غسل ​​الطبق حيث كان يخفق العجة.

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

مواصلة الاستعارة:

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

أدوات.NET

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

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

بدء موضوع

فئة Thread هي الفئة الأساسية في .NET للعمل مع سلاسل الرسائل. يقبل المنشئ أحد المندوبين:

  • ThreadStart — لا توجد معلمات
  • ParametrizedThreadStart - مع معلمة واحدة لكائن النوع.

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

new Thread(...).Start(...);

تمثل فئة ThreadPool مفهوم التجمع. في .NET، يعد تجمع مؤشرات الترابط جزءًا من الهندسة، وقد بذل المطورون في Microsoft الكثير من الجهد للتأكد من أنه يعمل على النحو الأمثل في مجموعة واسعة من السيناريوهات.

المفهوم العام:

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

لاستخدام مؤشر ترابط من التجمع، هناك طريقة QueueUserWorkItem تقبل مفوضًا من النوع WaitCallback، والذي له نفس التوقيع مثل ParametrizedThreadStart، وتؤدي المعلمة التي تم تمريرها إليها نفس الوظيفة.

ThreadPool.QueueUserWorkItem(...);

يتم استخدام طريقة تجمع مؤشرات الترابط الأقل شهرة RegisterWaitForSingleObject لتنظيم عمليات الإدخال والإخراج غير المحظورة. سيتم استدعاء المفوض الذي تم تمريره إلى هذه الطريقة عندما يتم "تحرير" WaitHandle الذي تم تمريره إلى الطريقة.

ThreadPool.RegisterWaitForSingleObject(...)

يحتوي .NET على مؤقت مؤشر ترابط ويختلف عن مؤقتات WinForms/WPF حيث سيتم استدعاء معالجه على مؤشر ترابط مأخوذ من التجمع.

System.Threading.Timer

هناك أيضًا طريقة غريبة إلى حد ما لإرسال مفوض للتنفيذ إلى مؤشر ترابط من التجمع - طريقة BeginInvoce.

DelegateInstance.BeginInvoke

أود أن أتناول بإيجاز الوظيفة التي يمكن استدعاء العديد من الطرق المذكورة أعلاه بها - CreateThread من Kernel32.dll Win32 API. هناك طريقة، بفضل آلية الأساليب الخارجية، لاستدعاء هذه الوظيفة. لقد رأيت مثل هذه الدعوة مرة واحدة فقط في مثال رهيب للكود القديم، ولا يزال دافع المؤلف الذي فعل هذا بالضبط لغزًا بالنسبة لي.

Kernel32.dll CreateThread

عرض وتصحيح المواضيع

يمكن عرض المواضيع التي قمت بإنشائها وجميع مكونات الطرف الثالث وتجمع .NET في نافذة المواضيع في Visual Studio. ستعرض هذه النافذة معلومات مؤشر الترابط فقط عندما يكون التطبيق قيد التصحيح وفي وضع الاستراحة. هنا يمكنك بسهولة عرض أسماء المكدسات وأولوياتها لكل مؤشر ترابط، وتبديل تصحيح الأخطاء إلى مؤشر ترابط معين. باستخدام خاصية الأولوية لفئة الخيط، يمكنك تعيين أولوية الخيط، والتي سوف ينظر إليها OC وCLR على أنها توصية عند تقسيم وقت المعالج بين سلاسل الرسائل.

.NET: أدوات للعمل مع تعدد مؤشرات الترابط وعدم التزامن. الجزء 1

مكتبة المهام الموازية

تم تقديم مكتبة المهام الموازية (TPL) في .NET 4.0. الآن هي الأداة القياسية والرئيسية للعمل مع عدم التزامن. أي رمز يستخدم أسلوبًا قديمًا يعتبر قديمًا. الوحدة الأساسية لـ TPL هي فئة المهام من مساحة الاسم System.Threading.Tasks. المهمة هي تجريد على موضوع. مع الإصدار الجديد من لغة C#، حصلنا على طريقة أنيقة للعمل مع المهام - عوامل التشغيل غير المتزامنة/المنتظرة. جعلت هذه المفاهيم من الممكن كتابة تعليمات برمجية غير متزامنة كما لو كانت بسيطة ومتزامنة، وهذا جعل من الممكن حتى للأشخاص الذين لديهم القليل من الفهم للعمل الداخلي للخيوط أن يكتبوا التطبيقات التي تستخدمها، وهي التطبيقات التي لا تتجمد عند إجراء عمليات طويلة. يعد استخدام async/await موضوعًا لمقال واحد أو حتى عدة مقالات، ولكن سأحاول الحصول على جوهره في بضع جمل:

  • غير المتزامن هو معدل لطريقة إرجاع المهمة أو الفراغ
  • والانتظار هو عامل انتظار المهام غير المحظور.

مرة أخرى: سيحرر عامل الانتظار، في الحالة العامة (هناك استثناءات)، خيط التنفيذ الحالي بشكل أكبر، وعندما تنتهي المهمة من تنفيذها، والخيط (في الواقع، سيكون من الأصح قول السياق ، ولكن المزيد عن ذلك لاحقًا) سيستمر في تنفيذ الطريقة بشكل أكبر. داخل .NET، يتم تنفيذ هذه الآلية بنفس طريقة إرجاع العائد، عندما تتحول الطريقة المكتوبة إلى فئة كاملة، وهي عبارة عن آلة حالة ويمكن تنفيذها في أجزاء منفصلة اعتمادًا على هذه الحالات. يمكن لأي شخص مهتم كتابة أي تعليمات برمجية بسيطة باستخدام asynс/await، وتجميع وعرض التجميع باستخدام JetBrains dotPeek مع تمكين Compiler Generated Code.

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

using TCO = System.Threading.Tasks.TaskCreationOptions;

public static async void VoidAsyncMethod() {
    var cancellationSource = new CancellationTokenSource();

    await Task.Factory.StartNew(
        // Code of action will be executed on other context
        () => Thread.Sleep(10000),
        cancellationSource.Token,
        TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness,
        scheduler
    );

    //  Code after await will be executed on captured context
}

يتم إنشاء المهمة بعدد من الخيارات:

  • LongRunning هو تلميح إلى أن المهمة لن تكتمل بسرعة، مما يعني أنه قد يكون من المفيد التفكير في عدم أخذ سلسلة رسائل من المجموعة، ولكن إنشاء سلسلة منفصلة لهذه المهمة حتى لا تلحق الضرر بالآخرين.
  • AttachedToParent - يمكن ترتيب المهام في تسلسل هرمي. إذا تم استخدام هذا الخيار، فقد تكون المهمة في حالة اكتملت فيها بنفسها وتنتظر تنفيذ أطفالها.
  • PreferFairness - يعني أنه سيكون من الأفضل تنفيذ المهام المرسلة للتنفيذ في وقت مبكر قبل تلك التي يتم إرسالها لاحقًا. لكن هذه مجرد توصية والنتائج غير مضمونة.

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

المعلمة الأخيرة هي كائن جدولة من النوع TaskScheduler. تم تصميم هذه الفئة وأحفادها للتحكم في استراتيجيات توزيع المهام عبر سلاسل الرسائل؛ بشكل افتراضي، سيتم تنفيذ المهمة على مؤشر ترابط عشوائي من التجمع.

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

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

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

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

public static async void AnotherMethod() {

    int result = await AsyncMethod(); // good

    result = AsyncMethod().Result; // bad

    AsyncMethod().Wait(); // bad

    IEnumerable<Task> tasks = new Task[] {
        AsyncMethod(), OtherAsyncMethod()
    };

    await Task.WhenAll(tasks); // good
    await Task.WhenAny(tasks); // good

    Task.WaitAll(tasks.ToArray()); // bad
}

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

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

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

تم أيضًا وضع علامة "سيئ" على الأمثلة الثالثة والأخيرة لنفس السبب وتحتوي على كافة المشكلات نفسها.

تعد الطريقتان WhenAny وWhenAll ملائمتين للغاية لانتظار مجموعة من المهام؛ حيث تقومان بجمع مجموعة من المهام في مهمة واحدة، والتي سيتم تشغيلها إما عند تشغيل مهمة من المجموعة لأول مرة، أو عند اكتمال تنفيذها جميعًا.

توقف المواضيع

لأسباب مختلفة، قد يكون من الضروري إيقاف التدفق بعد بدايته. هنالك عدة طرق لعمل هذا. تحتوي فئة Thread على طريقتين مسميتين بشكل مناسب: إجهاض и قطع. الأول لا ينصح بشدة باستخدامه، لأنه بعد استدعائها في أي لحظة عشوائية، أثناء معالجة أي تعليمات، سيتم طرح استثناء ThreadAbortedException. لا تتوقع أن يتم طرح مثل هذا الاستثناء عند زيادة أي متغير صحيح، أليس كذلك؟ وعند استخدام هذه الطريقة، فهذا موقف حقيقي للغاية. إذا كنت بحاجة إلى منع CLR من إنشاء مثل هذا الاستثناء في قسم معين من التعليمات البرمجية، فيمكنك تغليفه بالاستدعاءات Thread.BeginCriticalRegion, Thread.EndCriticalRegion. أي كود مكتوب في كتلة أخيرًا يتم تغليفه بمثل هذه الاستدعاءات. لهذا السبب، في أعماق رمز إطار العمل، يمكنك العثور على كتل ذات محاولة فارغة، ولكن ليست فارغة في النهاية. لا تشجع Microsoft هذه الطريقة كثيرًا لدرجة أنها لم تدرجها في .net core.

تعمل طريقة المقاطعة بشكل أكثر توقعًا. يمكنه مقاطعة الخيط باستثناء ThreadInterruptedException فقط خلال تلك اللحظات التي يكون فيها الخيط في حالة انتظار. يدخل هذه الحالة أثناء التعليق أثناء انتظار WaitHandle، أو القفل، أو بعد استدعاء Thread.Sleep.

كلا الخيارين الموصوفين أعلاه سيئان بسبب عدم القدرة على التنبؤ بهما. الحل هو استخدام الهيكل CancellationToken والطبقة CancellationTokenSource. النقطة المهمة هي: يتم إنشاء مثيل لفئة CancellationTokenSource ويمكن لمن يملكها فقط إيقاف العملية عن طريق استدعاء الطريقة إلغاء. يتم تمرير CancellationToken فقط إلى العملية نفسها. لا يمكن لمالكي CancellationToken إلغاء العملية بأنفسهم، ولكن يمكنهم فقط التحقق مما إذا كانت العملية قد تم إلغاؤها. هناك خاصية منطقية لهذا تم طلب الإلغاء والطريقة ThrowIfCancelRequested. هذا الأخير سوف يرمي استثناء TaskCancelledException إذا تم استدعاء الأسلوب Cancel على مثيل CancellationToken الذي يتم تكراره. وهذه هي الطريقة التي أوصي باستخدامها. يعد هذا تحسينًا عن الخيارات السابقة من خلال التحكم الكامل في النقطة التي يمكن فيها إحباط عملية الاستثناء.

الخيار الأكثر وحشية لإيقاف سلسلة المحادثات هو استدعاء وظيفة Win32 API TerminateThread. قد يكون سلوك CLR بعد استدعاء هذه الوظيفة غير متوقع. في MSDN ما يلي مكتوب حول هذه الوظيفة: "إن TerminateThread هي وظيفة خطيرة يجب استخدامها فقط في الحالات القصوى. "

تحويل واجهة برمجة التطبيقات القديمة إلى مهمة تعتمد على طريقة FromAsync

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

object state = null;
WebRequest wr = WebRequest.CreateHttp("http://github.com");
await Task.Factory.FromAsync(
    wr.BeginGetResponse,
    we.EndGetResponse
);

هذا مجرد مثال ومن غير المرجح أن تضطر إلى القيام بذلك باستخدام الأنواع المضمنة، ولكن أي مشروع قديم يعج ببساطة بأساليب BeginDoSomething التي تُرجع أساليب IAsyncResult وEndDoSomething التي تستقبلها.

تحويل واجهة برمجة التطبيقات القديمة إلى مهمة تعتمد على فئة TaskCompletionSource

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

ستقول إنني تحدثت بالفعل عن طريقة FromAsync لفئة TaskFactory المخصصة لهذه الأغراض. هنا سيتعين علينا أن نتذكر التاريخ الكامل لتطوير النماذج غير المتزامنة في .net التي قدمتها Microsoft على مدار الخمسة عشر عامًا الماضية: قبل النموذج غير المتزامن القائم على المهام (TAP)، كان هناك نمط البرمجة غير المتزامن (APP)، والذي كان حول الأساليب تبدأافعل شيئًا يعود IAsyncResult والأساليب النهايةافعل شيئًا يقبله ولإرث هذه السنوات، تعد طريقة FromAsync مثالية تمامًا، ولكن مع مرور الوقت، تم استبدالها بالنمط غير المتزامن القائم على الأحداث (EAP)، والذي يفترض أنه سيتم رفع حدث عند اكتمال العملية غير المتزامنة.

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

public static Task<Result> DoAsync(this SomeApiInstance someApiObj) {

    var completionSource = new TaskCompletionSource<Result>();
    someApiObj.Done += 
        result => completionSource.SetResult(result);
    someApiObj.Do();

    result completionSource.Task;
}

نصائح وحيل مصدر إكمال المهمة

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

باختصار، جوهر الحيلة هو كما يلي: تحتاج إلى تلقي معلومات من واجهة برمجة التطبيقات حول بعض الأحداث التي تحدث من جانبها، في حين أن واجهة برمجة التطبيقات، لسبب ما، لا يمكنها الإبلاغ عن الحدث، ولكن يمكنها فقط إرجاع الحالة. ومن الأمثلة على ذلك جميع واجهات برمجة التطبيقات المبنية على HTTP قبل زمن WebSocket أو عندما كان من المستحيل لسبب ما استخدام هذه التقنية. يمكن للعميل أن يطلب من خادم HTTP. لا يمكن لخادم HTTP نفسه بدء الاتصال بالعميل. الحل البسيط هو استطلاع الخادم باستخدام مؤقت، ولكن هذا يخلق حملًا إضافيًا على الخادم وتأخيرًا إضافيًا في المتوسط ​​TimerInterval / 2. للتغلب على ذلك، تم اختراع خدعة تسمى Long Polling، والتي تتضمن تأخير الاستجابة من الخادم حتى تنتهي المهلة أو يقع حدث ما. إذا حدث الحدث، فسيتم معالجته، وإذا لم يكن الأمر كذلك، فسيتم إرسال الطلب مرة أخرى.

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

لكن مثل هذا الحل سيثبت أنه فظيع بمجرد زيادة عدد العملاء الذين ينتظرون الحدث، لأن... يشغل كل عميل موضوعًا كاملاً في انتظار حدث ما. نعم، ونحصل على تأخير إضافي بمقدار 1 مللي ثانية عند تشغيل الحدث، وهذا في أغلب الأحيان ليس مهمًا، ولكن لماذا نجعل البرنامج أسوأ مما يمكن أن يكون؟ إذا قمنا بإزالة Thread.Sleep(1)، فعبثًا سنقوم بتحميل نواة معالج واحدة خاملة بنسبة 100٪، وتدور في دورة عديمة الفائدة. باستخدام TaskCompletionSource، يمكنك بسهولة إعادة إنشاء هذا الرمز وحل جميع المشكلات المحددة أعلاه:

class LongPollingApi {

    private Dictionary<int, TaskCompletionSource<Msg>> tasks;

    public async Task<Msg> AcceptMessageAsync(int userId, int duration) {

        var cs = new TaskCompletionSource<Msg>();
        tasks[userId] = cs;
        await Task.WhenAny(Task.Delay(duration), cs.Task);
        return cs.Task.IsCompleted ? cs.Task.Result : null;
    }

    public void SendMessage(int userId, Msg m) {

        if (tasks.TryGetValue(userId, out var completionSource))
            completionSource.SetResult(m);
    }
}

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

عندما نتلقى طلبًا لرسالة، نقوم بإنشاء TaskCompletionSource ووضعه في القاموس، ثم ننتظر ما يحدث أولاً: انتهاء الفاصل الزمني المحدد أو استلام رسالة.

مهمة القيمة: لماذا وكيف

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

public async Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return val;
    return await RequestById(id);
}

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

public Task<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return Task.FromResult(val);
    return RequestById(id);
}

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

public ValueTask<string> GetById(int id) {

    if (cache.TryGetValue(id, out string val))
        return new ValueTask<string>(val);
    return new ValueTask<string>(RequestById(id));
}

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

TaskSchedulers: إدارة استراتيجيات إطلاق المهام

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

  • CurrentThreadTaskScheduler - ينفذ المهام على الموضوع الحالي
  • LimitedConcurrencyLevelTaskScheduler - يحد من عدد المهام التي يتم تنفيذها في وقت واحد بواسطة المعلمة N، والتي يتم قبولها في المُنشئ
  • OrderedTaskScheduler - يتم تعريفه على أنه LimitedConcurrencyLevelTaskScheduler(1)، لذلك سيتم تنفيذ المهام بالتسلسل.
  • WorkStealingTaskScheduler - تنفذ سرقة العمل نهج توزيع المهام. في الأساس هو ThreadPool منفصل. يحل مشكلة أن .NET ThreadPool عبارة عن فئة ثابتة، واحدة لجميع التطبيقات، مما يعني أن التحميل الزائد أو الاستخدام غير الصحيح في جزء واحد من البرنامج يمكن أن يؤدي إلى آثار جانبية في جزء آخر. علاوة على ذلك، من الصعب للغاية فهم سبب هذه العيوب. الذي - التي. قد تكون هناك حاجة لاستخدام WorkStealingTaskSchedulers المنفصلة في أجزاء من البرنامج حيث قد يكون استخدام ThreadPool عدوانيًا ولا يمكن التنبؤ به.
  • QueuedTaskScheduler - يسمح لك بتنفيذ المهام وفقًا لقواعد قائمة الانتظار ذات الأولوية
  • ThreadPerTaskScheduler — يقوم بإنشاء موضوع منفصل لكل مهمة يتم تنفيذها عليه. يمكن أن يكون مفيدًا للمهام التي تستغرق وقتًا طويلاً غير متوقع لإكمالها.

هناك تفصيل جيد مقالة حول TaskSchedulers على مدونة Microsoft.

لتصحيح الأخطاء بشكل ملائم لكل ما يتعلق بالمهام، يحتوي Visual Studio على نافذة المهام. في هذه النافذة، يمكنك رؤية الحالة الحالية للمهمة والانتقال إلى سطر التعليمات البرمجية الذي يتم تنفيذه حاليًا.

.NET: أدوات للعمل مع تعدد مؤشرات الترابط وعدم التزامن. الجزء 1

Plinq والفئة الموازية

بالإضافة إلى المهام وكل ما يقال عنها، هناك أداتان أكثر إثارة للاهتمام في .NET: PLinq (Linq2Parallel) والفئة Parallel. يعد الأول بالتنفيذ المتوازي لجميع عمليات Linq على سلاسل رسائل متعددة. يمكن تكوين عدد المواضيع باستخدام طريقة ملحق WithDegreeOfParallelism. لسوء الحظ، في أغلب الأحيان، لا يحتوي PLinq في وضعه الافتراضي على معلومات كافية حول الأجزاء الداخلية لمصدر البيانات الخاص بك لتوفير زيادة كبيرة في السرعة، ومن ناحية أخرى، فإن تكلفة المحاولة منخفضة جدًا: تحتاج فقط إلى استدعاء الأسلوب AsParallel قبل سلسلة أساليب Linq وإجراء اختبارات الأداء. علاوة على ذلك، من الممكن تمرير معلومات إضافية إلى PLinq حول طبيعة مصدر بياناتك باستخدام آلية الأقسام. يمكنك قراءة المزيد هنا и هنا.

توفر الفئة الثابتة المتوازية طرقًا للتكرار عبر مجموعة Foreach بالتوازي، وتنفيذ حلقة For، وتنفيذ عدة مفوضين في الاستدعاء المتوازي. سيتم إيقاف تنفيذ الخيط الحالي حتى اكتمال الحسابات. يمكن تكوين عدد المواضيع عن طريق تمرير ParallelOptions كوسيطة أخيرة. يمكنك أيضًا تحديد TaskScheduler وCancellationToken باستخدام الخيارات.

النتائج

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

الاستنتاجات:

  • أنت بحاجة إلى معرفة أدوات العمل مع الخيوط وعدم التزامن والتوازي من أجل استخدام موارد أجهزة الكمبيوتر الحديثة.
  • يحتوي .NET على العديد من الأدوات المختلفة لهذه الأغراض
  • لم تظهر جميعها مرة واحدة، لذلك يمكنك غالبًا العثور على واجهات برمجة التطبيقات القديمة، ومع ذلك، هناك طرق لتحويل واجهات برمجة التطبيقات القديمة دون بذل الكثير من الجهد.
  • يتم تمثيل العمل مع سلاسل الرسائل في .NET بواسطة فئتي Thread وThreadPool
  • تعد أساليب Thread.Abort وThread.Interrupt وWin32 API TerminateThread خطيرة ولا يوصى باستخدامها. بدلاً من ذلك، من الأفضل استخدام آلية CancellationToken
  • يعد التدفق موردًا قيمًا وإمداداته محدودة. يجب تجنب المواقف التي تكون فيها سلاسل الرسائل مشغولة بانتظار الأحداث. لهذا من الملائم استخدام فئة TaskCompletionSource
  • أقوى أدوات .NET وأكثرها تقدمًا للعمل مع التوازي وعدم التزامن هي المهام.
  • يقوم مشغلو c# async/await بتنفيذ مفهوم الانتظار غير المحظور
  • يمكنك التحكم في توزيع المهام عبر سلاسل الرسائل باستخدام الفئات المشتقة من TaskScheduler
  • يمكن أن تكون بنية ValueTask مفيدة في تحسين المسارات الساخنة وحركة مرور الذاكرة
  • توفر نوافذ المهام والمواضيع في Visual Studio الكثير من المعلومات المفيدة لتصحيح أخطاء التعليمات البرمجية متعددة الخيوط أو غير المتزامنة
  • تعد PLinq أداة رائعة، ولكنها قد لا تحتوي على معلومات كافية حول مصدر بياناتك، ولكن يمكن إصلاح ذلك باستخدام آلية التقسيم
  • يتبع ...

المصدر: www.habr.com

إضافة تعليق