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

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

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 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

תוכן עניינים

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

ב-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” לברירת מחדל.

קודם כול,

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

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

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

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

2. מונחים במאמר

2.1. שני מונחים שכדאי להפריד קודם

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

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

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

ההבדל בין I/O-bound ל-CPU-boundI/O-bound שמרכזו המתנה חיצונית מאפשר להחזיר את ה-thread לעבודה אחרת בזמן ההמתנה עם await. CPU-bound שמרכזו החישוב עצמו הופך את ה-thread ואת ה-parallelism לנושא המרכזי.I/O-bound (המתנה חיצונית)אפשר להחזיר thread בזמן ההמתנהasync/await יעילים כאן במיוחדCPU-bound (החישוב עצמו)באיזה thread להריץ הופך לנושאגם קביעת ה-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 שנתפס
לאן חוזר ההמשך של awaitawait תופס את ה-SynchronizationContext בזמן כניסת ההמתנה, כך שב-WinForms/WPF חוזרים ל-UI thread ואפשר לגעת ב-controls, בעוד ב-ASP.NET Core אין לאן לחזור וההמשך רץ ב-thread pool.await תופס contextUI thread ב-WinForms או WPFל-ASP.NET Core אין לאן לחזוראחרי await אפשר לגעת ב-UIההמשך רץ ב-thread pool

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

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

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

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

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

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

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

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

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

הצורה הבסיסית של המתנה ל-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 מה-thread הנוכחי.

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

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

איור 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 זה רץ”. לכן נמנעים מזה.

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

איור 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 מתחילות באותו רגע.

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

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

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

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

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

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

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

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

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

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

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

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

עבודה מהסוג הזה, קל יותר לנהל אם מזינים ל-queue ו-consumer ייעודי מעבד לפי הסדר.

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

השוואה בין השלכה ללא ניהול לניהול ב-queueהשלכת Task.Run ללא ניהול משאירה מעורפל היכן רואים חריגות, מתי מסתיימים, ומה התקרה. הזנה ל-Channel וצריכה על ידי BackgroundService מאפשרת לנהל חריגות, עצירה ו-parallelism.השלכת Task.Run בלי ניהולחריגה, סיום ותקרה מעורפליםמזינים ל-queue של ChannelBackgroundService צורךאפשר לנהל חריגה, עצירה ו-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 מעצמו כדי להדביק את הפער.

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

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

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

הצורה של exclusion שחוצה 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 רגיל וממשיך כשחוזר ל-context המקורי. ספרייה כללית מוסיפה ConfigureAwait(false) ולא מניחה חזרה ל-context מסוים.UI / קוד אפליקציהawait someAsync()ממשיכים כשחוזרים ל-context המקוריספרייה כלליתawait someAsync().ConfigureAwait(false)לא מניחים חזרה ל-context מסוים

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

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

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

*איור 18: מתודה רגילה מחזירה Task / Task. מתירים async void רק ב-event handler.*

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

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

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

איור 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 יש פחות סיבה לסנכרן בכוח.

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

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

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

  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: מתוך טבלת ה-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)

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

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 / הגבלת parallelism
  5. אם מנתקים מאורך חיי הבקשה, לא fire-and-forget גולמי אלא queue
  6. מיישרים קו בטיפול בערך ההחזרה, ביטול, חריגות, exclusion ו-context

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

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

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

9. מקורות

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

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

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

שאלות נפוצות

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

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

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג