רוב הפעולות האיטיות בתוכנית הן המתנות: שרת אינטרנט שיענה, מסד נתונים שיחזיר שורות, קובץ שייקרא. async ו-await מאפשרים למתודה לעצור בזמן המתנה כזו בלי להחזיק thread כבן ערובה, ואז להמשיך מאיפה שעצרה. הקוד עדיין נקרא מלמעלה למטה כמו קוד רגיל.
מתודה async ראשונה
מתודת async מחזירה Task (בלי תוצאה) או Task<T> (תוצאה מטיפוס T). בתוכה, await ממתין ל-task אחר ונותן לכם את התוצאה שלו.
פלט:
Tea 2.50, cake 4.00, total 6.50
GetPriceAsync אומרת return 2.50m, והקומפיילר עוטף את זה ב-Task<decimal> שהמתודה מחזירה. await פותח את העטיפה שוב. Task.Delay היא הגרסה האסינכרונית של Thread.Sleep: היא מסתיימת אחרי שהזמן עבר, בלי לחסום thread בינתיים.
מאז C# 7.1, גם Main עצמה יכולה להיות async, וכך הייתם כותבים את התוכנית הזו ב-.NET מודרני:
static async Task Main()
{
decimal tea = await GetPriceAsync("tea");
Console.WriteLine(tea);
}
הדוגמאות להרצה בדף הזה קוראות למתודת async בשם RunAsync מתוך Main רגילה עם .GetAwaiter().GetResult(), וזה מה שהקומפיילר מייצר עבור Main אסינכרונית. באפליקציית קונסול זה בטוח; החלק על deadlocks מסביר למה אותה קריאה מסוכנת בקוד של ממשק משתמש.
מה קורה ב-await
מתודה async רצה באופן סינכרוני עד ה-await הראשון שלה על task שלא הסתיים. בנקודה הזו היא מחזירה Task לקוד הקורא, ושאר המתודה הופך ל-continuation שרץ כשה-task הממתין מסתיים.
פלט:
Calling DownloadAsync
Download: starting
DownloadAsync returned a task; doing other work
Download: finished
Download awaited
"Download: starting" מודפס לפני ש-DownloadAsync חוזרת, כי כל מה שעד ה-await הראשון רץ על ה-thread של הקוד הקורא. אחר כך המתודה מחזירה task שלא הסתיים, הקוד הקורא ממשיך, ו-"Download: finished" מופיע רק כשההשהיה נגמרת. קריאה למתודה async מתחילה אותה; ההמתנה לה היא הדרך לחכות לתוצאה.
async לא יוצר thread. בזמן שהמתודה עצורה אין בכלל thread שמחכה לה, ולכן שרת יכול להחזיק אלפי בקשות שממתינות למסד נתונים עם קומץ threads בלבד. לעבודה כבדה על המעבד שצריכה לרוץ על thread אחר, השתמשו ב-Task.Run, שמוסבר ב-tasks.
הרצת פעולות בו-זמנית עם Task.WhenAll
המתנה לקריאה אחת אחרי השנייה היא סדרתית: כל אחת מתחילה רק אחרי שהקודמת הסתיימה. כשהפעולות לא תלויות זו בזו, התחילו את כולן ואז המתינו להן יחד עם Task.WhenAll:
פלט לדוגמה:
Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms
הגרסה הסדרתית לוקחת את סכום שלוש ההמתנות, והמקבילית בערך כמו האיטית ביותר. Task.WhenAll מחזירה את התוצאות באותו סדר של ה-tasks שהעברתם, בלי קשר למי שהסתיים קודם. זה עובד גם עם רשימה, וזו הצורה המקובלת ל"הבא כל פריט": await Task.WhenAll(ids.Select(id => FetchAsync(id))).
Task.WhenAny היא המקבילה שמסתיימת ברגע שה-task הראשון מסתיים, שימושית ל-timeouts ול"התשובה הראשונה מנצחת".
חריגות בקוד אסינכרוני
חריגה שנזרקת בתוך מתודה async נשמרת ב-task המוחזר ונזרקת מחדש כשממתינים ל-task, כך ש-try/catch רגיל סביב ה-await עובד:
פלט:
Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7
הקריאה ל-LoadProfileAsync(-1) לא זרקה: החריגה חיה בתוך ה-task עד שמשהו ממתין לו. task שאף אחד לא ממתין לו מסתיר את החריגה שלו לגמרי, וזו עוד סיבה להמתין תמיד למה שהתחלתם. עם WhenAll, await זורק מחדש את החריגה הראשונה, בזמן שהמאפיין Exception של ה-task המשולב (AggregateException) מחזיק את כולן. קריאה של ok.Result בסדר שם, כי ok כבר הסתיים.
async void: רק ל-event handlers
מתודה async יכולה גם להחזיר void. הימנעו מזה בכל מקום חוץ מ-event handlers:
// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
await db.InsertAsync(order); // if this throws, the process may crash
}
// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
await db.InsertAsync(order);
}
// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
try { await SaveAsync(currentOrder); }
catch (Exception ex) { ShowError(ex); }
}
עם async void אין task להמתין לו, כך שהקוד הקורא ממשיך לפני שהעבודה הסתיימה, וחריגה שנזרקת בפנים נזרקת ישירות על ה-synchronization context (או על ה-thread pool), במקום שאף try/catch של קוד קורא לא מגיע אליו. באפליקציית קונסול או שרת, זה בדרך כלל מסיים את התהליך. בתוך event handler מסוג async void, תפסו הכול בעצמכם.
טעות קשורה היא קריאה למתודה async בלי await. הקומפיילר מזהיר (CS4014), והמתודה רצה ברקע בלי שאף אחד צופה בתוצאה שלה או בשגיאות שלה.
Deadlocks בגלל .Result ו-.Wait()
חסימה על task עם .Result או .Wait() היא המקום שבו קוד אסינכרוני משתבש הכי הרבה. באפליקציה עם synchronization context (WinForms, WPF, MAUI, ASP.NET הקלאסי), הרצף הוא:
- ה-thread של הממשק קורא ל-
GetDataAsync().Resultונחסם, בהמתנה ל-task. - בתוך
GetDataAsync,awaitמסתיים. כברירת מחדל ה-continuation שלו חייב לרוץ על ה-context שבו הוא התחיל: ה-thread של הממשק. - ה-thread של הממשק חסום בשלב 1, ולכן ה-continuation אף פעם לא רץ, ולכן ה-task אף פעם לא מסתיים, ולכן שלב 1 אף פעם לא נגמר.
האפליקציה קופאת בלי שום חריגה. לאפליקציות קונסול ול-ASP.NET Core אין context כזה, ולכן אותו קוד עובד בתוכנית בדיקה ונתקע באפליקציית דסקטופ. הפתרונות:
- השתמשו ב-
awaitלכל אורך שרשרת הקריאות במקום לחסום ("async all the way"). event handlers יכולים להיותasync voidלשם כך. - בקוד ספרייה שלא צריך לחזור ל-context של הקוד הקורא, כתבו
await SomethingAsync().ConfigureAwait(false);. אז ה-continuation רץ על ה-thread pool במקום על ה-context שנשמר, והספרייה חוסכת מעבר thread מיותר. זה מגן על קוד קורא שחוסם רק אם כלawaitבשרשרת עושה את אותו הדבר, אז התייחסו לזה כהיגיינה טובה של ספרייה, לא כפתרון ל-.Result.
קוד אפליקציה ב-ASP.NET Core לא צריך ConfigureAwait(false), כי אין context לחזור אליו.
טעויות נפוצות
- המתנה לקריאות בלתי תלויות אחת אחת. התחילו אותן, ואז
await Task.WhenAll(...). - מתודות
async void. החזירוTask; השאירו אתasync voidל-event handlers. .Resultו-.Wait()בקוד ממשק משתמש או ב-ASP.NET הקלאסי. הם גורמים ל-deadlock; המתינו במקום זאת.- שכחת
await. העבודה רצה בלי שאף אחד צופה בה, והחריגות שלה נעלמות. - עטיפת I/O ב-
Task.Run. API אסינכרוני כמוFile.ReadAllTextAsyncאוHttpClient.GetStringAsyncכבר משחרר את ה-thread;Task.Runרק מוסיף קפיצה ל-thread pool. - הנחה ש-
asyncפירושו "רץ על thread אחר". הפירוש הוא "יכול לעצור בלי לחסום". השתמשו ב-Task.Runלעבודה שתלויה במעבד.
שאלות נפוצות
איך async ו-await עובדים ב-C#?
סימון מתודה כ-async מאפשר לה להשתמש ב-await. כשהמתודה מגיעה ל-await על task שעוד לא הסתיים, היא חוזרת מיד לקוד הקורא ומחזירה לו Task שמייצג את שאר העבודה. כשהפעולה הממתינה מסתיימת, המתודה ממשיכה מאחרי ה-await. אף thread לא יושב חסום בזמן ההמתנה.
מה ההבדל בין Task ל-Task<T>?
Task מייצג פעולה שלא מפיקה ערך, הגרסה האסינכרונית של מתודת void. Task<T> מייצג פעולה שמפיקה T: await על Task<int> נותן לכם את ה-int. מתודה async שמוצהרת async Task<int> פשוט כותבת return 42;, והקומפיילר עוטף את זה ב-task.
איך מריצים כמה פעולות async בו-זמנית?
מתחילים את כולן קודם, ואז ממתינים להן יחד: var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);. כתיבה של await GetUserAsync(); await GetOrdersAsync(); מריצה אותן אחת אחרי השנייה, כך שהזמן הכולל הוא הסכום ולא הארוכה מביניהן.
למה async void זה רע ב-C#?
אי אפשר להמתין למתודת async void, כך שהקוד הקורא לא יודע מתי היא מסתיימת, וחריגה שנזרקת בתוכה לא יכולה להיתפס על ידי הקוד הקורא: היא נזרקת על ה-synchronization context ובדרך כלל מפילה את התהליך. החזירו Task במקום. async void קיים רק בשביל event handlers, שהחתימה שלהם דורשת void.
למה .Result או .Wait() גורמים ל-deadlock?
באפליקציה עם synchronization context (WinForms, WPF, ASP.NET הקלאסי), .Result חוסם את ה-thread של הממשק או של הבקשה, בזמן שההמשך של המתודה הממתינה מחכה לרוץ על אותו thread בדיוק. כל אחד מחכה לשני לנצח. השתמשו ב-await לאורך כל השרשרת במקום לחסום, והשתמשו ב-ConfigureAwait(false) בתוך קוד ספרייה.
האם Main יכולה להיות async ב-C#?
כן, מאז C# 7.1: static async Task Main() או static async Task<int> Main(). top-level statements (C# 9) יכולים להשתמש ב-await ישירות. בקוד ישן יותר, המקבילה היא קריאה ל-MainAsync().GetAwaiter().GetResult() מתוך Main רגילה, וזה בטוח שם כי לאפליקציית קונסול אין synchronization context.