async ות'רד ה-UI ב-WPF/‏WinForms בדף אחד

· עודכן בתאריך: · · C#, async/await, .NET, WPF, WinForms, UI, ת'רד

מה שהכי קל להתבלבל בו כשמשתמשים ב-async /‏ await ב-WPF /‏ WinForms הוא לאן חוזרים אחרי await, וכן מתי מותר לגעת ב-UI. בפרט, כש-Dispatcher,‏ BeginInvoke,‏ ConfigureAwait(false) ו-‏.Result /‏ .Wait() מתערבבים, קשה לראות את הסיבה לקפיאת המסך או לחריגות חוצות-ת’רדים.

במאמר הזה נתמקד רק בקשר שבין ת’רד ה-UI ל-‏async /‏ await ב-WPF /‏ WinForms. ציר ההחלטה הכללי של async /‏ await מתחבר למאמר טבלת החלטה מעשית ל-async/await ב-C# — Task.Run ו-ConfigureAwait.

בעבודה בפועל, המקומות שבאמת “מריחים דם” הם בערך אלה:

  • לא ברור איפה ההמשך רץ אחרי await
  • לא ברור אם מותר לגעת ב-UI אחרי שמכניסים Task.Run
  • לא ברור איפה כדאי לצרף ConfigureAwait(false)
  • המסך נתקע עקב .Result /‏ .Wait() /‏ .GetAwaiter().GetResult()
  • ה-Dispatcher של WPF וה-Invoke /‏ BeginInvoke /‏ InvokeAsync של WinForms מתערבבים בראש

גם WPF וגם WinForms הם מודלים שמרכזם ת’רד ה-UI. לכן, בסידור של async /‏ await, מה שהכי עוזר הוא לא דיון פילוסופי על “מהו אסינכרוני”, אלא להבהיר מה בדיוק קורה לת’רד ה-UI וללולאת ההודעות.

המאמר הזה מניח בעיקר אפליקציות WPF /‏ WinForms מ-‏.NET 6 ואילך, ועוקב לפי סדר נוח לעבודה בפועל אחרי לאן חוזרים לאחר await, אחרי Dispatcher, ואחרי הסיבות לתקיעה עם ConfigureAwait(false) ו-‏.Result /‏ .Wait().

שימו לב ש-Control.InvokeAsync ב-WinForms קיים רק מ-‏.NET 9 ואילך. לפני כן, ב-WinForms משתמשים בעיקר ב-BeginInvoke /‏ Invoke.

כמו כן, הקוד שמופיע במאמר הזה פורסם ב-GitHub כחבילת דוגמאות מלאה שאפשר לבנות ולהריץ (ספרייה בלתי-תלויית UI, דוגמאות WPF /‏ WinForms, ובדיקות יחידה ששחזרות את יעד החזרה של await ואת ה-deadlock).

wpf-winforms-ui-thread-async-await-one-sheet - komurasoft-blog-samples (GitHub)

תוכן עניינים

  1. קודם כול, המסקנה (במשפט אחד)
  2. קודם כול, לסדר בדף אחד
    • 2.1. התמונה הכוללת
    • 2.2. טבלת ההחלטה הראשונה
  3. מילים שבהן משתמש המאמר
    • 3.1. ת’רד ה-UI ולולאת ההודעות
    • 3.2. SynchronizationContext /‏ Dispatcher /‏ Invoke
  4. דפוסים אופייניים
    • 4.1. plain await במטפל אירוע UI
    • 4.2. Task.Run רק לחישוב CPU כבד
    • 4.3. ‏ConfigureAwait(false) הוא לא “הבטחה שלא חוזרים” אלא “לא כופה לחזור”
    • 4.4. הסיבה לתקיעה עם .Result /‏ .Wait() /‏ .GetAwaiter().GetResult()
  5. מתי להשתמש ב-Dispatcher /‏ Invoke
  6. אנטי-דפוסים נפוצים
  7. רשימת בדיקה לסקירת קוד
  8. חלוקה גסה בין המצבים
  9. סיכום
  10. מקורות

מפת הידע של המאמר

המאמר הזה מסודר סביב הבסיס שת’רד ה-UI ב-WPF/‏WinForms ממשיך להריץ לולאת הודעות: await רגיל תופס את ה-SynchronizationContext של אותו רגע ומחזיר את ההמשך לת’רד ה-UI, כך שאפשר לעדכן את ה-UI ישירות, ואילו ‏ConfigureAwait(false) לא מכריח את ההחזרה הזו ולכן מתאים לקוד ספרייה כללי. ‏Task.Run הוא כלי שמוציא רק חישוב CPU מת’רד ה-UI, ובחזרה מכל מקום אחר אל ה-UI צריך להשתמש ב-Dispatcher של WPF, ב-‏Control.BeginInvoke של WinForms, או ב-‏Control.InvokeAsync שממנו-.NET 9 ואילך. ולהפך, חסימה סינכרונית של ת’רד ה-UI עם .Result, ‏.Wait() או ‏.GetAwaiter().GetResult() מונעת מההמשך לחזור ל-UI ועלולה לגרום למבוי סתום או להקפאה, וב-event handler מסוג async void, אם לא תופסים את החריגה, היא בורחת עד ‏DispatcherUnhandledException של WPF או עד ‏ThreadException של WinForms והאפליקציה קורסת.

מפת הידע של ת'רד ה-UI ב-WPF/‏WinForms ושל async/‏awaitתרשים שמראה שת'רד ה-UI ממשיך להריץ לולאת הודעות; ש-await רגיל מחזיר את ההמשך ל-SynchronizationContext שנתפס; ש-‏ConfigureAwait(false) לא מכריח את החזרה הזו; ש-‏Task.Run מוציא חישוב CPU מת'רד ה-UI; ש-‏Dispatcher, ‏Control.BeginInvoke ו-‏InvokeAsync הם האמצעים המפורשים לחזור אל ה-UI; ש-‏.Result, ‏.Wait() ו-‏GetAwaiter().GetResult() עלולים לחסום את ת'רד ה-UI ולגרום למבוי סתום או להקפאה; ושחריגה מ-async void מגיעה למקלט הכללי של ה-UI.מחייבמשתמש במשתמש במשתמש במשתמש במשתמש ביורש אתמחייבמחייבמשתמש במונעמשתמש במענה מומלץ למחייבשימוש לא מומלץ למענה מומלץ למענה מומלץ לשימוש לא מומלץ למונעעלול לגרום לעלול לגרום לשימוש לא מומלץ לעלול לגרום למענה מומלץ לעלול לגרום למחייבההקשר של ת'רד ממשק המשתמשWPFWindows Formsלולאת ההודעותSynchronizationContextDispatcher(WPF)Control.Invoke / Control.BeginInvokeControl.InvokeAsyncTaskCompletionSourceCancellationToken(.NET)מנגנון מניעת כניסה חוזרת (Interlocked.Exchange וכדומה)תחרות בין ביטול לבין ביצועplain awaitConfigureAwait(false)קוד ספרייה כלליTask.Runעיבוד עתיר קלט/פלטחסימת ת'רד ממשק המשתמשהמתנה סינכרונית על קוד אסינכרוני (sync-over-async)קיפאון (deadlock).Result / .Wait() / .GetAwaiter().GetResult()async voidמתודת מטפל באירועתופס החריגות הכללי של הממשק (DispatcherUnhandledException / ThreadException)

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 26, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle

1. קודם כול, המסקנה (במשפט אחד)

  • אם עושים plain await במטפל אירוע UI ב-WPF /‏ WinForms, אפשר להניח שההמשך אחרי await חוזר בעיקרו לת’רד ה-UI
  • Task.Run הוא כלי להוציא חישוב CPU מת’רד ה-UI, לא כלי לעטיפת המתנת I/O
  • גם await Task.Run(...) בתוך מטפל UI — אם ה-await הוא plain, ההמשך חוזר בדרך כלל לת’רד ה-UI
  • ConfigureAwait(false) פירושו שלא כופים חזרה להקשר ה-UI שנתפס באותו await. נגיעה ישירה ב-UI בהמשך אחרי זה מסוכנת
  • .Result /‏ .Wait() /‏ .GetAwaiter().GetResult() חוסמים את ת’רד ה-UI. אם ההמשך של ה-await צריך לחזור ל-UI, זה נתקע בקלות רבה
  • ב-WPF, להחזרה מפורשת ל-UI משתמשים ב-Dispatcher.InvokeAsync
  • ב-WinForms, להחזרה מפורשת ל-UI, בעבר BeginInvoke, ומ-.NET 9 ואילך InvokeAsync שמשתלב טוב עם זרימת async
  • המדיניות הראשונית: בשכבה החיצונית ביותר של ה-UI — plain await, בספרייה כללית — לשקול ConfigureAwait(false), ולהחזיר ל-UI במפורש רק במקומות שבהם באמת צריך

בקיצור, ב-WPF /‏ WinForms

  1. באיזה ת’רד רצים עכשיו
  2. לאן חוזר ההמשך של await
  3. מי אחראי להחזיר ל-UI

אם עוצרים על שלוש הנקודות האלה, התמונה מתבהרת משמעותית.

שלוש השאלות שמבהירות את התמונהתרשים המראה שאם עוצרים על שלוש שאלות — באיזה ת'רד רצים עכשיו, לאן חוזר ההמשך של await, ומי אחראי להחזיר ל-UI — התמונה של קוד אסינכרוני ב-WPF וב-WinForms מתבהרת.באיזה ת'רד רצים עכשיובהירות בקוד UI אסינכרונילאן חוזר ההמשך של awaitמי אחראי להחזיר ל-UI

איור 1: שלוש השאלות לחזור אליהן כשמתבלבלים. ת’רד, יעד החזרה, ואחריות ההחזרה — בנפרד.

2. קודם כול, לסדר בדף אחד

2.1. התמונה הכוללת

הכי מהיר לתפוס את התמונה הכוללת דרך התרשים הזה.

התמונה הכוללת של ארבעת הדפוסים מנקודת מבט של מטפל אירוע UIתרשים המראה שמטפל אירוע UI מוביל לארבעה מסלולים — plain await ל-I/O שחוזר לת'רד ה-UI ומאפשר עדכון UI ישיר, await Task.Run לחישוב כבד שגם הוא חוזר לת'רד ה-UI, ConfigureAwait(false) שלא כופה חזרה ל-UI כך שנדרש Dispatcher או Invoke, ו-.Result / .Wait() / GetAwaiter().GetResult() שחוסמים את ת'רד ה-UI וגורמים לתקיעה או deadlock.מטפל אירוע UI (WPF / WinForms)plain await ל-API של I/Oתפיסת ה-UI SynchronizationContextההמשך מתחדש בת'רד ה-UIאפשר לכתוב עדכון UI ישירותawait Task.Run(...) לחישוב CPU כבדגוף החישוב רץ ב-ThreadPoolההמשך מתחדש בת'רד ה-UIawait SomeAsync().ConfigureAwait(false)לא כופה חזרה ל-UIההמשך בכל ת'רדעדכון UI ישיר מסוכן, נדרש Dispatcher / InvokeSomeAsync().Result / Wait() או GetAwaiter().GetResult()חוסם את ת'רד ה-UIההמשך לא יכול לחזור ל-UIתקיעה / deadlock / לפחות הקפאה

איור 2: התמונה הכוללת של ארבעת הדפוסים מנקודת מבט של מטפל UI. plain await ו-Task.Run חוזרים ל-UI, ConfigureAwait(false) לא כופה חזרה, ו-.Result /‏ .Wait() חוסמים את ת’רד ה-UI.

בעבודה בפועל, נתקלים בעיקר בארבעת הדפוסים האלה:

  1. plain await במטפל אירוע UI
  2. שימוש ב-Task.Run במטפל אירוע UI כדי להוציא CPU
  3. הסרת יעד החזרה עם ConfigureAwait(false)
  4. חסימת ת’רד ה-UI עם .Result /‏ .Wait()

2.2. טבלת ההחלטה הראשונה

מצב מה רץ בזמן ההמתנה ההמשך אחרי await מותר לגעת ב-UI ישירות? הבחירה הראשונה
await SomeIoAsync() במטפל UI המתנה לסיום I/O. ת’רד ה-UI עצמו יכול לחזור ללולאת ההודעות בדרך כלל ת’רד ה-UI כן plain await
await Task.Run(...) במטפל UI ה-CPU הכבד רץ ב-ThreadPool בדרך כלל ת’רד ה-UI כן Task.Run רק ל-CPU
await x.ConfigureAwait(false) במטפל UI יעד החזרה לא מקובע ל-UI כל ת’רד לא להימנע ברוב המקרים בקוד UI
x.Result /‏ x.Wait() בת’רד UI ת’רד ה-UI חסום בהמתנה ההמשך כמעט לא יכול לרוץ לא לא להשתמש
רוצים לעדכן UI אחרי ת’רד רקע או אחרי ConfigureAwait(false) רץ בת’רד שונה מה-UI כמו שהוא, זה לא ה-UI לא Dispatcher.InvokeAsync /‏ BeginInvoke /‏ InvokeAsync
כותבים ספרייה כללית שלא תלויה ב-UI לא תלוי בנסיבות הקורא לא כופה חזרה ל-UI לתכנן בלי לגעת לשקול ConfigureAwait(false)
רוצים לקרוא ל-async מבנאי או ממאפיין סינכרוני ת’רד ה-UI נוטה להיכנס להמתנה נתיב ההפעלה נוטה להיתקע לא להעביר ל-Loaded /‏ Shown /‏ InitializeAsync

החשוב בטבלה הזו הוא שplain await הוא דווקא בן ברית בקוד UI. האויב הוא לא await עצמו, אלא חסימה סינכרונית של ת’רד ה-UI.

הבחירה עצמה מרוכזת בטבלה הזו. הפרקים הבאים מחולקים כך: למה זה כך (פרקים 3–4), איך בוחרים כלי להחזרה ל-UI (פרק 5), איך מוצאים את זה בסקירת קוד (פרקים 6–7).

האויב הוא לא await אלא חסימה סינכרוניתתרשים המראה ש-plain await הוא דווקא בן ברית בקוד UI, ושהאויב האמיתי הוא חסימה סינכרונית של ת'רד ה-UI.plain awaitבקוד UI הוא דווקא בן בריתחסימה סינכרונית של ת'רד ה-UIמקור להקפאה או deadlock

איור 3: ליבת הטבלה. האויב אינו await עצמו, אלא חסימה סינכרונית של ת’רד ה-UI.

3. מילים שבהן משתמש המאמר

3.1. ת’רד ה-UI ולולאת ההודעות

ב-WPF /‏ WinForms, ה-UI בנוי בעיקרו כך שיש ת’רד UI אחד, שמריץ את טיפול הקלט, הציור והאירועים.

התפקיד של ת’רד ה-UI הזה הוא בערך כך:

  • מטפל בהודעות כמו לחיצת כפתור, קלט מקלדת וציור מחדש
  • הוא הת’רד היחיד שבטוח לגעת ממנו בפקדים ובאובייקטי UI
  • אם דוחסים לתוכו יותר מדי עיבוד, עדכון המסך ותגובת הקלט נעצרים

הליבה כאן היא שהתפקיד של ת’רד ה-UI הוא “לרוץ מהר”. אם חוסמים אותו לזמן ארוך, גם העכבר, גם המקלדת וגם הציור מחדש נתקעים, ומנקודת המבט של המשתמש זה נראה כ”נתקע”.

כדאי לשמור את התמונה הזו בראש בעזרת התרשים, כדי לא להתבלבל.

מעגל לולאת ההודעות ומה קורה כשהוא נחסםתרשים המראה שקלט משתמש או בקשת ציור מחדש מגיעים ללולאת ההודעות של ת'רד ה-UI, שמריצה את מטפל האירוע ומעדכנת את המסך וחוזרת ללולאה, אבל אם מטפל האירוע כולל עיבוד סינכרוני ארוך, לולאת ההודעות לא מסתובבת והמסך נראה תקוע.קלט משתמש / בקשת ציור מחדשלולאת ההודעות של ת'רד ה-UIהרצת מטפל האירועעדכון המסךעיבוד סינכרוני ארוךלולאת ההודעות לא מסתובבתהמסך נראה תקוע

איור 4: התפקיד של ת’רד ה-UI הוא להסתובב מהר בלולאת ההודעות; עיבוד סינכרוני ארוך עוצר את הלולאה והמסך נראה תקוע.

3.2. SynchronizationContext /‏ Dispatcher /‏ Invoke

המילים שמופיעות כאן הרבה, מחולקות בצורה מעשית:

מילה המשמעות כאן
ת’רד UI הת’רד שיצר את אובייקטי ה-UI. בעיקרו, רק הת’רד הזה בטוח לגעת ב-UI
לולאת הודעות המנגנון שבו ת’רד ה-UI מעבד הודעות אחת אחרי השנייה
SynchronizationContext הפשטה (abstraction) ל”החזרת עיבוד למקום ביצוע מסוים”
Dispatcher תור עבור ת’רד ה-UI ב-WPF
Invoke /‏ BeginInvoke /‏ InvokeAsync ה-API להשלכת עיבוד לת’רד ה-UI

אם כותבים ביתר דיוק איך נקבע יעד ההמשך, זה כך: await (השקול לברירת המחדל ConfigureAwait(true)) תופס קודם כול את SynchronizationContext.Current. רק אם זה null, הוא בודק את TaskScheduler.Current, ואם זה לא TaskScheduler.Default, ההמשך חוזר לאותו TaskScheduler. אם אף אחד מהשניים לא מתקיים — כלומר SynchronizationContext.Current הוא null וגם TaskScheduler.Current הוא ברירת המחדל — ההמשך רץ על ה-ThreadPool. בת’רד ה-UI של WPF /‏ WinForms מתקיימת האפשרות הראשונה, כלומר SynchronizationContext של ה-UI מוצב, ולכן בעבודה בפועל אפשר להניח בבטחה שה-SynchronizationContext של ה-UI פועל.

איך נקבע יעד ההמשך של awaitתרשים המראה שה-await הרגיל תופס קודם את SynchronizationContext.Current, ואם הוא null בודק את TaskScheduler.Current, ואם הוא לא ברירת המחדל חוזר לאותו TaskScheduler, ואם גם זה לא מתקיים ההמשך רץ על ThreadPool.ישnullלאכןawait רגיליש SynchronizationContext?חזרה לאותו הקשרTaskScheduler הוא ברירת מחדל?חזרה לאותו TaskSchedulerההמשך רץ על ThreadPoolבת'רד ה-UI זה הקשר ה-UI

איור 5: איך נקבע יעד ההמשך. בת’רד ה-UI מוצב ה-SynchronizationContext של ה-UI, ולכן ההמשך חוזר ל-UI.

התאמה בין המסגרות לבין הממומש בפועל הכי ברורה בטבלה:

מסגרת ההקשר בצד ה-UI ה-API המרכזי להחזרה מפורשת ל-UI
WPF DispatcherSynchronizationContext Dispatcher.InvokeAsync /‏ Dispatcher.BeginInvoke /‏ Dispatcher.Invoke
WinForms WindowsFormsSynchronizationContext Control.BeginInvoke /‏ Control.Invoke /‏ ‏.NET 9+ Control.InvokeAsync

ב-WPF, ה-Dispatcher הוא במרכז. ב-WinForms, ה-handle של הפקד ולולאת ההודעות הם במרכז, ו-BeginInvoke /‏ Invoke הם מה שבולט.

בעבודה בפועל, זכירת היחס הזה בין ההפשטה לבין הממומש בפועל מקטינה בלבול:

מ-SynchronizationContext המופשט לממומש בפועלתרשים המראה שהקוד הנוכחי משתמש ב-SynchronizationContext, שב-WPF הוא DispatcherSynchronizationContext וב-WinForms הוא WindowsFormsSynchronizationContext, וכל אחד מוביל ל-API המתאים להחזרה מפורשת ל-UI.הקוד הנוכחיSynchronizationContextWPF: DispatcherSynchronizationContextWinForms: WindowsFormsSynchronizationContextDispatcher.InvokeAsync / BeginInvoke / InvokeControl.BeginInvoke / Invoke / InvokeAsync(.NET 9+)

איור 6: ה-SynchronizationContext המופשט מתממש ב-WPF כ-Dispatcher וב-WinForms כ-API של Invoke על ה-Control.

4. דפוסים אופייניים

4.1. plain await במטפל אירוע UI

זו הצורה הכי ישירה.

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

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

בקוד הזה, LoadButton_Click מתחיל על ת’רד ה-UI. ו-‏await File.ReadAllTextAsync(...) הוא plain await, ולכן בדרך כלל הוא תופס באותו רגע את הקשר ה-UI.

בגלל זה:

  • בזמן ההמתנה ל-I/O של הקובץ, ת’רד ה-UI לא תפוס
  • ההמשך אחרי סיום הטעינה חוזר בדרך כלל לת’רד ה-UI
  • אפשר לכתוב PreviewTextBox.Text = text; ישירות

וזו הצורה הסופית.

כאן לא נדרש Dispatcher מיותר. אם עשיתם רק plain await בתוך מטפל UI, בדרך כלל אפשר לגעת ב-UI ישירות.

