טבלת החלטה ל-async/await ב-C# — Task.Run ו-ConfigureAwait

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

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

תוכן עניינים

  1. קודם המסקנה (במשפט אחד)
  2. מילים שנשתמש בהן במאמר הזה
    • 2.1. מילים שכדאי להבחין ביניהן קודם
    • 2.2. מילים שמופיעות הרבה
  3. קודם, טבלת ההחלטה
    • 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. כללי הבסיס של אופן הכתיבה
    • 4.1. ערך ההחזרה - קודם Task / Task<T>
    • 4.2. async void רק במטפלי אירועים
    • 4.3. מקבלים CancellationToken ומעבירים לזרם התחתון
    • 4.4. מחברים API אסינכרוניים באופן אסינכרוני עד הסוף
    • 4.5. כשיוצרים משימות עם LINQ, מקבעים עם ToArray / ToList
  5. אנטי-דפוסים נפוצים
  6. רשימת בדיקה לסקירת קוד
  7. חלוקה גסה בין השימושים
  8. סיכום
  9. מקורות

מפת הידע של המאמר

המאמר הזה מסדר שהדבר הראשון שכדאי לבדוק ב-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 ו-BackgroundService.

מפת הידע של ההחלטה המעשית ל-async/await ב-C#תרשים המראה איך ההבחנה בין I/O-bound ל-CPU-bound מתחברת לבחירה בין Task.Run ל-ConfigureAwait(false), ולבחירה בין WhenAll,‏ WhenAny ו-Channel.שימוש לא מומלץ למענה מומלץ למענה מומלץ לשימוש לא מומלץ לעלול לגרום למשתמש במענה מומלץ לשימוש לא מומלץ למשתמש במענה מומלץ לשימוש לא מומלץ למענה מומלץ למשתמש במענה מומלץ למענה מומלץ למשתמש במענה מומלץ למממש אתשימוש לא מומלץ למענה מומלץ למשתמש במענה מומלץ למחייבשימוש לא מומלץ למחייבמענה מומלץ לעיבוד עתיר קלט/פלטעיבוד עתיר מעבדTask.Runההקשר של ת'רד ממשק המשתמשטיפול בבקשה ב-ASP.NET Coreהמתנה סינכרונית על קוד אסינכרוני (sync-over-async)הרעבת מאגר הת'רדים (starvation)SynchronizationContextConfigureAwait(false)קוד ספרייה כלליCancellationToken(.NET)async voidמתודת מטפל באירועמתודה אסינכרונית רגילהTask.WhenAllTask.WhenAnyParallel.ForEachAsyncSemaphoreSlimChannelbackpressureBackgroundServiceIHostedServicefire-and-forgetעיבוד רקע שאורך חייו מנותק מהקוראPeriodicTimerIAsyncEnumerableawait usingValueTask

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 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” לברירת מחדל.

קודם כול,

  1. למה מחכה העיבוד הזה
  2. מי מחזיק באורך החיים של העיבוד הזה
  3. איפה שולטים במספר הביצועים במקביל

אם בודקים את שלוש השאלות האלה, ההיסוס פוחת משמעותית.

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

איור 1: לא בוחרים “בכל מקרה” - קודם בודקים שלושה דברים: סוג ההמתנה, אורך החיים, ומספר המקביליות.

2. מילים שנשתמש בהן במאמר הזה

2.1. מילים שכדאי להבחין ביניהן קודם

אם מפרידים בין שתי המילים האלה בהתחלה, ההבלבול פוחת משמעותית.

מילה המשמעות כאן
I/O-bound עיבוד שמרכזו המתנה חיצונית לסיום, כמו HTTP,‏ DB, קובץ, socket
CPU-bound עיבוד שמרכזו החישוב עצמו, כמו דחיסה, עיבוד תמונה, חישוב hash, המרה כבדה

async /‏ await יעילים במיוחד להמתנה ל-I/O - אפשר להחזיר את הת’רד לעבודה אחרת בזמן ההמתנה. לעומת זאת, חישוב CPU אינו “המתנה” אלא זמן שבו באמת מחשבים, ולכן הנושא הוא באיזה ת’רד להריץ אותו ו-איך קובעים את מספר המקביליות.

ההבדל בין I/O-bound ל-CPU-boundתרשים המראה ש-I/O-bound שמרכזו המתנה חיצונית מאפשר להחזיר את הת'רד לעבודה אחרת בזמן ההמתנה עם await, ואילו CPU-bound שמרכזו החישוב עצמו הופך את הת'רד וכמות המקביליות לנושא המרכזי.I/O-bound(המתנה חיצונית)אפשר להחזיר ת'רד בזמן ההמתנהasync/await יעילים כאן במיוחדCPU-bound(החישוב עצמו)באיזה ת'רד להריץ הופך לנושאגם קביעת מספר המקביליות הופכת לנושא

איור 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) הוא ציון שמותר להריץ את ההמשך בלי לחזור להקשר שנתפס
לאן חוזר ההמשך של awaitתרשים המראה ש-await תופס את ה-SynchronizationContext בזמן כניסת ההמתנה, כך שב-WinForms/WPF חוזרים לת'רד ה-UI ואפשר לגעת בפקדים, בעוד ב-ASP.NET Core אין לאן לחזור וההמשך רץ במאגר הת'רדים.await תופס הקשרת'רד ה-UI ב-WinForms או WPFל-ASP.NET Core אין לאן לחזוראחרי await אפשר לגעת ב-UIההמשך רץ במאגר הת'רדים

איור 3: הדיון על ConfigureAwait(false) מסתכם בנקודה אחת - לאן חוזר ההמשך של await.

מכאן נובעות המסקנות של 3.12: “בקוד UI טבעי יותר לא להוסיף אותו”, “בקוד אפליקציה ב-ASP.NET Core לא משנה הרבה אם מוסיפים או לא”, “בספרייה כללית שלא ידוע באיזה הקשר תרוץ, יש ערך בהוספתו”. הרקע המפורט מרוכז הכי טוב ב-ConfigureAwait FAQ שבפרק 9 - מקורות.

מה שחשוב במיוחד הוא ש-אסינכרוני ומקבילי הם שני דברים שונים.

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

כשהשניים האלה מתערבבים, נוצר רצון להשתמש ב-Task.Run בכל מקום. זו נקודת הפיצול הראשונה.

אסינכרוני ומקבילי הם שני דברים שוניםתרשים המראה שאסינכרוני הוא עניין של אופן ההמתנה ומקבילי הוא עניין של קידום בו-זמנית, וכשהשניים מתערבבים נוצר רצון להשתמש ב-Task.Run בכל מקום, וזו נקודת הפיצול הראשונה.אסינכרוני(אופן ההמתנה)כשמתערבב - שימוש מוגזם ב-Task.Runמקבילי(קידום בו-זמנית)זו נקודת הפיצול הראשונה

איור 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 / אפליקציה
טבלת ההחלטה המלאה לבחירת דפוס async/awaitתרשים המראה כיצד מתחילים מסוג העיבוד שרוצים לבצע ומפצלים לפי המתנה ל-I/O, מיקום הרצת חישוב CPU כבד, ואופן הטיפול בכמה עבודות, עד לדפוס הכתיבה המתאים בכל ענף.כןלאכןאירוע UI / שולחניבקשת ASP.NET Coreworker / רקעלאממתינים לסיום הכולמשתמשים במה שהסתיים ראשוןהרבה פריטיםזורמים בסדרמרווח קבועזרם רציףהעיבוד שרוצים לבצעממתינים ל-I/O חיצוני?מבצעים await ישירות ל-API האסינכרוניחישוב CPU כבד?איפה מריצים?שוקלים Task.Runלא עוטפים ב-Task.Run, מוציאים ל-worker או תורמריצים במקום, או קובעים דרגת מקביליותמטפלים בכמה עבודות?Task.WhenAllTask.WhenAnyParallel.ForEachAsync או SemaphoreSlimChannel&lt;T&gt;PeriodicTimerIAsyncEnumerable&lt;T&gt;

איור 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, מעבירים אותו כפי שהוא לזרם התחתון

זה די הדרך הסטנדרטית.

הצורה הבסיסית של המתנה ל-I/Oתרשים המראה שבהמתנה ל-HTTP, DB או קובץ, מחפשים קודם API בגרסה אסינכרונית ומבצעים לו await ישירות, ושעטיפת I/O אסינכרוני שכבר קיים ב-Task.Run רק זורקת מחדש בלי תועלת.המתנה ל-HTTP, DB או קובץקודם מחפשים API בגרסה אסינכרוניתמבצעים await ישירותעוטפים ב-Task.Runרק זורק מחדש, בלי תועלת

איור 6: הבסיס בהמתנה ל-I/O הוא “await ישירות ל-API האסינכרוני”. נמנעים מלעטוף ב-Task.Run.

3.3. אם עומס ה-CPU כבד, בוחרים היכן להשתמש ב-Task.Run

Task.Run יעיל כשרוצים להוציא חישוב CPU מהת’רד הנוכחי.

לדוגמה, אם מריצים חישוב כבד ישירות בתוך מטפל אירוע UI, המסך נתקע. במקרים כאלה Task.Run הוא הפתרון הטבעי.

איך Task.Run פועל ב-UIתרשים המראה שאם מריצים חישוב כבד ישירות באירוע UI המסך נתקע, בעוד שכשמוציאים את חישוב ה-CPU מת'רד ה-UI עם Task.Run, המסך ממשיך להגיב - הדוגמה הטיפוסית שבה Task.Run יעיל.מריצים חישוב כבד באירוע UIהמסך נתקעמוציאים מת'רד ה-UI עם Task.Runהמסך ממשיך להגיב

איור 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). בצד השרת אין את אותו “אחד”. הת’רד המקורי אמנם משתחרר, אבל ת’רד אחר מתמלא באותו זמן חישוב במקומו, אז ההפרש הוא אפס

מה שנשאר הוא עלות ההכנסה לתור וההחלפה בין ת’רדים, ופחיתות בהירות של “באיזה ת’רד זה רץ”. לכן נמנעים מזה.

הסיבה להימנע מ-Task.Run ב-ASP.NET Coreתרשים המראה שגם אם משלבים Task.Run בטיפול בבקשה, כמות החישוב לא משתנה ואין ת'רד מיוחד לפנות כמו ב-UI, כך שההפרש אפס, ומה שנשאר זה רק עלות ההחלפה בין ת'רדים וירידה בבהירות.משלבים Task.Run בטיפול בבקשהכמות החישוב לא משתנהאין ת'רד מיוחד אחד לפנותההפרש אפסנשארות עלות ההחלפה וירידה בבהירות

איור 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(), כל המשימות מתחילות באותו רגע.

התחלת כל המשימות יחד וסיום עם Task.WhenAllתרשים רצף המראה שהצד הקורא מתחיל את שלוש המשימות זו אחר זו, ואז ממתין להן ביחד עם Task.WhenAll עד שכולן מדווחות על סיום.משימה 3משימה 2משימה 1הצד הקוראמשימה 3משימה 2משימה 1הצד הקוראמתחילמתחילמתחילawait Task.WhenAll(...)הסתיימההסתיימההסתיימה

איור 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. כשבוחרים אותו רק כש”מספיק הראשון בלבד”, זה נשאר ברור.

הזרימה של אימות זוכה עם WhenAny לפני עצירת השארתרשים המראה שמה ש-Task.WhenAny מחזיר הוא המשימה שהסתיימה ראשונה ולא זו שהצליחה, ולכן מבצעים await לסיום אחד-אחד, מבטלים את השאר רק כשהצליח, ואם נכשל מוציאים מהמועמדים וממתינים לסיום הבא, וכשכולם נכשלים זורקים את הכשלים ביחד.הצליחהנכשלהכןלאמקבלים סיום ראשון עם WhenAnyהאם המשימה הזו הצליחה?מבטלים את השאר ומחזיריםמוציאים מהמועמדים ומתעדים כישלוןנשארו מועמדים?זורקים את הכשלים ביחד

איור 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

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

חלוקת הריכוז המקבילי לפי מספר הפריטיםתרשים המראה שכמה עיבודים עצמאיים אפשר להריץ ביחד עם Task.WhenAll, אבל אם מספר הפריטים גדול, חיבורים, זיכרון ועומס חיצוני עולים בבת אחת, ולכן קובעים תקרת מקביליות עם Parallel.ForEachAsync או SemaphoreSlim.מספר קטןגדולמספר הפריטים גדול?Task.WhenAll ביחדקובעים תקרת מקביליותParallel.ForEachAsyncSemaphoreSlim לשליטה חופשית

איור 11: נקודת ההכרעה היא אם מותר לשלוח את כל הפריטים בבת אחת. אם הרבה, קובעים במפורש את מספר המקביליות.

3.7. אם רוצים לזרום בסדר, Channel<T>

יש מקרים שרוצים לנתק מהצד הקורא עבודה מסוג “לא צריך להסתיים מיד, אבל חייבים לעבד אותה בוודאות”. שליחת דוא”ל, העברת יומן, טיפול לאחר Webhook, המרת קבצים, וכדומה.

אם משליכים Task.Run בעבודות כאלה,

  • איפה רואים חריגות
  • האם ממתינים בזמן הסיום
  • עד כמה מקבלים כשמספר הפריטים גדל

הופכים מעורפלים.

עבודה מהסוג הזה, קל יותר לנהל אם מזינים לתור וצרכן ייעודי מעבד לפי הסדר.

זרימת Channel חסום עם backpressureתרשים המראה שה-producer מבצע WriteAsync לתור, וכשאין מקום פנוי הוא ממתין עד שיתפנה, ואילו ה-consumer מבצע ReadAsync ומעבד לפי הסדר עם await.כןלאproducerWriteAsyncיש מקום פנוי בתור?נכנס ל-Channelממתין עד שיתפנהconsumer מבצע ReadAsyncמעבד לפי הסדר עם 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 אמיתי”.

השוואה בין השלכה ללא ניהול לניהול בתורתרשים המראה שהשלכת Task.Run ללא ניהול משאירה מעורפל היכן רואים חריגות, מתי מסתיימים, ומה התקרה, בעוד שהזנה ל-Channel וצריכה על ידי BackgroundService מאפשרת לנהל חריגות, עצירה ומספר מקביליות.השלכת Task.Run בלי ניהולחריגה, סיום ותקרה מעורפליםמזינים לתור ChannelBackgroundService צורךאפשר לנהל חריגה, עצירה ומקביליות

איור 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 בו-זמנית לאותו טיימר. בנוסף, אם זמן העיבוד ארוך מהמחזור, יש לטפל בעיכוב הזה כחלק מהתכנון. הטיימר לא מבצע מקבילה מעצמו כדי להדביק את הפער.

לולאת המחזור של PeriodicTimerתרשים המראה שממתינים למחזור הבא עם WaitForNextTickAsync, מריצים את העיבוד עם await וחוזרים להמתנה, שהעצירה נעשית עם CancellationToken, ושעיכוב כשהעיבוד ארוך מהמחזור צריך להיות מטופל בתכנון.ממתינים עם WaitForNextTickAsyncמריצים עיבוד עם awaitעוצרים עם CancellationTokenעיכוב מעיבוד ארוך מהמחזור מטופל בתכנון

איור 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>, קל יותר להחליט לפי האם משתמשים בתוצאה אחרי שכל הפריטים מוכנים, או לפי סדר ההגעה.

צורת ערך ההחזרה שנקבעת לפי אופן השימוש בתוצאהתרשים המראה שאם משתמשים בתוצאה אחרי שכל הפריטים מוכנים מחזירים רשימה שלמה עם Task, ואם משתמשים לפי סדר ההגעה מחזירים IAsyncEnumerable ומעבדים פריט-פריט עם await foreach בלי להצטבר בזיכרון.אחרי שהכול מוכןלפי סדר ההגעהאיך משתמשים בתוצאה?מחזירים רשימה שלמה עם Taskזורמים עם IAsyncEnumerableמעבדים פריט-פריט עם await foreachלא מצטברים בזיכרון

איור 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 מעשי מאוד.

הצורה של בלעדיות שחוצה awaitתרשים המראה שבקוד שחוצה await משתמשים ב-SemaphoreSlim במקום lock, נכנסים עם WaitAsync ומבצעים עיבוד שכולל await, ותמיד קוראים ל-Release ב-finally.lock לא יכול לחצות awaitמשתמשים ב-SemaphoreSlim במקוםנכנסים עם WaitAsyncמבצעים עיבוד שכולל awaitתמיד Release ב-finally

איור 16: הכניסה היא WaitAsync, היציאה היא Release ב-finally. חשוב לא לשבור את הזוג הזה.

3.12. מפרידים בין אופן הכתיבה של await ב-UI / קוד אפליקציה / ספרייה

ConfigureAwait(false) הוא לא משהו שמוסיפים בכל מקום.

בגדול, החלוקה היא כך.

חלוקת אופן הכתיבה בין UI/אפליקציה לספרייה כלליתתרשים המראה שקוד UI או אפליקציה מבצע await רגיל וממשיך כשחוזר להקשר המקורי, בעוד שספרייה כללית מוסיפה ConfigureAwait(false) ולא מניחה חזרה להקשר מסוים.UI / קוד אפליקציהawait someAsync()ממשיכים כשחוזרים להקשר המקוריספרייה כלליתawait someAsync().ConfigureAwait(false)לא מניחים חזרה להקשר מסוים

איור 17: קוד צד האפליקציה חוזר להקשר המקורי עם await רגיל, ובספרייה כללית שוקלים ConfigureAwait(false).

  • UI / קוד אפליקציה
    • קודם מספיק await רגיל
    • אם אחרי ה-await מבצעים עדכון UI או עיבוד שתלוי בהקשר האפליקציה, טבעי יותר לא להוסיף ConfigureAwait(false)
  • קוד אפליקציה ב-ASP.NET Core
    • בדרך כלל מספיק await רגיל
    • אין צורך לאכוף את ConfigureAwait(false) כשיטה בכל מקום
  • קוד ספרייה כללי
    • אם לא תלוי ב-UI או במודל האפליקציה, ConfigureAwait(false) יעיל

כלומר,

  • קוד צד האפליקציה - 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 בעצמכם.

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

*איור 18: מתודה רגילה מחזירה Task /‏ Task. מתירים async void רק במטפל אירוע.*

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

ההבדל הזה נוטה להפוך לבאג בהמשך, ולכן יציב יותר להחליט עליו מראש.

העברת CancellationTokenתרשים המראה שאם מעבירים לזרם התחתון את ה-token שהתקבל למעלה, זה נעצר כראוי גם באמצע, אך אם רק מקבלים ולא מעבירים, מתקבל קוד שנראה שאפשר לבטל אך לא נעצר בפועל.מקבלים token למעלהמעבירים כפי שהוא ל-API בזרם התחתוןנעצר כראוי גם באמצעמקבלים אבל לא מעביריםנראה שנעצר אך לא נעצר

איור 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(), ולכן גם באפליקציית קונסולה יש פחות סיבה לסנכרן בכוח.

