Menu
flag Ar iconالعربيةdown icon

async وawait في C#: Task وTask<T> وWhenAll والأفخاخ الشائعة

تتيح async وawait لشيفرة C# انتظار العمليات البطيئة دون حجز خيط. تعلّم كيف تعمل الدالة غير المتزامنة، وTask وTask<T>، وتشغيل العمليات المستقلة في الوقت نفسه بـ Task.WhenAll، والاستثناءات في الشيفرة غير المتزامنة، ولماذا تسبّب async void و.Result أخطاءً وجمودًا.

تحتوي هذه الصفحة على محررات قابلة للتشغيل - حرّر، شغّل، وشاهد النتيجة فوراً.

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

أول دالة غير متزامنة

تعيد الدالة async كائن Task (بلا نتيجة) أو Task<T> (نتيجة من نوع T). وداخلها تنتظر await مهمة أخرى وتعطيك نتيجتها.

المخرجات:

Tea 2.50, cake 4.00, total 6.50

تقول GetPriceAsync الكلمات return 2.50m، ويغلّف المترجم ذلك في Task<decimal> التي تعيدها الدالة. ثم تفكّ await التغليف. وTask.Delay النسخة غير المتزامنة من Thread.Sleep: تكتمل بعد مرور الوقت، دون حجز خيط في أثناء ذلك.

منذ C# 7.1 يمكن أن تكون Main نفسها غير متزامنة، وهكذا ستكتب هذا البرنامج في .NET الحديثة:

static async Task Main()
{
    decimal tea = await GetPriceAsync("tea");
    Console.WriteLine(tea);
}

تستدعي الأمثلة القابلة للتشغيل في هذه الصفحة دالة غير متزامنة RunAsync من Main عادية بـ .GetAwaiter().GetResult()، وهو ما يولّده المترجم لـ Main غير متزامنة. في تطبيق كونسول هذا آمن؛ ويشرح قسم الجمود لماذا الاستدعاء نفسه خطر في شيفرة الواجهات.

ما الذي يحدث عند await

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

المخرجات:

Calling DownloadAsync
  Download: starting
DownloadAsync returned a task; doing other work
  Download: finished
Download awaited

تُطبع "Download: starting" قبل أن تعود DownloadAsync، لأن كل ما قبل أول await يُنفَّذ على خيط المستدعي. ثم تسلّم الدالة مهمة غير منتهية، ويواصل المستدعي، ولا تظهر "Download: finished" إلا حين ينتهي التأخير. استدعاء دالة غير متزامنة يبدؤها؛ وانتظارها هو طريقة انتظار النتيجة.

لا تنشئ async خيطًا. أثناء توقف الدالة لا يوجد خيط ينتظرها إطلاقًا، ولهذا يستطيع الخادم أن يملك آلاف الطلبات المنتظرة على قاعدة بيانات بحفنة من الخيوط فقط. وللعمل كثيف المعالجة الذي يجب أن يُنفَّذ على خيط آخر استخدم Task.Run، المشروحة في المهام.

تشغيل العمليات في الوقت نفسه بـ Task.WhenAll

انتظار استدعاء بعد آخر تسلسلي: لا يبدأ كل منها إلا بعد انتهاء السابق. حين لا تعتمد العمليات على بعضها ابدأها كلها ثم انتظرها معًا بـ Task.WhenAll:

مخرجات على سبيل المثال:

Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms

تستغرق النسخة التسلسلية مجموع الانتظارات الثلاثة، والمتزامنة نحو أبطئها. تعيد Task.WhenAll النتائج بترتيب المهام التي مرّرتها نفسه، أيًّا كانت التي انتهت أولًا. وتعمل مع قائمة أيضًا، وهذا الشكل المعتاد لـ "اجلب كل عنصر": await Task.WhenAll(ids.Select(id => FetchAsync(id))).

وTask.WhenAny هي النظيرة التي تكتمل بمجرد اكتمال أول مهمة، وهي مفيدة للمهل الزمنية ولـ "أول إجابة تفوز".

الاستثناءات في الشيفرة غير المتزامنة

الاستثناء المرمي داخل دالة غير متزامنة يُخزَّن في المهمة المعادة ويُعاد رميه حين تُنتظر المهمة، فتعمل try/catch عادية حول await:

المخرجات:

Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7

لم يرمِ استدعاء LoadProfileAsync(-1) استثناءً: يعيش الاستثناء في المهمة حتى ينتظرها شيء ما. والمهمة التي لا ينتظرها أحد أبدًا تخفي استثناءها كليًا، وهذا سبب آخر لانتظار كل ما تبدؤه دائمًا. ومع WhenAll تعيد await رمي الاستثناء الأول، بينما تحمل خاصية Exception في المهمة المجمّعة (AggregateException) كل الاستثناءات. وقراءة ok.Result مقبولة هناك لأن ok اكتملت أصلًا.

async void: لمعالجات الأحداث فقط

يمكن للدالة غير المتزامنة أيضًا أن تعيد void. تجنّب ذلك في كل مكان ما عدا معالجات الأحداث:

// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
    await db.InsertAsync(order);   // if this throws, the process may crash
}

// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
    await db.InsertAsync(order);
}

// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
    try { await SaveAsync(currentOrder); }
    catch (Exception ex) { ShowError(ex); }
}

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

خطأ مرتبط هو استدعاء دالة غير متزامنة دون await. يحذّر المترجم (CS4014)، وتعمل الدالة في الخلفية دون أن يراقب شيء نتيجتها أو أخطاءها.

الجمود بسبب .Result و.Wait()

الحجز على مهمة بـ .Result أو .Wait() هو حيث تخطئ الشيفرة غير المتزامنة أكثر من غيره. في تطبيق له سياق تزامن (WinForms وWPF وMAUI وASP.NET الكلاسيكي) يكون التسلسل:

  1. يستدعي خيط الواجهة GetDataAsync().Result ويُحجز، منتظرًا المهمة.
  2. داخل GetDataAsync تنتهي await. افتراضيًا يجب أن يُنفَّذ استكمالها على السياق الذي بدأت عليه: خيط الواجهة.
  3. خيط الواجهة محجوز في الخطوة 1، فلا يُنفَّذ الاستكمال أبدًا، فلا تكتمل المهمة أبدًا، فلا تنتهي الخطوة 1 أبدًا.

يتجمّد التطبيق دون أي استثناء. تطبيقات الكونسول وASP.NET Core ليس لها مثل هذا السياق، ولهذا تعمل الشيفرة نفسها في برنامج اختبار وتتعلق في تطبيق سطح مكتب. الإصلاحات:

  • استخدم await على طول سلسلة الاستدعاءات صعودًا بدل الحجز ("غير متزامن على طول الطريق"). ويمكن أن تكون معالجات الأحداث async void لهذا الغرض.
  • في شيفرة المكتبات التي لا تحتاج إلى العودة إلى سياق المستدعي اكتب await SomethingAsync().ConfigureAwait(false);. عندها يُنفَّذ الاستكمال على مجمّع الخيوط بدل السياق الملتقط، وتتخطى المكتبة تبديل خيط غير ضروري. ولا يحمي ذلك مستدعيًا حاجزًا إلا إن فعلت كل await في السلسلة الشيء نفسه، فعامله كنظافة جيدة للمكتبات، لا كإصلاح لـ .Result.

شيفرة التطبيقات في ASP.NET Core لا تحتاج إلى ConfigureAwait(false)، لأنه لا يوجد سياق للعودة إليه.

أخطاء شائعة

  • انتظار استدعاءات مستقلة واحدًا واحدًا. ابدأها، ثم await Task.WhenAll(...).
  • دوال async void. أعد Task؛ واحتفظ بـ async void لمعالجات الأحداث.
  • .Result و.Wait() في شيفرة الواجهات أو ASP.NET الكلاسيكي. تسبّبان الجمود؛ انتظر بدلًا منهما.
  • نسيان await. يعمل العمل دون مراقبة وتختفي استثناءاته.
  • تغليف الإدخال والإخراج في Task.Run. واجهة API غير متزامنة مثل File.ReadAllTextAsync أو HttpClient.GetStringAsync تحرّر الخيط أصلًا؛ ولا تضيف Task.Run سوى قفزة إلى مجمّع الخيوط.
  • افتراض أن async تعني "يعمل على خيط آخر". تعني "يمكنه التوقف دون حجز". استخدم Task.Run للعمل كثيف المعالجة.

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

كيف تعمل async وawait في C#؟

تعليم دالة بـ async يتيح لها استخدام await. حين تصل الدالة إلى await على مهمة لم تنتهِ، تعود إلى مستدعيها فورًا، مسلّمة Task يمثّل بقية العمل. وحين تكتمل العملية المنتظرة تستأنف الدالة بعد await. لا يوجد خيط محجوز ينتظر أثناء ذلك.

ما الفرق بين Task وTask<T>؟

يمثّل Task عملية لا تنتج قيمة، وهو النسخة غير المتزامنة من دالة void. ويمثّل Task<T> عملية تنتج T: await على Task<int> يعطيك الـ int. والدالة غير المتزامنة المصرّح بها async Task<int> تكتب ببساطة return 42;، ويغلّفها المترجم في المهمة.

كيف أشغّل عدة عمليات غير متزامنة في الوقت نفسه؟

ابدأها كلها أولًا، ثم انتظرها معًا: var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);. كتابة await GetUserAsync(); await GetOrdersAsync(); تشغّلها واحدة تلو الأخرى، فيكون الزمن الكلي المجموع بدل الأطول.

لماذا async void سيئة في C#؟

لا يمكن انتظار دالة async void، فلا يعرف المستدعي متى تنتهي، ولا يستطيع المستدعي التقاط استثناء يُرمى داخلها: يُطلق على سياق التزامن ويُسقط العملية عادة. أعد Task بدلًا منها. وتوجد async void فقط لمعالجات الأحداث، التي يتطلب توقيعها void.

لماذا تسبّب .Result أو .Wait() جمودًا؟

في تطبيق له سياق تزامن (WinForms وWPF وASP.NET الكلاسيكي) تحجز .Result خيط الواجهة أو الطلب بينما ينتظر استكمال الدالة المنتظرة أن يُنفَّذ على ذلك الخيط نفسه. كل منهما ينتظر الآخر إلى الأبد. استخدم await على طول الطريق بدل الحجز، واستخدم ConfigureAwait(false) داخل شيفرة المكتبات.

هل يمكن أن تكون Main غير متزامنة في C#؟

نعم، منذ C# 7.1: static async Task Main() أو static async Task<int> Main(). ويمكن للعبارات عالية المستوى (C# 9) استخدام await مباشرة. وفي الشيفرة الأقدم المكافئ استدعاء MainAsync().GetAwaiter().GetResult() من Main عادية، وهذا آمن هناك لأن تطبيق الكونسول لا سياق تزامن له.

Coddy programming languages illustration

تعلّم البرمجة مع Coddy

ابدأ الآن