היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 9 Mar 2026)
- פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173315)
מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.
Go Komura (2026). async/await ב-C#: טבלת החלטה ל-Task.Run ו-ConfigureAwait. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173315 https://comcomponent.com/he/blog/csharp-async-await-best-practices/
- DOI (הגרסה האחרונה)
- 10.5281/zenodo.22173315
- DOI (הגרסה הזו)
- 10.5281/zenodo.22173316
async / await ב-C# משתמשים בהם כל יום, אבל מה שמבלבל בפועל אינו התחביר עצמו, אלא באיזה מצב בוחרים באיזו כתיבה.
מה שנפוץ בחיפושים הן שאלות כמו: מתי משתמשים ב-Task.Run, איפה שמים את ConfigureAwait(false), והאם מותר fire-and-forget.
- עוטפים ב-
Task.Runלמרות שזו המתנה ל-I/O - עושים
awaitאחד אחרי השני למרות שהעיבודים עצמאיים - מכניסים
fire-and-forgetבקלות ומאבדים מעקב אחרי חריגות וזמן סיום - מוסיפים
ConfigureAwait(false)באותו אופן בכל מקום - בוחרים ב-
ValueTaskרק כי “נראה קל יותר”
בנושאים האלה, קל יותר להתחיל מלזהות את סוג העיבוד, ולא ללמוד כל מקרה בנפרד.
המאמר מניח בעיקר פיתוח C# / .NET כללי מ-.NET 6 ואילך, ומסדר את הכתיבה סביב async / await בסדר שקל להחליט לפיו.
למשל הפיתוח הזה.
- אפליקציות desktop כמו WinForms / WPF
- אפליקציות / API של ASP.NET Core
- worker / background services
- אפליקציות console
- class libraries לשימוש חוזר
הקוד שמופיע במאמר מפורסם ב-GitHub כערכת דוגמה מלאה שאפשר לבנות ולהריץ (ספרייה, הדגמת console, ו-unit tests שמאמתות כל דפוס בטבלת ההחלטה).
csharp-async-await-best-practices - komurasoft-blog-samples (GitHub)
איך לקרוא את המאמר
המאמר ארוך למדי, לכן נקודות כניסה לפי מטרה.
| מטרה | איפה לקרוא |
|---|---|
| רוצים לראות רק את טבלת ההחלטה | הטבלה והתרשים ב-3.1. זה מרכז המאמר |
| רוצים להכיר את הכתיבה של כל דפוס | 3.2 ואילך. מתאימים אחד לאחד לשורות הטבלה ב-3.1 |
| רוצים לבדוק מחדש את הקוד שלכם | טבלת ה-anti-patterns ב-5 |
| רוצים ליישר קו בנקודות ה-review | ה-checklist ב-6 |
| רק המסקנה | 1 |
תוכן עניינים
- המסקנה בקצרה
- מונחים במאמר
- 2.1. שני מונחים שכדאי להפריד קודם
- 2.2. מונחים שחוזרים הרבה
- טבלת ההחלטה
- 3.1. overview
- 3.2. אם ממתינים ל-I/O, עושים await ישירות ל-API האסינכרוני
- 3.3. אם עומס ה-CPU כבד, בוחרים איפה להשתמש ב-Task.Run
- 3.4. כמה עיבודים עצמאיים: Task.WhenAll
- 3.5. אם רוצים את מה שמסתיים ראשון: Task.WhenAny
- 3.6. הרבה פריטים ורוצים להגביל parallelism: Parallel.ForEachAsync או SemaphoreSlim
- 3.7. אם רוצים לזרום לפי סדר: Channel<T>
- 3.8. אם רוצים לרוץ במרווח קבוע: PeriodicTimer
- 3.9. אם הנתונים מגיעים ברצף: IAsyncEnumerable<T>
- 3.10. אם רוצים לשחרר באופן אסינכרוני: await using
- 3.11. exclusion שחוצה await: SemaphoreSlim
- 3.12. כתיבת await שונה ב-UI / קוד אפליקציה / ספרייה
- כללי בסיס לכתיבה
- 4.1. ערך ההחזרה: קודם Task / Task<T>
- 4.2. async void רק ב-event handlers
- 4.3. מקבלים CancellationToken ומעבירים downstream
- 4.4. מחברים API אסינכרוניים באופן אסינכרוני עד הסוף
- 4.5. כשיוצרים tasks עם LINQ, מקבעים עם ToArray / ToList
- anti-patterns נפוצים
- checklist ל-code review
- איך בוחרים בפועל
- סיכום
- מקורות
ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 26, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle
1. המסקנה בקצרה
async/awaitהם כתיבה שלא חוסמת thread בזמן ההמתנה, לא מנגנון שמאיץ אוטומטית כל דבר או מעביר אותו ל-thread אחר מעצמו- קודם מפרידים אם העיבוד הוא המתנה ל-I/O או חישוב CPU
- אם זו המתנה ל-I/O, העיקרון הוא לעשות await ישירות ל-API האסינכרוני
- אם זה חישוב CPU, חושבים איפה ראוי להריץ את החישוב. ב-UI
Task.Runיכול להיות שימושי, אבל בטיפול בבקשה ב-ASP.NET Core בדרך כלל נמנעים מלכתובTask.Runומיד לעשות לו await - לעיבודים עצמאיים מרובים, בודקים קודם
Task.WhenAllולא await אחד אחרי השני - כשיש הרבה פריטים, לא שולחים הכול בבת אחת עם
Task.WhenAll, אלא קובעים תקרה ל-parallelism fire-and-forgetנראה פשוט אבל קשה לניהול. אם באמת רוצים לנתק את אורך החיים מהצד הקורא, עדיף להוציא למקום מנוהל כמו Channel או HostedService- ערך ההחזרה: קודם
Task/Task<T>.ValueTaskבוחרים אחרי מדידה, כשרואים שהוא נחוץ ConfigureAwait(false)שימושי בקוד ספרייה כללי, אבל בקוד UI או אפליקציה בדרך כלל מספיקawaitרגילasync voidלא משתמשים בו מחוץ ל-event handlers
במילים אחרות, הדבר הכי חשוב סביב async / await הוא לא להפוך את
“בכל מקרה Task.Run”, “בכל מקרה fire-and-forget”, “בכל מקרה ValueTask” לברירת מחדל.
קודם כול,
- למה מחכה העיבוד הזה
- מי מחזיק באורך החיים של העיבוד הזה
- איפה שולטים במספר הריצות במקביל
אם בודקים את שלוש השאלות האלה, ההיסוס פוחת משמעותית.
flowchart TB
accTitle: שלוש השאלות שמפחיתות היסוס
accDescr: אם בודקים בסדר למה מחכה העיבוד, מי מחזיק באורך חייו, ואיפה שולטים במספר הריצות במקביל — ההיסוס בכתיבה סביב async/await פוחת משמעותית.
q1["למה מחכים"] --> q2["מי מחזיק באורך החיים"]
q2 --> q3["איפה שולטים ב-parallelism"]
q3 --> less["ההיסוס בכתיבה פוחת משמעותית"]
q1 -.-> avoid["נמנעים מ-Task.Run כברירת מחדל"]
איור 1: לא בוחרים “בכל מקרה”. קודם בודקים שלושה דברים: סוג ההמתנה, אורך החיים, ו-parallelism.
2. מונחים במאמר
2.1. שני מונחים שכדאי להפריד קודם
אם מפרידים בין שני המונחים האלה בהתחלה, הבלבול פוחת משמעותית.
| מונח | המשמעות כאן |
|---|---|
| I/O-bound | עיבוד שמרכזו המתנה חיצונית לסיום, כמו HTTP, DB, קובץ, socket |
| CPU-bound | עיבוד שמרכזו החישוב עצמו, כמו דחיסה, עיבוד תמונה, חישוב hash, המרה כבדה |
async / await יעילים במיוחד להמתנה ל-I/O — אפשר להחזיר את ה-thread לעבודה אחרת בזמן ההמתנה. לעומת זאת, חישוב CPU אינו “המתנה” אלא זמן שבו באמת מחשבים, ולכן הנושא הוא באיזה thread להריץ אותו ו-איך קובעים את ה-parallelism.
flowchart TB
accTitle: ההבדל בין I/O-bound ל-CPU-bound
accDescr: I/O-bound שמרכזו המתנה חיצונית מאפשר להחזיר את ה-thread לעבודה אחרת בזמן ההמתנה עם await. CPU-bound שמרכזו החישוב עצמו הופך את ה-thread ואת ה-parallelism לנושא המרכזי.
io["I/O-bound (המתנה חיצונית)"] --> e1["אפשר להחזיר thread בזמן ההמתנה"]
e1 --> fit["async/await יעילים כאן במיוחד"]
cpu["CPU-bound (החישוב עצמו)"] --> e2["באיזה thread להריץ הופך לנושא"]
e2 --> par["גם קביעת ה-parallelism הופכת לנושא"]
איור 2: החלוקה הראשונה היא הזו. הנושא שבודקים שונה לפי אם מרכז הבעיה הוא המתנה או חישוב.
2.2. מונחים שחוזרים הרבה
| מונח | המשמעות כאן |
|---|---|
| blocking | להמשיך לתפוס את ה-thread כל עוד ממתינים לסיום |
fire-and-forget |
הפעלה שבה הצד הקורא לא מחכה לסיום |
SynchronizationContext |
מנגנון שמחזיק “איפה ממשיכים את מה שאחרי ה-await”. פירוט בהערה למטה |
| backpressure | מנגנון שמעכב את צד הכתיבה כשהזרימה מהירה מדי, כדי למנוע התרבות יתר |
IHostedService |
מנגנון של ה-generic host של .NET שקורא ל-StartAsync ב-startup ול-StopAsync בעצירה. זו נקודת הכניסה לעיבוד קבוע שרץ בהתאם לאורך חיי האפליקציה |
BackgroundService |
מחלקה מופשטת שמממשת IHostedService. אם עושים override רק ל-ExecuteAsync(CancellationToken) אחד, אפשר לכתוב לולאה קבועה. נרשמים עם AddHostedService<T>() (3.7) |
כשמשתמשים ב-Channel<T>, המקום שבו שמים את ה-consumer שלו הוא ה-BackgroundService הזה. בסעיף 3.7 נסביר צורה של “מזינים ל-queue, ו-consumer ייעודי מעבד לפי הסדר”, וזה בדיוק המקום שמנהל את אורך חיי ה-consumer הזה בהתאם ל-startup ולעצירה של האפליקציה.
הערה על SynchronizationContext
הדיון על ConfigureAwait(false) (3.12) מסתכם בסופו של דבר בהבנת המונח הזה בלבד.
await, כשהוא מריץ את הקוד שאחריו (ה-continuation), תופס את ה-SynchronizationContextשהיה קיים ברגע כניסת ההמתנה, וחוזר אליו כדי להריץ (אם לא הוגדרSynchronizationContext, בודקים אם משתמשים ב-TaskSchedulerשאינו ברירת המחדל)- ל-WinForms / WPF יש
SynchronizationContextשמעביר עיבוד חזרה ל-UI thread. בגלל זה אפשר לגעת רגיל ב-controls אחרי ה-await - ל-ASP.NET Core אין
SynchronizationContext. לכן אין “מקום לחזור אליו”, וההמשך של ה-awaitרץ כמו שהוא על thread פנוי ב-thread pool ConfigureAwait(false)הוא ציון שמותר להריץ את ההמשך בלי לחזור ל-context שנתפס
flowchart TB
accTitle: לאן חוזר ההמשך של await
accDescr: await תופס את ה-SynchronizationContext בזמן כניסת ההמתנה, כך שב-WinForms/WPF חוזרים ל-UI thread ואפשר לגעת ב-controls, בעוד ב-ASP.NET Core אין לאן לחזור וההמשך רץ ב-thread pool.
aw["await תופס context"] --> ui["UI thread ב-WinForms או WPF"]
aw --> asp["ל-ASP.NET Core אין לאן לחזור"]
ui --> touch["אחרי await אפשר לגעת ב-UI"]
asp --> pool["ההמשך רץ ב-thread pool"]
איור 3: הדיון על ConfigureAwait(false) מסתכם בנקודה אחת — לאן חוזר ההמשך של await.
מכאן נובעות המסקנות של 3.12: “בקוד UI טבעי יותר לא להוסיף אותו”, “בקוד אפליקציה ב-ASP.NET Core לא משנה הרבה אם מוסיפים או לא”, “בספרייה כללית שלא ידוע באיזה context תרוץ, יש ערך בהוספתו”. הרקע המפורט מרוכז הכי טוב ב-ConfigureAwait FAQ שבפרק 9 — מקורות.
מה שחשוב במיוחד הוא ש-אסינכרוניות ו-parallelism הם שני דברים שונים.
- אסינכרוניות: עניין של איך ממתינים
- parallelism: עניין של קידום בו-זמנית
כשהשניים האלה מתערבבים, נוצר רצון להשתמש ב-Task.Run בכל מקום.
זו נקודת הפיצול הראשונה.
flowchart TB
accTitle: אסינכרוניות ו-parallelism הם שני דברים שונים
accDescr: אסינכרוניות היא עניין של איך ממתינים, parallelism הוא עניין של קידום בו-זמנית. כשהשניים מתערבבים נוצר רצון להשתמש ב-Task.Run בכל מקום, וזו נקודת הפיצול הראשונה.
a["אסינכרוניות (איך ממתינים)"] -.-> mix["כשמתערבב — שימוש מוגזם ב-Task.Run"]
p["parallelism (קידום בו-זמנית)"] -.-> mix
mix --> fork["זו נקודת הפיצול הראשונה"]
איור 4: אסינכרוניות היא איך ממתינים, parallelism הוא קידום בו-זמנית. כשההבחנה הזו מיטשטשת, קל לגלוש לשימוש מוגזם ב-Task.Run.
3. טבלת ההחלטה
3.1. overview
אם מסתכלים קודם בטבלה הזו, מתקבל בגדול הכיוון.
| מצב | מה משתמשים בו קודם | מה בודקים |
|---|---|---|
| המתנה ל-HTTP / DB / קובץ וכדומה | await ישירות ל-API האסינכרוני |
לא עוטפים ב-Task.Run |
| חישוב כבד שלא רוצים לתקוע בו את ה-UI | Task.Run |
מוציאים את חישוב ה-CPU מ-UI thread |
| טיפול בבקשה ב-ASP.NET Core | await רגיל (plain) |
לא עושים await ל-Task.Run מיד |
| כמה עיבודים אסינכרוניים עצמאיים, במספר קטן | Task.WhenAll |
קודם מתחילים הכול, ואז ממתינים ביחד |
| משתמשים רק במה שהסתיים ראשון | Task.WhenAny |
חושבים על ביטול השאר ואיסוף חריגות |
| הרבה פריטים, רוצים תקרה | Parallel.ForEachAsync / SemaphoreSlim |
קובעים במפורש את ה-parallelism |
| עיבוד רקע שרוצים לזרום לפי סדר | Channel<T> |
חושבים על bounded queue ו-backpressure |
| עיבוד אסינכרוני במרווח קבוע | PeriodicTimer |
שומרים על timer אחד — consumer אחד |
| רוצים לעבד תוצאות בהדרגה | IAsyncEnumerable<T> / await foreach |
מתקדמים בלי לחכות לסיום כל הפריטים |
| נדרש שחרור אסינכרוני | await using |
משתמשים ב-IAsyncDisposable |
| exclusion שחוצה await | SemaphoreSlim.WaitAsync |
תמיד Release ב-try/finally |
| קוד ספרייה כללי | לשקול ConfigureAwait(false) |
לא תלוי ב-context של UI / אפליקציה |
flowchart TD
accTitle: טבלת ההחלטה המלאה לבחירת דפוס async/await
accDescr: מתחילים מסוג העיבוד שרוצים לבצע ומפצלים לפי המתנה ל-I/O, מיקום הרצת חישוב CPU כבד, ואופן הטיפול בכמה עבודות, עד לדפוס הכתיבה המתאים בכל ענף.
start["העיבוד שרוצים לבצע"] --> q1{"ממתינים ל-I/O חיצוני?"}
q1 -- "כן" --> p1["עושים await ישירות ל-API האסינכרוני"]
q1 -- "לא" --> q2{"חישוב CPU כבד?"}
q2 -- "כן" --> q3{"איפה מריצים?"}
q3 -- "אירוע UI / desktop" --> p2["שוקלים Task.Run"]
q3 -- "בקשת ASP.NET Core" --> p3["לא עוטפים ב-Task.Run, מוציאים ל-worker או queue"]
q3 -- "worker / רקע" --> p4["מריצים במקום, או קובעים דרגת parallelism"]
q2 -- "לא" --> q4{"מטפלים בכמה עבודות?"}
q4 -- "ממתינים לסיום הכול" --> p5["Task.WhenAll"]
q4 -- "משתמשים במה שהסתיים ראשון" --> p6["Task.WhenAny"]
q4 -- "הרבה פריטים" --> p7["Parallel.ForEachAsync או SemaphoreSlim"]
q4 -- "זורמים לפי סדר" --> p8["Channel<T>"]
q4 -- "מרווח קבוע" --> p9["PeriodicTimer"]
q4 -- "זרם רציף" --> p10["IAsyncEnumerable<T>"]
איור 5: overview של ההחלטה. קודם מפרידים בין המתנה ל-I/O לחישוב CPU, ולעבודות מרובות בוחרים כלי לפי אופן האיגוד.
מכאן נעבור על כל דפוס לפי הסדר.
3.2. אם ממתינים ל-I/O, עושים await ישירות ל-API האסינכרוני
זהו הדפוס הבסיסי ביותר.
לדוגמה, ב-HTTP, DB, קריאה/כתיבת קבצים, בודקים קודם האם יש API בגרסה אסינכרונית.
אם יש, העיקרון הוא לעשות לו await ישירות.
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
return await File.ReadAllTextAsync(path, cancellationToken);
}
מה שכדאי להימנע ממנו כאן הוא לעטוף I/O אסינכרוני שכבר קיים ב-Task.Run.
// דוגמה לא טובה
public async Task<string> LoadTextAsync(string path, CancellationToken cancellationToken)
{
return await Task.Run(() => File.ReadAllTextAsync(path, cancellationToken), cancellationToken);
}
זה רק זורק מחדש את ההמתנה ל-I/O ל-thread אחר, ומסבך בלי תועלת.
- אם זו המתנה ל-I/O,
Task.Runלא נחוץ - קודם מחפשים API אסינכרוני
- אם מקבלים token, מעבירים אותו כמו שהוא downstream
זו די הדרך הסטנדרטית.
flowchart TB
accTitle: הצורה הבסיסית של המתנה ל-I/O
accDescr: בהמתנה ל-HTTP, DB או קובץ, מחפשים קודם API בגרסה אסינכרונית ועושים לו await ישירות. עטיפת I/O אסינכרוני שכבר קיים ב-Task.Run רק זורקת מחדש בלי תועלת.
need["המתנה ל-HTTP, DB או קובץ"] --> find["קודם מחפשים API בגרסה אסינכרונית"]
find --> aw["עושים await ישירות"]
wrap["עוטפים ב-Task.Run"] -.-> bad["רק זורק מחדש, בלי תועלת"]
איור 6: הבסיס בהמתנה ל-I/O הוא “await ישירות ל-API האסינכרוני”. נמנעים מלעטוף ב-Task.Run.
3.3. אם עומס ה-CPU כבד, בוחרים איפה להשתמש ב-Task.Run
Task.Run עוזר כשרוצים להוציא חישוב CPU מה-thread הנוכחי.
לדוגמה, אם מריצים חישוב כבד ישירות בתוך event handler של UI, המסך נתקע.
במקרים כאלה Task.Run הוא הפתרון הטבעי.
flowchart TB
accTitle: איך Task.Run פועל ב-UI
accDescr: אם מריצים חישוב כבד ישירות באירוע UI המסך נתקע. כשמוציאים את חישוב ה-CPU מ-UI thread עם Task.Run, המסך ממשיך להגיב — הדוגמה הטיפוסית שבה Task.Run עוזר.
heavy["מריצים חישוב כבד באירוע UI"] -.-> freeze["המסך נתקע"]
run["מוציאים מ-UI thread עם Task.Run"] --> keep["המסך ממשיך להגיב"]
איור 7: Task.Run עוזר כשיש thread מיוחד שכדאי לפנות (UI thread).
public Task<byte[]> HashManyTimesAsync(byte[] data, int repeat, CancellationToken cancellationToken)
{
return Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
using var sha256 = System.Security.Cryptography.SHA256.Create();
byte[] current = data;
for (int i = 0; i < repeat; i++)
{
cancellationToken.ThrowIfCancellationRequested();
current = sha256.ComputeHash(current);
}
return current;
}, cancellationToken);
}
עם זאת, מה שחשוב כאן הוא איפה קוראים לזה.
- WinForms / WPF וכדומה (UI): יש מקרים ש-
Task.Runעוזר - טיפול בבקשה ב-ASP.NET Core: בדרך כלל נמנעים מלכתוב
Task.Runולעשות לוawaitמיד - עיבוד worker / רקע: מעבדים במקום, או מתכננים דרגת parallelism
אם משלבים Task.Run לטיפול בבקשה ב-ASP.NET Core ועושים לו await מיד, נוטה רק להוסיף scheduling מיותר.
זו נקודה שקל להבין לא נכון, אז נפריד את הסיבה. זה לא “כי זה כבר רץ על ה-thread pool, אז Task.Run חסר משמעות” (הריצה על ה-thread pool כשלעצמה זהה גם בעיבוד רקע של אפליקציית UI). הנקודה מצטמצמת לשתי אלה.
- ה-throughput לא גדל. כמות חישוב ה-CPU הכוללת לא משתנה, היא רק עוברת ל-thread אחר של ה-thread pool. מספר הבקשות שמעבדים בו-זמנית לא גדל
- גם לא משחררים המתנה. הסיבה ש-
Task.Runעוזר באפליקציית UI היא שיש thread מיוחד אחד שכדאי לפנות (UI thread). בצד השרת אין את אותו “אחד”. ה-thread המקורי אמנם משתחרר, אבל thread אחר מתמלא באותו זמן חישוב במקומו, אז ההפרש הוא אפס
מה שנשאר הוא עלות ההכנסה ל-queue וההחלפה בין threads, ופחות בהירות של “באיזה thread זה רץ”. לכן נמנעים מזה.
flowchart TB
accTitle: הסיבה להימנע מ-Task.Run ב-ASP.NET Core
accDescr: גם אם משלבים Task.Run בטיפול בבקשה, כמות החישוב לא משתנה ואין thread מיוחד לפנות כמו ב-UI, כך שההפרש אפס, ומה שנשאר זה רק עלות ההחלפה בין threads וירידה בבהירות.
tr["משלבים Task.Run בטיפול בבקשה"] --> r1["כמות החישוב לא משתנה"]
tr --> r2["אין thread מיוחד אחד לפנות"]
r1 --> zero["ההפרש אפס"]
r2 --> zero
zero --> cost["נשארות עלות ההחלפה וירידה בבהירות"]
איור 8: גם אם עושים await ל-Task.Run מיד בצד השרת, לא מתקבלים לא throughput ולא שחרור המתנה.
לכן, ב-ASP.NET Core נכון יותר לחשוב כך.
- אם זו המתנה ל-I/O,
awaitרגיל (plain) - אם זה עיבוד CPU קצר, מריצים במקום
- לעיבוד ארוך או עיבוד שרוצים לנתק מאורך חיי הבקשה, מוציאים ל-queue או ל-HostedService
כאשר יש רק גרסה סינכרונית של ה-API, כשקוראים לו מ-UI, יש מקרים שמשתמשים ב-Task.Run מסיבות של responsiveness של ה-UI.
אבל זה לא “I/O אסינכרוני”, אלא רק תפיסת thread שלם כדי לעקוף את הבעיה.
בצד שרת כמו ASP.NET Core, דרך הבריחה הזו לרוב לא מתרחבת טוב.
3.4. כמה עיבודים עצמאיים: Task.WhenAll
קוד שבו יש כמה עיבודים אסינכרוניים עצמאיים, אבל ממתינים להם אחד-אחד ככה, נפוץ.
// דוגמה שעצמאית אבל רצה בטור
string a = await _httpClient.GetStringAsync(urlA, cancellationToken);
string b = await _httpClient.GetStringAsync(urlB, cancellationToken);
string c = await _httpClient.GetStringAsync(urlC, cancellationToken);
אם הם לא תלויים זה בזה, טבעי יותר להתחיל את כולם קודם, ולהמתין לכולם ביחד בסוף.
public async Task<string[]> DownloadAllAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
Task<string>[] tasks = urls
.Select(url => _httpClient.GetStringAsync(url, cancellationToken))
.ToArray();
return await Task.WhenAll(tasks);
}
הנקודה היא ToArray().
LINQ הוא lazy, ולכן רק על ידי Select ייתכן שעדיין לא נעשתה enumeration בפועל.
אם מקבעים פעם אחת עם ToArray() או ToList(), כל ה-tasks מתחילות באותו רגע.
sequenceDiagram
accTitle: התחלת כל ה-tasks יחד וסיום עם Task.WhenAll
accDescr: הצד הקורא מתחיל את שלוש ה-tasks זו אחר זו, ואז ממתין להן ביחד עם Task.WhenAll עד שכולן מדווחות על סיום.
participant Caller as הצד הקורא
participant T1 as Task 1
participant T2 as Task 2
participant T3 as Task 3
Caller->>T1: מתחיל
Caller->>T2: מתחיל
Caller->>T3: מתחיל
Caller->>Caller: await Task.WhenAll(...)
T1-->>Caller: הסתיימה
T2-->>Caller: הסתיימה
T3-->>Caller: הסתיימה
איור 9: מתחילים קודם את כל ה-tasks העצמאיות, ורק אז ממתינים לסיום כולן עם Task.WhenAll.
הדפוס הזה מתאים כש:
- מספר הפריטים קטן, או בינוני
- רוצים להמתין לכל הפריטים ביחד
- אין בעיה להריץ בו-זמנית בלי תקרה
אם יש הרבה פריטים, בטוח יותר להוסיף תקרה ל-parallelism כמו בסעיף 3.6 הבא.
3.5. אם רוצים את מה שמסתיים ראשון: Task.WhenAny
לדוגמה, כשרוצים להשתמש במה שהגיב ראשון מבין כמה mirrors, Task.WhenAny ברור למדי.
public async Task<byte[]> DownloadFromFirstMirrorAsync(
IReadOnlyList<string> urls,
CancellationToken cancellationToken)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
List<Task<byte[]>> pending = urls
.Select(url => _httpClient.GetByteArrayAsync(url, cts.Token))
.ToList();
var failures = new List<Exception>();
try
{
while (pending.Count > 0)
{
Task<byte[]> finished = await Task.WhenAny(pending);
pending.Remove(finished);
try
{
byte[] data = await finished; // רק כשהצליח, יוצאים מכאן
cts.Cancel(); // עוצרים את השאר רק אחרי שנקבע הזוכה
return data;
}
catch (Exception ex)
{
// אם הצד הקורא עצר, זו לא "כישלון של mirror".
// אם מעבירים את זה כאן בלי לבדוק, כל הביטולים מצטברים
// כ"כישלון", והופכים בסוף ל-AggregateException שאי אפשר להבחין מכשל
cancellationToken.ThrowIfCancellationRequested();
// ה-mirror הזה נכשל. עדיין יש סיכוי בשאר, אז ממשיכים
failures.Add(ex);
}
}
}
finally
{
cts.Cancel(); // גם אם יוצאים בגלל חריגה, עוצרים את מה שעדיין מוריד
try
{
await Task.WhenAll(pending);
}
catch
{
// אוספים את הביטולים והכשלים של מי שלא זכה
}
}
throw new AggregateException("ההורדה נכשלה מכל ה-mirrors.", failures);
}
מה שחשוב בקוד הזה הוא הנקודה שבה מוציאים את הביטול רק אחרי שהזוכה נקבע.
- מה ש-
Task.WhenAnyמחזיר הוא ה-task שהסתיימה ראשונה, לא ה-task שהצליחה ראשונה. גם אם ה-mirror המהיר ביותר נכשל ב-404 או בניתוק חיבור, הוא זה שחוזר בתור “הזוכה” - אם מבטלים לפני שבודקים את התוצאה כאן, מגיעים למצב שבו עוצרים בעצמכם את שאר ה-mirrors שעדיין חיים, וזורקים מחדש את החריגה של הזוכה שנכשל. זו הצורה הגרועה ביותר שבה כל המשמעות של הכנת כמה mirrors נעלמת
- לכן, מוציאים את ה-tasks שהסתיימו אחת-אחת עם
await, ומבטלים את השאר רק כשהצליח. אם נכשל, מוציאים את ה-task הזו מהמועמדים וממתינים לסיום הבא Cancel()רק שולח בקשה, ולא ממתין עד שהצד השני נעצר. לכן ב-finallyממתינים לשאר, ומאתרים כאן את חריגות הביטול והכישלון. אם מדלגים על זה, נשארות חריגות שאף אחד לא צופה בהן בצד ה-task- כשהכול נכשל, זורקים את הכשלים ביחד. אם זורקים רק את החריגה הראשונה, נעלם המידע “איזה mirror נכשל ואיך”
- רק ביטול על ידי הצד הקורא לא נספר ככישלון, וזורקים אותו כמו שהוא החוצה. כש-
cancellationTokenנופל, כל ה-tasks מסתיימות ב-OperationCanceledException, ואם מצטברות ב-failures, זה הופך ל-AggregateExceptionבסוף, והפסקה או timeout של המשתמש נרשמים ומנוסים מחדש כ”כישלון כל ה-mirrors”. קוראים ל-ThrowIfCancellationRequested()בתחילת ה-catch, והביטול מוחזר כמו שהוא כ-OperationCanceledException
הדבר שכדאי להיזהר ממנו כאן הוא ש-WhenAny מחזיר רק זוכה אחד.
שאר העיבוד, אם לא עושים כלום, ממשיך לרוץ כרגיל.
לכן, צריך להחליט מראש:
- האם רוצים לבטל את השאר
- האם רוצים לצפות בחריגות
Task.WhenAny נוח, אבל דורש תכנון קצת יותר מ-WhenAll.
כשבוחרים אותו רק כש”מספיק הראשון בלבד”, זה נשאר ברור.
flowchart TB
accTitle: הזרימה של אימות זוכה עם WhenAny לפני עצירת השאר
accDescr: מה ש-Task.WhenAny מחזיר הוא ה-task שהסתיימה ראשונה ולא זו שהצליחה. לכן עושים await לסיום אחד-אחד, מבטלים את השאר רק כשהצליח, ואם נכשל מוציאים מהמועמדים וממתינים לסיום הבא. כשכולם נכשלים זורקים את הכשלים ביחד.
any["מקבלים סיום ראשון עם WhenAny"] --> chk{"האם ה-task הזו הצליחה?"}
chk -->|"הצליחה"| win["מבטלים את השאר ומחזירים"]
chk -->|"נכשלה"| next["מוציאים מהמועמדים ומתעדים כישלון"]
next --> rest{"נשארו מועמדים?"}
rest -->|"כן"| any
rest -->|"לא"| agg["זורקים את הכשלים ביחד"]
איור 10: “הסתיים ראשון” אינו “הצליח ראשון”. מוודאים את תוצאת הזוכה לפני עצירת השאר.
3.6. הרבה פריטים ורוצים להגביל parallelism: Parallel.ForEachAsync או SemaphoreSlim
Task.WhenAll מריץ את כל ה-tasks שנוצרו בו-זמנית.
לכן, אם מספר היעדים גדול, חיבורי HTTP, חיבורי DB, שימוש בזיכרון, והעומס על שירות חיצוני עולים בבת אחת.
במקרים כאלה, יציב יותר להחליט כמה מריצים בו-זמנית.
Parallel.ForEachAsync קריא מאוד לגבי הכוונה הזו.
public async Task DownloadAndSaveAsync(IEnumerable<string> urls, CancellationToken cancellationToken)
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = 8,
CancellationToken = cancellationToken
};
await Parallel.ForEachAsync(
urls.Select((url, index) => (url, index)),
options,
async (item, token) =>
{
string html = await _httpClient.GetStringAsync(item.url, token);
string path = Path.Combine("cache", $"{item.index}.html");
await File.WriteAllTextAsync(path, html, token);
});
}
הדפוס הזה מתאים כש:
- מספר הפריטים גדול
- כל פריט מטופל באופן עצמאי
- אבל רוצים להימנע מהכול-בבת-אחת
מצד שני, אם רוצים לשלוט בחופשיות רבה יותר, יש גם דרך של SemaphoreSlim.
לדוגמה, בקרה בסגנון “עד 4 בו-זמנית ל-API חיצוני מסוים”.
כלומר,
- מספר קטן —
Task.WhenAll - כמות גדולה —
Parallel.ForEachAsyncאוSemaphoreSlim
בחלוקה הזו, לרוב לא טועים משמעותית.
flowchart TB
accTitle: חלוקת הריכוז המקבילי לפי מספר הפריטים
accDescr: כמה עיבודים עצמאיים אפשר להריץ ביחד עם Task.WhenAll, אבל אם מספר הפריטים גדול, חיבורים, זיכרון ועומס חיצוני עולים בבת אחת, ולכן קובעים תקרת parallelism עם Parallel.ForEachAsync או SemaphoreSlim.
q{"מספר הפריטים גדול?"}
q -->|"מספר קטן"| all["Task.WhenAll ביחד"]
q -->|"גדול"| limit["קובעים תקרת parallelism"]
limit --> pfe["Parallel.ForEachAsync"]
limit --> sem["SemaphoreSlim לשליטה חופשית"]
איור 11: נקודת ההכרעה היא אם מותר לשלוח את כל הפריטים בבת אחת. אם הרבה, קובעים במפורש את ה-parallelism.
3.7. אם רוצים לזרום לפי סדר: Channel<T>
יש מקרים שרוצים לנתק מהצד הקורא עבודה מסוג “לא צריך להסתיים מיד, אבל חייבים לעבד אותה בוודאות”. שליחת דוא”ל, העברת לוגים, טיפול לאחר Webhook, המרת קבצים, וכדומה.
אם משליכים Task.Run בעבודות כאלה,
- איפה רואים חריגות
- האם ממתינים בזמן הסיום
- עד כמה מקבלים כשמספר הפריטים גדל
הופכים מעורפלים.
עבודה מהסוג הזה, קל יותר לנהל אם מזינים ל-queue ו-consumer ייעודי מעבד לפי הסדר.
flowchart LR
accTitle: זרימת Channel חסום עם backpressure
accDescr: ה-producer עושה WriteAsync ל-queue, וכשאין מקום פנוי הוא ממתין עד שיתפנה. ה-consumer עושה ReadAsync ומעבד לפי הסדר עם await.
p["producer"] --> w["WriteAsync"]
w --> q{"יש מקום פנוי ב-queue?"}
q -- "כן" --> c["נכנס ל-Channel"]
q -- "לא" --> b["ממתין עד שיתפנה"]
c --> d["consumer עושה ReadAsync"]
d --> e["מעבד לפי הסדר עם await"]
איור 12: זרימת Channel חסום. כשה-queue מלא, צד הכתיבה ממתין, וכך נוצר backpressure.
Channel<T> מאפשר לכתוב יחסית בפשטות את הצורה של producer / consumer.
public sealed class BackgroundTaskQueue
{
private readonly Channel<Func<CancellationToken, ValueTask>> _queue =
Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait
});
public ValueTask EnqueueAsync(
Func<CancellationToken, ValueTask> workItem,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(workItem);
return _queue.Writer.WriteAsync(workItem, cancellationToken);
}
public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken)
=> _queue.Reader.ReadAsync(cancellationToken);
}
BoundedChannelFullMode.Wait בדוגמה הזו הוא הגדרה שגורמת לצד הכתיבה להמתין אם ה-queue מלא.
זה ה-backpressure.
ב-ASP.NET Core, קל יותר להבין את הצורה שבה queue כזה נצרך יחד עם BackgroundService.
זה מקל לטפל בחריגות, עצירה, parallelism, ותקרה — יותר מ-“fire-and-forget אמיתי”.
flowchart TB
accTitle: השוואה בין השלכה ללא ניהול לניהול ב-queue
accDescr: השלכת Task.Run ללא ניהול משאירה מעורפל היכן רואים חריגות, מתי מסתיימים, ומה התקרה. הזנה ל-Channel וצריכה על ידי BackgroundService מאפשרת לנהל חריגות, עצירה ו-parallelism.
ff["השלכת Task.Run בלי ניהול"] -.-> vague["חריגה, סיום ותקרה מעורפלים"]
ch["מזינים ל-queue של Channel"] --> bs["BackgroundService צורך"]
bs --> mng["אפשר לנהל חריגה, עצירה ו-parallelism"]
איור 13: אם רוצים לנתק את הצד הקורא מאורך החיים, מוציאים למקום מנוהל, לא ל-fire-and-forget.
3.8. אם רוצים לרוץ במרווח קבוע: PeriodicTimer
לעיבוד אסינכרוני במרווח קבוע, PeriodicTimer קריא למדי.
public async Task RunPeriodicAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(cancellationToken))
{
await RefreshCacheAsync(cancellationToken);
}
}
היתרונות של הכתיבה הזו הם:
- קל יותר לעקוב אחרי הזרימה מ-Timer מבוסס callback
- אפשר לכתוב בבסיס
await - בעצירה אפשר להשתמש בפשטות ב-
CancellationToken
PeriodicTimer משתמשים בו בהנחה שלא שולחים כמה WaitForNextTickAsync בו-זמנית לאותו timer.
בנוסף, אם זמן העיבוד ארוך מהמחזור, יש לטפל בעיכוב הזה כחלק מהתכנון.
ה-timer לא מבצע parallelism מעצמו כדי להדביק את הפער.
flowchart TB
accTitle: לולאת המחזור של PeriodicTimer
accDescr: ממתינים למחזור הבא עם WaitForNextTickAsync, מריצים את העיבוד עם await וחוזרים להמתנה. העצירה נעשית עם CancellationToken, ועיכוב כשהעיבוד ארוך מהמחזור צריך להיות מטופל בתכנון.
tick["ממתינים עם WaitForNextTickAsync"] --> proc["מריצים עיבוד עם await"]
proc --> tick
stop["עוצרים עם CancellationToken"] -.-> tick
proc -.-> warn["עיכוב מעיבוד ארוך מהמחזור מטופל בתכנון"]
איור 14: רצים עם timer אחד — consumer אחד. ה-timer לא ידביק פער על ידי parallelism עצמאי.
3.9. אם הנתונים מגיעים ברצף: IAsyncEnumerable<T>
יש מצבים שרוצים לעבד לפי סדר ההגעה, במקום לצבור הכול ל-List<T> ורק אז להחזיר.
- קריאה בסדר של API עם pagination
- קריאת שורות קובץ בהדרגה
- זרימת תוצאת streaming כמו שהיא
במקרים כאלה, IAsyncEnumerable<T> ו-await foreach טבעיים.
public async Task ProcessUsersAsync(CancellationToken cancellationToken)
{
await foreach (User user in _userRepository.StreamUsersAsync(cancellationToken))
{
await ProcessUserAsync(user, cancellationToken);
}
}
הצורה הזו מתאימה כש:
- לא רוצים לחכות שכל הפריטים יהיו מוכנים
- רוצים לעבד פריט-פריט
- לא רוצים לצבור הכול בזיכרון
אם ערך ההחזרה יהיה Task<List<T>> או IAsyncEnumerable<T>,
קל יותר להחליט לפי האם משתמשים בתוצאה אחרי שכל הפריטים מוכנים, או לפי סדר ההגעה.
flowchart TB
accTitle: צורת ערך ההחזרה שנקבעת לפי אופן השימוש בתוצאה
accDescr: אם משתמשים בתוצאה אחרי שכל הפריטים מוכנים מחזירים רשימה שלמה עם Task. אם משתמשים לפי סדר ההגעה מחזירים IAsyncEnumerable ומעבדים פריט-פריט עם await foreach בלי לצבור בזיכרון.
q{"איך משתמשים בתוצאה?"}
q -->|"אחרי שהכול מוכן"| list["מחזירים רשימה שלמה עם Task"]
q -->|"לפי סדר ההגעה"| ae["זורמים עם IAsyncEnumerable"]
ae --> each["מעבדים פריט-פריט עם await foreach"]
ae -.-> mem["לא צוברים בזיכרון"]
איור 15: לצבור את כל הפריטים או לזרום לפי סדר ההגעה. אופן השימוש קובע את טיפוס ערך ההחזרה.
3.10. אם רוצים לשחרר באופן אסינכרוני: await using
טיפוס שבשחרורו נדרש עיבוד אסינכרוני כמו flush או סיום תקשורת, מממש IAsyncDisposable.
במקרה כזה, משתמשים ב-await using ולא ב-using.
public async Task WriteFileAsync(string path, byte[] data, CancellationToken cancellationToken)
{
await using var stream = new FileStream(
path,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true);
await stream.WriteAsync(data, cancellationToken);
}
הנקודה היא:
- אם
IAsyncDisposable, אזawait using - זה שכיח ש”הפתיחה” סינכרונית אבל “הסגירה” אסינכרונית
זה עוזר כשרוצים להימנע מהפער “הכתיבה כן אסינכרונית, אבל השחרור בסוף נשאר סינכרוני”.
3.11. exclusion שחוצה await: SemaphoreSlim
בקוד שחוצה await, יש מקרים שמשתמשים ב-SemaphoreSlim במקום lock.
public sealed class CacheRefresher
{
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task RefreshAsync(CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try
{
await RefreshCoreAsync(cancellationToken);
}
finally
{
_gate.Release();
}
}
private static Task RefreshCoreAsync(CancellationToken cancellationToken)
=> Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
מה שחשוב הוא שתי הנקודות האלה.
- נכנסים עם
WaitAsync - תמיד קוראים ל-
Releaseב-finally
במצבים כמו “רוצים שיכנס רק אחד בו-זמנית”, “רוצים להגביל קריאה ל-API חיצוני עד 3 בו-זמנית”,
SemaphoreSlim מעשי מאוד.
flowchart TB
accTitle: הצורה של exclusion שחוצה await
accDescr: בקוד שחוצה await משתמשים ב-SemaphoreSlim במקום lock, נכנסים עם WaitAsync ומבצעים עיבוד שכולל await, ותמיד קוראים ל-Release ב-finally.
lk["lock לא יכול לחצות await"] -.-> alt["משתמשים ב-SemaphoreSlim במקום"]
wait["נכנסים עם WaitAsync"] --> crit["מבצעים עיבוד שכולל await"]
crit --> rel["תמיד Release ב-finally"]
איור 16: הכניסה היא WaitAsync, היציאה היא Release ב-finally. חשוב לא לשבור את הזוג הזה.
3.12. כתיבת await שונה ב-UI / קוד אפליקציה / ספרייה
ConfigureAwait(false) הוא לא משהו שמוסיפים בכל מקום.
בגדול, החלוקה היא כך.
flowchart LR
accTitle: חלוקת הכתיבה בין UI/אפליקציה לספרייה כללית
accDescr: קוד UI או אפליקציה עושה await רגיל וממשיך כשחוזר ל-context המקורי. ספרייה כללית מוסיפה ConfigureAwait(false) ולא מניחה חזרה ל-context מסוים.
a["UI / קוד אפליקציה"] --> b["await someAsync()"]
b --> c["ממשיכים כשחוזרים ל-context המקורי"]
d["ספרייה כללית"] --> e["await someAsync().ConfigureAwait(false)"]
e --> f["לא מניחים חזרה ל-context מסוים"]
איור 17: קוד צד האפליקציה חוזר ל-context המקורי עם await רגיל, ובספרייה כללית שוקלים ConfigureAwait(false).
- UI / קוד אפליקציה
- קודם מספיק
awaitרגיל - אם אחרי ה-await מעדכנים UI או עושים עיבוד שתלוי ב-context של האפליקציה, טבעי יותר לא להוסיף
ConfigureAwait(false)
- קודם מספיק
- קוד אפליקציה ב-ASP.NET Core
- בדרך כלל מספיק
awaitרגיל - אין צורך לאכוף את
ConfigureAwait(false)כשיטה בכל מקום
- בדרך כלל מספיק
- קוד ספרייה כללי
- אם לא תלוי ב-UI או במודל האפליקציה,
ConfigureAwait(false)שימושי
- אם לא תלוי ב-UI או במודל האפליקציה,
כלומר,
- קוד צד האפליקציה —
awaitרגיל - ספרייה כללית — לשקול
ConfigureAwait(false)
אם זוכרים את זה, לא נתקעים בפועל.
4. כללי בסיס לכתיבה
4.1. ערך ההחזרה: קודם Task / Task<T>
את ערך ההחזרה של מתודה אסינכרונית, חושבים תחילה לפי הסדר הזה.
| ערך החזרה | החשיבה הראשונית |
|---|---|
Task |
הבסיס למתודה אסינכרונית בלי ערך החזרה |
Task<T> |
הבסיס למתודה אסינכרונית שמחזירה ערך |
ValueTask / ValueTask<T> |
בוחרים אחרי מדידה, כשרואים שיש צורך |
ValueTask נראה נוח, אבל לא תמיד עדיף על Task.
מכיוון שזה struct, יש עלות העתקה, ויש גם מגבלות בשימוש.
מה שחשוב במיוחד הוא ש-ValueTask כעיקרון מיועד להמתין לו רק פעם אחת.
לא מתאים לכתיבה שבה שומרים אותו במשתנה מקומי ומחכים לו כמה פעמים בקלות ראש.
לכן, בקוד אפליקציה יומיומי, קודם מספיק Task / Task<T>.
בנוסף, עדיף להוסיף את הסיומת Async לשם המתודה, לבהירות.
public Task SaveAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<int> CountAsync(CancellationToken cancellationToken)
{
return Task.FromResult(_count);
}
כמו למעלה, אם אין עיבוד ל-await, טבעי יותר להחזיר Task.CompletedTask או Task.FromResult בלי להוסיף async בכוח.
4.2. async void רק ב-event handlers
async void — העיקרון הוא להימנע ממנו מחוץ ל-event handlers.
הסיבה פשוטה:
- הצד הקורא לא יכול לעשות
await - לא יכולים לחכות לסיום
- טיפול בחריגות נהיה קשה
- קשה לבדוק
רק ב-event handler נדרש void, ולכן משתמשים בו רק שם.
private async void SaveButton_Click(object? sender, EventArgs e)
{
try
{
await SaveAsync(_saveCancellation.Token);
_statusLabel.Text = "נשמר.";
}
catch (OperationCanceledException)
{
_statusLabel.Text = "בוטל.";
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "שגיאת שמירה");
}
}
ב-event handler, חשוב להיות מודעים לכך שתופסים חריגה בפנים ומחזירים אותה לצד ה-UI בעצמכם.
flowchart TB
accTitle: הסיבה להימנע מ-async void והחריג היחיד
accDescr: מתודת async void לא יכולה להיות מוגשת ל-await, לא ניתן לחכות לסיומה, וטיפול בחריגות ובדיקות קשים. לכן נמנעים ממנה במתודות רגילות, ומשתמשים בה רק ב-event handler שדורש void, כשתופסים חריגות עם try/catch ומחזירים ל-UI.
av["מתודת async void"] --> p1["אי אפשר await"]
av --> p2["אי אפשר לחכות לסיום"]
av --> p3["חריגות ובדיקה קשים"]
ev["event handler הוא החריג"] -.-> duty["try/catch ומחזירים ל-UI"]
*איור 18: מתודה רגילה מחזירה Task / Task
4.3. מקבלים CancellationToken ומעבירים downstream
עבור פעולה שניתן לבטל, מקבלים CancellationToken ומעבירים אותו כמו שהוא downstream.
public async Task<string> DownloadTextAsync(string url, CancellationToken cancellationToken)
{
using HttpResponseMessage response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
מה שנפוץ כאן הוא דפוס שבו הרמה העליונה מקבלת את ה-token, אבל לא מעבירה אותו downstream. זה נוטה להפוך לקוד ש”נראה שאפשר לבטל, אבל לא נעצר באמצע בפועל”.
בנוסף, גם ל-timeout יש משמעות שונה בין “רוצים תקרה רק על ההמתנה” לבין “רוצים לעצור גם את העיבוד עצמו”.
- רוצים תקרה רק על ההמתנה:
WaitAsync - רוצים לעצור גם את העיבוד עצמו:
CancellationTokenSource.CancelAfterוהעברת ה-token
ההבדל הזה נוטה להפוך לבאג בהמשך, ולכן יציב יותר להחליט עליו מראש.
flowchart TB
accTitle: העברת CancellationToken
accDescr: אם מעבירים downstream את ה-token שהתקבל למעלה, זה נעצר כראוי גם באמצע. אם רק מקבלים ולא מעבירים, מתקבל קוד שנראה שאפשר לבטל אך לא נעצר בפועל.
up["מקבלים token למעלה"] --> pass["מעבירים כמו שהוא ל-API downstream"]
pass --> stop["נעצר כראוי גם באמצע"]
nopass["מקבלים אבל לא מעבירים"] -.-> fake["נראה שנעצר אך לא נעצר"]
איור 19: אם מקבלים token, מעבירים אותו עד הסוף. אי-העברה יוצרת “ביטול שלא עוצר”.
4.4. מחברים API אסינכרוניים באופן אסינכרוני עד הסוף
אם משתמשים ב-async / await, טבעי יותר לחבר באופן אסינכרוני עד הסוף ככל האפשר.
קווים מנחים להחלפה:
| כתיבה שרוצים להימנע ממנה | ההחלפה |
|---|---|
Task.Result / Task.Wait() |
await |
Task.WaitAll() |
await Task.WhenAll(...) |
Task.WaitAny() |
await Task.WhenAny(...) |
Thread.Sleep(...) |
await Task.Delay(...) |
במיוחד ב-UI וב-ASP.NET Core, אם מתערבבת המתנה סינכרונית, קשה יותר לקרוא את אופן ההיתקעות.
ב-C# של היום אפשר להשתמש גם ב-async Task Main(), ולכן גם באפליקציית console יש פחות סיבה לסנכרן בכוח.
flowchart TB
accTitle: החלפה שלא מערבבת המתנה סינכרונית
accDescr: אם משתמשים ב-async/await, מחברים באופן אסינכרוני עד הסוף — Result ו-Wait הופכים ל-await, ו-Thread.Sleep הופך ל-Task.Delay. התערבבות של המתנה סינכרונית הופכת את אופן ההיתקעות לקשה לקריאה.
chain["מחברים באופן אסינכרוני עד הסוף"] --> r1["Result ו-Wait הופכים ל-await"]
chain --> r2["Thread.Sleep הופך ל-Task.Delay"]
mix["המתנה סינכרונית מתערבבת"] -.-> clog["אופן ההיתקעות נהיה קשה לקריאה"]
איור 20: כשמחליטים להשתמש ב-async, לא נופלים באמצע להמתנה סינכרונית, אלא מחברים אסינכרונית עד הסוף.
4.5. כשיוצרים tasks עם LINQ, מקבעים עם ToArray / ToList
כשמשלבים Task.WhenAll או Task.WhenAny עם LINQ,
בטוח יותר לקבע פעם אחת עם ToArray() או ToList().
Task<User>[] tasks = userIds
.Select(id => _userRepository.GetAsync(id, cancellationToken))
.ToArray();
User[] users = await Task.WhenAll(tasks);
הסיבה היא ש-LINQ הוא lazy. אם קוראים בהנחה ש”כבר הכול התחיל”, אבל בפועל עדיין לא בוצעה enumeration — זה מסוכן בשקט.
- כשממתינים לכולם ביחד,
ToArray() - כשרוצים למחוק/להחליף באמצע,
ToList()
אם זוכרים את זה, קל יותר להחליט.
5. anti-patterns נפוצים
| anti-pattern | מה קשה בזה | ההחלפה הראשונית |
|---|---|---|
Task.Run(async () => await IoAsync()) |
זורק מחדש בלי צורך המתנה ל-I/O | await IoAsync() |
Task.Result / Wait() |
חוסם thread. נוטה להיתקע | await |
מערבבים Thread.Sleep() בזרימה אסינכרונית |
תופס thread גם בזמן ההמתנה | Task.Delay() |
משתמשים ב-async void במתודה רגילה |
אי אפשר לחכות, קשה לנהל חריגות | Task / Task<T> |
await בטור במקום שצריך Task.WhenAll |
מתעכב שלא לצורך | מתחילים הכול קודם ואז WhenAll |
שולחים כמות גדולה ב-WhenAll בבת אחת |
העומס קופץ | Parallel.ForEachAsync / SemaphoreSlim |
מנסים לחצות await עם lock |
לא מתאים למטרה | SemaphoreSlim.WaitAsync |
מסתפקים ב-Task.Run גולמי ל-fire-and-forget |
ניהול חריגה, עצירה ותקרה מעורפל | Channel<T> / BackgroundService |
מוסיפים ConfigureAwait(false) מכנית לקוד UI |
עדכון UI אחרי await נוטה להישבר | await רגיל |
הופכים ValueTask לברירת מחדל |
לרוב אין תועלת ביחס למורכבות | קודם Task |
מתוך הטבלה הזו, מה שנפוץ במיוחד בפועל הן שלוש אלה.
Task.Runלמרות שזה I/O- await בטור למרות שזה בעצם עצמאי
- אין ניהול אורך חיים ל-fire-and-forget
תיקון שלוש אלה בלבד כבר משפר משמעותית את הבהירות של הקוד.
flowchart TB
accTitle: שלושת התיקונים הנפוצים ביותר בפועל
accDescr: שלושה דפוסים נפוצים בפועל — Task.Run למרות שזה I/O, await בטור לעיבוד עצמאי, והזנחת אורך חיי fire-and-forget — כל אחד עם ההחלפה שלו, משפרים משמעותית את הבהירות כשמתקנים.
a1["Task.Run למרות שזה I/O"] --> f1["await ישיר ל-API האסינכרוני"]
a2["await בטור לעיבוד עצמאי"] --> f2["מתחילים קודם ואז WhenAll"]
a3["הזנחת אורך חיי fire-and-forget"] --> f3["מעבירים ל-Channel או BackgroundService"]
f1 --> better["הבהירות משתפרת משמעותית"]
f2 --> better
f3 --> better
איור 21: מתוך טבלת ה-anti-patterns, הכי יעיל לתקן קודם את שלוש אלה.
6. checklist ל-code review
ב-code review סביב async / await, בודקים לפי הסדר את הדברים האלה.
- האם אפשר להסביר במילים, כבר בהתחלה, אם העיבוד הזה I/O-bound או CPU-bound
- האם נשארו
Task.Result/Task.Wait()/Thread.Sleep() - האם עוטפים המתנה ל-I/O ב-
Task.Run - האם עושים await בטור שלא לצורך לעיבוד עצמאי
- ולהפך, האם שולחים כמות גדולה ב-
WhenAllבלי תקרה - אם מקבלים
CancellationToken, האם באמת מעבירים אותו downstream - האם
async voidקיים במקום שהוא לא event handler - אם מכניסים
fire-and-forget, האם הוחלט מי מנהל חריגה, עצירה ותקרה - אם משתמשים ב-
SemaphoreSlim, האםReleaseנמצא ב-finally - אם משתמשים ב-
ValueTask, האם יש סיבה מבוססת מדידה, והאם ההנחה היא await פעם אחת - האם קיום
ConfigureAwait(false)תואם לסוג הקוד- קוד UI / אפליקציה —
awaitרגיל - ספרייה כללית — לשקול
ConfigureAwait(false)
- קוד UI / אפליקציה —
ה-checklist הזה שימושי גם ליישור קו בין נקודות המבט של הצוות ב-review.
7. איך בוחרים בפועל
הרשימה של חלוקת השימושים מרוכזת ב-טבלת ההחלטה של 3.1. במקום להציג שוב אותה טבלה, קל יותר לחזור אליה כשצריך, ולכן לא נציב כאן טבלה.
- רוצים לראות “מה משתמשים בו קודם” לפי מצב → טבלת ההחלטה ב-3.1
- רוצים לראות את הכתיבה של כל דפוס → 3.2 עד 3.12 (מתאימים לשורות הטבלה ב-3.1)
יש עוד החלטה אחת שלא נכללת בטבלה של 3.1: טיפוס ערך ההחזרה. זה לא עניין של מצב אלא של תכנון המתודה, ולכן זה מרוכז ב-4.1. אם כותבים רק את המסקנה: קודם בוחרים Task / Task<T>, ו-ValueTask בוחרים אחרי מדידה, כשרואים את הצורך.
8. סיכום
שיטות העבודה המומלצות ל-async / await יעילות יותר בפועל כ-התאמת הטיפוס לפי סוג העיבוד, ולא כזכירת המון טכניקות קטנות.
סדר ההסתכלות בגדול הוא כך.
- מפרידים אם זו המתנה ל-I/O או חישוב CPU
- אם זה I/O, עושים await ישירות ל-API האסינכרוני
- אם זה חישוב CPU, מחליטים איפה ראוי להריץ אותו
- לעיבודים מרובים, בוחרים
WhenAll/WhenAny/ הגבלת parallelism - אם מנתקים מאורך חיי הבקשה, לא fire-and-forget גולמי אלא queue
- מיישרים קו בטיפול בערך ההחזרה, ביטול, חריגות, exclusion ו-context
מכיוון שהכתיבה של async / await עצמה תמציתית, שימוש רשלני בה מקשה על ראיית הכיוון. לעומת זאת,
- מתייחסים ל-I/O כ-I/O
- מתייחסים ל-CPU כ-CPU
- מנהלים עיבוד רקע כעיבוד רקע עם אורך חיים משלו
רק הפרדה בין השלושה האלה כבר משפרת משמעותית את הקריאות.
9. מקורות
- ערכת קוד הדוגמה המלאה של המאמר הזה (ספרייה, הדגמה, unit tests) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/csharp-async-await-best-practices
- Asynchronous programming scenarios - C#
- Asynchronous programming with async and await
- Task-based Asynchronous Pattern (TAP) in .NET
- ConfigureAwait FAQ
- Parallel.ForEachAsync Method
- Task.WaitAsync Method
- System.Threading.Channels library
- Create a Queue Service
- Background tasks with hosted services in ASP.NET Core
- Generate and consume async streams
- Implement a DisposeAsync method
- ValueTask Struct
- CA2012: Use ValueTasks correctly
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
async/await וה-UI thread ב-WPF וב-WinForms
ב-WPF וב-WinForms, לאן חוזר הקוד אחרי await, מתי להשתמש ב-Dispatcher או Invoke, מה ConfigureAwait(false) באמת עושה, ולמה .Result ו-.Wait(...
למה arguments נשברים — כללי command-line arguments ב-Windows
Windows מעביר ל-CreateProcess מחרוזת אחת שהמקבל מפצל. מכסה את כללי CommandLineToArgvW, CRT ו-.NET, ArgumentList, ובניה ב-C++.
שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת .NET — מה להחליט לפני שמוסיפים threads
כללי תכנון שמונעים מקוד multithreaded ב-.NET/C# לקרוס או להיתקע מדי פעם: להשתמש ב-Task במקום ליצור threads בעצמכם, לצמצם shared mutable s...
WMI/CIM מ-C# ומ-PowerShell — מדריך מעשי למידע חומרה, ניטור process ושאילתות remote
WMI/CIM הוא הדרך הסטנדרטית לשלוף serial number של מחשב, לנטר דיסק פנוי ולזהות process שהתחיל. המאמר מכסה CIM cmdlets כמו Get-CimInstance,...
מעמקי ה-I/O ב-Windows (פרק 4) — Cache Manager: מתי WriteFile באמת מגיע לדיסק
פרק 4 בסדרה שמסבירה בתרשימים את Cache Manager של Windows. המאמר עובר על מטמון שממומש כמיפוי קובץ, read-ahead ו-lazy writer, הבחירה בין Fl...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
תהליכון ה-UI וטיימרים
תהליכון ה-UI של WPF / WinForms, זרימות אסינכרוניות, Dispatcher ותכנון טיימרים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
באפליקציות Windows שכוללות UI, עיבוד רקע ו-I/O, איך משתמשים ב-async/await משפיע ישירות על איכות המימוש.
ייעוץ טכני וסקירת תכנון
אם רוצים ליישר קו בין ההחלטות סביב Task.Run ו-ConfigureAwait לבין חלוקת האחריות, זה מתאים לייעוץ טכני ול-design review.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מתי כדאי להשתמש ב-Task.Run ב-C#?
- Task.Run עוזר כשרוצים להוציא חישוב CPU מה-thread הנוכחי. לדוגמה, אם מריצים חישוב כבד ישירות ב-event handler של UI ב-WinForms / WPF, המסך נתקע, ולכן טבעי להוציא אותו מ-UI thread עם Task.Run. לעומת זאת, טיפול בבקשה ב-ASP.NET Core כבר רץ על ה-thread pool מלכתחילה, ולכן הוספת Task.Run ו-await מיד אחריו נוטה רק להוסיף scheduling מיותר — לכן בדרך כלל נמנעים מזה. עיבוד ארוך, או עיבוד שרוצים לנתק מאורך חיי הבקשה, עדיף להוציא ל-queue או ל-HostedService.
- אסור לעטוף I/O ב-await Task.Run()?
- I/O כמו HTTP, DB וקריאה/כתיבת קבצים, כעיקרון עושים לו await ישירות ל-API האסינכרוני. אין צורך לעטוף ב-Task.Run. עטיפת I/O שכבר אסינכרוני ב-Task.Run רק זורקת את ההמתנה ל-thread אחר, ומסבכת בלי תועלת. יש מקרים שמשתמשים ב-Task.Run מסיבות של responsiveness כשקוראים מ-UI ל-API שיש לו רק גרסה סינכרונית, אבל זה לא I/O אסינכרוני — זה רק תופס thread שלם כדי לעקוף את הבעיה, ולכן בצד השרת זו דרך בריחה שלא מתרחבת טוב.
- איפה שמים ConfigureAwait(false)?
- בקוד UI או קוד אפליקציה, מתחילים עם await רגיל. אם אחרי ה-await מעדכנים UI או עושים פעולה שתלויה ב-context של האפליקציה, טבעי יותר לא להוסיף ConfigureAwait(false). גם בקוד אפליקציה ב-ASP.NET Core בדרך כלל מספיק await רגיל, ואין צורך לאכוף את זה כשיטה בכל מקום. ConfigureAwait(false) שימושי בעיקר בקוד ספרייה כללי שלא תלוי ב-UI או במודל האפליקציה. אם זוכרים "קוד אפליקציה — plain await, ספרייה כללית — לשקול ConfigureAwait(false)", לא נתקעים בפועל.
- למה נמנעים מ-async void מחוץ ל-event handlers?
- כי ב-async void הצד הקורא לא יכול לעשות await, לא יכול לחכות לסיום, טיפול בחריגות נהיה קשה, וגם קשה לבדוק. מתודה רגילה מחזירה כעיקרון Task או Task<T>. רק ב-event handler נדרש void מבחינת החתימה, ולכן משתמשים בו רק שם. במקרה כזה חשוב לכתוב בעצמכם בתוך ה-handler תפיסת חריגות עם try/catch והחזרה לצד ה-UI.