async/await וה-UI thread ב-WPF וב-WinForms

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

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 12 Mar 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173372)

מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.

Go Komura (2026). async/await וה-UI thread ב-WPF וב-WinForms. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173372 https://comcomponent.com/he/blog/wpf-winforms-ui-thread-async-await-one-sheet/

DOI (הגרסה האחרונה)
10.5281/zenodo.22173372
DOI (הגרסה הזו)
10.5281/zenodo.22173373

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

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

בשטח, אלה המקומות שבהם באמת נתקעים:

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

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

המאמר מניח בעיקר אפליקציות 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 thread ו-message loop
    • 3.2. SynchronizationContext / Dispatcher / Invoke
  4. דפוסים נפוצים
    • 4.1. plain await ב-event handler של UI
    • 4.2. Task.Run רק לחישוב CPU כבד
    • 4.3. ConfigureAwait(false) אינו “הבטחה שלא חוזרים” אלא “לא מכריח לחזור”
    • 4.4. למה .Result / .Wait() / .GetAwaiter().GetResult() תוקעים
  5. מתי להשתמש ב-Dispatcher / Invoke
  6. anti-patterns נפוצים
  7. checklist ל-code review
  8. איך בוחרים בפועל
  9. סיכום
  10. מקורות

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 26, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

1. קודם כל, המסקנות

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

בקיצור, ב-WPF וב-WinForms עוצרים על שלוש נקודות:

  1. באיזה thread רצים עכשיו
  2. לאן חוזר ה-continuation של await
  3. מי אחראי להחזיר ל-UI

אם מחזיקים את שלושתן, התמונה מתבהרת.

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

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

2. מבט אחד על התמונה

2.1. סקירה

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

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

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

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

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

2.2. טבלת החלטה

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

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

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

הבעיה אינה await אלא חסימה סינכרוניתתרשים המראה ש-plain await בקוד UI הוא לטובתכם, ושהבעיה האמיתית היא חסימה סינכרונית של ה-UI thread.plain awaitבקוד UI זה לטובתכםחסימה סינכרונית של ה-UI threadמקור ל-freeze או deadlock

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

3. מונחים במאמר

3.1. UI thread ו-message loop

ב-WPF וב-WinForms, ה-UI בנוי בעיקר כך: יש UI thread אחד, שמריץ קלט, ציור ואירועים.

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

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

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

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

מעגל ה-message loop ומה קורה כשהוא נחסםתרשים המראה שקלט משתמש או בקשת repaint מגיעים ל-message loop של ה-UI thread, שמריץ את ה-event handler ומעדכן את המסך וחוזר ללולאה, אבל אם ה-event handler כולל עיבוד סינכרוני ארוך, ה-message loop לא מסתובב והמסך נראה תקוע.קלט משתמש / בקשת repaintmessage loop של ה-UI threadהרצת ה-event handlerעדכון המסךעיבוד סינכרוני ארוךה-message loop לא מסתובבהמסך נראה תקוע

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

3.2. SynchronizationContext / Dispatcher / Invoke

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

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

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

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

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

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

framework ה-context בצד ה-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 של ה-control וה-message loop הם במרכז, ו-BeginInvoke / Invoke הם מה שבולט.

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

מ-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 ב-event handler של 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 thread. ו-await File.ReadAllTextAsync(...) הוא plain await, ולכן בדרך כלל הוא תופס באותו רגע את ה-UI context.

בגלל זה:

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

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

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

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

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

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

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

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

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

זרימת plain await מ-Click handler ועד עדכון UIתרשים רצף המראה ש-Click handler מתחיל ב-UI thread, עושה await על I/O אסינכרוני, שומר את החזרה ל-UI context וחוזר ל-message loop בזמן ההמתנה, ולאחר סיום ה-I/O ה-continuation מתחדש ב-UI thread ומעדכן את ה-controls.UI SynchronizationContextI/O אסינכרוניUI threadUI SynchronizationContextI/O אסינכרוניUI threadבזמן ההמתנה חוזרים ל-message loopתחילת Click handlerawait על ReadAllTextAsyncשמירת החזרה ל-UIסיום ה-I/Oה-continuation מתחדש ב-UI threadעדכון TextBox / Label

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

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

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

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

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

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

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

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

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

  • לא לעטוף המתנת 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 handler עושה עליה plain await, ה-continuation של הקורא חוזר ל-UI

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

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

איור 11: ConfigureAwait(false) בספרייה לא מכריח false גם על ה-await של הקורא.

לעומת זאת, אם ה-UI handler עצמו כותב כך, זה מסוכן:

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

    PreviewTextBox.Text = text;
}

במקרה הזה, ה-continuation של אותו await ב-OpenButton_Click לא מכריח חזרה ל-UI. לכן PreviewTextBox.Text = text; עלול להפוך לגישה cross-thread.

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

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

ההשפעה של ConfigureAwait(false) על יעד ה-continuationתרשים המראה שב-UI handler שעושה await, אם לא מוסיפים ConfigureAwait(false) ה-continuation חוזר בדרך כלל ל-UI thread ואפשר לעדכן UI ישירות, אבל אם מוסיפים אותו ה-continuation לא מקובע ל-UI ועלול להתחדש בכל thread, ואז נדרש Dispatcher או Invoke לעדכון UI.לאכןawait ב-UI handlerמוסיפים ConfigureAwait(false)?ה-continuation בדרך כלל ב-UI threadקל לעדכן UI ישירותה-continuation לא מקובע ל-UIעלול להתחדש בכל threadעדכון 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 thread מסוכן.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • רוצים לגעת ב-UI בהמשך אחרי ConfigureAwait(false)
  • בתוך Task.Run, או במבנה שגם בחוץ לא חוזרים ל-UI
  • ההודעה מגיעה ממקום שמלכתחילה לא UI thread, כמו קבלת socket, timer או 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. קשרו את ה-token לאורך חיי הטופס.

// フォームのフィールド。閉じるときにキャンセルする
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);
}

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

  • ביטול וביצוע — “בדיקת דגל ואז פעולה” לא מספיקה. מיד אחרי בדיקת “האם כבר בוטל?” ולפני קריאה ל-action(), ייתכן שביטול יתרחש. באותו רגע tcs הופך למבוטל, והקורא שעשה await ממשיך הלאה ומתחיל פעולה חדשה. אחר כך, ה-delegate הישן שעדיין נמצא בתור רץ ומשכתב את המסך — התצוגה החדשה נדרסת על ידי הישנה, תקלה שקשה לשחזר. בגלל זה הקוד למעלה משתמש ב-Interlocked.Exchange כדי לגרום לביטול ולביצוע להיאבק על “זכות חד-פעמית”, כך שהצד שלא תפס חוזר בלי לעשות כלום
  • BeginInvoke לפני יצירת ה-handle (לפני Load), או אחרי סגירת הטופס, גורם ל-exception. שימו לב למחזור החיים של הצד הקורא
  • אם ה-control נהרס אחרי ההגשה, ה-delegate עלול להיזרק בלי לרוץ. במקרה כזה TaskCompletionSource לא מקבל לא תוצאה ולא exception, והצד שעושה await ימתין לנצח. תמיד להעביר token שקשור לסגירת הטופס, כמו בדוגמה למעלה. תוצאת הסגירה עולה כ-OperationCanceledException
  • File.ReadAllTextAsync הוא API מ-.NET Core 2.0 ואילך. אם רוצים אותה צורה ב-.NET Framework 4.8, יש להחליף ל-StreamReader.ReadToEndAsync וכדומה
המאבק על זכות הביטול והביצועתרשים המראה שצד הביטול וצד הביצוע נאבקים עם Interlocked.Exchange על זכות חד-פעמית, ורק מי שתפס ראשון ממשיך, כשצד הביטול סוגר את ה-Task כמבוטל וצד הביצוע מריץ את action ומכניס תוצאה, כדי למנוע delegate ישן שמשכתב את המסך.זכות חד-פעמיתצד הביטול תפס ראשוןצד הביצוע תפס ראשוןסגירת ה-Task כמבוטלה-delegate הישן חוזר בלי לעשות כלוםהרצת 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 handler, זה לא נדרש
  • משתמשים בזה כשרוצים לגעת ב-UI ממקום שהוא לא UI
  • לא להרבות ב-Invoke סינכרוני בתוך זרימת async

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

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

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

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

6. anti-patterns נפוצים

anti-pattern מה קשה בו התחליף הראשון
LoadAsync().Result ב-UI handler חוסם את ה-UI thread. נוטה ל-deadlock await LoadAsync()
LoadAsync().Wait() ב-UI handler אותו דבר. ה-message loop נעצר await LoadAsync()
LoadAsync().GetAwaiter().GetResult() ב-UI handler רק אופן עטיפת ה-exception שונה, החסימה זהה 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 של property מקור לתקיעה בזמן ההפעלה להעביר ל-Loaded / Shown / InitializeAsync

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

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

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

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

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

7. checklist ל-code review

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

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

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

8. איך בוחרים בפועל

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

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

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

9. סיכום

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

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

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

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

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

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

מצד שני:

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

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

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

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

10. מקורות

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

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

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

שאלות נפוצות

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

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

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג