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.
المحتويات
- النسخة المختصرة
- أوّلاً، انظر الصورة الكاملة في صفحة واحدة
- 2.1. الصورة الإجماليّة
- 2.2. جدول قرار سريع
- مصطلحات مستخدمة في هذا المقال
- الأنماط النموذجيّة
- متى تستخدم
Dispatcher/Invoke - أنماط مضادّة شائعة
- قائمة فحص للمراجعة
- دليل قاعدة عامّة تقريبيّة
- الخلاصة
- مراجع
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 أسهل بكثير عندما تفصل بين:
- أيّ thread يعمل عليه الكود الآن
- أين سيستأنف الاستكمال بعد
await - من يملك مسؤوليّة العودة إلى الواجهة
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"]
في التطبيقات الحقيقيّة، تتجمّع معظم المشكلات حول أربعة أنماط:
awaitعاديّ في معالج حدث UI- استخدام
Task.Runداخل معالج حدث UI لإبعاد عمل CPU - إزالة العودة إلى الواجهة باستخدام
ConfigureAwait(false) - حجب 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 إلى الظهور على السطح.
عمليّاً، يكفي تذكّر العلاقة بين التجريد والتنفيذ عند هذا المستوى تقريباً:
flowchart TD
A["Current code"] --> B["SynchronizationContext"]
B --> C["WPF: DispatcherSynchronizationContext"]
B --> D["WinForms: WindowsFormsSynchronizationContext"]
C --> E["Dispatcher.InvokeAsync / BeginInvoke / Invoke"]
D --> F["Control.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;
}
}
ما يحدث هنا تقريباً هو:
- يبدأ معالج الحدث على UI thread
- تتعامل
File.ReadAllBytesAsyncمع انتظار I/O بشكل لاتزامنيّ - يُنقل حساب الـ hash الثقيل وحده إلى ThreadPool عبر
Task.Run - لأنّ
await Task.Run(...)لا يزالawaitعاديّاً، يعود الاستكمال إلى UI thread - لا يزال يمكن كتابة
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
بالكلمات:
- يستدعي UI thread الـ
LoadTextAsync() - الـ
awaitداخلLoadTextAsync()يلتقط سياق الواجهة - يُحجب UI thread على
.Result - يكتمل الـ I/O
- يريد استكمال
LoadTextAsync()الاستئناف على UI thread - لكنّ UI thread محجوب بـ
.Result - الاستكمال لا يستطيع العمل، لذلك لا يستطيع
LoadTextAsync()الاكتمال - لا تنتهي
.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 |
الثلاثة الشائعة بشكل خاصّ هي:
.Result/.Wait()على UI thread- إضافة
ConfigureAwait(false)ميكانيكيّاً في كود الواجهة - خلط مسؤوليّة المكتبة بمسؤوليّة الواجهة بحيث يتسرّب
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 - من يملك مسؤوليّة العودة إلى الواجهة
كمجموعة قواعد ابتدائيّة، تقطع هذه الخمسة شوطاً طويلاً:
awaitعاديّ في الطبقة الخارجيّة للواجهةTask.Runفقط لعمل CPU الثقيل- النظر في
ConfigureAwait(false)في المكتبات العامّة - استخدم
Dispatcher/BeginInvoke/InvokeAsyncفقط عندما تحتاج فعلاً للعودة إلى الواجهة - لا تستخدم أبداً
.Result/.Wait()/.GetAwaiter().GetResult()على UI thread
async / await ذاته ليس آليّة سيّئة بشكل خاصّ.
لكن إذا استخدمته دون إبقاء UI thread في مركز الصورة، فإنّه يتحوّل بسرعة إلى وحل.
بالعكس:
- افصل بين العمل داخل الواجهة وخارجها
- ابق واعياً لأين تعود الاستكمالات
- لا تجلب الحجب التزامنيّ إلى الواجهة
هذه الثلاثة وحدها تجعل كود WPF / WinForms اللاتزامنيّ أهدأ بكثير. عندما تتجمّد الشاشة، فالمشكلة عادةً ليست أنّ “اللاتزامن سيّئ”، بل أنّ الكود يتحمّل دين UI thread بطريقة فوضويّة.
10. مراجع
- مقال مرتبط: أفضل ممارسات async/await في C# - جدول قرار لـ Task.Run و ConfigureAwait
- Threading Model - WPF
- DispatcherSynchronizationContext Class
- How to handle cross-thread operations with controls - Windows Forms
- WindowsFormsSynchronizationContext Class
- Events Overview - Windows Forms
- TaskScheduler.FromCurrentSynchronizationContext Method
- ConfigureAwait FAQ
- How Async/Await Really Works in C#
- Await, and UI, and deadlocks! Oh my!
- Threading model for WebView2 apps
مقالات ذات صلة
أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.
لماذا يستحقّ الأمر إدخال Generic Host / BackgroundService إلى تطبيق سطح المكتب - يصبح تنظيم البدء والـ lifetime والـ graceful shutdown أسهل بكثير
يشرح هذا المقال متى يستحقّ إدخال Generic Host و BackgroundService إلى تطبيقات سطح المكتب على Windows لتنظيم البدء وإدارة الـ lifetime وال...
أيّها نختار من بين Windows Forms وWPF وWinUI - جدول قرار للتطوير الجديد، الأصول القائمة، التوزيع، والتعبير عن الـ UI
هذا المقال يُنظّم اختيار WinForms أو WPF أو WinUI من زاوية الأصول القائمة والتوزيع وقدرة التعبير في الـ UI، ويُقدّم جدول قرار عمليّاً يُس...
كيف نختار بين PeriodicTimer و System.Threading.Timer و DispatcherTimer - تنظيم العمل الدوريّ في .NET أوّلاً
دليل عمليّ لاختيار المؤقّت الأنسب في .NET بين PeriodicTimer وSystem.Threading.Timer وDispatcherTimer وفق سياق التنفيذ ونموذج المعالجة وضم...
أفضل الممارسات لـ C# async/await - جدول قرار لـ Task.Run و ConfigureAwait
دليل عمليّ لـ async/await في C# يبدأ بفصل عمل I/O-bound عن CPU-bound، ثمّ يقدّم جداول قرار حول Task.Run و WhenAll و ConfigureAwait و fire...
إلى أين ينتهي unit test وأين يبدأ integration test - دليل عمليّ لرسم الحدّ الفاصل
دليل عمليّ يميّز unit test وintegration test بأربعة أسئلة: نتحقّق من منطقنا أم من الغراء، ويبقى المعنى مع fake، وما طبيعة الاعتماد، ومدى ...
أين يتصل هذا الموضوع
ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.
تطوير تطبيقات ويندوز
ندعم تطوير برامج ويندوز للأعمال، وتكامل الأجهزة، وأدوات التواصل.
الملف الشخصي للمؤلف
صفحة الملف الشخصي لمؤلف المقالة.
غو كومورا
مؤسّس شركة كومورا سوفت ذ.م.م.
يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.
روابط عامة