async/await و UI thread في WPF / WinForms في صفحة واحدة - أين تعود الاستكمالات، و Dispatcher، و ConfigureAwait، ولماذا تتعطّل ‎.Result / .Wait()

· · C#, async/await, .NET, WPF, WinForms, UI, Threading

أسهل موضع تضيع فيه عند استخدام async / await في WPF / WinForms هو أيّ thread تعود إليه التنفيذ بعد await، ومتى يكون من الآمن لمس الواجهة. بمجرّد أن يختلط Dispatcher و BeginInvoke و ConfigureAwait(false) و .Result / .Wait()، يصبح من الصعب رؤية سبب التجمّد واستثناءات cross-thread.

يركّز هذا المقال تحديداً على العلاقة بين UI thread و async / await في WPF / WinForms. أمّا اتّخاذ القرار الأوسع حول async / await، فيتّصل اتّصالاً طبيعيّاً بـ أفضل ممارسات async/await في C# - جدول قرار لـ Task.Run و ConfigureAwait.

المواضع التي تبدأ تنذر بمشكلات حقيقيّة في الإنتاج هي عادةً هذه:

  • عدم معرفة أين تستأنف التنفيذ بعد await
  • عدم معرفة ما إذا كان من الآمن لمس الواجهة بعد Task.Run
  • التردّد في موضع وضع ConfigureAwait(false)
  • تجميد الشاشة بـ .Result / .Wait() / .GetAwaiter().GetResult()
  • خلط Dispatcher في WPF مع Invoke / BeginInvoke / InvokeAsync في WinForms ذهنيّاً

كلّ من WPF و WinForms هما نموذجان متمحوران حول UI thread. لذلك فإنّ أنفع طريقة لفهم async / await هنا ليست فلسفة مجرّدة عن اللاتزامن، بل أن تكون صريحاً بشأن ما يفعله كودك بـ UI thread وحلقة الرسائل الخاصّة به.

يفترض هذا المقال في معظمه تطبيقات WPF / WinForms على .NET 6 وما بعدها، وينظّم التفكير العمليّ حول وجهات الاستكمال بعد await، و Dispatcher، و ConfigureAwait(false)، وسبب تعطّل .Result / .Wait().

ملاحظة واحدة خاصّة بالإصدار أوّلاً: Control.InvokeAsync في WinForms موجود فقط في .NET 9 وما بعدها. قبل ذلك، تظلّ الخيارات القياسيّة هي BeginInvoke و Invoke.

المحتويات

  1. النسخة المختصرة
  2. أوّلاً، انظر الصورة الكاملة في صفحة واحدة
  3. مصطلحات مستخدمة في هذا المقال
  4. الأنماط النموذجيّة
  5. متى تستخدم Dispatcher / Invoke
  6. أنماط مضادّة شائعة
  7. قائمة فحص للمراجعة
  8. دليل قاعدة عامّة تقريبيّة
  9. الخلاصة
  10. مراجع

1. النسخة المختصرة

  • إذا استخدمت await عاديّاً داخل معالج حدث UI في WPF / WinForms، فإنّ الاستكمال بعد await سيعود عادةً إلى UI thread
  • Task.Run هو لـ نقل عمل CPU الثقيل خارج UI thread، وليس لتغليف انتظار I/O
  • حتّى لو فعلت await Task.Run(...) داخل معالج UI، فطالما أنّ ذلك await عاديّ، فإنّ الاستكمال يستأنف عادةً على UI thread
  • ConfigureAwait(false) يعني عدم فرض العودة بالاستكمال إلى سياق UI الملتقط. لمس الواجهة مباشرةً بعد ذلك خطر
  • .Result / .Wait() / .GetAwaiter().GetResult() تحجب UI thread. إذا كان الاستكمال المنتظر يحتاج للعودة إلى الواجهة، فإنّها تتعطّل بسهولة بالغة
  • في WPF، الطريقة التمثيليّة للعودة صراحةً إلى الواجهة هي Dispatcher.InvokeAsync
  • في WinForms، الخيار التقليديّ هو BeginInvoke، وعلى .NET 9+ يلائم InvokeAsync تدفّق async بشكل خاصّ
  • قاعدة أوليّة عمليّة: await عاديّ في الطبقة الخارجيّة للواجهة، والنظر في ConfigureAwait(false) في المكتبات العامّة، والعودة إلى الواجهة صراحةً فقط حيث يلزم

بعبارة أخرى، يصبح فهم WPF / WinForms أسهل بكثير عندما تفصل بين:

  1. أيّ thread يعمل عليه الكود الآن
  2. أين سيستأنف الاستكمال بعد await
  3. من يملك مسؤوليّة العودة إلى الواجهة

2. أوّلاً، انظر الصورة الكاملة في صفحة واحدة

2.1. الصورة الإجماليّة

هذا المخطّط هو أسرع طريقة لإدخال الشكل العامّ إلى ذهنك.

flowchart LR
    A["UI event handler<br/>(WPF / WinForms)"] --> B["plain await<br/>I/O API"]
    B --> C["capture the UI SynchronizationContext"]
    C --> D["after await, resume on the UI thread"]
    D --> E["you can update the UI directly"]

    A --> F["await Task.Run(...)<br/>heavy CPU work"]
    F --> G["the heavy computation runs on the ThreadPool"]
    G --> H["after await, resume on the UI thread"]
    H --> E

    A --> I["await SomeAsync().ConfigureAwait(false)"]
    I --> J["do not force a return to the UI"]
    J --> K["continuation may resume on any thread"]
    K --> L["direct UI access is dangerous<br/>Dispatcher / Invoke is needed"]

    A --> M["SomeAsync().Result / Wait()<br/>GetAwaiter().GetResult()"]
    M --> N["block the UI thread"]
    N --> O["the continuation cannot return to the UI"]
    O --> P["hang / deadlock / at the very least, a visible freeze"]

في التطبيقات الحقيقيّة، تتجمّع معظم المشكلات حول أربعة أنماط:

  1. await عاديّ في معالج حدث UI
  2. استخدام Task.Run داخل معالج حدث UI لإبعاد عمل CPU
  3. إزالة العودة إلى الواجهة باستخدام ConfigureAwait(false)
  4. حجب UI thread بـ .Result / .Wait()

2.2. جدول قرار سريع

الموقف ما يعمل أثناء الانتظار الاستكمال بعد await هل الوصول المباشر إلى الواجهة آمن؟ الخيار الأوّل
await SomeIoAsync() داخل معالج UI انتظار اكتمال I/O بينما يستطيع UI thread العودة إلى حلقة الرسائل عادةً UI thread نعم await عاديّ
await Task.Run(...) داخل معالج UI يعمل عمل CPU الثقيل على ThreadPool عادةً UI thread نعم Task.Run لعمل CPU فقط
await x.ConfigureAwait(false) داخل معالج UI لا يثبَّت الاستكمال على الواجهة أيّ thread لا تجنّبه عادةً في كود الواجهة
x.Result / x.Wait() على UI thread يُحجب UI thread بالانتظار يواجه الاستكمال صعوبةً في العمل أصلاً لا لا تستخدمه
تحديث الواجهة بعد عمل على thread خلفيّ أو بعد ConfigureAwait(false) الكود يعمل بالفعل خارج UI thread لا يزال ليس على UI thread لا Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync

النقطة المهمّة في هذا الجدول هي أنّ await العاديّ هو عادةً حليفك في كود الواجهة. العدوّ ليس await ذاته، بل حجب UI thread تزامنيّاً.

3. مصطلحات مستخدمة في هذا المقال

3.1. UI thread وحلقة الرسائل

في WPF / WinForms، الشكل الأساسيّ هو أنّ هناك UI thread واحد، وهو مسؤول عن الإدخال والرسم ومعالجة الأحداث.

ذلك UI thread مسؤول عموماً عن:

  • معالجة رسائل مثل نقرات الأزرار وإدخال المفاتيح وطلبات إعادة الرسم
  • أن يكون الـ thread الوحيد المسموح له بلمس عناصر التحكّم وكائنات UI بأمان
  • تعطيل تحديثات الشاشة وإدخال المستخدم إذا حمّلته فوق طاقته بعمل طويل الأمد

الفكرة الرئيسة هي أنّ مهمّة UI thread هي أن يستمرّ في الدوران بسرعة. إذا حجبته لفترة طويلة، تتعطّل إدخال الفأرة والمفاتيح وإعادة الرسم، ومن وجهة نظر المستخدم “تجمّد” التطبيق.

هذا المخطّط نموذج ذهنيّ جيّد ينبغي الاحتفاظ به:

flowchart LR
    A["User input / repaint requests"] --> B["Message loop on the UI thread"]
    B --> C["Run event handlers"]
    C --> D["Update the screen"]
    D --> B

    C --> E["Long synchronous work"]
    E --> F["The message loop stops turning"]
    F --> G["The UI looks frozen"]

3.2. SynchronizationContext / Dispatcher / Invoke

فيما يلي تقسيم عمليّ للمصطلحات التي تظهر كثيراً:

المصطلح المعنى في هذا المقال
UI thread الـ thread الذي أنشأ كائنات UI. عادةً هذا الـ thread فقط يستطيع لمس الواجهة بأمان
حلقة الرسائل الآليّة التي يعالج بها UI thread الرسائل واحدةً تلو الأخرى
SynchronizationContext تجريد لـ “إعادة نشر الاستكمال إلى سياق التنفيذ هذا”
Dispatcher طابور WPF للعمل الذي يجب أن يعمل على UI thread
Invoke / BeginInvoke / InvokeAsync واجهات لنشر العمل إلى UI thread

بدقّة أكبر، عند تحديد أين يُستكمل التنفيذ، يفضّل await أوّلاً SynchronizationContext الحاليّ، وإذا لم يكن هناك أيّ منه فقد يأخذ بعين الاعتبار أيضاً TaskScheduler غير افتراضيّ. لكن في عمل WPF / WinForms اليوميّ، يكفي عادةً التفكير فيه على أنّ SynchronizationContext الخاصّ بالواجهة هو ما يهمّ.

هذه طريقة جيّدة لتصوّر التعيين الخاصّ بكلّ إطار:

الإطار سياق جانب الواجهة واجهة API تمثيليّة للعودة صراحةً إلى الواجهة
WPF DispatcherSynchronizationContext Dispatcher.InvokeAsync / Dispatcher.BeginInvoke / Dispatcher.Invoke
WinForms WindowsFormsSynchronizationContext Control.BeginInvoke / Control.Invoke / .NET 9+ Control.InvokeAsync

يتمحور WPF حول Dispatcher. أمّا WinForms فيتمحور بشكل أوضح حول مقبض عنصر تحكّم وحلقة الرسائل، لذلك يميل BeginInvoke / Invoke إلى الظهور على السطح.

عمليّاً، يكفي تذكّر العلاقة بين التجريد والتنفيذ عند هذا المستوى تقريباً:

Current codeSynchronizationContextWPF: DispatcherSynchronizationContextWinForms: WindowsFormsSynchronizationContextDispatcher.InvokeAsync / BeginInvoke / InvokeControl.BeginInvoke / Invoke / InvokeAsync(.NET 9+)

4. الأنماط النموذجيّة

4.1. await عاديّ داخل معالج حدث UI

هذا أبسط شكل.

private async void LoadButton_Click(object sender, RoutedEventArgs e)
{
    LoadButton.IsEnabled = false;
    StatusText.Text = "Loading...";

    try
    {
        string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
        PreviewTextBox.Text = text;
        StatusText.Text = "Done";
    }
    catch (Exception ex)
    {
        StatusText.Text = ex.Message;
    }
    finally
    {
        LoadButton.IsEnabled = true;
    }
}

في هذا الكود، يبدأ LoadButton_Click على UI thread. ولأنّ await File.ReadAllTextAsync(...) هو await عاديّ، فإنّه عادةً يلتقط سياق الواجهة الحاليّ.

ينتج عن ذلك السلوك التالي:

  • انتظار I/O للملفّ لا يشغل UI thread
  • بعد اكتمال القراءة، يستأنف التنفيذ عادةً على UI thread
  • يمكن كتابة PreviewTextBox.Text = text; مباشرةً

لا حاجة إلى Dispatcher إضافيّ هنا. إذا كنت داخل معالج UI واستخدمت للتوّ await عاديّاً، يمكنك عادةً مواصلة لمس الواجهة مباشرةً.

ينطبق التفسير ذاته في WinForms. ما دمت تستخدم await عاديّاً داخل معالج Click، فإنّ الاستكمال سيعود عادةً إلى جانب الواجهة.

بصريّاً، يبدو التدفّق هكذا:

sequenceDiagram
    participant UI as UI thread
    participant IO as Async I/O
    participant Ctx as UI SynchronizationContext

    UI->>UI: Start click handler
    UI->>IO: await ReadAllTextAsync
    UI-->>Ctx: reserve continuation back to the UI
    Note over UI: while waiting, return to the message loop
    IO-->>Ctx: I/O completes
    Ctx-->>UI: resume continuation on the UI thread
    UI->>UI: update TextBox / Label

4.2. استخدم Task.Run فقط لعمل CPU الثقيل

Task.Run مفيد عندما تريد نقل عمل CPU الثقيل خارج UI thread.

private async void HashButton_Click(object sender, RoutedEventArgs e)
{
    HashButton.IsEnabled = false;
    ResultText.Text = "Computing...";

    try
    {
        byte[] data = await File.ReadAllBytesAsync(InputPathTextBox.Text);

        string hash = await Task.Run(() =>
        {
            using SHA256 sha256 = SHA256.Create();
            byte[] digest = sha256.ComputeHash(data);
            return Convert.ToHexString(digest);
        });

        ResultText.Text = hash;
    }
    catch (Exception ex)
    {
        ResultText.Text = ex.Message;
    }
    finally
    {
        HashButton.IsEnabled = true;
    }
}

ما يحدث هنا تقريباً هو:

  1. يبدأ معالج الحدث على UI thread
  2. تتعامل File.ReadAllBytesAsync مع انتظار I/O بشكل لاتزامنيّ
  3. يُنقل حساب الـ hash الثقيل وحده إلى ThreadPool عبر Task.Run
  4. لأنّ await Task.Run(...) لا يزال await عاديّاً، يعود الاستكمال إلى UI thread
  5. لا يزال يمكن كتابة ResultText.Text = hash; مباشرةً

بعبارة أخرى، داخل Task.Run فقط هو ما يكون على thread آخر. لا ينتقل التنفيذ بشكل دائم إلى مكان غير الواجهة بعد await.

عند رؤيته صورةً واحدةً يصبح من الأصعب فهمه خطأً:

sequenceDiagram
    participant UI as UI thread
    participant IO as Async I/O
    participant Pool as ThreadPool

    UI->>IO: await ReadAllBytesAsync
    IO-->>UI: plain await resumes on the UI
    UI->>Pool: send heavy CPU work via Task.Run
    Pool-->>UI: return the computed result
    Note over UI: continuation after await Task.Run(...) resumes on the UI
    UI->>UI: reflect the result on screen

تنبيهان مهمّان هنا:

  • لا تغلّف انتظار I/O داخل Task.Run
  • فكّر في Task.Run لا على أنّه “جعل شيء ما لاتزامنيّاً”، بل على أنّه “إنشاء مكان لتشغيل عمل CPU بعيداً عن الواجهة”

كود مثل Task.Run(async () => await File.ReadAllTextAsync(...)) في الغالب لا يفعل سوى نقل I/O إلى ThreadPool دون فائدة حقيقيّة.

4.3. ConfigureAwait(false) يعني “لا تفرض العودة”، وليس “ضمان عدم العودة”

هذا هو الجزء الذي يُساء فهمه أكثر من غيره.

أوّلاً، ConfigureAwait(false) يلائم بشكل أفضل كود المكتبات العامّة الذي لا يعتمد على الواجهة أو على نموذج تطبيق محدّد.

public sealed class DocumentRepository
{
    public async Task<string> LoadNormalizedTextAsync(string path, CancellationToken cancellationToken)
    {
        string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
        return text.Replace("\r\n", "\n", StringComparison.Ordinal);
    }
}

هذه الطريقة لا تلمس الواجهة. يمكن استخدامها من WPF أو WinForms أو ASP.NET Core أو worker. في كود كهذا، يكون ConfigureAwait(false) طبيعيّاً تماماً.

ولا يزال جانب الواجهة قادراً على استخدام await العاديّ:

private readonly DocumentRepository _repository = new();

private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
    OpenButton.IsEnabled = false;
    StatusText.Text = "Loading...";

    try
    {
        string text = await _repository.LoadNormalizedTextAsync(
            PathTextBox.Text,
            CancellationToken.None);

        PreviewTextBox.Text = text;
        StatusText.Text = "Done";
    }
    catch (Exception ex)
    {
        StatusText.Text = ex.Message;
    }
    finally
    {
        OpenButton.IsEnabled = true;
    }
}

النقطة المهمّة هي أنّ ConfigureAwait(false) داخل المكتبة لا يفرض على await المستدعِي أن يصبح false أيضاً.

يمنحك ذلك فصلاً نظيفاً:

  • داخل المكتبة، لا يعود الاستكمال إلى الواجهة
  • عندما ينتظر معالج UI استدعاء المكتبة بـ await عاديّ، فإنّ استكمال المستدعِي لا يزال يعود إلى الواجهة

في المقابل، هذا خطر داخل معالج UI نفسه:

private async void OpenButton_Click(object sender, RoutedEventArgs e)
{
    string text = await _repository.LoadNormalizedTextAsync(
        PathTextBox.Text,
        CancellationToken.None).ConfigureAwait(false);

    PreviewTextBox.Text = text;
}

في هذه الحالة، استكمال ذلك await داخل OpenButton_Click لم يعد مفروضاً عليه العودة إلى الواجهة. لذلك يمكن أن يصبح PreviewTextBox.Text = text; وصول cross-thread.

هناك نقطة أخرى دقيقة لكنّها مهمّة. ConfigureAwait(false) لا يعني “ينتقل دائماً إلى ThreadPool”.

إذا اكتملت العمليّة المنتظَرة تزامنيّاً ولم تكن بحاجة فعلاً للتعليق، فقد يستمرّ الاستكمال في التدفّق على الـ thread الحاليّ. لذلك فإنّ ConfigureAwait(false) لا يعني:

  • “ينتقل دائماً إلى thread آخر”
  • “من هنا فصاعداً، الكود بالتأكيد لم يعد على UI thread”

ما يعنيه فقط هو:

  • لا تفرض على هذا الاستكمال لـ await العودة إلى سياق الواجهة الأصليّ

هذا التفسير يسبّب حوادث أقلّ بكثير.

كصورة، يبدو هكذا:

flowchart LR
    A["await inside a UI handler"] --> B{"Add ConfigureAwait(false)?"}
    B -- no --> C["continuation normally resumes on the UI thread"]
    C --> D["direct UI update stays easy"]

    B -- yes --> E["continuation is not pinned to the UI"]
    E --> F["it may resume on any thread"]
    F --> G["Dispatcher / Invoke is needed for UI updates"]

4.4. لماذا تتعطّل .Result / .Wait() / .GetAwaiter().GetResult()

هذا هو وضع الفشل الذي يصادفه الناس أكثر من غيره.

private void LoadButton_Click(object sender, RoutedEventArgs e)
{
    string text = LoadTextAsync().Result;
    PreviewTextBox.Text = text;
}

private async Task<string> LoadTextAsync()
{
    string text = await File.ReadAllTextAsync(FilePathTextBox.Text);
    return text.ToUpperInvariant();
}

للوهلة الأولى، يبدو الأمر “مجرّد الحصول على النتيجة تزامنيّاً”. لكنّ ذلك على UI thread خطر تماماً.

يبدو التدفّق هكذا:

sequenceDiagram
    participant UI as UI thread
    participant IO as Async I/O
    participant Ctx as UI SynchronizationContext

    UI->>UI: Start LoadButton_Click
    UI->>IO: call LoadTextAsync()
    IO-->>UI: return an incomplete Task
    UI->>UI: block with .Result
    IO-->>Ctx: I/O completes and wants to post the continuation to the UI
    Ctx-->>UI: tries to run the continuation
    Note over UI: but the UI is blocked by .Result
    Note over UI, Ctx: the continuation cannot run, so completion never happens

بالكلمات:

  1. يستدعي UI thread الـ LoadTextAsync()
  2. الـ await داخل LoadTextAsync() يلتقط سياق الواجهة
  3. يُحجب UI thread على .Result
  4. يكتمل الـ I/O
  5. يريد استكمال LoadTextAsync() الاستئناف على UI thread
  6. لكنّ UI thread محجوب بـ .Result
  7. الاستكمال لا يستطيع العمل، لذلك لا يستطيع LoadTextAsync() الاكتمال
  8. لا تنتهي .Result أبداً

إذن فالواجهة تقول: “سأنتظر حتّى تنتهي”، بينما تقول الطريقة اللاتزامنيّة: “لا أستطيع الانتهاء إلّا إذا عدت إلى الواجهة”. ينتهي بهما المطاف ينتظر كلٌّ منهما الآخر.

أحد سوء الفهم الشائع هو الاعتقاد بأنّ GetAwaiter().GetResult() آمن. لكنّ المشكلة الجوهريّة، حجب UI thread، هي ذاتها. الفرق الرئيسيّ هو كيفيّة تغليف الاستثناءات.

لذلك في كود الواجهة، من الأكثر أماناً معاملة هذه الثلاثة برائحة واحدة:

  • .Result
  • .Wait()
  • .GetAwaiter().GetResult()

لاحظ أيضاً أنّه من الخطر استدعاء Task.Wait() على الـ Task الذي يعيده Dispatcher.InvokeAsync(...) في WPF. يشير توثيق WPF نفسه إلى أنّ الانتظار بهذه الطريقة قد يتسبّب في deadlock. باختصار، الاتّجاه الكلّيّ لـ “نشر شيء إلى الواجهة ثمّ انتظاره تزامنيّاً” يميل إلى التعطّل بشدّة في سياق الواجهة.

هل سيتسبّب في deadlock دائماً؟ ليس بالضرورة. إذا لم يكن الاستكمال بحاجة للعودة إلى الواجهة، فقد “يكتفي” بتجميد الواجهة بدلاً من الـ deadlock. لكنّ ذلك لا يزال سيّئاً بما فيه الكفاية، لذا في كود الواجهة القاعدة العمليّة هي تجنّبه.

5. متى تستخدم Dispatcher / Invoke

إذا نظّمت القواعد أعلاه، فإنّ معالج UI الذي يستخدم await عاديّاً عادةً لا يحتاج إلى Dispatcher / Invoke صريح.

تحتاجه في حالات مثل:

  • تريد لمس الواجهة بعد ConfigureAwait(false)
  • كودك مهيكل بحيث لا يعود إلى الواجهة بعد Task.Run أو عمل خلفيّ آخر
  • تصل إشعارات من مستقبِل socket أو timer أو callback لا يبدأ على UI thread
  • تفصل عمداً بين طبقات UI وغير UI وتريد أن تجعل التحديث النهائيّ للواجهة فقط صريحاً

في WPF، الواجهة API التمثيليّة هي Dispatcher.InvokeAsync.

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

    await Dispatcher.InvokeAsync(() =>
    {
        PreviewTextBox.Text = text;
        StatusText.Text = "Done";
    });
}

في WinForms، على .NET 9 وما بعدها، InvokeAsync لطيف بشكل خاصّ مع تدفّق async.

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    string text = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

    await previewTextBox.InvokeAsync(() =>
    {
        previewTextBox.Text = text;
        statusLabel.Text = "Done";
    });
}

في أنماط WinForms الأقدم، يكون BeginInvoke هو الخيار المعتاد. يرسل Invoke العمل تزامنيّاً ويجعل المستدعِي ينتظر. أمّا BeginInvoke فينشر ويعود فوراً. في تدفّق async، عادةً ما يلائم الجانب غير المحجوب بشكل أفضل.

كتمييز تقريبيّ:

ما تريد فعله WPF WinForms
الدخول إلى الواجهة تزامنيّاً Dispatcher.Invoke Control.Invoke
النشر إلى الواجهة لاتزامنيّاً Dispatcher.InvokeAsync / Dispatcher.BeginInvoke Control.BeginInvoke / .NET 9+ Control.InvokeAsync
الدمج طبيعيّاً مع async / await Dispatcher.InvokeAsync .NET 9+ Control.InvokeAsync، أو BeginInvoke قبل ذلك

في العمل اليوميّ، عادةً ما يكفي هذا الحدس التقريبيّ:

  • إذا كنت تفعل فقط await عاديّاً داخل معالج UI، فأنت لست بحاجة إليه
  • استخدمه عندما تحتاج للمس الواجهة من مكان ليس هو الواجهة
  • لا تفرط في استخدام Invoke التزامنيّ داخل تدفّق async

ذلك وحده يمنع كثيراً من الحوادث.

عند الشكّ، يكفي مخطّط القرار هذا:

flowchart TD
    A["Is this continuation already on the UI thread?"] --> B{"Yes?"}
    B -- yes --> C["Keep using plain await and update the UI directly"]
    B -- no --> D{"Do you need to touch the UI?"}
    D -- no --> E["Keep processing without marshaling"]
    D -- yes --> F["WPF: Dispatcher.InvokeAsync"]
    D -- yes --> G["WinForms: BeginInvoke / InvokeAsync"]

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

النمط المضادّ لماذا يضرّ الاستبدال الأوّل
LoadAsync().Result داخل معالج UI يحجب UI thread ويتسبّب في deadlock بسهولة await LoadAsync()
LoadAsync().Wait() داخل معالج UI المشكلة ذاتها، تتوقّف حلقة الرسائل عن الدوران await LoadAsync()
LoadAsync().GetAwaiter().GetResult() داخل معالج UI مشكلة الحجب ذاتها مع تغليف استثناءات مختلف await LoadAsync()
إضافة ConfigureAwait(false) ميكانيكيّاً إلى كود الواجهة تصبح تحديثات الواجهة المباشرة بعد await هشّة احتفظ بـ await العاديّ في الطبقة الخارجيّة للواجهة
Task.Run(async () => await IoAsync()) ينقل I/O إلى ThreadPool دون داعٍ await IoAsync()
كود مكتبة يحتفظ بـ Dispatcher أو Control مباشرةً تنتشر تبعيّة الواجهة عميقاً جدّاً وتؤذي إعادة الاستخدام دع المكتبة تعيد البيانات وقم بـ marshal في طبقة الواجهة
الإفراط في استخدام Dispatcher.Invoke / Control.Invoke داخل تدفّق async يخلق دورات حجب جديدة فكّر في Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync
مزامنة عمل async داخل المنشِئات أو في getter الخصائص يخلق توقّفات بدء التشغيل بسهولة انقله إلى Loaded / Shown / InitializeAsync

الثلاثة الشائعة بشكل خاصّ هي:

  1. .Result / .Wait() على UI thread
  2. إضافة ConfigureAwait(false) ميكانيكيّاً في كود الواجهة
  3. خلط مسؤوليّة المكتبة بمسؤوليّة الواجهة بحيث يتسرّب Dispatcher عميقاً في قاعدة الكود

إزالة هذه الثلاثة وحدها تهدّئ الأمور كثيراً بالفعل.

7. قائمة فحص للمراجعة

عند مراجعة async / await في WPF / WinForms، يساعد فحص هذه الأمور بالترتيب:

  • هل لا تزال .Result / .Wait() / .GetAwaiter().GetResult() موجودة في معالجات أحداث الواجهة أو في مسارات تهيئة الواجهة؟
  • هل يُستخدم Task.Run فقط لـ عمل CPU، وليس لتغليف I/O؟
  • هل أُضيف ConfigureAwait(false) ميكانيكيّاً إلى كود الواجهة؟
  • وعلى العكس، هل لا يزال كود المكتبات العامّة يجرّ افتراضات سياق الواجهة معه؟
  • عندما يلمس الكود الواجهة مباشرةً بعد await، هل يمكنك القول حقّاً إنّه لا يزال على سياق الواجهة؟
  • حيث يجب على الكود فعلاً العودة صراحةً إلى الواجهة، هل يستخدم Dispatcher.InvokeAsync / BeginInvoke / InvokeAsync؟
  • هل تتكاثر استدعاءات marshaling التزامنيّة مثل Dispatcher.Invoke / Control.Invoke دون حاجة فعليّة؟
  • هل يُجبر عمل async على العودة إلى التزامن داخل المنشِئات أو الخصائص التزامنيّة أو الأحداث التزامنيّة؟
  • هل تشير طبقة المكتبة مباشرةً إلى Window أو Control أو Dispatcher؟

قائمة الفحص هذه مفيدة أيضاً لتوحيد رؤية الفريق حول أين تكمن مسؤوليّة الواجهة فعلاً.

8. دليل قاعدة عامّة تقريبيّة

ما تريد فعله الخيار الأوّل
انتظار HTTP / DB / I/O ملفّ في معالج UI await عاديّ
تشغيل عمل CPU ثقيل دون تجميد الواجهة await Task.Run(...)
تحديث الواجهة بعد ConfigureAwait(false) أو من thread خلفيّ WPF: Dispatcher.InvokeAsync / WinForms: BeginInvoke أو .NET 9+ InvokeAsync
كتابة مكتبة عامّة فكّر في ConfigureAwait(false)
مزامنة كود async إلى تزامنيّ في الواجهة عادةً لا تفعل؛ وسّع async إلى الأعلى بدلاً من ذلك
أداء تهيئة بدء التشغيل Loaded / Shown / InitializeAsync صريح
مواصلة لمس الواجهة مباشرةً بعد await احتفظ بـ await العاديّ في الطبقة الخارجيّة للواجهة

9. الخلاصة

ما يهمّ فعلاً في async / await في WPF / WinForms ليس شعوراً غامضاً بأنّ “اللاتزامن صعب”. ما يهمّ هو الفصل بين:

  • أين بدأ التنفيذ
  • أين يعود الاستكمال بعد await
  • من يملك مسؤوليّة العودة إلى الواجهة

كمجموعة قواعد ابتدائيّة، تقطع هذه الخمسة شوطاً طويلاً:

  1. await عاديّ في الطبقة الخارجيّة للواجهة
  2. Task.Run فقط لعمل CPU الثقيل
  3. النظر في ConfigureAwait(false) في المكتبات العامّة
  4. استخدم Dispatcher / BeginInvoke / InvokeAsync فقط عندما تحتاج فعلاً للعودة إلى الواجهة
  5. لا تستخدم أبداً .Result / .Wait() / .GetAwaiter().GetResult() على UI thread

async / await ذاته ليس آليّة سيّئة بشكل خاصّ. لكن إذا استخدمته دون إبقاء UI thread في مركز الصورة، فإنّه يتحوّل بسرعة إلى وحل.

بالعكس:

  • افصل بين العمل داخل الواجهة وخارجها
  • ابق واعياً لأين تعود الاستكمالات
  • لا تجلب الحجب التزامنيّ إلى الواجهة

هذه الثلاثة وحدها تجعل كود WPF / WinForms اللاتزامنيّ أهدأ بكثير. عندما تتجمّد الشاشة، فالمشكلة عادةً ليست أنّ “اللاتزامن سيّئ”، بل أنّ الكود يتحمّل دين UI thread بطريقة فوضويّة.

10. مراجع

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

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

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

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

غو كومورا

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

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

روابط عامة

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