جدول قرار عملي لـ C# async/await - Task.Run و ConfigureAwait

· آخر تحديث: · · C#, async/await, .NET, التصميم

سجل التعديلات (3 تحديثات، آخر تحديث 3 Sep، 2026)

سجل بالتغييرات التي أُجريت على هذا المقال. وحيثما حُفظت نسخة سابقة، تبقى متاحة للقراءة عبر رابط دائم يحمل معرّف DOI.

أُضيفت روابط الاستشارة الموجودة في الأصل الياباني (consultation_services). ولم يتغيّر نصّ المقالة نفسه. قراءة النسخة السابقة لهذا التحديث (DOI: 10.5281/zenodo.22240804)
أُعيدَت الترجمة العربية كترجمة كاملة عن النص الياباني الأصلي، وأُضيفَت خريطة المعرفة.
أعيدت الترجمة كترجمة كاملة عن النص الياباني الأصلي. كانت النسخة العربية السابقة مختصراً يسقط أبواباً وجداول ورسوم Mermaid وتعليقات الأشكال وFAQ. أُعيدت هذه العناصر وفق الأصل الياباني، والادّعاءات التقنية مطابقة للنسخة اليابانية.
النشر الأول
الاستشهاد بهذا المقال(DOI: 10.5281/zenodo.21621317)

هذا المقال محفوظ على Zenodo. يرد أدناه معرّف DOI الذي يشير دائمًا إلى أحدث نسخة، ومعرّف DOI المثبَّت على النسخة التي تقرؤها.

小村 豪 (2026). جدول قرار عملي لـ C# async/await - Task.Run و ConfigureAwait. شركة كومورا سوفت ذ.م.م.. https://doi.org/10.5281/zenodo.21621317 https://comcomponent.com/ar/blog/2026/03/09/001-csharp-async-await-best-practices/

DOI (أحدث نسخة)
10.5281/zenodo.21621317
DOI (هذه النسخة)
10.5281/zenodo.22279698

نستخدم async / await في C# يوميّاً، لكنّ ما يُحيّر في العمل ليس النحو نفسه بقدر أيّ كتابة تُختار في أيّ موضع. وما يكثر في البحث هو: متى يُستخدم Task.Run، وأين يُوضَع ConfigureAwait(false)، وهل يجوز fire-and-forget.

  • تغليف انتظار I/O بـ Task.Run
  • await متسلسل عنصراً عنصراً لمعالجة مستقلّة
  • إدخال fire-and-forget باستسهال ثمّ فقدان أثر الاستثناءات وتوقيت الإنهاء
  • وضع ConfigureAwait(false) بالطريقة نفسها في كلّ موضع
  • اختيار ValueTask فقط لأنّه «يبدو أخفّ»

هذه أسهل ترتيباً إن دخلت من تمييز نوع المعالجة أوّلاً لا من حفظ بنود متفرّقة.

يفترض هذا المقال أساساً تطوير تطبيقات C# / .NET عامّة على .NET 6 فما بعد، ويرتّب كتابات async / await بترتيب يسهّل القرار.

المفترض تطوير مثل:

  • تطبيقات سطح مكتب WinForms / WPF
  • تطبيقات ويب / API بـ ASP.NET Core
  • worker / خدمة خلفيّة
  • تطبيقات وحدة التحكّم
  • مكتبات فئات قابلة لإعادة الاستخدام

وشيفرة هذا المقال منشورة على GitHub كعيّنة كاملة قابلة للبناء والتشغيل (مكتبة، عرض وحدة تحكّم، واختبارات وحدة تتحقّق من كلّ نمط في جدول القرار).

csharp-async-await-best-practices - komurasoft-blog-samples (GitHub)

كيف تقرأ هذا المقال

المقال طويل إلى حدّ ما، لذا نضع أوّلاً مداخل حسب الغرض.

الغرض أين تقرأ
أريد جدول القرار فقط جدول وشكل 3.1. مركز المقال هنا
أريد طريقة كتابة كلّ نمط من 3.2 فصاعداً. تقابل صفوف جدول 3.1 واحداً لواحد
أريد مراجعة شيفرتي جدول الأنماط المضادّة في 5.
أريد توحيد زاوية المراجعة قائمة الفحص في 6.
الخلاصة فقط 1.

المحتويات

  1. الخلاصة أوّلاً (في جملة)
  2. الكلمات المستخدمة في هذا المقال
    • 2.1. كلمتان تُميَّزان أوّلاً
    • 2.2. كلمات تظهر كثيراً
  3. جدول القرار الذي يُنظَر إليه أوّلاً
    • 3.1. الصورة العامّة
    • 3.2. إن كان انتظار I/O فـ await لواجهة async كما هي
    • 3.3. إن ثقل حمل CPU فاختر أين يُستخدم Task.Run
    • 3.4. إن كانت معالجات مستقلّة متعدّدة فـ Task.WhenAll
    • 3.5. إن استخدمت أوّل ما ينتهي فـ Task.WhenAny
    • 3.6. إن كثرت العناصر وأردت حدّاً للتوازي فـ Parallel.ForEachAsync أو SemaphoreSlim
    • 3.7. إن أردت تدفّقاً مرتّباً فـ Channel<T>
    • 3.8. إن أردت إدارة بفاصل ثابت فـ PeriodicTimer
    • 3.9. إن وصلت البيانات تباعاً فـ IAsyncEnumerable<T>
    • 3.10. إن أردت تحريرًا غير متزامن فـ await using
    • 3.11. إن لزم إقصاء يعبر await فـ SemaphoreSlim
    • 3.12. فرّق كتابة await بين واجهة المستخدم وشيفرة التطبيق والمكتبة
  4. قواعد الكتابة الأساسيّة
    • 4.1. القيمة المُرجَعة أوّلاً Task / Task<T>
    • 4.2. async void لمعالج الحدث فقط
    • 4.3. اقبل CancellationToken ومرّره إلى الأسفل
    • 4.4. صل الواجهة غير المتزامنة حتى النهاية
    • 4.5. عند صنع مهامّ بـ LINQ ثبّت بـ ToArray / ToList
  5. أنماط مضادّة شائعة
  6. قائمة فحص عند المراجعة
  7. تقسيم تقريبي للاستخدام
  8. الخلاصة
  9. مراجع

في المخطّط، يشير الخطّ المتّصل إلى علاقة قائمة دائماً، ويشير الخطّ المتقطّع إلى علاقة مشروطة (شروط قيامها مذكورة في شرح كلّ علاقة في الصفحة التفصيليّة). القائمة الكاملة للعلاقات (المجموع 26، مع الأدلّة ودرجة اليقين) وتعريفات المفاهيم الرئيسة مجمّعة في صفحة تفاصيل خريطة المعرفة (باليابانية). البيانات: JSON-LD / Turtle

1. الخلاصة أوّلاً (في جملة)

  • async / await كتابة كي لا يُسدّ الخيط أثناء الانتظار، وليست آليّة تسريع تلقائي لكلّ شيء أو نقلاً تلقائيّاً إلى خيط آخر
  • ميّز أوّلاً أهي المعالجة انتظار I/O أم حساب CPU
  • إن كان انتظار I/O فالأساس await لواجهة async كما هي
  • إن كان حساب CPU فكّر أين ينبغي تشغيل ذلك الحساب. في الواجهة قد ينفع Task.Run، وفي معالجة طلب ASP.NET Core يُتجنَّب أساساً كتابة Task.Run ثمّ await فوراً
  • المعالجات المستقلّة المتعدّدة انظر أوّلاً في Task.WhenAll بدل await المتسلسل
  • إن كثرت العناصر فلا ترمِ الكلّ معاً بـ Task.WhenAll، بل قرّر حدّاً أعلى لدرجة التوازي
  • fire-and-forget يبدو سهلاً وإدارته صعبة. إن فصلت العمر عن المستدعي حقّاً، فأخرج إلى موضع مُدار مثل Channel أو HostedService أثبت
  • القيمة المُرجَعة أوّلاً Task / Task<T>. ValueTask يُختار بعد قياس وظهور الحاجة
  • ConfigureAwait(false) قوي في شيفرة مكتبة عامّة، وفي شيفرة الواجهة أو التطبيق يكفي await العادي أوّلاً
  • async void لا يُستخدم خارج معالج الحدث

بعبارة أخرى، أهمّ ما في محيط async / await ألّا تصير إلى «Task.Run كيفما اتّفق» و«fire-and-forget كيفما اتّفق» و«ValueTask كيفما اتّفق».

أوّلاً انظر في هذه الثلاثة:

  1. ماذا تنتظر تلك المعالجة
  2. من يملك عمر تلك المعالجة
  3. أين تُتحكَّم درجة التنفيذ المتزامن

بهذا يقلّ الحيرة كثيراً.

ثلاثة أسئلة تقلّل الحيرةيبيّن أنّ النظر بالترتيب في ماذا تنتظر المعالجة، ومن يملك عمرها، وأين تُتحكَّم درجة التنفيذ المتزامن، يقلّل حيرة كتابة async/await.ماذا تنتظر؟من يملك العمر؟أين تُتحكَّم درجة التنفيذ المتزامن؟حيرة الكتابة تقلّ كثيراًتجنّب Task.Run كيفما اتّفق

الشكل 1: لا تختر «كيفما اتّفق». انظر أوّلاً في نوع الانتظار والعمر ودرجة التنفيذ المتزامن.

2. الكلمات المستخدمة في هذا المقال

2.1. كلمتان تُميَّزان أوّلاً

تمييز هاتين أوّلاً يقلّل الالتباس كثيراً.

الكلمة معناها هنا
I/O-bound معالجة مركزها انتظار اكتمال خارجي مثل HTTP وقاعدة البيانات والملفّ والمقبس
CPU-bound معالجة مركزها حساب CPU نفسه مثل الضغط ومعالجة الصورة وحساب الـ hash والتحويل الثقيل

async / await ينفع خصوصاً في انتظار I/O، إذ يُعاد الخيط إلى عمل آخر أثناء الانتظار. أمّا حساب CPU فليس «انتظاراً» بل زمن حساب فعلي، فيصير الموضوع على أيّ خيط يُشغَّل وكيف تُقرَّر درجة التوازي.

الفرق بين I/O-bound و CPU-boundيبيّن التمييز: I/O-bound الذي مركزه انتظار اكتمال خارجي يعيد الخيط إلى عمل آخر أثناء await فينفع فيه async/await خصوصاً، في حين أنّ CPU-bound الذي مركزه الحساب نفسه موضوعه على أيّ خيط يُشغَّل وكيف تُقرَّر درجة التوازي.I/O-bound (انتظار اكتمال خارجي)أثناء الانتظار يُعاد الخيط إلى عمل آخرasync/await ينفع خصوصاًCPU-bound (الحساب نفسه)الموضوع على أيّ خيط يُشغَّلتقرير درجة التوازي موضوع أيضاً

الشكل 2: أوّل تمييز هو هذان. إن كان المركز انتظاراً أو حساباً تغيّر موضوع التفكير.

2.2. كلمات تظهر كثيراً

الكلمة معناها هنا
الحجب (blocking) شغل ذلك الخيط باستمرار أثناء انتظار الاكتمال
fire-and-forget طريقة تشغيل لا ينتظر فيها المستدعي الاكتمال
SynchronizationContext الآليّة التي تحمل «أين تُشغَّل تتمّة await». التفاصيل في التكميل أدناه
backpressure آليّة تنتظر جانب الكتابة عند فرط التدفق لتمنع الازدياد الزائد
IHostedService آليّة يستدعي فيها المضيف العامّ في .NET StartAsync عند البدء و StopAsync عند الإيقاف. مدخل معالجة مقيمة تتحرّك مع عمر التطبيق
BackgroundService صنف مجرّد ينفّذ IHostedService. يكفي تجاوز ExecuteAsync(CancellationToken) واحد لكتابة حلقة مقيمة. يُسجَّل بـ AddHostedService<T>() (3.7)

عند استخدام Channel<T>، موضع جانب الاستهلاك (المستهلك) هو هذا BackgroundService. في 3.7 نعالج شكل «الوضع في طابور ومستهلك مخصّص يعالج بالترتيب»، والعلاقة أنّ عمر ذلك المستهلك يُدار هنا وفق بدء التطبيق وإيقافه.

تكميل SynchronizationContext

حديث ConfigureAwait(false) (3.12) يتجمّع في فهم هذه الكلمة الواحدة.

  • await عند تنفيذ شيفرة التتمّة (الاستمرار) يمسك SynchronizationContext لحظة دخول الانتظار ويعيد التنفيذ إليه (إن لم يُضبَط SynchronizationContext ينظر هل استُخدم TaskScheduler غير الافتراضي)
  • WinForms / WPF يحملان SynchronizationContext يعيد المعالجة إلى خيط الواجهة. لذلك تلمس عناصر التحكّم عاديّاً بعد await
  • ASP.NET Core بلا SynchronizationContext. لذا لا «موضع عودة»، وتتمّة await تعمل كما هي على خيط تجمّع خيوط فارغ
  • ConfigureAwait(false) تعيين يجيز تنفيذ التتمّة دون العودة إلى ذلك السياق الممسوك
اختلاف موضع عودة تتمّة awaitيبيّن أنّ await يمسك SynchronizationContext لحظة دخول الانتظار ويعيد التتمّة إليه، ففي WinForms/WPF تعود إلى خيط الواجهة فتستطيع لمس عناصر التحكّم، أمّا ASP.NET Core فلا موضع عودة وتعمل التتمّة على تجمّع الخيوط.await يمسك السياقخيط واجهة WinForms أو WPFASP.NET Core بلا موضع عودةبعد await تستطيع لمس الواجهةالتتمّة تعمل على تجمّع الخيوط

الشكل 3: حديث ConfigureAwait(false) يتجمّع في نقطة واحدة: إلى أين تعود تتمّة await.

من هنا تخرج خلاصة 3.12: «في شيفرة الواجهة أطبيع ألّا تضع»، «في شيفرة تطبيق ASP.NET Core الفرق صغير وضعاً أو حذفاً»، «في مكتبة عامّة لا يُعرَف على أيّ جانب ستُشغَّل ثمّة قيمة للوضع». الخلفية الأوضح مجمّعة في ConfigureAwait FAQ في 9. مراجع.

الأهمّ خصوصاً أنّ غير المتزامن والتوازي شيئان مختلفان.

  • غير المتزامن: حديث طريقة الانتظار
  • التوازي: حديث التقدّم معاً

إن اختلط الاثنان رغبت في استخدام Task.Run في كلّ موضع. هذا أوّل مفترق.

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

الشكل 4: غير المتزامن طريقة انتظار، والتوازي تقدّم معاً. إن انهار هذا التمييز سهل إساءة استخدام Task.Run.

3. جدول القرار الذي يُنظَر إليه أوّلاً

3.1. الصورة العامّة

انظر أوّلاً في هذا الجدول فيتحدّد الاتجاه تقريباً.

الوضع ما يُستخدم أوّلاً نقطة النظر
انتظار HTTP / قاعدة بيانات / ملفّ وغيرها await لواجهة async كما هي لا تغلف بـ Task.Run
حساب ثقيل لا تريد إيقاف الواجهة Task.Run إخراج حساب CPU عن خيط الواجهة
معالجة طلب ASP.NET Core await عادي لا await فوراً بعد Task.Run
معالجات غير متزامنة مستقلّة قليلة Task.WhenAll ابدأ الكلّ أوّلاً ثمّ انتظر مجتمعاً
استخدام أوّل ما ينتهي فقط Task.WhenAny فكّر في إلغاء الباقي واسترداد الاستثناءات
عناصر كثيرة وتريد حدّاً أعلى Parallel.ForEachAsync / SemaphoreSlim صرّح بدرجة التوازي
معالجة خلفيّة تريد تدفّقها مرتّباً Channel<T> فكّر في طابور محدود و backpressure
معالجة غير متزامنة بفاصل ثابت PeriodicTimer احفظ مؤقّتاً واحداً ومستهلكاً واحداً
معالجة النتائج تباعاً IAsyncEnumerable<T> / await foreach تقدّم دون انتظار اكتمال الكلّ
يلزم تحرير غير متزامن await using استخدم IAsyncDisposable
إقصاء يعبر await SemaphoreSlim.WaitAsync Release حتماً في try/finally
شيفرة مكتبة عامّة انظر في ConfigureAwait(false) لا تعتمد على سياق خاص بواجهة المستخدم / التطبيق
نعملانعمحدث واجهة / سطح مكتبطلب ASP.NET Coreworker / خلفيّةلاانتظر حتّى ينتهي الكلّاستخدم أوّل ما ينتهيالعناصر كثيرةتدفّق مرتّبفاصل ثابتتيّار تباعيالمعالجة المراد تنفيذهاتنتظر I/O خارجيّاً؟await لواجهة async كما هيحساب CPU ثقيل؟أين تُشغَّل؟انظر في Task.Runلا تغلف بـ Task.Run إن لزم فأخرج إلى عامل أو طابور آخرنفّذ في الموضع أو صرّح بدرجة التوازيتتعامل مع أعمال متعدّدة؟Task.WhenAllTask.WhenAnyParallel.ForEachAsync أو SemaphoreSlimChannel&lt;T&gt;PeriodicTimerIAsyncEnumerable&lt;T&gt;

الشكل 5: الصورة العامّة للقرار. ميّز أوّلاً انتظار I/O عن حساب CPU، واختر الأداة لمعالجات متعدّدة حسب طريقة الجمع.

فيما يلي ننظر في كلّ نمط بالترتيب.

3.2. إن كان انتظار I/O فـ await لواجهة async كما هي

هذا النمط الأساس.

مثلاً HTTP وقاعدة البيانات وقراءة الملفّات وكتابتها: انظر أوّلاً هل توجد واجهة async. إن وُجدت فالأساس await لها كما هي.

public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await File.ReadAllTextAsync(path, cancellationToken);
}

ما يُتجنَّب هنا تغليف I/O غير متزامن أصلاً بـ Task.Run.

// 良くない例
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
    return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}

هذا مجرّد إعادة رمي انتظار I/O إلى خيط آخر، يصعّب الترتيب بلا فائدة.

  • انتظار I/O لا يحتاج Task.Run
  • ابحث أوّلاً عن واجهة async
  • إن قبلت token فمرّره كما هو إلى الأسفل

هذا مسار معتمد إلى حدّ بعيد.

الشكل الأساسي لانتظار I/Oيبيّن أنّ أساس انتظار HTTP وقاعدة البيانات والملفّ البحث أوّلاً عن واجهة async ثمّ await كما هي، وأنّ تغليف I/O غير متزامن أصلاً بـ Task.Run مجرّد إعادة رمي إلى خيط آخر بلا فائدة.انتظار HTTP وقاعدة البيانات والملفّابحث أوّلاً عن واجهة asyncawait كما هيالتغليف بـ Task.Runإعادة رمي بلا فائدة

الشكل 6: أساس انتظار I/O «await لواجهة async كما هي». تجنّب التغليف بـ Task.Run.

3.3. إن ثقل حمل CPU فاختر أين يُستخدم Task.Run

ينفع Task.Run حين تريد إخراج حساب CPU عن الخيط الحالي.

مثلاً إن أدرت حساباً ثقيلاً كما هو في معالج حدث الواجهة توقّفت الشاشة. هنا Task.Run صريح.

كيف ينفع Task.Run في الواجهةيبيّن الموضع النموذجي الذي ينفع فيه Task.Run: إدارة حساب ثقيل كما هو في معالج حدث الواجهة توقف الشاشة، وإخراج حساب CPU عن خيط الواجهة بـ Task.Run يحفظ استجابة الشاشة.إدارة حساب ثقيل في حدث الواجهةتوقّف الشاشةالإخراج عن خيط الواجهة بـ Task.Runالشاشة تظلّ تستجيب

الشكل 7: ينفع Task.Run حين يوجد خيط خاص ينبغي إخلاؤه (خيط الواجهة).

public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        cancellationToken.ThrowIfCancellationRequested();

        using var sha256 = System.Security.Cryptography.SHA256.Create();
        byte[] current = data;

        for (int i = 0; i < repeat; i++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            current = sha256.ComputeHash(current);
        }

        return current;
    }, cancellationToken);
}

غير أنّ المهمّ هنا من أين تُستدعى.

  • واجهة WinForms / WPF: ثمّة مواضع ينفع فيها Task.Run
  • معالجة طلب ASP.NET Core: كتابة Task.Run ثمّ await فوراً تُتجنَّب أساساً
  • worker / معالجة خلفيّة: نفّذ في الموضع أو صمّم درجة التوازي

وضع Task.Run طبقة واحدة في معالجة طلب ASP.NET Core ثمّ await فوراً يزيد جدولة زائدة في الغالب.

هذا سهل سوء الفهم، لذا نفصل السبب. ليس «يعمل على تجمّع الخيوط لذا Task.Run بلا معنى» (العمل على تجمّع الخيوط نفسه قائم أيضاً في معالجة خلفيّة لتطبيق واجهة). النقطتان هما:

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

ما يبقى تكلفة الوضع في الطابور وتبديل الخيوط، وأنّ «على أيّ خيط يعمل» يصير أقلّ وضوحاً بدرجة. لذلك يُتجنَّب.

سبب تجنّب Task.Run في ASP.NET Coreيبيّن أنّ وضع Task.Run في معالجة الطلب لا يغيّر مجموع الحساب ولا يوجد خيط خاص ينبغي إخلاؤه كما في الواجهة فالصافي صفر، وما يبقى تكلفة التبديل وصعوبة القراءة فقط.وضع Task.Run في معالجة الطلبمجموع الحساب لا يتغيّرلا خيط واحد خاص ينبغي إخلاؤهالصافي صفرتبقى تكلفة التبديل وصعوبة القراءة

الشكل 8: حتّى مع await فوري لـ Task.Run في جانب الخادم لا تحصل على معدّل إنجاز ولا تحرير انتظار.

لذا في ASP.NET Core أقوم أن تفكّر هكذا.

  • انتظار I/O: await عادي
  • حساب CPU قصير: نفّذ في الموضع
  • معالجة طويلة أو تريد فصلها عن عمر الطلب: أخرج إلى طابور أو HostedService

لاحظ أنّ استدعاء واجهة sync فقط من الواجهة قد يستخدم Task.Run لاستجابة الواجهة. غير أنّ هذا ليس «I/O غير متزامن» بل شغل خيط واحد للتهرّب فحسب. في جانب خادم مثل ASP.NET Core هذا المخرج لا يمتدّ أساساً بسهولة.

3.4. إن كانت معالجات مستقلّة متعدّدة فـ Task.WhenAll

كثيراً ما تظهر شيفرة تنتظر عنصراً عنصراً رغم وجود معالجات غير متزامنة مستقلّة متعدّدة.

// 独立しているのに直列になっている例
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);

إن لم يعتمد بعضها على بعض، فأصرح أن تبدأ الكلّ أوّلاً ثمّ تنتظر مجتمعاً أخيراً.

public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    Task<string>[] tasks = urls
        .Select(url => _httpClient.GetStringAsync(url, cancellationToken))
        .ToArray();

    return await Task.WhenAll(tasks);
}

النقطة هي ToArray(). LINQ تنفيذ مؤجَّل، فـ Select وحده قد يعني أنّ التعداد لم يتمّ بعد. التثبيت بـ ToArray() أو ToList() يبدأ كلّ المهامّ عند تلك اللحظة.

Task 3Task 2Task 1المستدعيTask 3Task 2Task 1المستدعيالبدءالبدءالبدءawait Task.WhenAll(...)الاكتمالالاكتمالالاكتمال

الشكل 9: المهامّ المستقلّة تُبدأ كلّها أوّلاً ثمّ يُنتظَر اكتمالها مجتمعاً بـ Task.WhenAll.

يناسب هذا النمط حين:

  • العناصر قليلة أو متوسّطة
  • تريد انتظار الكلّ مجتمعاً
  • لا مشكلة في التشغيل المتزامن بلا حدّ أعلى

إن كثرت العناصر فأأمن وضع حدّ أعلى لدرجة التوازي كما في 3.6 التالي.

3.5. إن استخدمت أوّل ما ينتهي فـ Task.WhenAny

مثلاً حين تريد استخدام أوّل من استجاب من مرايا متعدّدة، Task.WhenAny أوضح.

public async Task<byte[]> DownloadFromFirstMirrorAsync(
    IReadOnlyList<string> urls,
    CancellationToken cancellationToken)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    List<Task<byte[]>> pending = urls
        .Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
        .ToList();

    var failures = new List<Exception>();

    try
    {
        while (pending.Count > 0)
        {
            Task<byte[]> finished = await Task.WhenAny(pending);
            pending.Remove(finished);

            try
            {
                byte[] data = await finished;   // يُخرَج من هنا عند النجاح فقط
                cts.Cancel();                   // يُوقَف الباقي بعد تقرّر الفائز
                return data;
            }
            catch (Exception ex)
            {
                // إن أوقف المستدعي فليس ذلك «فشل مرآة».
                // إن تُرِك هذا يمرّ، تتكدّس إلغاءات كلّ المهامّ كفشل،
                // وتصير أخيراً AggregateException فلا تُميَّز عن العطل
                cancellationToken.ThrowIfCancellationRequested();

                // هذه المرآة فشلت. ما زال في البقيّة أمل فنتابع
                failures.Add(ex);
            }
        }
    }
    finally
    {
        cts.Cancel();   // يُوقَف التنزيل الباقي حتى عند الخروج باستثناء

        try
        {
            await Task.WhenAll(pending);
        }
        catch
        {
            // يُستَردّ إلغاء غير الفائز أو فشله
        }
    }

    throw new AggregateException("فشل الجلب من كلّ المرايا.", failures);
}

ما له معنى في الترتيب هنا أنّ الإلغاء يُصدَر «بعد تقرّر الفائز».

  • ما يرجعه Task.WhenAny هو أوّل مهمّة اكتملت، لا أوّل مهمّة نجحت. إن فشل أسرع مرآة بـ 404 أو انقطاع اتّصال، عاد ذلك أيضاً «فائزاً»
  • إن ألغيت قبل النظر في النتيجة، أوقفت بنفسك المرايا الباقية الحيّة ثمّ أعدت طرح استثناء الفائز الفاشل. أسوأ كسر يُفرغ معنى إعداد مرايا متعدّدة تماماً
  • لذا تُخرَج المهمّة المكتملة واحدة واحدة بـ await، وعند النجاح فقط تُلغى البقيّة. عند الفشل تُخرَج تلك المهمّة من المرشّحين وتُنتظَر الاكتمالات التالية
  • Cancel() يصدر الطلب فقط ولا ينتظر توقّف الطرف. لذا تنتظر البقيّة في finally وترصد هنا استثناءات الإلغاء أو الفشل. إن أُسقط هذا بقي استثناء لا يراه أحد في جانب المهمّة
  • عند فشل الكلّ تُطرَح الإخفاقات فرادى مجتمعة. طرح استثناء الأوّل وحده يُسقط «أيّ مرآة فشلت وكيف»
  • إلغاء المستدعي وحده لا يُعدّ فشلاً ويُطرَح إلى الخارج كما هو. إن سقط cancellationToken انتهت كلّ المهامّ بـ OperationCanceledException، فإن كُدِّس هذا في failures صار أخيراً AggregateException، ويُسجَّل انقطاع المستخدم أو المهلة ويعاد كـ «عطل كلّ المرايا». استدعِ ThrowIfCancellationRequested() في رأس catch، وأعد الإلغاء كما هو OperationCanceledException

ما ينبغي الانتباه إليه أنّ WhenAny يرجع فائزاً واحداً فقط. المعالجات الباقية إن لم تفعل شيئاً ظلّت تعمل كما هي.

لذا تحتاج أن تقرّر أوّلاً:

  • هل تريد إلغاء البقيّة
  • هل تريد رصد الاستثناءات

Task.WhenAny مريح، لكنّ التصميم يزيد قليلاً عن WhenAll. أوضح أن تختاره فقط حين «يكفي الأوّل وحده».

تدفّق إيقاف البقيّة بعد التحقّق من الفائز في WhenAnyيبيّن أنّ ما يرجعه Task.WhenAny هو أوّل مهمّة اكتملت لا أوّل مهمّة نجحت، لذا تُنتظَر الاكتمالات واحدة بـ await وتُلغى البقيّة عند النجاح فقط، وعند الفشل تُخرَج من المرشّحين ويُنتظَر الاكتمال التالي، وعند فناء الكلّ تُطرَح الإخفاقات مجتمعة.نجاحفشلنعملاالحصول على أوّل اكتمال بـ WhenAnyهل نجحت تلك المهمّة؟إلغاء البقيّة والإرجاعالإخراج من المرشّحين وتسجيل الفشلهل بقي مرشّحون؟طرح الإخفاقات مجتمعة

الشكل 10: «أوّل اكتمال» ليس «أوّل نجاح». تحقّق من نتيجة الفائز ثمّ أوقف البقيّة.

3.6. إن كثرت العناصر وأردت حدّاً للتوازي فـ Parallel.ForEachAsync أو SemaphoreSlim

Task.WhenAll يشغّل كلّ المهامّ المصنوعة معاً. لذا إن كثرت العناصر زاد دفعة واحدة اتّصال HTTP واتّصال قاعدة البيانات واستخدام الذاكرة والحمل على خدمة خارجيّة.

هنا أثبت أن تقرّر كم عنصراً يُشغَّل معاً.

Parallel.ForEachAsync نيّته سهلة القراءة إلى حدّ بعيد.

public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = 8,
        CancellationToken = cancellationToken
    };

    await Parallel.ForEachAsync(
        urls.Select((url, index) => (url, index)),
        options,
        async (item, token) =>
        {
            string html = await _httpClient.GetStringAsync(item.url, token);
            string path = Path.Combine("cache", $"{item.index}.html");
            await File.WriteAllTextAsync(path, html, token);
        });
}

يناسب هذا النمط حين:

  • العناصر كثيرة
  • معالجة كلّ عنصر مستقلّة
  • لكن تريد تجنّب الكلّ دفعة واحدة

ومن جهة أخرى إن أردت تحكّماً أحرّ ثمّة طريقة SemaphoreSlim. مثلاً تحكّم «واجهة خارجيّة معيّنة حتى 4 معاً».

أي أنّ التقسيم:

  • بضعة عناصر: Task.WhenAll
  • عناصر كثيرة: Parallel.ForEachAsync أو SemaphoreSlim

لا يخطئ كثيراً.

تقسيم طريقة جمع التوازي حسب عدد العناصريبيّن أنّ بضعة معالجات مستقلّة يجوز تشغيلها كلّها معاً بـ Task.WhenAll، لكنّ كثرة العناصر تزيد دفعة واحدة الاتّصال والذاكرة والحمل الخارجي، لذا يُقرَّر حدّ أعلى لدرجة التنفيذ المتزامن بـ Parallel.ForEachAsync أو SemaphoreSlim.بضعة تقريباًكثيرهل عدد العناصر كبير؟دفعة واحدة بـ Task.WhenAllتقرير حدّ أعلى لدرجة التوازيParallel.ForEachAsyncتحكّم أحرّ بـ SemaphoreSlim

الشكل 11: المفصل هل يجوز رمي الكلّ دفعة واحدة. إن كثرت فصرّح بدرجة التنفيذ المتزامن.

3.7. إن أردت تدفّقاً مرتّباً فـ Channel<T>

قد تريد فصل عمل «لا يلزم انتهاؤه الآن لكن تريد معالجته بيقين» عن المستدعي. إرسال بريد، نقل سجلّ، معالجة لاحقة لـ Webhook، تحويل ملفّ، وغيرها.

إن رميت هنا Task.Run بلا انتظار صار غامضاً:

  • أين تُرى الاستثناءات
  • هل تنتظر عند الإنهاء
  • إلى أيّ حد تقبل عند ازدياد العناصر

هذا النوع من العمل أسهل إدارة إن وُضع في طابور وعالجه مستهلك مخصّص بالترتيب.

نعملاproducerWriteAsyncهل في الطابور فراغ؟الدخول إلى Channelالانتظار حتّى يفرغالمستهلك ReadAsyncالمعالجة بالترتيب بـ await

الشكل 12: تدفّق Channel محدود. إن امتلأ الطابور انتظر جانب الكتابة فيسري backpressure.

Channel<T> يكتب شكل producer / consumer بصراحة كبيرة.

public sealed class BackgroundTaskQueue
{
    private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
        Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
            new BoundedChannelOptions(100)
            {
                FullMode = BoundedChannelFullMode.Wait
            });

    public ValueTask EnqueueAsync(
        Func<CancellationToken, ValueTask> workItem,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(workItem);
        return _queue.Writer.WriteAsync(workItem, cancellationToken);
    }

    public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
        => _queue.Reader.ReadAsync(cancellationToken);
}

BoundedChannelFullMode.Wait في هذا المثال إعداد إن امتلأ الطابور فانتظر جانب الكتابة. هذا هو backpressure.

في ASP.NET Core أوضح استهلاك مثل هذا الطابور مع BackgroundService. أسهل في معالجة الاستثناء والإيقاف ودرجة التوازي والحدّ الأعلى من «fire-and-forget الحقيقي».

مقابلة الرمي بلا انتظار وإدارة الطابوريبيّن أنّ الرمي بلا انتظار بـ Task.Run يجعل الاستثناء والإنهاء وعدد القبول غامضاً، في حين أنّ الوضع في طابور Channel واستهلاك BackgroundService بالترتيب يسهّل إدارة الاستثناء والإيقاف ودرجة التوازي والحدّ الأعلى.رمي بلا انتظار بـ Task.Run عارٍالاستثناء والإنهاء والحدّ غامضةالوضع في طابور ChannelBackgroundService يستهلكيمكن إدارة الاستثناء والإيقاف ودرجة التوازي

الشكل 13: إن فصلت العمر عن المستدعي فأخرج إلى موضع مُدار لا إلى fire-and-forget.

3.8. إن أردت إدارة بفاصل ثابت فـ PeriodicTimer

للمعالجة غير المتزامنة بفاصل ثابت، PeriodicTimer سهل القراءة إلى حدّ بعيد.

public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
    using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));

    while (await timer.WaitForNextTickAsync(cancellationToken))
    {
        await RefreshCacheAsync(cancellationToken);
    }
}

حسن هذه الكتابة:

  • أسهل تتبّع تدفّق من Timer بنمط callback
  • يمكن الكتابة على أساس await
  • عند الإيقاف يُستخدم CancellationToken بصراحة

ملاحظة: PeriodicTimer يُستخدم بافتراض ألّا تُطلَق عدّة WaitForNextTickAsync معاً على مؤقّت واحد. ثمّ إن طال زمن المعالجة عن الدورة لزم التعامل مع ذلك التأخير كتصميم. المؤقّت لا يوازي من تلقاء نفسه ليلحق.

الحلقة الدوريّة لـ PeriodicTimerيبيّن حلقة تنتظر الدورة التالية بـ WaitForNextTickAsync ثمّ تنفّذ المعالجة بـ await وتعود إلى الانتظار، والإيقاف بـ CancellationToken، وأنّ تأخير معالجة أطول من الدورة يُعالَج كتصميم.الانتظار بـ WaitForNextTickAsyncتنفيذ المعالجة بـ awaitالإيقاف بـ CancellationTokenتأخير معالجة أطول من الدورة يُعالَج بالتصميم

الشكل 14: أدِر بمؤقّت واحد ومستهلك واحد. المؤقّت لا يوازي من تلقاء نفسه ليلحق.

3.9. إن وصلت البيانات تباعاً فـ IAsyncEnumerable<T>

ثمّة مواضع تريد المعالجة بالترتيب ممّا وصل بدل تجميع الكلّ في List<T> ثمّ الإرجاع.

  • قراءة واجهة مقسَّمة صفحات بالترتيب
  • قراءة أسطر ملفّ تباعاً
  • تمرير نتيجة تيّار كما هي

هنا IAsyncEnumerable<T> و await foreach طبيعيّان.

public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
    await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
    {
        await ProcessUserAsync(user, cancellationToken);
    }
}

يناسب هذا الشكل حين:

  • لا تريد الانتظار حتّى يكتمل الكلّ
  • تريد المعالجة عنصراً عنصراً
  • لا تريد تجميع الكلّ في الذاكرة

الاختيار بين Task<List<T>> و IAsyncEnumerable<T> للقيمة المُرجَعة أوضح إن قرّرته بـ هل تستخدم بعد اكتمال الكلّ، أم بالترتيب الذي وصل.

شكل القيمة المُرجَعة يُقرَّر بطريقة استخدام النتيجةيبيّن طريقة القرار: إن استخدمت بعد اكتمال الكلّ فأرجع القائمة كلّها بـ Task، وإن استخدمت عنصراً عنصراً بترتيب الوصول فأرجع IAsyncEnumerable وعالج بـ await foreach.بعد اكتمال الكلّبترتيب الوصولكيف تُستخدم النتيجة؟إرجاع القائمة كلّها بـ Taskالتمرير بـ IAsyncEnumerableالمعالجة عنصراً عنصراً بـ await foreachلا تجميع لكلّ العناصر في الذاكرة

الشكل 15: اكتمال الكلّ أم التمرير بترتيب الوصول. طريقة الاستخدام تقرّر نوع القيمة المُرجَعة.

3.10. إن أردت تحريرًا غير متزامن فـ await using

النوع الذي يحتاج معالجة غير متزامنة عند التحرير مثل التفريغ وإنهاء الاتّصال ينفّذ IAsyncDisposable. عندئذٍ استخدم await using لا using.

public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
    await using var stream = new FileStream(
        path,
        FileMode.Create,
        FileAccess.Write,
        FileShare.None,
        bufferSize: 81920,
        useAsync: true);

    await stream.WriteAsync(data, cancellationToken);
}

النقاط:

  • إن كان IAsyncDisposable فـ await using
  • «الفتح» قد يكون متزامناً و«الإغلاق» غير متزامن، وهذا شائع

ينفع حين تريد تجنّب الانزياح «الكتابة صارت async والتحرير الأخير وحده متزامن».

3.11. إن لزم إقصاء يعبر await فـ SemaphoreSlim

في شيفرة تعبر await ثمّة مواضع تستخدم SemaphoreSlim بدل lock.

public sealed class CacheRefresher
{
    private readonly SemaphoreSlim _gate = new(1, 1);

    public async Task RefreshAsync(CancellationToken cancellationToken)
    {
        await _gate.WaitAsync(cancellationToken);
        try
        {
            await RefreshCoreAsync(cancellationToken);
        }
        finally
        {
            _gate.Release();
        }
    }

    private static Task RefreshCoreAsync(CancellationToken cancellationToken)
        => Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}

المهمّ نقطتان:

  • الدخول بـ WaitAsync
  • استدعاء Release حتماً في finally

في مواضع «عنصر واحد فقط معاً» أو «استدعاء واجهة خارجيّة حتى 3 معاً» SemaphoreSlim عمليّ إلى حدّ بعيد.

شكل الإقصاء الذي يعبر awaitيبيّن أنّ الشيفرة التي تعبر await تستخدم SemaphoreSlim بدل lock، وأنّ النقطتين المهمّتين الدخول بـ WaitAsync وتنفيذ معالجة تشمل await ثمّ استدعاء Release حتماً في finally.lock لا يعبر awaitبدلاً منه SemaphoreSlimالدخول بـ WaitAsyncتنفيذ معالجة تشمل awaitRelease حتماً في finally

الشكل 16: المدخل WaitAsync والمخرج Release في finally. عدم كسر هذا الزوج هو الجوهر.

3.12. فرّق كتابة await بين واجهة المستخدم وشيفرة التطبيق والمكتبة

ConfigureAwait(false) ليس شيئاً يُوضَع في كلّ موضع فيصلح.

التقسيم التقريبي هكذا.

شيفرة واجهة / تطبيقawait someAsync()العودة إلى السياق الأصلي والمتابعةمكتبة عامّةawait someAsync().ConfigureAwait(false)بلا افتراض العودة إلى سياق معيّن

الشكل 17: شيفرة جانب التطبيق await عادي وتعود إلى السياق الأصلي، والمكتبة العامّة تنظر في ConfigureAwait(false).

  • شيفرة واجهة / تطبيق
    • يكفي await العادي أوّلاً
    • إن نفّذت بعد await تحديث واجهة أو معالجة تعتمد على سياق التطبيق، فأطبيع ألّا تضع ConfigureAwait(false)
  • شيفرة تطبيق ASP.NET Core
    • عادة يكفي await العادي
    • لا حاجة لفرض ConfigureAwait(false) كعادة على الكلّ
  • شيفرة مكتبة عامّة
    • إن لم تعتمد على واجهة المستخدم أو نموذج التطبيق فـ ConfigureAwait(false) قوي

أي احفظ:

  • شيفرة جانب التطبيق await عادي
  • المكتبة العامّة تنظر في ConfigureAwait(false)

فلا تضيق في العمل عادة.

4. قواعد الكتابة الأساسيّة

4.1. القيمة المُرجَعة أوّلاً Task / Task<T>

قيمة دالة async المُرجَعة فكّر فيها بهذا الترتيب أوّلاً.

القيمة المُرجَعة التفكير الأوّل
Task أساس دالة async بلا قيمة راجعة
Task<T> أساس دالة async ترجع قيمة
ValueTask / ValueTask<T> اختر بعد قياس وظهور الحاجة

ValueTask يبدو مريحاً، لكنّه ليس دائماً أفضل من Task. هو بنية فله تكلفة نسخ، ولاستخدامه قيود أيضاً.

الأهمّ خصوصاً أنّ ValueTask مفترض أساساً على await مرّة واحدة. لا يناسب كتابة تحتفظ به باستسهال في متغيّر محلّي وتنتظره مرّات.

لذا في شيفرة تطبيق يوميّة يكفي أوّلاً Task / Task<T>.

ثمّ أوضح أن تضع لاحقة Async على اسم الدالة.

public Task SaveAsync(CancellationToken cancellationToken)
{
    return Task.CompletedTask;
}

public Task<int> CountAsync(CancellationToken cancellationToken)
{
    return Task.FromResult(_count);
}

كما أعلاه، إن لم توجد معالجة await فأصرح أن ترجع Task.CompletedTask أو Task.FromResult دون إلصاق async عنوة.

4.2. async void لمعالج الحدث فقط

async void أساسه التجنّب خارج معالج الحدث.

السبب بسيط:

  • المستدعي لا يستطيع await
  • لا يستطيع انتظار الاكتمال
  • تصعب معالجة الاستثناءات
  • يصعب الاختبار

معالج الحدث وحده يحتاج void فيُستخدم هناك فقط.

private async void SaveButton_Click(object? sender, EventArgs e)
{
    try
    {
        await SaveAsync(_saveCancellation.Token);
        _statusLabel.Text = "تمّ الحفظ.";
    }
    catch (OperationCanceledException)
    {
        _statusLabel.Text = "تمّ الإلغاء.";
    }
    catch (Exception ex)
    {
        MessageBox.Show(this, ex.Message, "خطأ الحفظ");
    }
}

في معالج الحدث مهمّ وعي أن تكتب بنفسك حتّى إمساك الاستثناء داخلاً وإعادته إلى جانب الواجهة.

سبب تجنّب async void والاستثناء الوحيديبيّن أنّ async void لا يستطيع المستدعي await ولا انتظار الاكتمال وتصعب معالجة الاستثناءات والاختبار فيُتجنَّب في الدوالّ العاديّة، ويُستخدم فقط في معالج الحدث الذي يحتاج void في التوقيع، وفيه try/catch وإعادة الاستثناء إلى جانب الواجهة.دالة async voidلا تستطيع awaitلا تستطيع انتظار الاكتمالالاستثناء والاختبار صعبانمعالج الحدث استثناءtry/catch والإعادة إلى الواجهة

الشكل 18: الدالة العاديّة ترجع Task / Task. السماح بـ async void لمعالج الحدث فقط.

4.3. اقبل CancellationToken ومرّره إلى الأسفل

إن كانت العملية قابلة للإلغاء فاقبل CancellationToken ومرّره كما هو إلى الأسفل.

public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
    using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync(cancellationToken);
}

الشائع هنا نمط يقبل token في الأعلى ولا يمرّره إلى الأسفل. فيصير غالباً شيفرة «تبدو قابلة للإلغاء ولا تتوقّف في الأثناء».

ثمّ إنّ المهلة يتغيّر معناها حسب «تريد حدّاً أعلى للانتظار فقط» أم «تريد إيقاف المعالجة الفعليّة نفسها أيضاً».

  • حدّ أعلى للانتظار فقط: WaitAsync
  • إيقاف المعالجة الفعليّة نفسها أيضاً: CancellationTokenSource.CancelAfter ونشر token

هذا الفرق يسهل أن يصير خللاً لاحقاً، فاستقراره يقرَّر أوّلاً.

نشر CancellationTokenيبيّن أنّ تمرير CancellationToken المقبول في الأعلى كما هو إلى واجهة الأسفل يوقف في الأثناء أيضاً، وأنّ القبول دون التمرير يصير شيفرة تبدو قابلة للتوقّف ولا تتوقّف في الأثناء.قبول token في الأعلىالتمرير كما هو إلى واجهة الأسفلتوقّف سليم في الأثناء أيضاًالقبول دون التمريرتبدو متوقّفة ولا تتوقّف

الشكل 19: إن قبلت token فمرّره حتّى الأسفل. نقص التمرير يولّد «إلغاء لا يتوقّف».

4.4. صل الواجهة غير المتزامنة حتى النهاية

إن استخدمت async / await فأصرح أن تصل غير متزامن حتّى النهاية قدر الإمكان.

دليل الاستبدال كالتالي.

الكتابة التي ترغب فيها موضع الاستبدال
Task.Result / Task.Wait() await
Task.WaitAll() await Task.WhenAll(...)
Task.WaitAny() await Task.WhenAny(...)
Thread.Sleep(...) await Task.Delay(...)

في الواجهة و ASP.NET Core خصوصاً، إن اختلطت كتابة انتظار متزامن صعب قراءة شكل الانسداد.

في C# الحالي يمكن أيضاً async Task Main()، لذا قلّ كثيراً سبب المزامنة عنوة حتّى في تطبيق وحدة التحكّم.

استبدال لا يخلط انتظاراً متزامناًيبيّن أنّ استخدام async/await يصل غير متزامن حتّى النهاية، وأنّ Result و Wait يُستبدلان بـ await و Thread.Sleep بـ Task.Delay، وأنّ اختلاط كتابة انتظار متزامن يصعّب قراءة شكل الانسداد.الوصل غير المتزامن حتّى النهايةResult و Wait إلى awaitThread.Sleep إلى Task.Delayاختلاط انتظار متزامنشكل الانسداد يصعب قراءته

الشكل 20: إن قرّرت استخدام async فلا تُسقِط في الأثناء إلى انتظار متزامن، وصل غير متزامن حتّى النهاية.

4.5. عند صنع مهامّ بـ LINQ ثبّت بـ ToArray / ToList

عند جمع Task.WhenAll أو Task.WhenAny مع LINQ، أأمن التثبيت مرّة بـ ToArray() أو ToList().

Task<User>[] tasks = userIds
    .Select(id => _userRepository.GetAsync(id, cancellationToken))
    .ToArray();

User[] users = await Task.WhenAll(tasks);

السبب أنّ LINQ تنفيذ مؤجَّل. قراءة «الكلّ بدأ بعد» ثمّ اكتشاف أنّ التعداد لم يتمّ بعد خطر متواضع.

  • انتظار الكلّ مجتمعاً: ToArray()
  • حذف أو استبدال في الأثناء: ToList()

احفظ هذا فيسهل التقسيم.

5. أنماط مضادّة شائعة

النمط المضادّ ما الذي يضايق الاستبدال الأوّل
Task.Run(async () => await IoAsync()) إعادة رمي انتظار I/O بلا فائدة await IoAsync()
Task.Result / Wait() يسدّ الخيط. يسهل الانسداد await
خلط Thread.Sleep() في تدفّق async يشغل الخيط أثناء الانتظار أيضاً Task.Delay()
استخدام async void في دالة عاديّة لا تنتظر، وتصعب إدارة الاستثناء Task / Task<T>
await متسلسل في موضع ينبغي فيه Task.WhenAll يتأخّر بلا داعٍ ابدأ الكلّ أوّلاً ثمّ WhenAll
رمي عناصر كثيرة دفعة بـ WhenAll يقفز الحمل Parallel.ForEachAsync / SemaphoreSlim
محاولة عبور await بـ lock لا يطابق الغرض SemaphoreSlim.WaitAsync
إنهاء fire-and-forget بـ Task.Run عارٍ إدارة الاستثناء والإيقاف والحدّ غامضة Channel<T> / BackgroundService
وضع ConfigureAwait(false) آليّاً في شيفرة الواجهة يسهل كسر تحديث الواجهة بعد await await عادي
جعل ValueTask المعيار كثيراً ما لا تخرج فائدة مقابل التعقيد أوّلاً Task

من هذا الجدول، الثلاثة الأكثر مشاهدة في العمل هي:

  1. Task.Run رغم أنّه I/O
  2. await متسلسل رغم الاستقلال الحقيقي
  3. لا إدارة عمر لـ fire-and-forget

إصلاح هذه الثلاثة وحدها يحسّن وضوح الشيفرة كثيراً.

ثلاثة إصلاحات تُرى خصوصاً في العمليبيّن أنّ إصلاح ثلاثة تُرى خصوصاً في العمل يحسّن الوضوح: التغليف بـ Task.Run رغم أنّه I/O، و await المتسلسل رغم الاستقلال الحقيقي، وترك عمر fire-and-forget، كلّ إلى موضع استبداله.Task.Run رغم أنّه I/Oawait مباشر لواجهة asyncawait متسلسل لمعالجة مستقلّةالبدء أوّلاً ثمّ WhenAllترك عمر fire-and-forgetإلى Channel أو BackgroundServiceالوضوح يتحسّن كثيراً

الشكل 21: من جدول الأنماط المضادّة، إصلاح هذه الثلاثة أوّلاً هو الأكثر أثراً.

6. قائمة فحص عند المراجعة

في مراجعة شيفرة محيط async / await أكّد تقريباً هذا من الأعلى.

  • هل يمكن شرح أنّ المعالجة I/O-bound أم CPU-bound بالكلام أوّلاً
  • هل بقي Task.Result / Task.Wait() / Thread.Sleep()
  • هل غُلِّف انتظار I/O بـ Task.Run
  • هل تُنتظَر معالجات مستقلّة بـ await متسلسل بلا داعٍ
  • وبالعكس، هل رُميت عناصر كثيرة بلا حدّ بـ WhenAll
  • إن قُبل CancellationToken فهل مُرِّر فعلاً إلى الأسفل
  • هل يوجد async void خارج معالج الحدث
  • إن وُضع fire-and-forget فهل تقرّر من يدير الاستثناء والإيقاف والحدّ الأعلى
  • إن استُخدم SemaphoreSlim فهل دخل Release في finally
  • إن استُخدم ValueTask فهل ثمّة سبب قياسي وهل هو مفترض على await مرّة واحدة
  • هل وجود ConfigureAwait(false) أو غيابه يطابق نوع تلك الشيفرة
    • شيفرة واجهة / تطبيق: await عادي
    • مكتبة عامّة: انظر في ConfigureAwait(false)

قائمة الفحص هذه سهلة الاستخدام أيضاً لتوحيد زاوية المراجعة في الفريق.

7. تقسيم تقريبي للاستخدام

قائمة التقسيم مجمّعة في جدول القرار في 3.1. أسهل البحث أن تعود إلى هناك عند الحاجة من أن نعيد الجدول نفسه، لذا لا نضع جدولاً هنا.

  • أريد رؤية «ما يُستخدم أوّلاً» حسب الوضع → جدول القرار في 3.1
  • أريد رؤية كتابة كلّ نمط → 3.2 إلى 3.12 (تقابل صفوف جدول 3.1)

حكم واحد غير داخل جدول 3.1. نوع القيمة المُرجَعة. هذا حديث تصميم الدالة لا الوضع، لذا جُمع في 4.1. الخلاصة فقط: اختر أوّلاً Task / Task<T>، و ValueTask بعد قياس وظهور الحاجة.

8. الخلاصة

أفضل ممارسات async / await في العمل تنفع أكثر كترتيب اختيار الشكل وفق نوع المعالجة من حفظ تقنيّات دقيقة كثيرة.

ترتيب النظر تقريباً هكذا.

  1. ميّز انتظار I/O عن حساب CPU
  2. إن كان I/O فـ await لواجهة async كما هي
  3. إن كان حساب CPU فقرّر أين ينبغي التشغيل
  4. إن كانت معالجات متعدّدة فاختر WhenAll / WhenAny / حدّ درجة التوازي
  5. إن أخرجت عن عمر الطلب فضع في طابور لا fire-and-forget عارٍ
  6. وحّد التعامل مع القيمة المُرجَعة والإلغاء والاستثناء والإقصاء والسياق

async / await كتابته نفسها موجزة، فإن استُخدم بلا عناية صعب رؤية الاتجاه. بالمقابل،

  • عامل I/O كـ I/O
  • عامل CPU كـ CPU
  • أدِر عمر المعالجة الخلفيّة كمعالجة خلفيّة

تمييز هذه الثلاثة وحدها يزيد سهولة القراءة كثيراً.

9. مراجع

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

متى ينبغي استخدام Task.Run في C#؟
ينفع Task.Run حين تريد إخراج حساب CPU عن الخيط الحالي. مثلاً إن أدرت حساباً ثقيلاً كما هو في معالج حدث WinForms / WPF توقّفت الشاشة، فإخراجه عن خيط الواجهة بـ Task.Run صريح. بالمقابل معالجة طلب ASP.NET Core تعمل أصلاً على ThreadPool، فوضع Task.Run ثمّ await فوراً يزيد جدولة زائدة في الغالب ويُتجنَّب أساساً. المعالجة الطويلة أو التي تريد فصلها عن عمر الطلب الأولى إخراجها إلى طابور أو HostedService.
ألا يجوز تغليف معالجة I/O بـ await Task.Run()؟
انتظار I/O مثل HTTP وقاعدة البيانات وقراءة الملفّات وكتابتها أساسه await لواجهة async كما هي، ولا حاجة لتغليف Task.Run. تغليف I/O غير متزامن أصلاً بـ Task.Run مجرّد إعادة رمي انتظار I/O إلى خيط آخر، يصعّب الترتيب بلا فائدة. نعم قد يُستخدم Task.Run من الواجهة لاستدعاء واجهة sync فقط حفاظاً على الاستجابة، لكنّ هذا ليس I/O غير متزامن بل شغل خيط واحد للتهرّب، وهو مخرج لا يمتدّ بسهولة في جانب الخادم.
أين يُوضَع ConfigureAwait(false)؟
في شيفرة واجهة المستخدم أو التطبيق يكفي await العادي أوّلاً. إن نفّذت بعد await تحديث واجهة أو معالجة تعتمد على سياق التطبيق، فمن الطبيعي ألّا تضع ConfigureAwait(false). شيفرة تطبيق ASP.NET Core أيضاً يكفي فيها await العادي عادة، ولا حاجة لفرضه كعادة على الكلّ. ConfigureAwait(false) قوي في شيفرة مكتبة عامّة لا تعتمد على واجهة المستخدم أو نموذج التطبيق. احفظ «جانب التطبيق await عادي، والمكتبة العامّة تنظر في ConfigureAwait(false)» فلا تضيق في العمل عادة.
لماذا يُتجنَّب async void خارج معالجات الأحداث؟
لأنّ المستدعي لا يستطيع await، ولا انتظار الاكتمال، وتصعب معالجة الاستثناءات، ويصعب الاختبار. الدوالّ العاديّة أساسها إرجاع Task أو Task<T>. معالج الحدث وحده يحتاج void في التوقيع فيُستخدم هناك فقط، وفي تلك الحالة مهمّ أن تكتب بنفسك try/catch داخل المعالج وتمسك الاستثناء وتعيده إلى جانب الواجهة.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

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

العودة إلى المدونة