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 בסדר שקל להחליט לפיו.
הפיתוח המונח כולל למשל את אלה.
- אפליקציות שולחניות כמו WinForms / WPF
- אפליקציות/API אינטרנט של ASP.NET Core
- worker / שירותי רקע
- אפליקציות קונסולה
- ספריות מחלקות לשימוש חוזר
נציין שהקוד שמופיע במאמר הזה מפורסם ב-GitHub כערכת דוגמה מלאה שאפשר לבנות ולהריץ (ספרייה, הדגמת קונסולה, ובדיקות יחידה שמאמתות כל דפוס בטבלת ההחלטה).
csharp-async-await-best-practices - komurasoft-blog-samples (GitHub)
איך קוראים את המאמר הזה
זהו מאמר ארוך למדי, ולכן נשים תחילה נקודות כניסה לפי מטרה.
| מטרה | היכן לקרוא |
|---|---|
| רוצים לראות רק את טבלת ההחלטה | הטבלה והתרשים ב-3.1. זה מרכז המאמר |
| רוצים להכיר את אופן הכתיבה של כל דפוס | 3.2 ואילך. מתאימים אחד לאחד לשורות הטבלה ב-3.1 |
| רוצים לבדוק מחדש את הקוד שלכם | טבלת האנטי-דפוסים ב-5 |
| רוצים ליישר קו בנקודות הסקירה | רשימת הבדיקה ב-6 |
| רק המסקנה | 1 |
תוכן עניינים
- קודם המסקנה (במשפט אחד)
- מילים שנשתמש בהן במאמר הזה
- 2.1. מילים שכדאי להבחין ביניהן קודם
- 2.2. מילים שמופיעות הרבה
- קודם, טבלת ההחלטה
- 3.1. תמונה כללית
- 3.2. אם ממתינים ל-I/O, מבצעים await ישירות ל-API האסינכרוני
- 3.3. אם עומס ה-CPU כבד, בוחרים היכן להשתמש ב-Task.Run
- 3.4. אם יש כמה עיבודים עצמאיים, Task.WhenAll
- 3.5. אם רוצים להשתמש במה שמסתיים ראשון, Task.WhenAny
- 3.6. אם יש הרבה פריטים ורוצים להגביל מקביליות, Parallel.ForEachAsync או SemaphoreSlim
- 3.7. אם רוצים לזרום בסדר, Channel<T>
- 3.8. אם רוצים לרוץ במרווח קבוע, PeriodicTimer
- 3.9. אם הנתונים מגיעים ברצף, IAsyncEnumerable<T>
- 3.10. אם רוצים לשחרר באופן אסינכרוני, await using
- 3.11. אם צריך בלעדיות שחוצה await, SemaphoreSlim
- 3.12. מפרידים בין אופן הכתיבה של await ב-UI / קוד אפליקציה / ספרייה
- כללי הבסיס של אופן הכתיבה
- 4.1. ערך ההחזרה - קודם Task / Task<T>
- 4.2. async void רק במטפלי אירועים
- 4.3. מקבלים CancellationToken ומעבירים לזרם התחתון
- 4.4. מחברים API אסינכרוניים באופן אסינכרוני עד הסוף
- 4.5. כשיוצרים משימות עם LINQ, מקבעים עם ToArray / ToList
- אנטי-דפוסים נפוצים
- רשימת בדיקה לסקירת קוד
- חלוקה גסה בין השימושים
- סיכום
- מקורות
מפת הידע של המאמר
המאמר הזה מסדר שהדבר הראשון שכדאי לבדוק ב-async/await ב-C# הוא ההבחנה אם העיבוד I/O-bound או CPU-bound: המתנה ל-I/O מבצעים לה await ישירות ל-API האסינכרוני, וחישוב CPU כדאי להריץ ב-Task.Run ב-UI, ולהימנע ממנו בטיפול בבקשה ב-ASP.NET Core. ConfigureAwait(false) יעיל יותר בקוד ספרייה כללי שלא תלוי בהקשר של UI או אפליקציה, יותר מאשר ב-ASP.NET Core שאין לו SynchronizationContext. כמה עיבודי I/O עצמאיים מאגדים עם Task.WhenAll או Task.WhenAny, ואם יש הרבה פריטים מגבילים את מספר המקביליות עם Parallel.ForEachAsync או SemaphoreSlim, ועיבוד שרוצים לנתק את אורך חייו מהצד הקורא כדאי לנהל לא עם fire-and-forget אלא עם Channel
flowchart LR
accTitle: מפת הידע של ההחלטה המעשית ל-async/await ב-C#
accDescr: תרשים המראה איך ההבחנה בין I/O-bound ל-CPU-bound מתחברת לבחירה בין Task.Run ל-ConfigureAwait(false), ולבחירה בין WhenAll, WhenAny ו-Channel.
io_bound_operation["עיבוד עתיר קלט/פלט"]
cpu_bound_operation["עיבוד עתיר מעבד"]
taskrun_dotnet["Task.Run"]
ui_thread_context["ההקשר של ת'רד ממשק המשתמש"]
asp_net_core_request_thread["טיפול בבקשה ב-ASP.NET Core"]
sync_over_async["המתנה סינכרונית על קוד אסינכרוני (sync-over-async)"]
threadpool_starvation["הרעבת מאגר הת'רדים (starvation)"]
synchronizationcontext["SynchronizationContext"]
configureawait_false["ConfigureAwait(false)"]
generic_library_code["קוד ספרייה כללי"]
cancellationtoken_dotnet["CancellationToken(.NET)"]
async_void["async void"]
event_handler_method["מתודת מטפל באירוע"]
regular_async_method["מתודה אסינכרונית רגילה"]
task_whenall["Task.WhenAll"]
task_whenany["Task.WhenAny"]
parallel_foreachasync["Parallel.ForEachAsync"]
semaphoreslim["SemaphoreSlim"]
channel_t["Channel<T>"]
backpressure["backpressure"]
backgroundservice["BackgroundService"]
ihostedservice["IHostedService"]
fire_and_forget["fire-and-forget"]
decoupled_background_work["עיבוד רקע שאורך חייו מנותק מהקורא"]
periodictimer["PeriodicTimer"]
iasyncenumerable["IAsyncEnumerable<T>"]
await_using["await using"]
valuetask["ValueTask"]
taskrun_dotnet -->|"שימוש לא מומלץ ל"| io_bound_operation
taskrun_dotnet -->|"מענה מומלץ ל"| cpu_bound_operation
taskrun_dotnet -->|"מענה מומלץ ל"| ui_thread_context
taskrun_dotnet -->|"שימוש לא מומלץ ל"| asp_net_core_request_thread
sync_over_async -->|"עלול לגרום ל"| threadpool_starvation
ui_thread_context -->|"משתמש ב"| synchronizationcontext
configureawait_false -->|"מענה מומלץ ל"| generic_library_code
configureawait_false -->|"שימוש לא מומלץ ל"| ui_thread_context
io_bound_operation -.->|"משתמש ב"| cancellationtoken_dotnet
async_void -->|"מענה מומלץ ל"| event_handler_method
async_void -->|"שימוש לא מומלץ ל"| regular_async_method
task_whenall -->|"מענה מומלץ ל"| io_bound_operation
task_whenany -.->|"משתמש ב"| cancellationtoken_dotnet
parallel_foreachasync -->|"מענה מומלץ ל"| io_bound_operation
semaphoreslim -.->|"מענה מומלץ ל"| io_bound_operation
channel_t -.->|"משתמש ב"| backpressure
backgroundservice -->|"מענה מומלץ ל"| channel_t
backgroundservice -->|"מממש את"| ihostedservice
fire_and_forget -->|"שימוש לא מומלץ ל"| decoupled_background_work
channel_t -->|"מענה מומלץ ל"| decoupled_background_work
periodictimer -->|"משתמש ב"| cancellationtoken_dotnet
iasyncenumerable -->|"מענה מומלץ ל"| io_bound_operation
await_using -.->|"מחייב"| io_bound_operation
valuetask -->|"שימוש לא מומלץ ל"| regular_async_method
event_handler_method -.->|"מחייב"| ui_thread_context
ihostedservice -->|"מענה מומלץ ל"| asp_net_core_request_thread
בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 26, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle
1. קודם המסקנה (במשפט אחד)
-
async/awaitהם צורת כתיבה שלא חוסמת ת’רד בזמן ההמתנה, ולא מנגנון שמאיץ אוטומטית כל דבר או מעביר אותו לת’רד אחר מעצמו - קודם מחלקים אם העיבוד הוא המתנה ל-I/O או חישוב CPU
- אם זו המתנה ל-I/O, העיקרון הוא לבצע await ישירות ל-API האסינכרוני
- אם זה חישוב CPU, חושבים היכן ראוי להריץ את החישוב הזה. ב-UI
Task.Runיכול להיות שימושי, אבל בטיפול בבקשה ב-ASP.NET Core, בדרך כלל נמנעים מלכתובTask.Runומיד לבצע לו await - לעיבודים עצמאיים מרובים, בודקים קודם
Task.WhenAllולא await בזה אחר זה - כשיש הרבה פריטים, לא שולחים הכול בבת אחת עם
Task.WhenAll, אלא קובעים תקרה למספר המקביליות -
fire-and-forgetנראה פשוט אבל קשה לניהול. אם באמת רוצים לנתק את אורך החיים מהצד הקורא, עדיף להוציא למקום מנוהל כמו Channel או HostedService - ערך ההחזרה - קודם
Task/Task<T>.ValueTaskבוחרים אחרי מדידה, כשרואים שהוא נחוץ -
ConfigureAwait(false)יעיל בקוד ספרייה כללי, אבל בקוד UI או אפליקציה מספיק בדרך כללawaitרגיל -
async voidלא משתמשים בו מחוץ למטפלי אירועים
במילים אחרות, הדבר הכי חשוב סביב async / await הוא לא להפוך את
“בכל מקרה Task.Run”, “בכל מקרה fire-and-forget”, “בכל מקרה ValueTask” לברירת מחדל.
קודם כול,
- למה מחכה העיבוד הזה
- מי מחזיק באורך החיים של העיבוד הזה
- איפה שולטים במספר הביצועים במקביל
אם בודקים את שלוש השאלות האלה, ההיסוס פוחת משמעותית.
flowchart TB
accTitle: שלוש השאלות שמפחיתות היסוס
accDescr: תרשים המראה שאם בודקים בסדר למה מחכה העיבוד, מי מחזיק באורך חייו, ואיפה שולטים במספר הביצועים במקביל - ההיסוס באופן הכתיבה סביב async/await פוחת משמעותית.
q1["למה מחכים"] --> q2["מי מחזיק באורך החיים"]
q2 --> q3["איפה שולטים במספר המקביליות"]
q3 --> less["ההיסוס באופן הכתיבה פוחת משמעותית"]
q1 -.-> avoid["נמנעים מ-Task.Run כברירת מחדל"]
איור 1: לא בוחרים “בכל מקרה” - קודם בודקים שלושה דברים: סוג ההמתנה, אורך החיים, ומספר המקביליות.
2. מילים שנשתמש בהן במאמר הזה
2.1. מילים שכדאי להבחין ביניהן קודם
אם מפרידים בין שתי המילים האלה בהתחלה, ההבלבול פוחת משמעותית.
| מילה | המשמעות כאן |
|---|---|
| I/O-bound | עיבוד שמרכזו המתנה חיצונית לסיום, כמו HTTP, DB, קובץ, socket |
| CPU-bound | עיבוד שמרכזו החישוב עצמו, כמו דחיסה, עיבוד תמונה, חישוב hash, המרה כבדה |
async / await יעילים במיוחד להמתנה ל-I/O - אפשר להחזיר את הת’רד לעבודה אחרת בזמן ההמתנה. לעומת זאת, חישוב CPU אינו “המתנה” אלא זמן שבו באמת מחשבים, ולכן הנושא הוא באיזה ת’רד להריץ אותו ו-איך קובעים את מספר המקביליות.
flowchart TB
accTitle: ההבדל בין I/O-bound ל-CPU-bound
accDescr: תרשים המראה ש-I/O-bound שמרכזו המתנה חיצונית מאפשר להחזיר את הת'רד לעבודה אחרת בזמן ההמתנה עם await, ואילו CPU-bound שמרכזו החישוב עצמו הופך את הת'רד וכמות המקביליות לנושא המרכזי.
io["I/O-bound(המתנה חיצונית)"] --> e1["אפשר להחזיר ת'רד בזמן ההמתנה"]
e1 --> fit["async/await יעילים כאן במיוחד"]
cpu["CPU-bound(החישוב עצמו)"] --> e2["באיזה ת'רד להריץ הופך לנושא"]
e2 --> par["גם קביעת מספר המקביליות הופכת לנושא"]
איור 2: החלוקה הראשונה היא הזו. הנושא שבודקים שונה לפי אם מרכזה של הבעיה המתנה או חישוב.
2.2. מילים שמופיעות הרבה
| מילה | המשמעות כאן |
|---|---|
| חסימה (blocking) | להמשיך לתפוס את הת’רד כל עוד ממתינים לסיום |
fire-and-forget |
אופן הפעלה שבו הצד הקורא לא ממתין לסיום |
SynchronizationContext |
מנגנון שמחזיק “היכן ממשיכים את מה שאחרי ה-await”. פירוט בהערה למטה |
| backpressure | מנגנון שמעכב את צד הכתיבה כשהזרימה מהירה מדי, כדי למנוע התרבות יתר |
IHostedService |
מנגנון של ה-host הכללי של .NET שקורא ל-StartAsync בזמן ההפעלה ול-StopAsync בזמן העצירה. זו נקודת הכניסה לעיבוד קבוע שרץ בהתאם לאורך חיי האפליקציה |
BackgroundService |
מחלקה מופשטת שמממשת IHostedService. אם דורסים רק ExecuteAsync(CancellationToken) אחד, אפשר לכתוב לולאה קבועה. נרשמים עם AddHostedService<T>() (3.7) |
כשמשתמשים ב-Channel<T>, המקום שבו שמים את הצרכן (consumer) שלו הוא ה-BackgroundService הזה. בסעיף 3.7 נסביר צורה של “מזינים לתור, וצרכן ייעודי מעבד לפי הסדר”, וזה בדיוק המקום שמנהל את אורך חיי הצרכן הזה בהתאם להפעלה ועצירה של האפליקציה.
הערה על SynchronizationContext
הדיון על ConfigureAwait(false) (3.12) מסתכם בסופו של דבר בהבנת המילה הזו בלבד.
-
await, כשהוא מריץ את הקוד שאחריו (ה-continuation), תופס את ה-SynchronizationContextשהיה קיים ברגע כניסת ההמתנה, וחוזר אליו כדי להריץ (אם לא הוגדרSynchronizationContext, בודקים אם משתמשים ב-TaskSchedulerשאינו ברירת המחדל) - ל-WinForms / WPF יש
SynchronizationContextשמעביר עיבוד חזרה לת’רד ה-UI. בגלל זה אפשר לגעת רגיל בפקדים אחרי ה-await - ל-ASP.NET Core אין
SynchronizationContext. ולכן אין “מקום לחזור אליו”, וההמשך של ה-awaitרץ כפי שהוא על ת’רד פנוי במאגר הת’רדים -
ConfigureAwait(false)הוא ציון שמותר להריץ את ההמשך בלי לחזור להקשר שנתפס
flowchart TB
accTitle: לאן חוזר ההמשך של await
accDescr: תרשים המראה ש-await תופס את ה-SynchronizationContext בזמן כניסת ההמתנה, כך שב-WinForms/WPF חוזרים לת'רד ה-UI ואפשר לגעת בפקדים, בעוד ב-ASP.NET Core אין לאן לחזור וההמשך רץ במאגר הת'רדים.
aw["await תופס הקשר"] --> ui["ת'רד ה-UI ב-WinForms או WPF"]
aw --> asp["ל-ASP.NET Core אין לאן לחזור"]
ui --> touch["אחרי await אפשר לגעת ב-UI"]
asp --> pool["ההמשך רץ במאגר הת'רדים"]
איור 3: הדיון על ConfigureAwait(false) מסתכם בנקודה אחת - לאן חוזר ההמשך של await.
מכאן נובעות המסקנות של 3.12: “בקוד UI טבעי יותר לא להוסיף אותו”, “בקוד אפליקציה ב-ASP.NET Core לא משנה הרבה אם מוסיפים או לא”, “בספרייה כללית שלא ידוע באיזה הקשר תרוץ, יש ערך בהוספתו”. הרקע המפורט מרוכז הכי טוב ב-ConfigureAwait FAQ שבפרק 9 - מקורות.
מה שחשוב במיוחד הוא ש-אסינכרוני ומקבילי הם שני דברים שונים.
- אסינכרוני: עניין של אופן ההמתנה
- מקבילי: עניין של קידום בו-זמנית
כשהשניים האלה מתערבבים, נוצר רצון להשתמש ב-Task.Run בכל מקום.
זו נקודת הפיצול הראשונה.
flowchart TB
accTitle: אסינכרוני ומקבילי הם שני דברים שונים
accDescr: תרשים המראה שאסינכרוני הוא עניין של אופן ההמתנה ומקבילי הוא עניין של קידום בו-זמנית, וכשהשניים מתערבבים נוצר רצון להשתמש ב-Task.Run בכל מקום, וזו נקודת הפיצול הראשונה.
a["אסינכרוני(אופן ההמתנה)"] -.-> mix["כשמתערבב - שימוש מוגזם ב-Task.Run"]
p["מקבילי(קידום בו-זמנית)"] -.-> mix
mix --> fork["זו נקודת הפיצול הראשונה"]
איור 4: אסינכרוני הוא אופן ההמתנה, מקבילי הוא קידום בו-זמנית. כשההבחנה הזו מיטשטשת, קל לגלוש לשימוש מוגזם ב-Task.Run.
3. קודם, טבלת ההחלטה
3.1. תמונה כללית
אם מסתכלים קודם בטבלה הזו, מתקבל בגדול הכיוון.
| מצב | מה משתמשים בו קודם | מה בודקים |
|---|---|---|
| המתנה ל-HTTP / DB / קובץ וכדומה | await ישירות ל-API האסינכרוני |
לא עוטפים ב-Task.Run |
| חישוב כבד שלא רוצים לתקוע בו את ה-UI | Task.Run |
מוציאים את חישוב ה-CPU מת’רד ה-UI |
| טיפול בבקשה ב-ASP.NET Core | await רגיל (plain) |
לא מבצעים await ל-Task.Run מיד |
| כמה עיבודים אסינכרוניים עצמאיים, במספר קטן | Task.WhenAll |
קודם מתחילים הכול, ואז ממתינים ביחד |
| משתמשים רק במה שהסתיים ראשון | Task.WhenAny |
חושבים על ביטול השאר ואיסוף חריגות |
| הרבה פריטים, רוצים תקרה | Parallel.ForEachAsync / SemaphoreSlim |
קובעים במפורש את מספר המקביליות |
| עיבוד רקע שרוצים לזרום בסדר | Channel<T> |
חושבים על תור חסום ו-backpressure |
| עיבוד אסינכרוני במרווח קבוע | PeriodicTimer |
שומרים על טיימר אחד - צרכן אחד |
| רוצים לעבד תוצאות בהדרגה | IAsyncEnumerable<T> / await foreach |
מתקדמים בלי לחכות לסיום כל הפריטים |
| נדרש שחרור אסינכרוני | await using |
משתמשים ב-IAsyncDisposable |
| בלעדיות שחוצה await | SemaphoreSlim.WaitAsync |
תמיד Release ב-try/finally |
| קוד ספרייה כללי | לשקול ConfigureAwait(false) |
לא תלוי בהקשר של 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 / שולחני" --> p2["שוקלים Task.Run"]
q3 -- "בקשת ASP.NET Core" --> p3["לא עוטפים ב-Task.Run, מוציאים ל-worker או תור"]
q3 -- "worker / רקע" --> p4["מריצים במקום, או קובעים דרגת מקביליות"]
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: התמונה הכללית של ההחלטה. קודם מפרידים בין המתנה ל-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 לת’רד אחר, וקשה יותר לארגן בלי תועלת.
- אם זו המתנה ל-I/O,
Task.Runלא נחוץ - קודם מחפשים API אסינכרוני
- אם מקבלים token, מעבירים אותו כפי שהוא לזרם התחתון
זה די הדרך הסטנדרטית.
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 מהת’רד הנוכחי.
לדוגמה, אם מריצים חישוב כבד ישירות בתוך מטפל אירוע UI, המסך נתקע.
במקרים כאלה Task.Run הוא הפתרון הטבעי.
flowchart TB
accTitle: איך Task.Run פועל ב-UI
accDescr: תרשים המראה שאם מריצים חישוב כבד ישירות באירוע UI המסך נתקע, בעוד שכשמוציאים את חישוב ה-CPU מת'רד ה-UI עם Task.Run, המסך ממשיך להגיב - הדוגמה הטיפוסית שבה Task.Run יעיל.
heavy["מריצים חישוב כבד באירוע UI"] -.-> freeze["המסך נתקע"]
run["מוציאים מת'רד ה-UI עם Task.Run"] --> keep["המסך ממשיך להגיב"]
איור 7: Task.Run יעיל כשיש ת’רד מיוחד שכדאי לפנות (ת’רד ה-UI).
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 / רקע: מעבדים במקום, או מתכננים דרגת מקביליות
אם משלבים Task.Run לטיפול בבקשה ב-ASP.NET Core ומבצעים לו await מיד, נוטה רק להוסיף תזמון מיותר.
זו נקודה שקל להבין לא נכון, אז נסביר את הסיבה בנפרד. זה לא “כי זה כבר רץ על מאגר הת’רדים, אז Task.Run חסר משמעות” (הריצה על מאגר הת’רדים כשלעצמה זהה גם בעיבוד רקע של אפליקציית UI). הנקודה מצטמצמת לשתי אלה.
- התפוקה לא גדלה. כמות חישוב ה-CPU הכוללת לא משתנה, היא רק עוברת לת’רד אחר של מאגר הת’רדים. מספר הבקשות שמעבדים בו-זמנית לא גדל
- גם לא משחררים המתנה. הסיבה ש-
Task.Runיעיל באפליקציית UI היא שיש ת’רד מיוחד אחד שכדאי לפנות (ת’רד ה-UI). בצד השרת אין את אותו “אחד”. הת’רד המקורי אמנם משתחרר, אבל ת’רד אחר מתמלא באותו זמן חישוב במקומו, אז ההפרש הוא אפס
מה שנשאר הוא עלות ההכנסה לתור וההחלפה בין ת’רדים, ופחיתות בהירות של “באיזה ת’רד זה רץ”. לכן נמנעים מזה.
flowchart TB
accTitle: הסיבה להימנע מ-Task.Run ב-ASP.NET Core
accDescr: תרשים המראה שגם אם משלבים Task.Run בטיפול בבקשה, כמות החישוב לא משתנה ואין ת'רד מיוחד לפנות כמו ב-UI, כך שההפרש אפס, ומה שנשאר זה רק עלות ההחלפה בין ת'רדים וירידה בבהירות.
tr["משלבים Task.Run בטיפול בבקשה"] --> r1["כמות החישוב לא משתנה"]
tr --> r2["אין ת'רד מיוחד אחד לפנות"]
r1 --> zero["ההפרש אפס"]
r2 --> zero
zero --> cost["נשארות עלות ההחלפה וירידה בבהירות"]
איור 8: גם אם מבצעים await ל-Task.Run מיד בצד השרת, לא מתקבלת לא תפוקה ולא שחרור המתנה.
לכן, ב-ASP.NET Core נכון יותר לחשוב כך.
- אם זו המתנה ל-I/O,
awaitרגיל (plain) - אם זה עיבוד CPU קצר, מריצים במקום
- לעיבוד ארוך או עיבוד שרוצים לנתק מאורך חיי הבקשה, מוציאים לתור או ל-HostedService
נציין שכאשר יש רק גרסה סינכרונית של ה-API, כשקוראים לו מ-UI, יש מקרים שמשתמשים ב-Task.Run מסיבות של רספונסיביות של ה-UI.
אבל זה לא “I/O אסינכרוני”, אלא רק תפיסת ת’רד שלם כדי להימנע מהבעיה.
בצד שרת כמו 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 הוא הרצה מושהית, ולכן רק על ידי Select ייתכן שעדיין לא נעשה מנייה בפועל.
אם מקבעים פעם אחת עם ToArray() או ToList(), כל המשימות מתחילות באותו רגע.
sequenceDiagram
accTitle: התחלת כל המשימות יחד וסיום עם Task.WhenAll
accDescr: תרשים רצף המראה שהצד הקורא מתחיל את שלוש המשימות זו אחר זו, ואז ממתין להן ביחד עם Task.WhenAll עד שכולן מדווחות על סיום.
participant Caller as הצד הקורא
participant T1 as משימה 1
participant T2 as משימה 2
participant T3 as משימה 3
Caller->>T1: מתחיל
Caller->>T2: מתחיל
Caller->>T3: מתחיל
Caller->>Caller: await Task.WhenAll(...)
T1-->>Caller: הסתיימה
T2-->>Caller: הסתיימה
T3-->>Caller: הסתיימה
איור 9: מתחילים קודם את כל המשימות העצמאיות, ורק אז ממתינים לסיום כולן עם Task.WhenAll.
הדפוס הזה מתאים כש:
- מספר הפריטים קטן, או בינוני
- רוצים להמתין לכל הפריטים ביחד
- אין בעיה להריץ בו-זמנית ללא תקרה
מקרה שהוא.
אם יש הרבה פריטים, בטוח יותר להוסיף תקרה למספר המקביליות כמו בסעיף 3.6 הבא.
3.5. אם רוצים להשתמש במה שהסתיים ראשון, Task.WhenAny
לדוגמה, כשרוצים להשתמש במה שהגיב ראשון מבין כמה שרתי מראה (mirror), 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("ההורדה נכשלה מכל ה-mirror.", failures);
}
מה שחשוב לסדר בקוד הזה הוא הנקודה שבה מוציאים את הביטול רק אחרי שהזוכה נקבע.
- מה ש-
Task.WhenAnyמחזיר הוא המשימה שהסתיימה ראשונה, לא המשימה שהצליחה ראשונה. גם אם ה-mirror המהיר ביותר נכשל ב-404 או בניתוק חיבור, הוא זה שחוזר בתור “הזוכה” - אם מבטלים לפני שבודקים את התוצאה כאן, מגיעים למצב שבו עוצרים בעצמכם את שאר ה-mirror שעדיין חיים, וזורקים מחדש את החריגה של הזוכה שנכשל. זו הצורה הגרועה ביותר שבה כל המשמעות של הכנת כמה mirror נעלמת
- לכן, מוציאים את המשימות שהסתיימו אחת-אחת עם
await, ומבטלים את השאר רק כשהצליח. אם נכשל, מוציאים את המשימה הזו מהמועמדים וממתינים לסיום הבא -
Cancel()רק שולח בקשה, ולא ממתין עד שהצד השני נעצר. לכן ב-finallyממתינים לשאר, ומאתרים כאן את חריגות הביטול והכישלון. אם מדלגים על זה, נשארות חריגות שאף אחד לא צופה בהן בצד המשימה - כשהכול נכשל, זורקים את הכשלים ביחד. אם זורקים רק את החריגה הראשונה, נעלם המידע “איזה mirror נכשל ואיך”
- רק ביטול על ידי הצד הקורא לא נספר ככישלון, וזורקים אותו כמות שהוא החוצה. כש-
cancellationTokenנופל, כל המשימות מסתיימות ב-OperationCanceledException, ואם מצטברות ב-failures, זה הופך ל-AggregateExceptionבסוף, והפסקה או timeout של המשתמש נרשמים ומנוסים מחדש כ”כישלון כל ה-mirror”. קוראים ל-ThrowIfCancellationRequested()בתחילת ה-catch, והביטול מוחזר כפי שהוא כ-OperationCanceledException
הדבר שכדאי להיזהר ממנו כאן הוא ש-WhenAny מחזיר רק זוכה אחד.
שאר העיבוד, אם לא עושים כלום, ממשיך לרוץ כרגיל.
לכן, צריך להחליט מראש:
- האם רוצים לבטל את השאר
- האם רוצים לצפות בחריגות
Task.WhenAny נוח, אבל דורש תכנון קצת יותר מ-WhenAll.
כשבוחרים אותו רק כש”מספיק הראשון בלבד”, זה נשאר ברור.
flowchart TB
accTitle: הזרימה של אימות זוכה עם WhenAny לפני עצירת השאר
accDescr: תרשים המראה שמה ש-Task.WhenAny מחזיר הוא המשימה שהסתיימה ראשונה ולא זו שהצליחה, ולכן מבצעים await לסיום אחד-אחד, מבטלים את השאר רק כשהצליח, ואם נכשל מוציאים מהמועמדים וממתינים לסיום הבא, וכשכולם נכשלים זורקים את הכשלים ביחד.
any["מקבלים סיום ראשון עם WhenAny"] --> chk{"האם המשימה הזו הצליחה?"}
chk -->|"הצליחה"| win["מבטלים את השאר ומחזירים"]
chk -->|"נכשלה"| next["מוציאים מהמועמדים ומתעדים כישלון"]
next --> rest{"נשארו מועמדים?"}
rest -->|"כן"| any
rest -->|"לא"| agg["זורקים את הכשלים ביחד"]
איור 10: “הסתיים ראשון” אינו “הצליח ראשון”. מוודאים את תוצאת הזוכה לפני עצירת השאר.
3.6. אם יש הרבה פריטים ורוצים להגביל מקביליות, Parallel.ForEachAsync או SemaphoreSlim
Task.WhenAll מריץ את כל המשימות שנוצרו בו-זמנית.
לכן, אם מספר היעדים גדול, חיבורי 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, אבל אם מספר הפריטים גדול, חיבורים, זיכרון ועומס חיצוני עולים בבת אחת, ולכן קובעים תקרת מקביליות עם Parallel.ForEachAsync או SemaphoreSlim.
q{"מספר הפריטים גדול?"}
q -->|"מספר קטן"| all["Task.WhenAll ביחד"]
q -->|"גדול"| limit["קובעים תקרת מקביליות"]
limit --> pfe["Parallel.ForEachAsync"]
limit --> sem["SemaphoreSlim לשליטה חופשית"]
איור 11: נקודת ההכרעה היא אם מותר לשלוח את כל הפריטים בבת אחת. אם הרבה, קובעים במפורש את מספר המקביליות.
3.7. אם רוצים לזרום בסדר, Channel<T>
יש מקרים שרוצים לנתק מהצד הקורא עבודה מסוג “לא צריך להסתיים מיד, אבל חייבים לעבד אותה בוודאות”. שליחת דוא”ל, העברת יומן, טיפול לאחר Webhook, המרת קבצים, וכדומה.
אם משליכים Task.Run בעבודות כאלה,
- איפה רואים חריגות
- האם ממתינים בזמן הסיום
- עד כמה מקבלים כשמספר הפריטים גדל
הופכים מעורפלים.
עבודה מהסוג הזה, קל יותר לנהל אם מזינים לתור וצרכן ייעודי מעבד לפי הסדר.
flowchart LR
accTitle: זרימת Channel חסום עם backpressure
accDescr: תרשים המראה שה-producer מבצע WriteAsync לתור, וכשאין מקום פנוי הוא ממתין עד שיתפנה, ואילו ה-consumer מבצע ReadAsync ומעבד לפי הסדר עם await.
p["producer"] --> w["WriteAsync"]
w --> q{"יש מקום פנוי בתור?"}
q -- "כן" --> c["נכנס ל-Channel"]
q -- "לא" --> b["ממתין עד שיתפנה"]
c --> d["consumer מבצע ReadAsync"]
d --> e["מעבד לפי הסדר עם await"]
איור 12: זרימת Channel חסום. כשהתור מלא, צד הכתיבה ממתין, וכך נוצר 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 בדוגמה הזו הוא הגדרה שגורמת לצד הכתיבה להמתין אם התור מלא.
זה ה-backpressure.
ב-ASP.NET Core, קל יותר להבין את הצורה שבה תור כזה נצרך יחד עם BackgroundService.
זה מקל לטפל בחריגות, עצירה, מספר מקביליות, ותקרה - יותר מ”fire-and-forget אמיתי”.
flowchart TB
accTitle: השוואה בין השלכה ללא ניהול לניהול בתור
accDescr: תרשים המראה שהשלכת Task.Run ללא ניהול משאירה מעורפל היכן רואים חריגות, מתי מסתיימים, ומה התקרה, בעוד שהזנה ל-Channel וצריכה על ידי BackgroundService מאפשרת לנהל חריגות, עצירה ומספר מקביליות.
ff["השלכת Task.Run בלי ניהול"] -.-> vague["חריגה, סיום ותקרה מעורפלים"]
ch["מזינים לתור Channel"] --> bs["BackgroundService צורך"]
bs --> mng["אפשר לנהל חריגה, עצירה ומקביליות"]
איור 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 בו-זמנית לאותו טיימר.
בנוסף, אם זמן העיבוד ארוך מהמחזור, יש לטפל בעיכוב הזה כחלק מהתכנון.
הטיימר לא מבצע מקבילה מעצמו כדי להדביק את הפער.
flowchart TB
accTitle: לולאת המחזור של PeriodicTimer
accDescr: תרשים המראה שממתינים למחזור הבא עם WaitForNextTickAsync, מריצים את העיבוד עם await וחוזרים להמתנה, שהעצירה נעשית עם CancellationToken, ושעיכוב כשהעיבוד ארוך מהמחזור צריך להיות מטופל בתכנון.
tick["ממתינים עם WaitForNextTickAsync"] --> proc["מריצים עיבוד עם await"]
proc --> tick
stop["עוצרים עם CancellationToken"] -.-> tick
proc -.-> warn["עיכוב מעיבוד ארוך מהמחזור מטופל בתכנון"]
איור 14: רצים עם טיימר אחד - צרכן אחד. הטיימר לא ידביק פער על ידי מקבול עצמאי.
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. אם צריך בלעדיות שחוצה 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: הצורה של בלעדיות שחוצה 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 רגיל וממשיך כשחוזר להקשר המקורי, בעוד שספרייה כללית מוסיפה ConfigureAwait(false) ולא מניחה חזרה להקשר מסוים.
a["UI / קוד אפליקציה"] --> b["await someAsync()"]
b --> c["ממשיכים כשחוזרים להקשר המקורי"]
d["ספרייה כללית"] --> e["await someAsync().ConfigureAwait(false)"]
e --> f["לא מניחים חזרה להקשר מסוים"]
איור 17: קוד צד האפליקציה חוזר להקשר המקורי עם await רגיל, ובספרייה כללית שוקלים ConfigureAwait(false).
- UI / קוד אפליקציה
- קודם מספיק
awaitרגיל - אם אחרי ה-await מבצעים עדכון UI או עיבוד שתלוי בהקשר האפליקציה, טבעי יותר לא להוסיף
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 רק במטפלי אירועים
async void העיקרון הוא להימנע ממנו מחוץ למטפלי אירועים.
הסיבה פשוטה:
- הצד הקורא לא יכול לבצע
await - לא יכולים להמתין לסיום
- טיפול בחריגות נהיה קשה
- קשה לבדוק
בגלל אלה.
רק במטפל אירוע נדרש 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, "שגיאת שמירה");
}
}
במטפל אירוע, חשוב להיות מודעים לכך שתופסים חריגה בפנים ומחזירים אותה לצד ה-UI בעצמכם.
flowchart TB
accTitle: הסיבה להימנע מ-async void והחריג היחיד
accDescr: תרשים המראה שמתודת async void לא יכולה להיות מוגשת ל-await, לא ניתן להמתין לסיומה, וטיפול בחריגות ובדיקות קשים, ולכן נמנעים ממנה במתודות רגילות, ומשתמשים בה רק במטפל אירוע שדורש void, כשתופסים חריגות עם try/catch ומחזירים ל-UI.
av["מתודת async void"] --> p1["אי אפשר await"]
av --> p2["אי אפשר להמתין לסיום"]
av --> p3["חריגות ובדיקה קשים"]
ev["מטפל אירוע הוא החריג"] -.-> duty["try/catch ומחזירים ל-UI"]
*איור 18: מתודה רגילה מחזירה Task / Task
4.3. מקבלים CancellationToken ומעבירים לזרם התחתון
עבור פעולה שניתן לבטל, מקבלים CancellationToken ומעבירים אותו כפי שהוא לזרם התחתון.
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, אבל לא מעבירה אותו לזרם התחתון. זה נוטה להפוך לקוד ש”נראה שאפשר לבטל, אבל לא נעצר באמצע בפועל”.
בנוסף, גם ל-timeout יש משמעות שונה בין “רוצים תקרה רק על ההמתנה” לבין “רוצים לעצור גם את העיבוד עצמו”.
- רוצים תקרה רק על ההמתנה:
WaitAsync - רוצים לעצור גם את העיבוד עצמו:
CancellationTokenSource.CancelAfterוהעברת ה-token
ההבדל הזה נוטה להפוך לבאג בהמשך, ולכן יציב יותר להחליט עליו מראש.
flowchart TB
accTitle: העברת CancellationToken
accDescr: תרשים המראה שאם מעבירים לזרם התחתון את ה-token שהתקבל למעלה, זה נעצר כראוי גם באמצע, אך אם רק מקבלים ולא מעבירים, מתקבל קוד שנראה שאפשר לבטל אך לא נעצר בפועל.
up["מקבלים token למעלה"] --> pass["מעבירים כפי שהוא ל-API בזרם התחתון"]
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(), ולכן גם באפליקציית קונסולה יש פחות סיבה לסנכרן בכוח.
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. כשיוצרים משימות עם 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 הוא הרצה מושהית. אם קוראים בהנחה ש”כבר הכול התחיל”, אבל בפועל עדיין לא בוצעה מנייה - זה מסוכן בשקט.
- כשממתינים לכולם ביחד,
ToArray() - כשרוצים למחוק/להחליף באמצע,
ToList()
אם זוכרים את זה, קל יותר להחליט.
5. אנטי-דפוסים נפוצים
| אנטי-דפוס | מה קשה בזה | ההחלפה הראשונית |
|---|---|---|
Task.Run(async () => await IoAsync()) |
זורק מחדש בלי צורך המתנה ל-I/O | await IoAsync() |
Task.Result / Wait() |
חוסם ת’רד. נוטה להיתקע | await |
מערבבים Thread.Sleep() בזרימה אסינכרונית |
תופס ת’רד גם בזמן ההמתנה | 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: מתוך טבלת האנטי-דפוסים, הכי יעיל לתקן קודם את שלוש אלה.
6. רשימת בדיקה לסקירת קוד
בסקירת קוד סביב async / await, בודקים לפי הסדר את הדברים האלה.
- האם אפשר להסביר במילים, כבר בהתחלה, אם העיבוד הזה I/O-bound או CPU-bound
- האם נשארו
Task.Result/Task.Wait()/Thread.Sleep() - האם עוטפים המתנה ל-I/O ב-
Task.Run - האם מבצעים await טורי שלא לצורך לעיבוד עצמאי
- ולהפך, האם שולחים כמות גדולה ב-
WhenAllבלי תקרה - אם מקבלים
CancellationToken, האם באמת מעבירים אותו לזרם התחתון - האם
async voidקיים במקום שהוא לא מטפל אירוע - אם מכניסים
fire-and-forget, האם הוחלט מי מנהל חריגה, עצירה ותקרה - אם משתמשים ב-
SemaphoreSlim, האםReleaseנמצא ב-finally - אם משתמשים ב-
ValueTask, האם יש סיבה מבוססת מדידה, והאם ההנחה היא await פעם אחת - האם קיום
ConfigureAwait(false)תואם לסוג הקוד- קוד UI / אפליקציה -
awaitרגיל - ספרייה כללית - לשקול
ConfigureAwait(false)
- קוד UI / אפליקציה -
רשימת הבדיקה הזו שימושית גם ליישור קו בין נקודות המבט של הצוות בסקירה.
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/ הגבלת מקביליות - אם מנתקים מאורך חיי הבקשה, לא fire-and-forget גולמי אלא תור
- מיישרים קו בטיפול בערך ההחזרה, ביטול, חריגות, בלעדיות והקשר
מכיוון שאופן הכתיבה של async / await עצמו תמציתי, שימוש רשלני בו מקשה על ראיית הכיוון. לעומת זאת,
- מתייחסים ל-I/O כ-I/O
- מתייחסים ל-CPU כ-CPU
- מנהלים עיבוד רקע כעיבוד רקע עם אורך חיים משלו
רק הפרדה בין השלושה האלה כבר משפרת משמעותית את הקריאות.
9. מקורות
- ערכת קוד הדוגמה המלאה של המאמר הזה (ספרייה, הדגמה, בדיקות יחידה) 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
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
מה זה .NET Generic Host - התשתית ל-DI, תצורה ולוגים
מסדרים את התפקיד של Generic Host דרך היחסים בין DI, תצורה, לוגים, IHostedService ו-BackgroundService, ומסכמים מנקודת מבט מעשית איפה הוא ב...
מה זה .NET Native AOT — ההבדל מ-JIT ומ-trimming
מהו Native AOT — סקירה לפי ההבדל מ-JIT, ReadyToRun, self-contained, single-file, trimming ו-source generator, וסיכום מעשי של המקרים ש...
שלושה סוגי טיימרים ב-.NET — מתי להשתמש ב-PeriodicTimer/Timer/DispatcherTimer
מסודר כאן ההבדל בין PeriodicTimer / System.Threading.Timer / DispatcherTimer, ואיך לבחור ביניהם לעיבוד async, callback ב-ThreadPool וע...
למה להשתמש ב-Generic Host וב-BackgroundService של .NET באפליקציית desktop
בכלי Windows ובאפליקציות שרצות ברקע, מסודר כאן איך להשתמש ב-Generic Host וב-BackgroundService כדי לסדר הפעלה, עיבוד תקופתי, סיום, לוג, הג...
async ות'רד ה-UI ב-WPF/WinForms בדף אחד
מסודר כאן ב-WPF / WinForms: לאן חוזרים אחרי await, Dispatcher / Invoke, ConfigureAwait(false), והמקומות שבהם .Result / .Wait() נתקעים.
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
תהליכון ה-UI וטיימרים
תהליכון ה-UI של WPF / WinForms, זרימות אסינכרוניות, Dispatcher ותכנון טיימרים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
באפליקציות Windows שכוללות UI, עיבוד רקע ו-I/O, אופן השימוש ב-async/await משפיע ישירות על איכות המימוש.
ייעוץ טכני וסקירת תכנון
אם תרצו לסדר את ההחלטות סביב Task.Run ו-ConfigureAwait יחד עם חלוקת האחריות, זה יכול להוביל לייעוץ טכני וסקירת תכנון.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מתי כדאי להשתמש ב-Task.Run ב-C#?
- Task.Run יעיל כשרוצים להוציא חישוב CPU מהת'רד הנוכחי. לדוגמה, אם מריצים חישוב כבד ישירות בתוך מטפל אירוע UI ב-WinForms / WPF, המסך נתקע, ולכן טבעי להוציא אותו מת'רד ה-UI עם Task.Run. לעומת זאת, טיפול בבקשה ב-ASP.NET Core כבר רץ על מאגר הת'רדים מלכתחילה, ולכן הוספת Task.Run וביצוע await מיד אחריו נוטה רק להוסיף תזמון מיותר, ולכן זה נמנע בדרך כלל. עיבוד ארוך או עיבוד שרוצים לנתק מאורך חיי הבקשה, עדיף להוציא לתור או ל-HostedService.
- אסור לעטוף עיבוד I/O ב-await Task.Run()?
- המתנה ל-I/O כמו HTTP, DB וקריאה/כתיבת קבצים, בדרך כלל מבצעים await ישירות ל-API הגרסה האסינכרונית, ואין צורך לעטוף ב-Task.Run. עטיפת I/O אסינכרוני שכבר קיים ב-Task.Run רק זורקת מחדש את ההמתנה לת'רד אחר, וקשה יותר לארגן בלי תועלת. יש מקרים שבהם משתמשים ב-Task.Run מסיבות של רספונסיביות כשקוראים מ-UI ל-API שיש לו רק גרסה סינכרונית, אבל זה לא I/O אסינכרוני אלא רק תפיסת ת'רד שלם כדי להימנע מהבעיה, ולכן בצד השרת זו דרך בריחה שלא מתרחבת טוב.
- איפה כדאי לשים ConfigureAwait(false)?
- בקוד UI או קוד אפליקציה, מתחילים עם await רגיל. אם אחרי ה-await מבצעים עדכון UI או פעולה שתלויה בהקשר של האפליקציה, טבעי יותר לא להוסיף ConfigureAwait(false). גם קוד האפליקציה ב-ASP.NET Core בדרך כלל מספיק לו await רגיל, ואין צורך לאכוף את זה כשיטה בכל מקום. ConfigureAwait(false) יעיל בעיקר בקוד ספרייה כללי שלא תלוי ב-UI או במודל האפליקציה. אם זוכרים "קוד אפליקציה - plain await, ספרייה כללית - לשקול ConfigureAwait(false)", לא נתקעים בפועל בשטח.
- למה כדאי להימנע מ-async void מחוץ למטפלי אירועים?
- כי ב-async void הצד הקורא לא יכול לבצע await, לא יכול להמתין לסיום, טיפול בחריגות נהיה קשה, וגם קשה לבדוק. מתודה רגילה מחזירה בעיקרון Task או Task<T>. רק במטפל אירוע נדרש void מבחינת החתימה, ולכן משתמשים בו רק שם, ואז חשוב לזכור לכתוב בעצמכם בתוך המטפל תפיסת חריגות עם try/catch והחזרתן לצד ה-UI.