המטפל הזה הוא async void מכיוון שהחתימה של מטפל אירוע UI דורשת void, וזה מקרה חריג שבו async void מותר. יש סיבה ברורה למקם try /‏ catch בפנים דווקא בגלל זה. ב-async Task, החריגה נטענת על ה-Task שמוחזר, והקורא מקבל אותה כשעושה await. ל-async void אין Task כזה, ולכן חריגה שיוצאת החוצה נזרקת מחדש אל ה-SynchronizationContext שבו התחילה אותו מטפל, כלומר לת’רד ה-UI. חריגה בלתי מטופלת בת’רד ה-UI מגיעה ל-Application.DispatcherUnhandledException ב-WPF או ל-Application.ThreadException ב-WinForms, ואם לא מטפלים בה שם, האפליקציה קורסת.

כלומר, במטפל async void הבסיס הוא לתפוס בפנים, ואם, כמו בדוגמה למעלה, “מציגים את הכשל כטקסט סטטוס ומחזירים את הכפתור ב-finally”, זה נסגר בטבעיות מבחינת ה-UI. הרשת הכללית של האפליקציה (כמו DispatcherUnhandledException) היא רק רשת הביטחון האחרונה.

לאן הולכת חריגה מ-async voidתרשים המראה שחריגה מ-async Task נטענת על ה-Task המוחזר והקורא מקבל אותה ב-await, אבל חריגה מ-async void שיוצאת החוצה נזרקת מחדש לת'רד ה-UI ומגיעה לרשת הכללית, ואם לא מטפלים שם האפליקציה קורסת.async Taskasync voidחריגה שיצאה מחוץ למטפלasync Task או async void?נטענת על ה-Task המוחזרהקורא שעשה await מקבל אותהנזרקת מחדש לת'רד ה-UIמגיעה לרשת הכלליתאם לא מטפלים, האפליקציה קורסת

איור 7: ל-async void אין Task לטעון עליו חריגה, ולכן הבסיס הוא לתפוס אותה בתוך המטפל עצמו.

גם ב-WinForms ההסתכלות זהה. כל עוד עושים plain await בתוך מטפל Click, ההמשך חוזר בדרך כלל לצד ה-UI.

בתרשים, זה נראה כך:

זרימת plain await ממטפל Click ועד עדכון UIתרשים רצף המראה שמטפל Click מתחיל בת'רד ה-UI, עושה await על I/O אסינכרוני, שומר את החזרה להקשר ה-UI וחוזר ללולאת ההודעות בזמן ההמתנה, ולאחר סיום ה-I/O ההמשך מתחדש בת'רד ה-UI ומעדכן את הפקדים.UI SynchronizationContextI/O אסינכרונית'רד ה-UIUI SynchronizationContextI/O אסינכרונית'רד ה-UIבזמן ההמתנה חוזרים ללולאת ההודעותתחילת מטפל Clickawait על ReadAllTextAsyncשמירת ההחזרה ל-UIסיום ה-I/Oההמשך מתחדש בת'רד ה-UIעדכון TextBox / Label

איור 8: בזמן ההמתנה של plain await, ת’רד ה-UI חוזר ללולאת ההודעות, וההמשך מתחדש בו אחרי סיום ה-I/O.

4.2. Task.Run רק לחישוב CPU כבד

Task.Run עוזר כשרוצים להוציא חישוב CPU כבד מת’רד ה-UI.

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

    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
  2. ההמתנה ל-I/O של File.ReadAllBytesAsync זורמת באופן אסינכרוני
  3. רק חישוב ה-hash הכבד יוצא ל-ThreadPool עם Task.Run
  4. ההמשך של await Task.Run(...) הוא plain await, ולכן הוא חוזר לת’רד ה-UI
  5. אפשר לכתוב ResultText.Text = hash; ישירות

כלומר, רק מה שבתוך Task.Run רץ בת’רד אחר. זה לא שעוברים לצמיתות ל”מקום שהוא כבר לא ה-UI” עד אחרי ה-await.

אם רואים את זה בתרשים אחד, קשה יותר להתבלבל.

Task.Run רץ ב-ThreadPool בלבד, וההמשך חוזר ל-UIתרשים רצף המראה שת'רד ה-UI עושה await על ReadAllBytesAsync ומתחדש ב-UI כי זה plain await, ואז שולח את החישוב הכבד עם Task.Run ל-ThreadPool, ואחרי קבלת התוצאה ההמשך שוב מתחדש בת'רד ה-UI ומעדכן את המסך.ThreadPoolI/O אסינכרונית'רד ה-UIThreadPoolI/O אסינכרונית'רד ה-UIההמשך של await Task.Run(...) מתחדש ב-UIawait על ReadAllBytesAsyncמתחדש ב-UI כי זה plain awaitשליחת חישוב CPU כבד עם Task.Runהחזרת תוצאת החישובשיקוף התוצאה על המסך

איור 9: רק Task.Run רץ ב-ThreadPool; ההמשך של await חוזר לת’רד ה-UI, כך שאפשר לשקף את התוצאה ישירות.

יש שתי נקודות לתשומת לב כאן:

  • לא לעטוף המתנת I/O ב-Task.Run
  • Task.Run הוא לא “הפיכה לאסינכרוני”, אלא יצירת “יעד בריחה ל-CPU”

כתיבה כמו Task.Run(async () => await File.ReadAllTextAsync(...)) רק זורקת מחדש בלי תועלת את המתנת ה-I/O ל-ThreadPool.

מתי משתמשים ב-Task.Runתרשים המראה שאם רוצים להוציא חישוב CPU כבד, Task.Run הוא הכלי הנכון, אבל אם רוצים להוציא המתנת I/O, אין לעטוף אותה ב-Task.Run כי זה רק זורק מחדש את ההמתנה בלי תועלת.חישוב CPU כבדהמתנת I/Oמה רוצים להוציא?הוצאה עם Task.Runלא לעטוף ב-Task.Runרק זורק מחדש את ההמתנה בלי תועלת

איור 10: Task.Run הוא לא כלי ל”הפיכה לאסינכרוני”, אלא כלי ליצירת יעד בריחה ל-CPU.

4.3. ‏ConfigureAwait(false) הוא לא “הבטחה שלא חוזרים” אלא “לא כופה לחזור”

זו הנקודה שהכי קל לטעות בה.

קודם כול, ConfigureAwait(false) מתאים לקוד ספרייה כללי שלא תלוי ב-UI או במודל אפליקציה מסוים.

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);
    }
}

השיטה הזו לא נוגעת ב-UI. היא נכתבת כך שאפשר להשתמש בה גם ב-WPF, גם ב-WinForms, גם ב-ASP.NET Core וגם ב-worker. בקוד כזה, טבעי להוסיף ConfigureAwait(false).

והקריאה מצד ה-UI יכולה להישאר plain await.

private readonly DocumentRepository _repository = new();

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

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

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

החשוב כאן הוא ש-ConfigureAwait(false) בתוך הספרייה לא כופה false גם על ה-await של הקורא.

כלומר:

  • בתוך הספרייה לא חוזרים ל-UI
  • אם מטפל UI עושה עליה plain await, ההמשך של הקורא חוזר ל-UI

וכך נוצרת הפרדה כזו.

ההפרדה בין הספרייה ל-UIתרשים המראה שבתוך ספרייה כללית, ה-await עם ConfigureAwait(false) לא דורש חזרה להקשר UI, אבל אם מטפל UI עושה עליה plain await, ההמשך של הקורא חוזר ל-UI, מכיוון שהציון הפנימי לא חל על החוץ.await בתוך הספרייהConfigureAwait(false)לא דורש חזרה ל-UIplain await של מטפל UIההמשך של הקורא חוזר ל-UIהציון הפנימי לא חל על החוץ

איור 11: ConfigureAwait(false) בספרייה לא כופה false גם על ה-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 לא כופה חזרה ל-UI. לכן PreviewTextBox.Text = text; עלול להפוך לגישה חוצת-ת’רדים.

יש עוד נקודה חשובה, אף שהיא לא בולטת. גם אם מוסיפים ConfigureAwait(false), לא בטוח שזה תמיד עובר ל-ThreadPool — אם ה-await הזה הושלם מיד בלי המתנה, ההמשך יכול להמשיך לרוץ פשוט על אותו ת’רד. לקרוא את זה כ”תמיד עובר לת’רד אחר” או “מכאן והלאה זה כבר לא UI לעולם” זה מקור לתקלות. המשמעות היא רק שההמשך של אותו await לא כופה חזרה להקשר ה-UI המקורי, ותו לא.

בתרשים זה נראה כך:

ההשפעה של ConfigureAwait(false) על יעד ההמשךתרשים המראה שבמטפל UI שעושה await, אם לא מוסיפים ConfigureAwait(false) ההמשך חוזר בדרך כלל לת'רד ה-UI ואפשר לעדכן UI ישירות, אבל אם מוסיפים אותו ההמשך לא מקובע ל-UI ועלול להתחדש בכל ת'רד, ואז נדרש Dispatcher או Invoke לעדכון UI.לאכןawait במטפל UIמוסיפים ConfigureAwait(false)?ההמשך בדרך כלל בת'רד ה-UIקל לעדכן UI ישירותההמשך לא מקובע ל-UIעלול להתחדש בכל ת'רדעדכון UI דורש Dispatcher / Invoke

איור 12: מה שמשתנה עם ConfigureAwait(false) הוא רק “האם כופים חזרה ל-UI” — לא יותר מזה.

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 מסוכן.

בתרשים, הזרימה נראית כך:

איך .Result חוסם את ת'רד ה-UI וגורם ל-deadlockתרשים רצף המראה שת'רד ה-UI מתחיל את LoadButton_Click, קורא ל-LoadTextAsync שמחזיר Task לא גמור, נחסם בהמתנה עם .Result, ואז כשה-I/O מסתיים וההמשך רוצה לחזור ל-UI הוא לא יכול לרוץ כי ה-UI תפוס ב-.Result, כך שהמשימה לעולם לא מסתיימת.UI SynchronizationContextI/O אסינכרונית'רד ה-UIUI SynchronizationContextI/O אסינכרונית'רד ה-UIאבל ה-UI תפוס ב-.Resultההמשך לא יכול לרוץ, אז לא מסתייםתחילת LoadButton_Clickקריאה ל-LoadTextAsync()החזרת Task לא גמורהמתנה חוסמת עם .Resultסיום I/O, רוצים להחזיר את ההמשך ל-UIרוצים להריץ את ההמשך

איור 13: ה-UI שנחסם ב-.Result לא נותן להמשך לחזור אליו, וה-Task לא מסתיים לעולם.

אם מנסחים במילים מה קורה, זה כך:

  1. ת’רד ה-UI קורא ל-LoadTextAsync()
  2. ה-await בתוך LoadTextAsync() תופס את הקשר ה-UI
  3. ת’רד ה-UI ממתין עם .Result
  4. ה-I/O מסתיים
  5. ההמשך של LoadTextAsync() רוצה לחזור לת’רד ה-UI
  6. אבל ת’רד ה-UI תפוס ב-.Result
  7. ההמשך לא יכול לרוץ, ולכן LoadTextAsync() לא מסתיים
  8. .Result לא נגמר

כלומר, ה-UI אומר “אני ממתין עד שתסיים”, והצד האסינכרוני אומר “אני יכול לסיים רק אם אחזור ל-UI” — ושניהם מחכים זה לזה. תחושה ממש לא נעימה.

המבנה של המתנה הדדיתתרשים המראה שת'רד ה-UI ממתין ל-.Result לסיום העיבוד האסינכרוני, וההמשך האסינכרוני צריך שת'רד ה-UI יתפנה כדי לחזור אליו, ולכן שני הצדדים ממתינים זה לזה ולא מתקדמים.ת'רד ה-UI ממתין עם .Resultצריך שהעיבוד האסינכרוני יסתייםההמשך צריך לחזור ל-UIצריך שת'רד ה-UI יתפנהשני הצדדים מחכים זה לזה

איור 14: “אני ממתין שתסיים” מול “אני יכול לסיים רק כשאחזור ל-UI” — התנגשות שיוצרת המתנה הדדית.

טעות נפוצה כאן היא לחשוב ש-GetAwaiter().GetResult() בטוח יותר. אבל המהות של חסימת ת’רד ה-UI זהה. מה שבעיקר שונה הוא אופן העטיפה של החריגה.

לכן, בטוח יותר להתייחס לשלושת אלה כאל אותה “ריח” חשוד ב-UI:

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

יש לציין: מאותה סיבה, גם Task.Wait() מת’רד ה-UI על ה-Task שה-Dispatcher.InvokeAsync(...) של WPF מחזיר, על ה-DispatcherOperation שלו, מסוכן. InvokeAsync רק מכניס את הדלגט שהעברתם לתור של ה-Dispatcher, וההרצה בפועל קורית רק כשת’רד ה-UI מסובב את התור הזה. אם ת’רד ה-UI עצור ב-Wait(), התור לא מסתובב, ולכן ה-Task הזה לעולם לא יושלם. אותו סיפור נמצא גם בצד DispatcherOperation: מפורש ב-DispatcherOperation.Wait() שהמתנה על פעולה שרצה באותו ת’רד תגרום ל-InvalidOperationException. כלומר, המסלול של “חסימה והמתנה” עצמו לא נועד לזה מלכתחילה. בהקשר ה-UI, הכיוון של “להמתין באופן סינכרוני למשהו שהושלך” בעצמו הוא מה שנוטה להיתקע. הסבר מפורט על אופן התקיעה נמצא ב-Await, and UI, and deadlocks! Oh my!, וזה קריא בקלות.

הסכנה בהמתנה סינכרונית ל-InvokeAsyncתרשים המראה ש-Dispatcher.InvokeAsync רק מכניס דלגט לתור, וההרצה קורית רק כשת'רד ה-UI מסובב את התור, כך שאם ת'רד ה-UI עצור ב-Wait התור לא מסתובב וה-Task לעולם לא מושלם.הכנסה לתור עם InvokeAsyncמתבצע כשת'רד ה-UI מסובב את התורת'רד ה-UI עצור ב-Waitהתור לא מסתובבה-Task לעולם לא מושלם

איור 15: כשת’רד ה-UI עצמו ממתין באופן סינכרוני למה שהושלך, זירת ההרצה — התור — לא מסתובבת, וזה לא נגמר לעולם.

האם זה “בהכרח יגרום ל-deadlock”? לא בהכרח. בקוד שבו ההמשך במקרה לא חוזר ל-UI, זה עשוי רק להקפיא את ה-UI בלי deadlock ממש. אבל גם זה מספיק גרוע, ולכן עדיף לא לעשות את זה ב-UI בעיקרון.

5. מתי להשתמש ב-Dispatcher /‏ Invoke

בהתבסס על כל זה, במטפל UI עם plain await, בדרך כלל לא נדרש Dispatcher /‏ Invoke מפורש.

זה נדרש, לדוגמה, במקרים כאלה:

  • רוצים לגעת ב-UI בהמשך אחרי ConfigureAwait(false)
  • בתוך Task.Run, או במבנה שגם בחוץ לא חוזרים ל-UI
  • ההודעה מגיעה ממקום שמלכתחילה לא ת’רד UI, כמו קבלת socket, טיימר או callback של אירוע
  • בשכבה שמפרידה בכוונה בין UI ללא-UI, ורוצים לציין במפורש רק את עדכון ה-UI האחרון

ב-WPF, הנציג המרכזי הוא 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 = "完了";
    });
}

ב-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 = "完了";
    });
}

בדפוס הוותיק של WinForms משתמשים ב-BeginInvoke. ‏Invoke הוא שליחה סינכרונית שמשאירה את הצד הקורא ממתין. BeginInvoke מפרסם וחוזר מיד. בזרימת async, בדרך כלל הצד שלא חוסם משתלב טוב יותר.

ההבדל בין Invoke ל-BeginInvokeתרשים המראה ש-Invoke הוא שליחה סינכרונית שמשאירה את הקורא ממתין, בעוד BeginInvoke מפרסם וחוזר מיד ומתאים לזרימת async.Invoke (שליחה סינכרונית)משאיר את הקורא ממתיןBeginInvoke (פרסום)חוזר מידמשתלב עם זרימת async

איור 16: גם Invoke וגם BeginInvoke “משליכים ל-UI”, אבל האופי שונה — האחד מחכה והשני חוזר מיד.

עם זאת, Control.BeginInvoke מחזיר IAsyncResult, ולכן אי אפשר לעשות עליו await ישירות. בסביבות בלי Control.InvokeAsync (כמו ‏.NET Framework 4.8,‏ .NET 6 /‏ 8 וכדומה), אם רוצים להעלות את זה על זרימת async, הכי טבעי לעטוף עם TaskCompletionSource ולהפוך ל-Task.

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

public static class ControlUiExtensions
{
    // כדי שזה יעבוד גם ב-.NET Framework 4.8 בלי שינוי, משתמשים בגרסה הגנרית
    // של TaskCompletionSource. מ-.NET 5 ואילך אפשר לכתוב גם עם הגרסה הלא-גנרית.
    //
    // לא הפכנו את cancellationToken לאופציונלי. אם הפקד נהרס אחרי ש-BeginInvoke
    // כבר קיבל את הדלגט, הדלגט שהוגש נזרק בלי לרוץ, ו-TaskCompletionSource
    // לא מקבל לא תוצאה ולא חריגה. בלי פתח לביטול, הצד שעושה
    // await ימתין לנצח
    public static Task InvokeOnUiAsync(
        this Control control, Action action, CancellationToken cancellationToken)
    {
        if (control is null)
        {
            throw new ArgumentNullException(nameof(control));
        }

        if (action is null)
        {
            throw new ArgumentNullException(nameof(action));
        }

        if (!control.IsHandleCreated)
        {
            throw new InvalidOperationException("ウィンドウハンドルがまだ作成されていません。");
        }

        if (!control.InvokeRequired)
        {
            action();
            return Task.CompletedTask;
        }

        // כדי שההמשך של הצד שעשה await לא ירוץ ישירות על ת'רד ה-UI,
        // מציינים במפורש שההמשך זורם באופן אסינכרוני.
        var tcs = new TaskCompletionSource<bool>(
            TaskCreationOptions.RunContinuationsAsynchronously);

        // ביטול וביצוע נאבקים על אותה "זכות חד-פעמית".
        // עם Interlocked.Exchange, רק מי שהצליח לכתוב 1 ראשון ממשיך הלאה.
        // כתיבה שבודקת דגל ואז קוראת ל-action() משאירה פתח: אם הביטול קורה
        // מיד אחרי הבדיקה, נשאר מסלול שבו "הקורא כבר קיבל ביטול והתחיל
        // פעולה חדשה, אבל הדלגט הישן מגיע אחר כך ומשכתב את המסך"
        int claimed = 0;   // 0 = טרם הוכרע / 1 = מישהו כבר תפס

        // אם מתבטלים, ה-Task נסגר גם אם הדלגט לא רץ.
        // את הרישום מסירים תמיד עם סיום ה-Task (בלי זה, tcs יישאר תפוס
        // כל עוד הטוקן חי). CancellationTokenRegistration.Dispose הוא
        // thread-safe, ולכן אפשר לקרוא לו מכל ת'רד
        CancellationTokenRegistration registration = cancellationToken.Register(() =>
        {
            if (Interlocked.Exchange(ref claimed, 1) == 0)
            {
                tcs.TrySetCanceled(cancellationToken);
            }
        });

        tcs.Task.ContinueWith(
            _ => registration.Dispose(),
            CancellationToken.None,
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);

        try
        {
            control.BeginInvoke(new Action(() =>
            {
                // ייתכן ביטול בפרק הזמן שבין ההגשה לבין שת'רד ה-UI מתחיל לרוץ.
                // אם לא מצליחים לתפוס את הזכות כאן, סימן שצד הביטול תפס קודם,
                // ולכן חוזרים בלי לגעת במסך כלל
                if (Interlocked.Exchange(ref claimed, 1) != 0)
                {
                    return;
                }

                try
                {
                    action();
                    tcs.TrySetResult(true);
                }
                catch (Exception ex)
                {
                    tcs.TrySetException(ex);
                }
            }));
        }
        catch (Exception ex)
        {
            // גם BeginInvoke עצמו עלול לזרוק (למשל אם ה-handle כבר לא קיים).
            // בלי לסגור כאן, זה יישאר תלוי בהמתנה שוב.
            // הדלגט לא ירוץ, ולכן גם כאן קודם תופסים את הזכות ואז סוגרים
            if (Interlocked.Exchange(ref claimed, 1) == 0)
            {
                tcs.TrySetException(ex);
            }
        }

        return tcs.Task;
    }
}

צד הקורא נראה כמעט זהה לדוגמה של InvokeAsync. קשרו את הטוקן לאורך חיי הטופס.

// フォームのフィールド。閉じるときにキャンセルする
private readonly CancellationTokenSource _formClosing = new();

protected override void OnFormClosed(FormClosedEventArgs e)
{
    // 投稿済みのデリゲートが実行されないまま捨てられても、
    // ここで await 側を畳めるようにしておく
    _formClosing.Cancel();
    base.OnFormClosed(e);
}

private async Task RefreshPreviewAsync(string path, CancellationToken cancellationToken)
{
    using var linked = CancellationTokenSource.CreateLinkedTokenSource(
        cancellationToken, _formClosing.Token);

    string text = await File.ReadAllTextAsync(path, linked.Token).ConfigureAwait(false);

    await previewTextBox.InvokeOnUiAsync(() =>
    {
        previewTextBox.Text = text;
        statusLabel.Text = "完了";
    }, linked.Token);
}

בצורה הזו, גם חריגה שקרתה בצד ה-UI מתקבלת ב-try /‏ catch במקום שבו נעשה ה-await. יש ארבע נקודות לזכור:

  • ביטול וביצוע — “בדיקת דגל ואז פעולה” לא מספיקה. מיד אחרי בדיקת “האם כבר בוטל?” ולפני קריאה ל-action(), ייתכן שביטול יתרחש. באותו רגע tcs הופך למבוטל, והקורא שעשה await ממשיך הלאה ומתחיל פעולה חדשה. אחר כך, הדלגט הישן שעדיין נמצא בתור רץ ומשכתב את המסך — התצוגה החדשה נדרסת על ידי הישנה, תקלה שקשה לשחזר. בגלל זה הקוד למעלה משתמש ב-Interlocked.Exchange כדי לגרום לביטול ולביצוע להיאבק על “זכות חד-פעמית”, כך שהצד שלא תפס חוזר בלי לעשות כלום
  • BeginInvoke לפני יצירת ה-handle (לפני Load), או אחרי סגירת הטופס, גורם לחריגה. שימו לב למחזור החיים של הצד הקורא
  • אם הפקד נהרס אחרי ההגשה, הדלגט עלול להיזרק בלי לרוץ. במקרה כזה TaskCompletionSource לא מקבל לא תוצאה ולא חריגה, והצד שעושה await ימתין לנצח. תמיד להעביר טוקן שקשור לסגירת הטופס, כמו בדוגמה למעלה. תוצאת הסגירה עולה כ-OperationCanceledException
  • File.ReadAllTextAsync הוא API מ-.NET Core 2.0 ואילך. אם רוצים אותה צורה ב-.NET Framework 4.8, יש להחליף ל-StreamReader.ReadToEndAsync וכדומה
המאבק על זכות הביטול והביצועתרשים המראה שצד הביטול וצד הביצוע נאבקים עם Interlocked.Exchange על זכות חד-פעמית, ורק מי שתפס ראשון ממשיך, כשצד הביטול סוגר את ה-Task כמבוטל וצד הביצוע מריץ את action ומכניס תוצאה, כדי למנוע דלגט ישן שמשכתב את המסך.זכות חד-פעמיתצד הביטול תפס ראשוןצד הביצוע תפס ראשוןסגירת ה-Task כמבוטלהדלגט הישן חוזר בלי לעשות כלוםהרצת action והכנסת תוצאה

איור 17: לא בודקים דגל ואז פועלים — נותנים לצד הביטול ולצד הביצוע להיאבק על זכות חד-פעמית, ומי שלא תפס חוזר בלי לעשות כלום.

בתור הבחנה, מספיק החלוקה הזו:

מה רוצים WPF WinForms
כניסה סינכרונית ל-UI Dispatcher.Invoke Control.Invoke
השלכה אסינכרונית ל-UI Dispatcher.InvokeAsync /‏ Dispatcher.BeginInvoke Control.BeginInvoke /‏ ‏.NET 9+ Control.InvokeAsync
רוצים שזה ישתלב בטבעיות עם async /‏ await Dispatcher.InvokeAsync .NET 9+ Control.InvokeAsync, ולפני כן BeginInvoke

מבחינת התחושה בעבודה בפועל:

  • אם רק עושים plain await במטפל UI, זה לא נדרש
  • משתמשים בזה כשרוצים לגעת ב-UI ממקום שהוא לא UI
  • לא להרבות ב-Invoke סינכרוני בתוך זרימת async

זה מקטין מאוד את התקלות.

אם מתבלבלים, מספיק תרשים החלטה כזה:

תרשים החלטה - האם נדרש Dispatcher או Invokeתרשים המראה שאם ממשיכים לכתוב בת'רד ה-UI, אפשר להשאיר plain await ולעדכן UI ישירות, ואם לא, בודקים אם רוצים לגעת ב-UI, ואם כן משתמשים ב-Dispatcher.InvokeAsync ב-WPF או ב-BeginInvoke / InvokeAsync ב-WinForms, ואם לא ממשיכים בעיבוד כרגיל.כןלאלאכןכןהמקום שממשיכים לכתוב בו הוא ת'רד ה-UI?כן?אפשר להשאיר plain await ולעדכן UIרוצים לגעת ב-UI?המשך עיבוד רגילWPF: Dispatcher.InvokeAsyncWinForms: BeginInvoke / InvokeAsync

איור 18: לפי השאלה אם ממשיכים על ת’רד ה-UI, מחליטים אם plain await מספיק או שנדרש Dispatcher / Invoke.

6. אנטי-דפוסים נפוצים

אנטי-דפוס מה קשה בו התחליף הראשון
LoadAsync().Result במטפל UI חוסם את ת’רד ה-UI. נוטה ל-deadlock await LoadAsync()
LoadAsync().Wait() במטפל UI אותו דבר. לולאת ההודעות נעצרת await LoadAsync()
LoadAsync().GetAwaiter().GetResult() במטפל UI רק אופן עטיפת החריגה שונה, החסימה זהה await LoadAsync()
הוספת ConfigureAwait(false) באופן מכני לקוד UI עדכון UI אחרי await נוטה להישבר לשמור על plain await בשכבה החיצונית ביותר של ה-UI
Task.Run(async () => await IoAsync()) זריקה מיותרת של I/O מחדש await IoAsync()
קוד ספרייה שאוחז ישירות ב-Dispatcher או ב-Control התלות ב-UI מעמיקה. קשה לשימוש חוזר הספרייה מחזירה רק נתונים, וה-marshal נעשה בצד ה-UI
שימוש רב ב-Dispatcher.Invoke /‏ Control.Invoke בזרימת async קל ליצור טבעת חסימות לשקול Dispatcher.InvokeAsync /‏ BeginInvoke /‏ InvokeAsync
הפיכת async לסינכרוני בבנאי או ב-getter של מאפיין מקור לתקיעה בזמן ההפעלה להעביר ל-Loaded /‏ Shown /‏ InitializeAsync

מבין אלה, שלושה נתקלים בהם הכי הרבה:

  1. .Result /‏ .Wait() בת’רד ה-UI
  2. הוספה מכנית של ConfigureAwait(false) לקוד UI
  3. אחריות הספרייה וה-UI מתערבבות, וה-Dispatcher חודר עמוק פנימה

רק בהסרת שלושת אלה, הקוד נרגע משמעותית.

שלושת המקומות עם שיעור ההיתקלות הגבוה ביותרתרשים המראה שהסרת שלושת המקומות עם שיעור ההיתקלות הגבוה — .Result או .Wait בת'רד ה-UI, ConfigureAwait(false) שמוסף מכנית, ו-Dispatcher שחודר עמוק כי האחריות מתערבבת — מרגיעה משמעותית את הקוד..Result או .Wait בת'רד ה-UIהסרת שלושת אלהConfigureAwait מכניDispatcher שחודר עמוקהקוד נרגע משמעותית

איור 19: מבין האנטי-דפוסים, שלושת אלה נתקלים בהם הכי הרבה, וההשפעה של הסרתם גדולה.

7. רשימת בדיקה לסקירת קוד

התוכן זהה לטבלת ההחלטה בסעיף 2.2 ולאנטי-דפוסים בפרק 6, אבל כאן זה בצורת שאלות שבודקים בסדר כשפותחים קוד.

  • האם נשארו .Result /‏ .Wait() /‏ .GetAwaiter().GetResult() במטפל אירוע UI או במסלול אתחול UI
  • האם Task.Run משמש רק לחישוב CPU? הוא לא עוטף I/O?
  • האם ConfigureAwait(false) לא נכנס מכנית לקוד UI
  • ולהפך, האם הספרייה הכללית לא גוררת תלות בהקשר UI
  • במקומות שנוגעים ב-UI ישירות אחרי await, אפשר לוודא באמת שזה בהקשר UI
  • במקומות שדורשים חזרה מפורשת ל-UI, האם משתמשים ב-Dispatcher.InvokeAsync /‏ BeginInvoke /‏ InvokeAsync
  • האם marshal סינכרוני כמו Dispatcher.Invoke /‏ Control.Invoke לא הולך וגדל שלא לצורך
  • האם לא הופכים async לסינכרוני בכוח מבנאי, ממאפיין סינכרוני או מאירוע סינכרוני
  • האם שכבת הספרייה לא מפנה ישירות ל-Window /‏ Control /‏ Dispatcher

רשימת הבדיקה הזו נוחה גם לתיאום בין חברי הצוות לגבי “מה אחריות ה-UI”.

8. חלוקה גסה בין המצבים

הבחירה לפי מצב מרוכזת בטבלת ההחלטה בסעיף 2.2, אז כאן נשאיר רק דרך זכירה קצרה לקחת הביתה:

  • בשכבה החיצונית ביותר של ה-UI, plain await. אפשר לגעת ב-UI ישירות אחרי await בזכות זה
  • Task.Run הוא יעד בריחה ל-CPU. הוא לא כלי לעטיפת המתנת I/O
  • ConfigureAwait(false) הוא כלי לספרייה כללית. לא מוסיפים אותו מכנית לקוד UI
  • Dispatcher /‏ BeginInvoke /‏ InvokeAsync — רק כשנוגעים ב-UI ממקום שהוא לא UI
  • לא להשתמש בשלושת ההמתנות בת’רד ה-UI (‏.Result /‏ .Wait() /‏ .GetAwaiter().GetResult()). אם מתחשק לסנכרן, מרחיבים את ה-async עד הקורא עצמו

ההסבר נמצא בפרק 4, הבחירה בין Dispatcher /‏ Invoke בפרק 5, והנקודות למציאה בקוד בפועל בפרקים 6–7.

9. סיכום

מה שבאמת חשוב ב-async /‏ await ב-WPF /‏ WinForms הוא לא התחושה של “אסינכרוני זה קשה”, אלא

  • איפה זה התחיל עכשיו
  • לאן חוזר ההמשך של await
  • מי אחראי להחזיר ל-UI

לחשוב על שלושת אלה בנפרד.

בתור כללי בסיס, שמירה על אלה מספיקה כדי להתמודד היטב:

  1. בשכבה החיצונית ביותר של ה-UI, plain await
  2. Task.Run רק לחישוב CPU כבד
  3. בספרייה כללית, לשקול ConfigureAwait(false)
  4. רק כשצריך לחזור ל-UI, Dispatcher /‏ BeginInvoke /‏ InvokeAsync
  5. בת’רד ה-UI לא משתמשים ב-.Result /‏ .Wait() /‏ .GetAwaiter().GetResult()

async /‏ await עצמם לא מנגנון קפדני במיוחד. אבל אם משתמשים בהם בלי להסתכל דרך ת’רד ה-UI כמרכז, זה הופך פתאום לבוץ.

מצד שני:

  • להפריד בין החוץ לפנים של ה-UI
  • להיות מודעים ליעד החזרה
  • לא להכניס חסימות

רק שמירה על שלושת אלה הופכת את הקוד האסינכרוני ב-WPF /‏ WinForms לשקט הרבה יותר. קוד שגורם למסך להיתקע הוא בדרך כלל לא “כי אסינכרוני זה רע”, אלא סתם דרך רשלנית ל”לקחת הלוואה” מת’רד ה-UI.

שלושת העקרונות של קוד UI אסינכרוני שקטתרשים המראה ששמירה על הפרדה בין חוץ לפנים של ה-UI, מודעות ליעד החזרה של await, ואי-הכנסת חסימות, מספיקה כדי שהקוד האסינכרוני ב-WPF וב-WinForms יהיה שקט.הפרדה בין חוץ לפנים של ה-UIקוד אסינכרוני שקטמודעות ליעד ההחזרהאי-הכנסת חסימות

איור 20: שלושת עקרונות הסיכום. להפריד, להיות מודעים ליעד ההחזרה, לא לחסום — וכך המסך לא נתקע.

10. מקורות

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

לאן חוזרים אחרי await?
כשעושים plain await (בלי ConfigureAwait) במטפל אירוע UI ב-WPF /‏ WinForms, ההמשך אחרי ה-await חוזר בדרך כלל אל ת'רד ה-UI. הסיבה היא ש-await תופס את ה-UI SynchronizationContext הנוכחי ומחזיר אליו את ההמשך, כך שאפשר לכתוב עדכון של TextBox או Label ישירות אחרי ה-await. גם ב-await Task.Run(...), גוף החישוב עצמו רץ ב-ThreadPool, אבל אם זה plain await, ההמשך מתחדש בת'רד ה-UI.
למה שימוש ב-.Result או ב-.Wait() בת'רד ה-UI גורם לתקיעה?
בזמן שת'רד ה-UI ממתין עם .Result, ההמשך של העיבוד האסינכרוני מנסה לחזור להקשר ה-UI שנתפס, אבל ת'רד ה-UI חסום בגלל ה-.Result ולכן ההמשך לא יכול לרוץ — וכך שני הצדדים ממתינים זה לזה ונוצר deadlock. גם GetAwaiter().GetResult() דומה מהותית — ההבדל היחיד הוא באופן שבו החריגה עטופה, אבל זה עדיין חוסם את ת'רד ה-UI. ב-UI כדאי להימנע משלושתם — .Result,‏ .Wait() ו-‏GetAwaiter().GetResult() — ולהשתמש ב-await.
כדאי להוסיף ConfigureAwait(false) לקוד UI?
עדיף שלא. המשמעות של ConfigureAwait(false) היא "לא לכפות חזרה להקשר ה-UI שנתפס", ולכן ההמשך עלול להתחדש בכל ת'רד, ועדכון UI מיד אחרי כן עלול להפוך לגישה חוצת-ת'רדים (cross-thread). זה מתאים לקוד ספרייה כללי שלא תלוי ב-UI, והמדיניות היא לשמור על plain await בשכבה החיצונית ביותר של ה-UI.
מתי כדאי להשתמש ב-Task.Run?
רק כשרוצים להוציא חישוב CPU כבד מת'רד ה-UI. עטיפת המתנת I/O ב-Task.Run רק זורקת מחדש את ההמתנה ל-ThreadPool בלי שום תועלת. רק מה שבתוך Task.Run רץ בת'רד אחר, ואם ה-await Task.Run(...) הוא plain await, ההמשך שלו בדרך כלל חוזר לת'רד ה-UI, כך שאפשר לכתוב את שיקוף התוצאה על המסך ישירות.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג