מה שהכי קל להתבלבל בו כשמשתמשים ב-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)
תוכן עניינים
- קודם כול, המסקנה (במשפט אחד)
- קודם כול, לסדר בדף אחד
- 2.1. התמונה הכוללת
- 2.2. טבלת ההחלטה הראשונה
- מילים שבהן משתמש המאמר
- 3.1. ת’רד ה-UI ולולאת ההודעות
- 3.2.
SynchronizationContext/Dispatcher/Invoke
- דפוסים אופייניים
- 4.1. plain
awaitבמטפל אירוע UI - 4.2.
Task.Runרק לחישוב CPU כבד - 4.3.
ConfigureAwait(false)הוא לא “הבטחה שלא חוזרים” אלא “לא כופה לחזור” - 4.4. הסיבה לתקיעה עם
.Result/.Wait()/.GetAwaiter().GetResult()
- 4.1. plain
- מתי להשתמש ב-
Dispatcher/Invoke - אנטי-דפוסים נפוצים
- רשימת בדיקה לסקירת קוד
- חלוקה גסה בין המצבים
- סיכום
- מקורות
מפת הידע של המאמר
המאמר הזה מסודר סביב הבסיס שת’רד ה-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 והאפליקציה קורסת.
flowchart LR
accTitle: מפת הידע של ת'רד ה-UI ב-WPF/WinForms ושל async/await
accDescr: תרשים שמראה שת'רד ה-UI ממשיך להריץ לולאת הודעות; ש-await רגיל מחזיר את ההמשך ל-SynchronizationContext שנתפס; ש-ConfigureAwait(false) לא מכריח את החזרה הזו; ש-Task.Run מוציא חישוב CPU מת'רד ה-UI; ש-Dispatcher, Control.BeginInvoke ו-InvokeAsync הם האמצעים המפורשים לחזור אל ה-UI; ש-.Result, .Wait() ו-GetAwaiter().GetResult() עלולים לחסום את ת'רד ה-UI ולגרום למבוי סתום או להקפאה; ושחריגה מ-async void מגיעה למקלט הכללי של ה-UI.
ui_thread_context["ההקשר של ת'רד ממשק המשתמש"]
wpf["WPF"]
windows_forms["Windows Forms"]
message_loop["לולאת ההודעות"]
synchronizationcontext["SynchronizationContext"]
wpf_dispatcher["Dispatcher(WPF)"]
winforms_begininvoke["Control.Invoke / Control.BeginInvoke"]
winforms_invokeasync["Control.InvokeAsync"]
taskcompletionsource["TaskCompletionSource"]
cancellationtoken_dotnet["CancellationToken(.NET)"]
reentrancy_guard["מנגנון מניעת כניסה חוזרת (Interlocked.Exchange וכדומה)"]
cancel_execute_race["תחרות בין ביטול לבין ביצוע"]
plain_await["plain await"]
configureawait_false["ConfigureAwait(false)"]
generic_library_code["קוד ספרייה כללי"]
taskrun_dotnet["Task.Run"]
io_bound_operation["עיבוד עתיר קלט/פלט"]
ui_thread_blocking["חסימת ת'רד ממשק המשתמש"]
sync_over_async["המתנה סינכרונית על קוד אסינכרוני (sync-over-async)"]
deadlock["קיפאון (deadlock)"]
task_result_wait[".Result / .Wait() / .GetAwaiter().GetResult()"]
async_void["async void"]
event_handler_method["מתודת מטפל באירוע"]
ui_unhandled_exception_handler["תופס החריגות הכללי של הממשק (DispatcherUnhandledException / ThreadException)"]
ui_thread_context -->|"מחייב"| message_loop
wpf -->|"משתמש ב"| synchronizationcontext
windows_forms -->|"משתמש ב"| synchronizationcontext
wpf -->|"משתמש ב"| wpf_dispatcher
windows_forms -->|"משתמש ב"| winforms_begininvoke
windows_forms -.->|"משתמש ב"| winforms_invokeasync
winforms_invokeasync -->|"יורש את"| winforms_begininvoke
winforms_begininvoke -.->|"מחייב"| taskcompletionsource
taskcompletionsource -.->|"מחייב"| cancellationtoken_dotnet
taskcompletionsource -.->|"משתמש ב"| reentrancy_guard
reentrancy_guard -->|"מונע"| cancel_execute_race
plain_await -->|"משתמש ב"| synchronizationcontext
plain_await -->|"מענה מומלץ ל"| ui_thread_context
configureawait_false -.->|"מחייב"| synchronizationcontext
configureawait_false -->|"שימוש לא מומלץ ל"| ui_thread_context
configureawait_false -->|"מענה מומלץ ל"| generic_library_code
taskrun_dotnet -->|"מענה מומלץ ל"| ui_thread_context
taskrun_dotnet -->|"שימוש לא מומלץ ל"| io_bound_operation
taskrun_dotnet -->|"מונע"| ui_thread_blocking
sync_over_async -->|"עלול לגרום ל"| deadlock
task_result_wait -.->|"עלול לגרום ל"| deadlock
task_result_wait -->|"שימוש לא מומלץ ל"| ui_thread_context
wpf_dispatcher -->|"עלול לגרום ל"| deadlock
async_void -->|"מענה מומלץ ל"| event_handler_method
async_void -->|"עלול לגרום ל"| ui_unhandled_exception_handler
event_handler_method -.->|"מחייב"| ui_thread_context
בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 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
- באיזה ת’רד רצים עכשיו
- לאן חוזר ההמשך של
await - מי אחראי להחזיר ל-UI
אם עוצרים על שלוש הנקודות האלה, התמונה מתבהרת משמעותית.
flowchart TB
accTitle: שלוש השאלות שמבהירות את התמונה
accDescr: תרשים המראה שאם עוצרים על שלוש שאלות — באיזה ת'רד רצים עכשיו, לאן חוזר ההמשך של await, ומי אחראי להחזיר ל-UI — התמונה של קוד אסינכרוני ב-WPF וב-WinForms מתבהרת.
q1["באיזה ת'רד רצים עכשיו"] --> goal["בהירות בקוד UI אסינכרוני"]
q2["לאן חוזר ההמשך של await"] --> goal
q3["מי אחראי להחזיר ל-UI"] --> goal
איור 1: שלוש השאלות לחזור אליהן כשמתבלבלים. ת’רד, יעד החזרה, ואחריות ההחזרה — בנפרד.
2. קודם כול, לסדר בדף אחד
2.1. התמונה הכוללת
הכי מהיר לתפוס את התמונה הכוללת דרך התרשים הזה.
flowchart LR
accTitle: התמונה הכוללת של ארבעת הדפוסים מנקודת מבט של מטפל אירוע UI
accDescr: תרשים המראה שמטפל אירוע UI מוביל לארבעה מסלולים — plain await ל-I/O שחוזר לת'רד ה-UI ומאפשר עדכון UI ישיר, await Task.Run לחישוב כבד שגם הוא חוזר לת'רד ה-UI, ConfigureAwait(false) שלא כופה חזרה ל-UI כך שנדרש Dispatcher או Invoke, ו-.Result / .Wait() / GetAwaiter().GetResult() שחוסמים את ת'רד ה-UI וגורמים לתקיעה או deadlock.
A["מטפל אירוע UI (WPF / WinForms)"] --> B["plain await ל-API של I/O"]
B --> C["תפיסת ה-UI SynchronizationContext"]
C --> D["ההמשך מתחדש בת'רד ה-UI"]
D --> E["אפשר לכתוב עדכון UI ישירות"]
A --> F["await Task.Run(...) לחישוב CPU כבד"]
F --> G["גוף החישוב רץ ב-ThreadPool"]
G --> H["ההמשך מתחדש בת'רד ה-UI"]
H --> E
A --> I["await SomeAsync().ConfigureAwait(false)"]
I --> J["לא כופה חזרה ל-UI"]
J --> K["ההמשך בכל ת'רד"]
K --> L["עדכון UI ישיר מסוכן, נדרש Dispatcher / Invoke"]
A --> M["SomeAsync().Result / Wait() או GetAwaiter().GetResult()"]
M --> N["חוסם את ת'רד ה-UI"]
N --> O["ההמשך לא יכול לחזור ל-UI"]
O --> P["תקיעה / deadlock / לפחות הקפאה"]
איור 2: התמונה הכוללת של ארבעת הדפוסים מנקודת מבט של מטפל UI. plain await ו-Task.Run חוזרים ל-UI, ConfigureAwait(false) לא כופה חזרה, ו-.Result / .Wait() חוסמים את ת’רד ה-UI.
בעבודה בפועל, נתקלים בעיקר בארבעת הדפוסים האלה:
- plain
awaitבמטפל אירוע UI - שימוש ב-
Task.Runבמטפל אירוע UI כדי להוציא CPU - הסרת יעד החזרה עם
ConfigureAwait(false) - חסימת ת’רד ה-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).
flowchart TB
accTitle: האויב הוא לא await אלא חסימה סינכרונית
accDescr: תרשים המראה ש-plain await הוא דווקא בן ברית בקוד UI, ושהאויב האמיתי הוא חסימה סינכרונית של ת'רד ה-UI.
pa["plain await"] --> friend["בקוד UI הוא דווקא בן ברית"]
blk["חסימה סינכרונית של ת'רד ה-UI"] --> enemy["מקור להקפאה או deadlock"]
איור 3: ליבת הטבלה. האויב אינו await עצמו, אלא חסימה סינכרונית של ת’רד ה-UI.
3. מילים שבהן משתמש המאמר
3.1. ת’רד ה-UI ולולאת ההודעות
ב-WPF / WinForms, ה-UI בנוי בעיקרו כך שיש ת’רד UI אחד, שמריץ את טיפול הקלט, הציור והאירועים.
התפקיד של ת’רד ה-UI הזה הוא בערך כך:
- מטפל בהודעות כמו לחיצת כפתור, קלט מקלדת וציור מחדש
- הוא הת’רד היחיד שבטוח לגעת ממנו בפקדים ובאובייקטי UI
- אם דוחסים לתוכו יותר מדי עיבוד, עדכון המסך ותגובת הקלט נעצרים
הליבה כאן היא שהתפקיד של ת’רד ה-UI הוא “לרוץ מהר”. אם חוסמים אותו לזמן ארוך, גם העכבר, גם המקלדת וגם הציור מחדש נתקעים, ומנקודת המבט של המשתמש זה נראה כ”נתקע”.
כדאי לשמור את התמונה הזו בראש בעזרת התרשים, כדי לא להתבלבל.
flowchart LR
accTitle: מעגל לולאת ההודעות ומה קורה כשהוא נחסם
accDescr: תרשים המראה שקלט משתמש או בקשת ציור מחדש מגיעים ללולאת ההודעות של ת'רד ה-UI, שמריצה את מטפל האירוע ומעדכנת את המסך וחוזרת ללולאה, אבל אם מטפל האירוע כולל עיבוד סינכרוני ארוך, לולאת ההודעות לא מסתובבת והמסך נראה תקוע.
A["קלט משתמש / בקשת ציור מחדש"] --> B["לולאת ההודעות של ת'רד ה-UI"]
B --> C["הרצת מטפל האירוע"]
C --> D["עדכון המסך"]
D --> B
C --> E["עיבוד סינכרוני ארוך"]
E --> F["לולאת ההודעות לא מסתובבת"]
F --> G["המסך נראה תקוע"]
איור 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 פועל.
flowchart TB
accTitle: איך נקבע יעד ההמשך של await
accDescr: תרשים המראה שה-await הרגיל תופס קודם את SynchronizationContext.Current, ואם הוא null בודק את TaskScheduler.Current, ואם הוא לא ברירת המחדל חוזר לאותו TaskScheduler, ואם גם זה לא מתקיים ההמשך רץ על ThreadPool.
a["await רגיל"] --> sc{"יש SynchronizationContext?"}
sc -->|"יש"| toSc["חזרה לאותו הקשר"]
sc -->|"null"| ts{"TaskScheduler הוא ברירת מחדל?"}
ts -->|"לא"| toTs["חזרה לאותו TaskScheduler"]
ts -->|"כן"| pool["ההמשך רץ על ThreadPool"]
toSc -.-> ui["בת'רד ה-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 הם מה שבולט.
בעבודה בפועל, זכירת היחס הזה בין ההפשטה לבין הממומש בפועל מקטינה בלבול:
flowchart TD
accTitle: מ-SynchronizationContext המופשט לממומש בפועל
accDescr: תרשים המראה שהקוד הנוכחי משתמש ב-SynchronizationContext, שב-WPF הוא DispatcherSynchronizationContext וב-WinForms הוא WindowsFormsSynchronizationContext, וכל אחד מוביל ל-API המתאים להחזרה מפורשת ל-UI.
A["הקוד הנוכחי"] --> B["SynchronizationContext"]
B --> C["WPF: DispatcherSynchronizationContext"]
B --> D["WinForms: WindowsFormsSynchronizationContext"]
C --> E["Dispatcher.InvokeAsync / BeginInvoke / Invoke"]
D --> F["Control.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) היא רק רשת הביטחון האחרונה.
flowchart TB
accTitle: לאן הולכת חריגה מ-async void
accDescr: תרשים המראה שחריגה מ-async Task נטענת על ה-Task המוחזר והקורא מקבל אותה ב-await, אבל חריגה מ-async void שיוצאת החוצה נזרקת מחדש לת'רד ה-UI ומגיעה לרשת הכללית, ואם לא מטפלים שם האפליקציה קורסת.
ex["חריגה שיצאה מחוץ למטפל"] --> kind{"async Task או async void?"}
kind -->|"async Task"| task["נטענת על ה-Task המוחזר"]
task --> caller["הקורא שעשה await מקבל אותה"]
kind -->|"async void"| ctx["נזרקת מחדש לת'רד ה-UI"]
ctx --> global["מגיעה לרשת הכללית"]
global --> crash["אם לא מטפלים, האפליקציה קורסת"]
איור 7: ל-async void אין Task לטעון עליו חריגה, ולכן הבסיס הוא לתפוס אותה בתוך המטפל עצמו.
גם ב-WinForms ההסתכלות זהה.
כל עוד עושים plain await בתוך מטפל Click, ההמשך חוזר בדרך כלל לצד ה-UI.
בתרשים, זה נראה כך:
sequenceDiagram
accTitle: זרימת plain await ממטפל Click ועד עדכון UI
accDescr: תרשים רצף המראה שמטפל Click מתחיל בת'רד ה-UI, עושה await על I/O אסינכרוני, שומר את החזרה להקשר ה-UI וחוזר ללולאת ההודעות בזמן ההמתנה, ולאחר סיום ה-I/O ההמשך מתחדש בת'רד ה-UI ומעדכן את הפקדים.
participant UI as ת'רד ה-UI
participant IO as I/O אסינכרוני
participant Ctx as UI SynchronizationContext
UI->>UI: תחילת מטפל Click
UI->>IO: await על ReadAllTextAsync
UI-->>Ctx: שמירת ההחזרה ל-UI
Note over UI: בזמן ההמתנה חוזרים ללולאת ההודעות
IO-->>Ctx: סיום ה-I/O
Ctx-->>UI: ההמשך מתחדש בת'רד ה-UI
UI->>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;
}
}
מה שקורה בקוד הזה הוא בערך כך:
- מטפל האירוע מתחיל בת’רד ה-UI
- ההמתנה ל-I/O של
File.ReadAllBytesAsyncזורמת באופן אסינכרוני - רק חישוב ה-hash הכבד יוצא ל-ThreadPool עם
Task.Run - ההמשך של
await Task.Run(...)הוא plainawait, ולכן הוא חוזר לת’רד ה-UI - אפשר לכתוב
ResultText.Text = hash;ישירות
כלומר, רק מה שבתוך Task.Run רץ בת’רד אחר.
זה לא שעוברים לצמיתות ל”מקום שהוא כבר לא ה-UI” עד אחרי ה-await.
אם רואים את זה בתרשים אחד, קשה יותר להתבלבל.
sequenceDiagram
accTitle: Task.Run רץ ב-ThreadPool בלבד, וההמשך חוזר ל-UI
accDescr: תרשים רצף המראה שת'רד ה-UI עושה await על ReadAllBytesAsync ומתחדש ב-UI כי זה plain await, ואז שולח את החישוב הכבד עם Task.Run ל-ThreadPool, ואחרי קבלת התוצאה ההמשך שוב מתחדש בת'רד ה-UI ומעדכן את המסך.
participant UI as ת'רד ה-UI
participant IO as I/O אסינכרוני
participant Pool as ThreadPool
UI->>IO: await על ReadAllBytesAsync
IO-->>UI: מתחדש ב-UI כי זה plain await
UI->>Pool: שליחת חישוב CPU כבד עם Task.Run
Pool-->>UI: החזרת תוצאת החישוב
Note over UI: ההמשך של await Task.Run(...) מתחדש ב-UI
UI->>UI: שיקוף התוצאה על המסך
איור 9: רק Task.Run רץ ב-ThreadPool; ההמשך של await חוזר לת’רד ה-UI, כך שאפשר לשקף את התוצאה ישירות.
יש שתי נקודות לתשומת לב כאן:
- לא לעטוף המתנת I/O ב-
Task.Run -
Task.Runהוא לא “הפיכה לאסינכרוני”, אלא יצירת “יעד בריחה ל-CPU”
כתיבה כמו Task.Run(async () => await File.ReadAllTextAsync(...)) רק זורקת מחדש בלי תועלת את המתנת ה-I/O ל-ThreadPool.
flowchart TB
accTitle: מתי משתמשים ב-Task.Run
accDescr: תרשים המראה שאם רוצים להוציא חישוב CPU כבד, Task.Run הוא הכלי הנכון, אבל אם רוצים להוציא המתנת I/O, אין לעטוף אותה ב-Task.Run כי זה רק זורק מחדש את ההמתנה בלי תועלת.
q{"מה רוצים להוציא?"}
q -->|"חישוב CPU כבד"| ok["הוצאה עם Task.Run"]
q -->|"המתנת I/O"| ng["לא לעטוף ב-Task.Run"]
ng --> why["רק זורק מחדש את ההמתנה בלי תועלת"]
איור 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
וכך נוצרת הפרדה כזו.
flowchart TB
accTitle: ההפרדה בין הספרייה ל-UI
accDescr: תרשים המראה שבתוך ספרייה כללית, ה-await עם ConfigureAwait(false) לא דורש חזרה להקשר UI, אבל אם מטפל UI עושה עליה plain await, ההמשך של הקורא חוזר ל-UI, מכיוון שהציון הפנימי לא חל על החוץ.
lib["await בתוך הספרייה"] --> nof["ConfigureAwait(false)"]
nof --> stay["לא דורש חזרה ל-UI"]
uih["plain await של מטפל UI"] --> back["ההמשך של הקורא חוזר ל-UI"]
nof -.-> note["הציון הפנימי לא חל על החוץ"]
איור 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 המקורי, ותו לא.
בתרשים זה נראה כך:
flowchart LR
accTitle: ההשפעה של ConfigureAwait(false) על יעד ההמשך
accDescr: תרשים המראה שבמטפל UI שעושה await, אם לא מוסיפים ConfigureAwait(false) ההמשך חוזר בדרך כלל לת'רד ה-UI ואפשר לעדכן UI ישירות, אבל אם מוסיפים אותו ההמשך לא מקובע ל-UI ועלול להתחדש בכל ת'רד, ואז נדרש Dispatcher או Invoke לעדכון UI.
A["await במטפל UI"] --> B{"מוסיפים ConfigureAwait(false)?"}
B -- "לא" --> C["ההמשך בדרך כלל בת'רד ה-UI"]
C --> D["קל לעדכן UI ישירות"]
B -- "כן" --> E["ההמשך לא מקובע ל-UI"]
E --> F["עלול להתחדש בכל ת'רד"]
F --> G["עדכון 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 מסוכן.
בתרשים, הזרימה נראית כך:
sequenceDiagram
accTitle: איך .Result חוסם את ת'רד ה-UI וגורם ל-deadlock
accDescr: תרשים רצף המראה שת'רד ה-UI מתחיל את LoadButton_Click, קורא ל-LoadTextAsync שמחזיר Task לא גמור, נחסם בהמתנה עם .Result, ואז כשה-I/O מסתיים וההמשך רוצה לחזור ל-UI הוא לא יכול לרוץ כי ה-UI תפוס ב-.Result, כך שהמשימה לעולם לא מסתיימת.
participant UI as ת'רד ה-UI
participant IO as I/O אסינכרוני
participant Ctx as UI SynchronizationContext
UI->>UI: תחילת LoadButton_Click
UI->>IO: קריאה ל-LoadTextAsync()
IO-->>UI: החזרת Task לא גמור
UI->>UI: המתנה חוסמת עם .Result
IO-->>Ctx: סיום I/O, רוצים להחזיר את ההמשך ל-UI
Ctx-->>UI: רוצים להריץ את ההמשך
Note over UI: אבל ה-UI תפוס ב-.Result
Note over UI, Ctx: ההמשך לא יכול לרוץ, אז לא מסתיים
איור 13: ה-UI שנחסם ב-.Result לא נותן להמשך לחזור אליו, וה-Task לא מסתיים לעולם.
אם מנסחים במילים מה קורה, זה כך:
- ת’רד ה-UI קורא ל-
LoadTextAsync() - ה-
awaitבתוךLoadTextAsync()תופס את הקשר ה-UI - ת’רד ה-UI ממתין עם
.Result - ה-I/O מסתיים
- ההמשך של
LoadTextAsync()רוצה לחזור לת’רד ה-UI - אבל ת’רד ה-UI תפוס ב-
.Result - ההמשך לא יכול לרוץ, ולכן
LoadTextAsync()לא מסתיים -
.Resultלא נגמר
כלומר, ה-UI אומר “אני ממתין עד שתסיים”, והצד האסינכרוני אומר “אני יכול לסיים רק אם אחזור ל-UI” — ושניהם מחכים זה לזה. תחושה ממש לא נעימה.
flowchart TB
accTitle: המבנה של המתנה הדדית
accDescr: תרשים המראה שת'רד ה-UI ממתין ל-.Result לסיום העיבוד האסינכרוני, וההמשך האסינכרוני צריך שת'רד ה-UI יתפנה כדי לחזור אליו, ולכן שני הצדדים ממתינים זה לזה ולא מתקדמים.
ui["ת'רד ה-UI ממתין עם .Result"] --> need["צריך שהעיבוד האסינכרוני יסתיים"]
cont["ההמשך צריך לחזור ל-UI"] --> free["צריך שת'רד ה-UI יתפנה"]
need --> cycle["שני הצדדים מחכים זה לזה"]
free --> cycle
איור 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!, וזה קריא בקלות.
flowchart TB
accTitle: הסכנה בהמתנה סינכרונית ל-InvokeAsync
accDescr: תרשים המראה ש-Dispatcher.InvokeAsync רק מכניס דלגט לתור, וההרצה קורית רק כשת'רד ה-UI מסובב את התור, כך שאם ת'רד ה-UI עצור ב-Wait התור לא מסתובב וה-Task לעולם לא מושלם.
post["הכנסה לתור עם InvokeAsync"] --> run["מתבצע כשת'רד ה-UI מסובב את התור"]
wait["ת'רד ה-UI עצור ב-Wait"] --> norun["התור לא מסתובב"]
norun --> never["ה-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, בדרך כלל הצד שלא חוסם משתלב טוב יותר.
flowchart TB
accTitle: ההבדל בין Invoke ל-BeginInvoke
accDescr: תרשים המראה ש-Invoke הוא שליחה סינכרונית שמשאירה את הקורא ממתין, בעוד BeginInvoke מפרסם וחוזר מיד ומתאים לזרימת async.
inv["Invoke (שליחה סינכרונית)"] --> waitc["משאיר את הקורא ממתין"]
bi["BeginInvoke (פרסום)"] --> ret["חוזר מיד"]
ret --> fit["משתלב עם זרימת 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וכדומה
flowchart TB
accTitle: המאבק על זכות הביטול והביצוע
accDescr: תרשים המראה שצד הביטול וצד הביצוע נאבקים עם Interlocked.Exchange על זכות חד-פעמית, ורק מי שתפס ראשון ממשיך, כשצד הביטול סוגר את ה-Task כמבוטל וצד הביצוע מריץ את action ומכניס תוצאה, כדי למנוע דלגט ישן שמשכתב את המסך.
race["זכות חד-פעמית"] --> c["צד הביטול תפס ראשון"]
race --> e["צד הביצוע תפס ראשון"]
c --> c2["סגירת ה-Task כמבוטל"]
c --> c3["הדלגט הישן חוזר בלי לעשות כלום"]
e --> e2["הרצת 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
זה מקטין מאוד את התקלות.
אם מתבלבלים, מספיק תרשים החלטה כזה:
flowchart TD
accTitle: תרשים החלטה - האם נדרש Dispatcher או Invoke
accDescr: תרשים המראה שאם ממשיכים לכתוב בת'רד ה-UI, אפשר להשאיר plain await ולעדכן UI ישירות, ואם לא, בודקים אם רוצים לגעת ב-UI, ואם כן משתמשים ב-Dispatcher.InvokeAsync ב-WPF או ב-BeginInvoke / InvokeAsync ב-WinForms, ואם לא ממשיכים בעיבוד כרגיל.
A["המקום שממשיכים לכתוב בו הוא ת'רד ה-UI?"] --> B{"כן?"}
B -- "כן" --> C["אפשר להשאיר plain await ולעדכן UI"]
B -- "לא" --> D{"רוצים לגעת ב-UI?"}
D -- "לא" --> E["המשך עיבוד רגיל"]
D -- "כן" --> F["WPF: Dispatcher.InvokeAsync"]
D -- "כן" --> G["WinForms: 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 |
מבין אלה, שלושה נתקלים בהם הכי הרבה:
-
.Result/.Wait()בת’רד ה-UI - הוספה מכנית של
ConfigureAwait(false)לקוד UI - אחריות הספרייה וה-UI מתערבבות, וה-
Dispatcherחודר עמוק פנימה
רק בהסרת שלושת אלה, הקוד נרגע משמעותית.
flowchart TB
accTitle: שלושת המקומות עם שיעור ההיתקלות הגבוה ביותר
accDescr: תרשים המראה שהסרת שלושת המקומות עם שיעור ההיתקלות הגבוה — .Result או .Wait בת'רד ה-UI, ConfigureAwait(false) שמוסף מכנית, ו-Dispatcher שחודר עמוק כי האחריות מתערבבת — מרגיעה משמעותית את הקוד.
a1[".Result או .Wait בת'רד ה-UI"] --> fix["הסרת שלושת אלה"]
a2["ConfigureAwait מכני"] --> fix
a3["Dispatcher שחודר עמוק"] --> fix
fix --> calm["הקוד נרגע משמעותית"]
איור 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
לחשוב על שלושת אלה בנפרד.
בתור כללי בסיס, שמירה על אלה מספיקה כדי להתמודד היטב:
- בשכבה החיצונית ביותר של ה-UI, plain
await -
Task.Runרק לחישוב CPU כבד - בספרייה כללית, לשקול
ConfigureAwait(false) - רק כשצריך לחזור ל-UI,
Dispatcher/BeginInvoke/InvokeAsync - בת’רד ה-UI לא משתמשים ב-
.Result/.Wait()/.GetAwaiter().GetResult()
async / await עצמם לא מנגנון קפדני במיוחד.
אבל אם משתמשים בהם בלי להסתכל דרך ת’רד ה-UI כמרכז, זה הופך פתאום לבוץ.
מצד שני:
- להפריד בין החוץ לפנים של ה-UI
- להיות מודעים ליעד החזרה
- לא להכניס חסימות
רק שמירה על שלושת אלה הופכת את הקוד האסינכרוני ב-WPF / WinForms לשקט הרבה יותר. קוד שגורם למסך להיתקע הוא בדרך כלל לא “כי אסינכרוני זה רע”, אלא סתם דרך רשלנית ל”לקחת הלוואה” מת’רד ה-UI.
flowchart TB
accTitle: שלושת העקרונות של קוד UI אסינכרוני שקט
accDescr: תרשים המראה ששמירה על הפרדה בין חוץ לפנים של ה-UI, מודעות ליעד החזרה של await, ואי-הכנסת חסימות, מספיקה כדי שהקוד האסינכרוני ב-WPF וב-WinForms יהיה שקט.
r1["הפרדה בין חוץ לפנים של ה-UI"] --> calm["קוד אסינכרוני שקט"]
r2["מודעות ליעד ההחזרה"] --> calm
r3["אי-הכנסת חסימות"] --> calm
איור 20: שלושת עקרונות הסיכום. להפריד, להיות מודעים ליעד ההחזרה, לא לחסום — וכך המסך לא נתקע.
10. מקורות
- חבילת הדוגמאות המלאה של המאמר הזה (ספרייה בלתי-תלויית UI, דוגמאות WPF / WinForms, בדיקות יחידה) - komurasoft-blog-samples (GitHub)
- מאמר קשור: טבלת החלטה מעשית ל-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 של .NET באפליקציית desktop
בכלי Windows ובאפליקציות שרצות ברקע, מסודר כאן איך להשתמש ב-Generic Host וב-BackgroundService כדי לסדר הפעלה, עיבוד תקופתי, סיום, לוג, הג...
שלושה סוגי טיימרים ב-.NET — מתי להשתמש ב-PeriodicTimer/Timer/DispatcherTimer
מסודר כאן ההבדל בין PeriodicTimer / System.Threading.Timer / DispatcherTimer, ואיך לבחור ביניהם לעיבוד async, callback ב-ThreadPool וע...
טבלת החלטה ל-async/await ב-C# — Task.Run ו-ConfigureAwait
המאמר מסדר את שיטות העבודה המומלצות ל-async/await ב-C#, כולל טבלת החלטה עבור המתנה ל-I/O, חישוב CPU, Task.Run, ConfigureAwait(false) ו-...
רשימת בדיקה לטיפול בטוח בתהליכי ילד ביישום Windows
כדי לטפל בבטחה בתהליכי ילד ביישום Windows, תכנון הבעלות על עץ התהליכים ונוהל הסיום חשוב יותר מבחירת ה-API להפעלה. המאמר מסדר את Job Objec...
איך בוחרים בין WinForms, WPF ו-WinUI - טבלת החלטה מהשטח
המאמר מסדר את הבחירה בין WinForms, WPF ו-WinUI מנקודות המבט של פיתוח חדש, נכסים קיימים, הפצה, ביטוי ה-UI ומבנה הצוות.
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
תהליכון ה-UI וטיימרים
תהליכון ה-UI של WPF / WinForms, זרימות אסינכרוניות, Dispatcher ותכנון טיימרים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
ת'רד ה-UI ב-WPF / WinForms ו-async/await הם אחת הנקודות שהכי קל להיתקע בהן במימוש של פיתוח אפליקציות Windows.
ייעוץ טכני וסקירת תכנון
בשלב שבו רוצים לסדר את חלוקת האחריות בין ה-UI לעיבוד ברקע ואת אופן השימוש ב-Dispatcher, אפשר לבחון זאת כייעוץ טכני וסקירת תכנון.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- לאן חוזרים אחרי 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, כך שאפשר לכתוב את שיקוף התוצאה על המסך ישירות.