جدول قرار عملي لـ 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. |
المحتويات
- الخلاصة أوّلاً (في جملة)
- الكلمات المستخدمة في هذا المقال
- 2.1. كلمتان تُميَّزان أوّلاً
- 2.2. كلمات تظهر كثيراً
- جدول القرار الذي يُنظَر إليه أوّلاً
- 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.1. القيمة المُرجَعة أوّلاً Task / Task<T>
- 4.2. async void لمعالج الحدث فقط
- 4.3. اقبل CancellationToken ومرّره إلى الأسفل
- 4.4. صل الواجهة غير المتزامنة حتى النهاية
- 4.5. عند صنع مهامّ بـ LINQ ثبّت بـ ToArray / ToList
- أنماط مضادّة شائعة
- قائمة فحص عند المراجعة
- تقسيم تقريبي للاستخدام
- الخلاصة
- مراجع
في المخطّط، يشير الخطّ المتّصل إلى علاقة قائمة دائماً، ويشير الخطّ المتقطّع إلى علاقة مشروطة (شروط قيامها مذكورة في شرح كلّ علاقة في الصفحة التفصيليّة). القائمة الكاملة للعلاقات (المجموع 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 كيفما اتّفق».
أوّلاً انظر في هذه الثلاثة:
- ماذا تنتظر تلك المعالجة
- من يملك عمر تلك المعالجة
- أين تُتحكَّم درجة التنفيذ المتزامن
بهذا يقلّ الحيرة كثيراً.
flowchart TB
accTitle: ثلاثة أسئلة تقلّل الحيرة
accDescr: يبيّن أنّ النظر بالترتيب في ماذا تنتظر المعالجة، ومن يملك عمرها، وأين تُتحكَّم درجة التنفيذ المتزامن، يقلّل حيرة كتابة async/await.
q1["ماذا تنتظر؟"] --> q2["من يملك العمر؟"]
q2 --> q3["أين تُتحكَّم درجة التنفيذ المتزامن؟"]
q3 --> less["حيرة الكتابة تقلّ كثيراً"]
q1 -.-> avoid["تجنّب Task.Run كيفما اتّفق"]
الشكل 1: لا تختر «كيفما اتّفق». انظر أوّلاً في نوع الانتظار والعمر ودرجة التنفيذ المتزامن.
2. الكلمات المستخدمة في هذا المقال
2.1. كلمتان تُميَّزان أوّلاً
تمييز هاتين أوّلاً يقلّل الالتباس كثيراً.
| الكلمة | معناها هنا |
|---|---|
| I/O-bound | معالجة مركزها انتظار اكتمال خارجي مثل HTTP وقاعدة البيانات والملفّ والمقبس |
| CPU-bound | معالجة مركزها حساب CPU نفسه مثل الضغط ومعالجة الصورة وحساب الـ hash والتحويل الثقيل |
async / await ينفع خصوصاً في انتظار I/O، إذ يُعاد الخيط إلى عمل آخر أثناء الانتظار. أمّا حساب CPU فليس «انتظاراً» بل زمن حساب فعلي، فيصير الموضوع على أيّ خيط يُشغَّل وكيف تُقرَّر درجة التوازي.
flowchart TB
accTitle: الفرق بين I/O-bound و CPU-bound
accDescr: يبيّن التمييز: I/O-bound الذي مركزه انتظار اكتمال خارجي يعيد الخيط إلى عمل آخر أثناء await فينفع فيه async/await خصوصاً، في حين أنّ CPU-bound الذي مركزه الحساب نفسه موضوعه على أيّ خيط يُشغَّل وكيف تُقرَّر درجة التوازي.
io["I/O-bound (انتظار اكتمال خارجي)"] --> e1["أثناء الانتظار يُعاد الخيط إلى عمل آخر"]
e1 --> fit["async/await ينفع خصوصاً"]
cpu["CPU-bound (الحساب نفسه)"] --> e2["الموضوع على أيّ خيط يُشغَّل"]
e2 --> par["تقرير درجة التوازي موضوع أيضاً"]
الشكل 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)تعيين يجيز تنفيذ التتمّة دون العودة إلى ذلك السياق الممسوك
flowchart TB
accTitle: اختلاف موضع عودة تتمّة await
accDescr: يبيّن أنّ await يمسك SynchronizationContext لحظة دخول الانتظار ويعيد التتمّة إليه، ففي WinForms/WPF تعود إلى خيط الواجهة فتستطيع لمس عناصر التحكّم، أمّا ASP.NET Core فلا موضع عودة وتعمل التتمّة على تجمّع الخيوط.
aw["await يمسك السياق"] --> ui["خيط واجهة WinForms أو WPF"]
aw --> asp["ASP.NET Core بلا موضع عودة"]
ui --> touch["بعد await تستطيع لمس الواجهة"]
asp --> pool["التتمّة تعمل على تجمّع الخيوط"]
الشكل 3: حديث ConfigureAwait(false) يتجمّع في نقطة واحدة: إلى أين تعود تتمّة await.
من هنا تخرج خلاصة 3.12: «في شيفرة الواجهة أطبيع ألّا تضع»، «في شيفرة تطبيق ASP.NET Core الفرق صغير وضعاً أو حذفاً»، «في مكتبة عامّة لا يُعرَف على أيّ جانب ستُشغَّل ثمّة قيمة للوضع». الخلفية الأوضح مجمّعة في ConfigureAwait FAQ في 9. مراجع.
الأهمّ خصوصاً أنّ غير المتزامن والتوازي شيئان مختلفان.
- غير المتزامن: حديث طريقة الانتظار
- التوازي: حديث التقدّم معاً
إن اختلط الاثنان رغبت في استخدام Task.Run في كلّ موضع.
هذا أوّل مفترق.
flowchart TB
accTitle: غير المتزامن والتوازي شيئان مختلفان
accDescr: يبيّن أنّ غير المتزامن حديث طريقة الانتظار والتوازي حديث التقدّم معاً، وأنّ اختلاطهما يرغّب في استخدام Task.Run في كلّ موضع، فهذا أوّل مفترق.
a["غير المتزامن (حديث طريقة الانتظار)"] -.-> mix["الاختلاط يؤدّي إلى كثرة Task.Run"]
p["التوازي (حديث التقدّم معاً)"] -.-> mix
mix --> fork["هذا أوّل مفترق"]
الشكل 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) |
لا تعتمد على سياق خاص بواجهة المستخدم / التطبيق |
flowchart TD
start["المعالجة المراد تنفيذها"] --> q1{"تنتظر I/O خارجيّاً؟"}
q1 -- "نعم" --> p1["await لواجهة async كما هي"]
q1 -- "لا" --> q2{"حساب CPU ثقيل؟"}
q2 -- "نعم" --> q3{"أين تُشغَّل؟"}
q3 -- "حدث واجهة / سطح مكتب" --> p2["انظر في Task.Run"]
q3 -- "طلب ASP.NET Core" --> p3["لا تغلف بـ Task.Run إن لزم فأخرج إلى عامل أو طابور آخر"]
q3 -- "worker / خلفيّة" --> p4["نفّذ في الموضع أو صرّح بدرجة التوازي"]
q2 -- "لا" --> q4{"تتعامل مع أعمال متعدّدة؟"}
q4 -- "انتظر حتّى ينتهي الكلّ" --> p5["Task.WhenAll"]
q4 -- "استخدم أوّل ما ينتهي" --> p6["Task.WhenAny"]
q4 -- "العناصر كثيرة" --> p7["Parallel.ForEachAsync أو SemaphoreSlim"]
q4 -- "تدفّق مرتّب" --> p8["Channel<T>"]
q4 -- "فاصل ثابت" --> p9["PeriodicTimer"]
q4 -- "تيّار تباعي" --> p10["IAsyncEnumerable<T>"]
الشكل 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 فمرّره كما هو إلى الأسفل
هذا مسار معتمد إلى حدّ بعيد.
flowchart TB
accTitle: الشكل الأساسي لانتظار I/O
accDescr: يبيّن أنّ أساس انتظار HTTP وقاعدة البيانات والملفّ البحث أوّلاً عن واجهة async ثمّ await كما هي، وأنّ تغليف I/O غير متزامن أصلاً بـ Task.Run مجرّد إعادة رمي إلى خيط آخر بلا فائدة.
need["انتظار HTTP وقاعدة البيانات والملفّ"] --> find["ابحث أوّلاً عن واجهة async"]
find --> aw["await كما هي"]
wrap["التغليف بـ Task.Run"] -.-> bad["إعادة رمي بلا فائدة"]
الشكل 6: أساس انتظار I/O «await لواجهة async كما هي». تجنّب التغليف بـ Task.Run.
3.3. إن ثقل حمل CPU فاختر أين يُستخدم Task.Run
ينفع Task.Run حين تريد إخراج حساب CPU عن الخيط الحالي.
مثلاً إن أدرت حساباً ثقيلاً كما هو في معالج حدث الواجهة توقّفت الشاشة.
هنا Task.Run صريح.
flowchart TB
accTitle: كيف ينفع Task.Run في الواجهة
accDescr: يبيّن الموضع النموذجي الذي ينفع فيه Task.Run: إدارة حساب ثقيل كما هو في معالج حدث الواجهة توقف الشاشة، وإخراج حساب CPU عن خيط الواجهة بـ Task.Run يحفظ استجابة الشاشة.
heavy["إدارة حساب ثقيل في حدث الواجهة"] -.-> freeze["توقّف الشاشة"]
run["الإخراج عن خيط الواجهة بـ Task.Run"] --> keep["الشاشة تظلّ تستجيب"]
الشكل 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في تطبيق الواجهة لأنّ ثمّة خيط واحد خاص ينبغي إخلاؤه (خيط الواجهة). في جانب الخادم لا يوجد ذلك الواحد. الخيط الأصلي يُحرَّر فعلاً، لكنّ خيطاً آخر يُملأ بالحساب للمدّة نفسها، فالصافي صفر
ما يبقى تكلفة الوضع في الطابور وتبديل الخيوط، وأنّ «على أيّ خيط يعمل» يصير أقلّ وضوحاً بدرجة. لذلك يُتجنَّب.
flowchart TB
accTitle: سبب تجنّب Task.Run في ASP.NET Core
accDescr: يبيّن أنّ وضع Task.Run في معالجة الطلب لا يغيّر مجموع الحساب ولا يوجد خيط خاص ينبغي إخلاؤه كما في الواجهة فالصافي صفر، وما يبقى تكلفة التبديل وصعوبة القراءة فقط.
tr["وضع Task.Run في معالجة الطلب"] --> r1["مجموع الحساب لا يتغيّر"]
tr --> r2["لا خيط واحد خاص ينبغي إخلاؤه"]
r1 --> zero["الصافي صفر"]
r2 --> zero
zero --> cost["تبقى تكلفة التبديل وصعوبة القراءة"]
الشكل 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() يبدأ كلّ المهامّ عند تلك اللحظة.
sequenceDiagram
participant Caller as المستدعي
participant T1 as Task 1
participant T2 as Task 2
participant T3 as Task 3
Caller->>T1: البدء
Caller->>T2: البدء
Caller->>T3: البدء
Caller->>Caller: await Task.WhenAll(...)
T1-->>Caller: الاكتمال
T2-->>Caller: الاكتمال
T3-->>Caller: الاكتمال
الشكل 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.
أوضح أن تختاره فقط حين «يكفي الأوّل وحده».
flowchart TB
accTitle: تدفّق إيقاف البقيّة بعد التحقّق من الفائز في WhenAny
accDescr: يبيّن أنّ ما يرجعه Task.WhenAny هو أوّل مهمّة اكتملت لا أوّل مهمّة نجحت، لذا تُنتظَر الاكتمالات واحدة بـ await وتُلغى البقيّة عند النجاح فقط، وعند الفشل تُخرَج من المرشّحين ويُنتظَر الاكتمال التالي، وعند فناء الكلّ تُطرَح الإخفاقات مجتمعة.
any["الحصول على أوّل اكتمال بـ WhenAny"] --> chk{"هل نجحت تلك المهمّة؟"}
chk -->|"نجاح"| win["إلغاء البقيّة والإرجاع"]
chk -->|"فشل"| next["الإخراج من المرشّحين وتسجيل الفشل"]
next --> rest{"هل بقي مرشّحون؟"}
rest -->|"نعم"| any
rest -->|"لا"| agg["طرح الإخفاقات مجتمعة"]
الشكل 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
لا يخطئ كثيراً.
flowchart TB
accTitle: تقسيم طريقة جمع التوازي حسب عدد العناصر
accDescr: يبيّن أنّ بضعة معالجات مستقلّة يجوز تشغيلها كلّها معاً بـ Task.WhenAll، لكنّ كثرة العناصر تزيد دفعة واحدة الاتّصال والذاكرة والحمل الخارجي، لذا يُقرَّر حدّ أعلى لدرجة التنفيذ المتزامن بـ Parallel.ForEachAsync أو SemaphoreSlim.
q{"هل عدد العناصر كبير؟"}
q -->|"بضعة تقريباً"| all["دفعة واحدة بـ Task.WhenAll"]
q -->|"كثير"| limit["تقرير حدّ أعلى لدرجة التوازي"]
limit --> pfe["Parallel.ForEachAsync"]
limit --> sem["تحكّم أحرّ بـ SemaphoreSlim"]
الشكل 11: المفصل هل يجوز رمي الكلّ دفعة واحدة. إن كثرت فصرّح بدرجة التنفيذ المتزامن.
3.7. إن أردت تدفّقاً مرتّباً فـ Channel<T>
قد تريد فصل عمل «لا يلزم انتهاؤه الآن لكن تريد معالجته بيقين» عن المستدعي. إرسال بريد، نقل سجلّ، معالجة لاحقة لـ Webhook، تحويل ملفّ، وغيرها.
إن رميت هنا Task.Run بلا انتظار صار غامضاً:
- أين تُرى الاستثناءات
- هل تنتظر عند الإنهاء
- إلى أيّ حد تقبل عند ازدياد العناصر
هذا النوع من العمل أسهل إدارة إن وُضع في طابور وعالجه مستهلك مخصّص بالترتيب.
flowchart LR
p["producer"] --> w["WriteAsync"]
w --> q{"هل في الطابور فراغ؟"}
q -- "نعم" --> c["الدخول إلى Channel"]
q -- "لا" --> b["الانتظار حتّى يفرغ"]
c --> d["المستهلك ReadAsync"]
d --> e["المعالجة بالترتيب بـ 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 الحقيقي».
flowchart TB
accTitle: مقابلة الرمي بلا انتظار وإدارة الطابور
accDescr: يبيّن أنّ الرمي بلا انتظار بـ Task.Run يجعل الاستثناء والإنهاء وعدد القبول غامضاً، في حين أنّ الوضع في طابور Channel واستهلاك BackgroundService بالترتيب يسهّل إدارة الاستثناء والإيقاف ودرجة التوازي والحدّ الأعلى.
ff["رمي بلا انتظار بـ Task.Run عارٍ"] -.-> vague["الاستثناء والإنهاء والحدّ غامضة"]
ch["الوضع في طابور Channel"] --> bs["BackgroundService يستهلك"]
bs --> mng["يمكن إدارة الاستثناء والإيقاف ودرجة التوازي"]
الشكل 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 معاً على مؤقّت واحد.
ثمّ إن طال زمن المعالجة عن الدورة لزم التعامل مع ذلك التأخير كتصميم.
المؤقّت لا يوازي من تلقاء نفسه ليلحق.
flowchart TB
accTitle: الحلقة الدوريّة لـ PeriodicTimer
accDescr: يبيّن حلقة تنتظر الدورة التالية بـ WaitForNextTickAsync ثمّ تنفّذ المعالجة بـ await وتعود إلى الانتظار، والإيقاف بـ CancellationToken، وأنّ تأخير معالجة أطول من الدورة يُعالَج كتصميم.
tick["الانتظار بـ WaitForNextTickAsync"] --> proc["تنفيذ المعالجة بـ await"]
proc --> tick
stop["الإيقاف بـ CancellationToken"] -.-> tick
proc -.-> warn["تأخير معالجة أطول من الدورة يُعالَج بالتصميم"]
الشكل 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> للقيمة المُرجَعة أوضح إن قرّرته بـ هل تستخدم بعد اكتمال الكلّ، أم بالترتيب الذي وصل.
flowchart TB
accTitle: شكل القيمة المُرجَعة يُقرَّر بطريقة استخدام النتيجة
accDescr: يبيّن طريقة القرار: إن استخدمت بعد اكتمال الكلّ فأرجع القائمة كلّها بـ Task، وإن استخدمت عنصراً عنصراً بترتيب الوصول فأرجع IAsyncEnumerable وعالج بـ await foreach.
q{"كيف تُستخدم النتيجة؟"}
q -->|"بعد اكتمال الكلّ"| list["إرجاع القائمة كلّها بـ Task"]
q -->|"بترتيب الوصول"| ae["التمرير بـ IAsyncEnumerable"]
ae --> each["المعالجة عنصراً عنصراً بـ await foreach"]
ae -.-> mem["لا تجميع لكلّ العناصر في الذاكرة"]
الشكل 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 عمليّ إلى حدّ بعيد.
flowchart TB
accTitle: شكل الإقصاء الذي يعبر await
accDescr: يبيّن أنّ الشيفرة التي تعبر await تستخدم SemaphoreSlim بدل lock، وأنّ النقطتين المهمّتين الدخول بـ WaitAsync وتنفيذ معالجة تشمل await ثمّ استدعاء Release حتماً في finally.
lk["lock لا يعبر await"] -.-> alt["بدلاً منه SemaphoreSlim"]
wait["الدخول بـ WaitAsync"] --> crit["تنفيذ معالجة تشمل await"]
crit --> rel["Release حتماً في finally"]
الشكل 16: المدخل WaitAsync والمخرج Release في finally. عدم كسر هذا الزوج هو الجوهر.
3.12. فرّق كتابة await بين واجهة المستخدم وشيفرة التطبيق والمكتبة
ConfigureAwait(false) ليس شيئاً يُوضَع في كلّ موضع فيصلح.
التقسيم التقريبي هكذا.
flowchart LR
a["شيفرة واجهة / تطبيق"] --> b["await someAsync()"]
b --> c["العودة إلى السياق الأصلي والمتابعة"]
d["مكتبة عامّة"] --> e["await someAsync().ConfigureAwait(false)"]
e --> f["بلا افتراض العودة إلى سياق معيّن"]
الشكل 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, "خطأ الحفظ");
}
}
في معالج الحدث مهمّ وعي أن تكتب بنفسك حتّى إمساك الاستثناء داخلاً وإعادته إلى جانب الواجهة.
flowchart TB
accTitle: سبب تجنّب async void والاستثناء الوحيد
accDescr: يبيّن أنّ async void لا يستطيع المستدعي await ولا انتظار الاكتمال وتصعب معالجة الاستثناءات والاختبار فيُتجنَّب في الدوالّ العاديّة، ويُستخدم فقط في معالج الحدث الذي يحتاج void في التوقيع، وفيه try/catch وإعادة الاستثناء إلى جانب الواجهة.
av["دالة async void"] --> p1["لا تستطيع await"]
av --> p2["لا تستطيع انتظار الاكتمال"]
av --> p3["الاستثناء والاختبار صعبان"]
ev["معالج الحدث استثناء"] -.-> duty["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
هذا الفرق يسهل أن يصير خللاً لاحقاً، فاستقراره يقرَّر أوّلاً.
flowchart TB
accTitle: نشر CancellationToken
accDescr: يبيّن أنّ تمرير CancellationToken المقبول في الأعلى كما هو إلى واجهة الأسفل يوقف في الأثناء أيضاً، وأنّ القبول دون التمرير يصير شيفرة تبدو قابلة للتوقّف ولا تتوقّف في الأثناء.
up["قبول token في الأعلى"] --> pass["التمرير كما هو إلى واجهة الأسفل"]
pass --> stop["توقّف سليم في الأثناء أيضاً"]
nopass["القبول دون التمرير"] -.-> fake["تبدو متوقّفة ولا تتوقّف"]
الشكل 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()، لذا قلّ كثيراً سبب المزامنة عنوة حتّى في تطبيق وحدة التحكّم.
flowchart TB
accTitle: استبدال لا يخلط انتظاراً متزامناً
accDescr: يبيّن أنّ استخدام async/await يصل غير متزامن حتّى النهاية، وأنّ Result و Wait يُستبدلان بـ await و Thread.Sleep بـ Task.Delay، وأنّ اختلاط كتابة انتظار متزامن يصعّب قراءة شكل الانسداد.
chain["الوصل غير المتزامن حتّى النهاية"] --> r1["Result و Wait إلى await"]
chain --> r2["Thread.Sleep إلى Task.Delay"]
mix["اختلاط انتظار متزامن"] -.-> clog["شكل الانسداد يصعب قراءته"]
الشكل 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 |
من هذا الجدول، الثلاثة الأكثر مشاهدة في العمل هي:
Task.Runرغم أنّه I/O- await متسلسل رغم الاستقلال الحقيقي
- لا إدارة عمر لـ fire-and-forget
إصلاح هذه الثلاثة وحدها يحسّن وضوح الشيفرة كثيراً.
flowchart TB
accTitle: ثلاثة إصلاحات تُرى خصوصاً في العمل
accDescr: يبيّن أنّ إصلاح ثلاثة تُرى خصوصاً في العمل يحسّن الوضوح: التغليف بـ Task.Run رغم أنّه I/O، و await المتسلسل رغم الاستقلال الحقيقي، وترك عمر fire-and-forget، كلّ إلى موضع استبداله.
a1["Task.Run رغم أنّه I/O"] --> f1["await مباشر لواجهة async"]
a2["await متسلسل لمعالجة مستقلّة"] --> f2["البدء أوّلاً ثمّ WhenAll"]
a3["ترك عمر fire-and-forget"] --> f3["إلى Channel أو BackgroundService"]
f1 --> better["الوضوح يتحسّن كثيراً"]
f2 --> better
f3 --> better
الشكل 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 في العمل تنفع أكثر كترتيب اختيار الشكل وفق نوع المعالجة من حفظ تقنيّات دقيقة كثيرة.
ترتيب النظر تقريباً هكذا.
- ميّز انتظار I/O عن حساب CPU
- إن كان I/O فـ
awaitلواجهة async كما هي - إن كان حساب CPU فقرّر أين ينبغي التشغيل
- إن كانت معالجات متعدّدة فاختر
WhenAll/WhenAny/ حدّ درجة التوازي - إن أخرجت عن عمر الطلب فضع في طابور لا fire-and-forget عارٍ
- وحّد التعامل مع القيمة المُرجَعة والإلغاء والاستثناء والإقصاء والسياق
async / await كتابته نفسها موجزة، فإن استُخدم بلا عناية صعب رؤية الاتجاه. بالمقابل،
- عامل I/O كـ I/O
- عامل CPU كـ CPU
- أدِر عمر المعالجة الخلفيّة كمعالجة خلفيّة
تمييز هذه الثلاثة وحدها يزيد سهولة القراءة كثيراً.
9. مراجع
- مجموعة شيفرة العيّنة لهذا المقال (مكتبة، عرض، اختبارات وحدة) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-async-await-best-practices
- Asynchronous programming scenarios - C#
- Asynchronous programming with async and await
- Task-based Asynchronous Pattern (TAP) in .NET
- ConfigureAwait FAQ
- Parallel.ForEachAsync Method
- Task.WaitAsync Method
- System.Threading.Channels library
- Create a Queue Service
- Background tasks with hosted services in ASP.NET Core
- Generate and consume async streams
- Implement a DisposeAsync method
- ValueTask Struct
- CA2012: Use ValueTasks correctly
مقالات ذات صلة
أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.
أفضل الممارسات العمليّة لتعدّد مؤشّرات الترابط: نسخة .NET ── ما تقرِّره قبل إضافة مؤشّرات ترابط
قواعد تصميم عمليّة تمنع شيفرة .NET/C# متعدّدة مؤشّرات الترابط من الانهيار أو التجمّد أحياناً: اركب على Task بدل إنشاء المؤشّرات بنفسك، قل...
كيف نفهم عزل الجلسات في Windows ── Session 0 وRDP وتشغيل عدة مستخدمين معاً
نوضح مفهوم «الجلسة» الذي يربك كثيراً من مطوّري تطبيقات Windows. نشرح عملياً سبب عزل Session 0 الذي يمنع الخدمة من عرض واجهة، وسلوك الجلسا...
كيف تختار وسيلة الاتّصال بين عمليّات Windows ── جدول قرار للأنابيب المسمّاة / TCP / gRPC / الذاكرة المشتركة / COM
كيف تختار وسيلة التواصل بين تطبيقات Windows؟ ننظّم في جدول قرار مجالات تفوّق ومطبّات الأنابيب المسمّاة، وTCP المحليّ، وgRPC، والذاكرة الم...
كيف تختار مكان حفظ بيانات تطبيق Windows محليّاً ── جدول قرار بين SQLite وJSON وRegistry وAccess
أين ينبغي حفظ بيانات تطبيق سطح مكتب Windows، وبأيّ صيغة؟ نرتّب الفرق بين AppData وProgramData، ونقاط قوّة وعيوب كلّ من SQLite وملفّات JSO...
ما هو Generic Host في .NET - أساس DI والإعدادات والسجلات
ننظّم دور Generic Host من علاقة DI والإعدادات والسجلات و IHostedService و BackgroundService، ونلخّص أين يؤثّر وأين يصير زائداً من منظور ع...
أين يتصل هذا الموضوع
ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.
تطوير تطبيقات ويندوز
في تطبيقات Windows التي تشمل واجهة المستخدم والمعالجة الخلفية وعمليات I/O، يتّصل حسن استخدام async/await مباشرة بجودة التنفيذ.
الاستشارات التقنية ومراجعة التصميم
إن أردت ترتيب قرارات استخدام Task.Run وConfigureAwait جنباً إلى جنب مع تقسيم المسؤوليات، فذلك يقود إلى الاستشارة التقنية ومراجعة التصميم.
الأسئلة الشائعة
أسئلة شائعة حول موضوع هذه المقالة.
- متى ينبغي استخدام 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 داخل المعالج وتمسك الاستثناء وتعيده إلى جانب الواجهة.
الملف الشخصي للمؤلف
صفحة الملف الشخصي لمؤلف المقالة.
غو كومورا
مؤسّس شركة كومورا سوفت ذ.م.م.
يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.