החלפה שלא מערבבת המתנה סינכרוניתתרשים המראה שאם משתמשים ב-async/await, מחברים באופן אסינכרוני עד הסוף - Result ו-Wait הופכים ל-await, ו-Thread.Sleep הופך ל-Task.Delay, ושהתערבבות של המתנה סינכרונית הופכת את אופן ההיתקעות לקשה לקריאה.מחברים באופן אסינכרוני עד הסוףResult ו-Wait הופכים ל-awaitThread.Sleep הופך ל-Task.Delayהמתנה סינכרונית מתערבבתאופן ההיתקעות נהיה קשה לקריאה

איור 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

מתוך הטבלה הזו, מה שנפוץ במיוחד בפועל בשטח הן שלוש אלה.

  1. Task.Run למרות שזה I/O
  2. await טורי למרות שזה בעצם עצמאי
  3. אין ניהול אורך חיים ל-fire-and-forget

תיקון שלוש אלה בלבד כבר משפר משמעותית את הבהירות של הקוד.

שלושת התיקונים הנפוצים ביותר בפועל בשטחתרשים המראה ששלושה דפוסים נפוצים בפועל - Task.Run למרות שזה I/O, await טורי לעיבוד עצמאי, והזנחת אורך חיי fire-and-forget - כל אחד עם ההחלפה שלו, משפרים משמעותית את הבהירות כשמתקנים.Task.Run למרות שזה I/Oawait ישיר ל-API האסינכרוניawait טורי לעיבוד עצמאימתחילים קודם ואז WhenAllהזנחת אורך חיי fire-and-forgetמעבירים ל-Channel או BackgroundServiceהבהירות משתפרת משמעותית

איור 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)

רשימת הבדיקה הזו שימושית גם ליישור קו בין נקודות המבט של הצוות בסקירה.

7. חלוקה גסה בין השימושים

הרשימה של חלוקת השימושים מרוכזת בטבלת ההחלטה של 3.1. במקום להציג שוב אותה טבלה, קל יותר לחזור אליה כשצריך, ולכן לא נציב כאן טבלה.

  • רוצים לראות “מה משתמשים בו קודם” לפי מצב → טבלת ההחלטה ב-3.1
  • רוצים לראות את אופן הכתיבה של כל דפוס → 3.2 עד 3.12 (מתאימים לשורות הטבלה ב-3.1)

יש עוד החלטה אחת שלא נכללת בטבלה של 3.1: טיפוס ערך ההחזרה. זה לא עניין של מצב אלא של תכנון המתודה, ולכן זה מרוכז ב-4.1. אם כותבים רק את המסקנה: קודם בוחרים Task /‏ Task<T>, ו-ValueTask בוחרים אחרי מדידה, כשרואים את הצורך.

8. סיכום

שיטות העבודה המומלצות ל-async /‏ await יעילות יותר בפועל כהתאמת הטיפוס לפי סוג העיבוד, ולא כזכירת המון טכניקות קטנות.

סדר ההסתכלות בגדול הוא כך.

  1. מפרידים אם זו המתנה ל-I/O או חישוב CPU
  2. אם זה I/O, מבצעים await ישירות ל-API האסינכרוני
  3. אם זה חישוב CPU, מחליטים איפה ראוי להריץ אותו
  4. לעיבודים מרובים, בוחרים WhenAll /‏ WhenAny / הגבלת מקביליות
  5. אם מנתקים מאורך חיי הבקשה, לא fire-and-forget גולמי אלא תור
  6. מיישרים קו בטיפול בערך ההחזרה, ביטול, חריגות, בלעדיות והקשר

מכיוון שאופן הכתיבה של async /‏ await עצמו תמציתי, שימוש רשלני בו מקשה על ראיית הכיוון. לעומת זאת,

  • מתייחסים ל-I/O כ-I/O
  • מתייחסים ל-CPU כ-CPU
  • מנהלים עיבוד רקע כעיבוד רקע עם אורך חיים משלו

רק הפרדה בין השלושה האלה כבר משפרת משמעותית את הקריאות.

9. מקורות

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

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

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

שאלות נפוצות

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

מתי כדאי להשתמש ב-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.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג