Menu

Task ב-C#: Task.Run, WhenAll, WhenAny, ביטול ותהליכונים

Task היא יחידת העבודה האסינכרונית והמקבילית ב-.NET. למדו את Task.Run לעבודה שתלויה במעבד, קריאת תוצאות, שילוב משימות עם WhenAll ו-WhenAny, המשכים, ביטול עם CancellationToken, במה Task שונה מ-Thread, ומתי Parallel.For מתאים יותר.

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

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

Task.Run: עבודה על מאגר התהליכונים

Task.Run מקבלת ביטוי למדה ומכניסה אותו לתור של מאגר התהליכונים, קבוצת תהליכונים ש-.NET שומרת זמינים כדי לא ליצור תהליכון חדש לכל עבודה. היא מחזירה Task, או Task<T> אם הלמדה מחזירה ערך.

פלט:

Started, completed yet: False
Primes up to 200,000: 17984
Status: RanToCompletion

Result חוסמת את התהליכון הקורא עד שהמשימה מסתיימת. זה מקובל ב-Main של תוכנית קונסולה, אבל בתוך קוד אסינכרוני הייתם כותבים int primes = await task;, שמחכה בלי לחסום. אותו דבר חל על task.Wait() עבור Task בלי תוצאה.

Task.Run מיועדת לעבודה שתלויה במעבד: חישובים שמעסיקים ליבה. ל-I/O, כמו קריאת קובץ או קריאה ל-API ברשת, השתמשו במתודה האסינכרונית שהספרייה כבר מספקת (File.ReadAllTextAsync, HttpClient.GetStringAsync) ובצעו עליה await ישירות. בזמן שה-I/O ממתין, קריאה אסינכרונית עם await לא מחזיקה שום תהליכון, כך שעטיפה שלה ב-Task.Run לא מרוויחה כלום ורק מוסיפה מעבר דרך מאגר התהליכונים. המקרה שבאמת תופס תהליכון מהמאגר הוא Task.Run סביב קריאה חוסמת סינכרונית כמו File.ReadAllText: התהליכון הזה יושב בטל עד שהדיסק או הרשת עונים.

כמה משימות בבת אחת: WhenAll

פצלו עבודה עצמאית למשימות, הפעילו את כולן, ושלבו את התוצאות עם Task.WhenAll. במחשב עם כמה ליבות החלקים רצים במקביל.

פלט:

Parts: 14999995, 14999999, 15000003, 15000000
Total: 59999997

כל חלק עובד על טווח משלו ומחזיר סכום משלו, כך שהמשימות לא חולקות כלום ולא צריכות נעילה. WhenAll מחזירה את התוצאות לפי סדר המשימות, לא משנה באיזה סדר התהליכונים סיימו. כשמשימות כן צריכות לעדכן מצב משותף, ראו lock.

WhenAny ו-timeouts

Task.WhenAny מסתיימת כשהראשונה מבין המשימות שלה מסתיימת, ומחזירה את המשימה הזו. תחרות בין העבודה האמיתית ל-Task.Delay נותנת timeout:

פלט:

fast mirror answered after 100 ms
Timed out after 300 ms

WhenAny מחזירה את המשימה המנצחת עצמה, לא את התוצאה שלה, אז מבצעים עליה await שוב כדי לקבל את הערך (או את החריגה, אם היא נכשלה). שימו לב שהמשימות המפסידות ממשיכות לרוץ; WhenAny לא עוצרת אותן. כדי באמת לעצור את העבודה האיטית, בטלו אותה עם token כמו שמוצג בהמשך. ב-.NET 6 ומעלה, await work.WaitAsync(TimeSpan.FromMilliseconds(300)) מבטא את אותו timeout בקריאה אחת.

המשכים עם ContinueWith

לפני await, הדרך להריץ קוד אחרי שמשימה הסתיימה הייתה ContinueWith. עדיין תראו אותה בקוד ישן:

פלט:

Report total: 42

כל המשך מקבל את המשימה הקודמת ורץ כשהיא מסתיימת. await עושה את אותו הדבר עם הרבה פחות טקסים, מטפלת בחריגות באופן טבעי וחוזרת להקשר הנכון, אז העדיפו אותה בקוד חדש. ContinueWith רצה גם כשהמשימה הקודמת נכשלה או בוטלה, כך שקוד ישן צריך לבדוק את t.IsFaulted או להעביר TaskContinuationOptions כדי לא לקרוא ל-.Result על משימה שנכשלה.

ביטול עם CancellationToken

משימות מבוטלות בשיתוף פעולה. CancellationTokenSource מנפיק token; הקוד שעושה את העבודה בודק את ה-token ועוצר את עצמו. שום דבר לא נקטע בכוח.

פלט:

Cancelled before finishing all 10 orders
Processed 3

ה-token מועבר למטה דרך כל שכבה, ו-APIs אסינכרוניים כמו Task.Delay, HttpClient וקריאות למסד נתונים מקבלים אותו גם כן, כך שביטול עוצר אותם באמצע ההמתנה. תפסו OperationCanceledException; ה-TaskCanceledException שחלק מה-APIs זורקים יורשת ממנה. CancellationTokenSource הוא IDisposable, ומכאן ה-using.

Thread מול Task

Thread הוא תהליכון של מערכת ההפעלה שאתם יוצרים בעצמכם. הוא כבד יותר: לכל אחד יש מחסנית משלו (1 MB כברירת מחדל ב-Windows), ההפעלה שלו לוקחת זמן מדיד, והוא לא נותן תוצאה או טיפול בחריגות.

פלט:

Thread started
Result: 42
ThreadTask
רץ עלתהליכון ייעודי חדש של מערכת ההפעלהתהליכון מהמאגר (או בלי תהליכון, ל-I/O)
עלות הפעלהגבוההנמוכה
ערך תוצאהלא, חולקים משתנהTask<T>.Result / await
חריגותחריגות שלא טופלו מפילות את התהליךנשמרות במשימה, נזרקות מחדש ב-await
ביטולדגלים ידנייםCancellationToken מובנה
המתנהJoin()await, Wait(), WhenAll

פנו ל-Thread רק כשצריך משהו שהמאגר לא נותן: לולאה ארוכת טווח שלעולם לא צריכה לשאול תהליכון מהמאגר, עדיפות מסוימת, או single-threaded apartment עבור COM. למשימות ארוכות טווח, Task.Factory.StartNew(work, TaskCreationOptions.LongRunning) מקבלת תהליכון ייעודי ושומרת על ה-API של משימות.

Parallel.For ו-Parallel.ForEach

עבור "עשו את זה לכל פריט, עם כל הליבות", System.Threading.Tasks.Parallel פשוט יותר מיצירת משימות ידנית. הוא מחלק את הטווח בין תהליכונים וחוזר כשכל האיטרציות הסתיימו:

פלט לדוגמה:

image 4 on thread 7
image 3 on thread 6
image 5 on thread 8
image 0 on thread 1
image 1 on thread 4
image 2 on thread 5
Total pixels: 385425

השורות של כל תמונה יוצאות בסדר שבו התהליכונים הריצו אותן, ומזהי התהליכונים משתנים מהרצה להרצה; רק הסכום קבוע. זה הטבע של לולאות מקבילות: אסור שאיטרציות יהיו תלויות בסדר של אחרות, וכל מצב משותף צריך סנכרון (Interlocked.Add כאן). Parallel.ForEach עושה את אותו הדבר על כל אוסף, ו-.NET 6 מוסיפה את Parallel.ForEachAsync לגופים אסינכרוניים.

טעויות נפוצות

  • .Result או .Wait() בתוך קוד אסינכרוני. חוסם תהליכון ועלול לגרום ל-deadlock באפליקציות עם ממשק משתמש; השתמשו ב-await במקום.
  • תפיסת טיפוס החריגה המקורי סביב .Result. כישלונות מגיעים עטופים ב-AggregateException; await פורקת אותם.
  • Fire and forget. Task.Run(...) בלי await ובלי לשמור את המשימה מאבד את החריגות שלה.
  • Task.Run סביב I/O. סביב מתודה אסינכרונית זה רק מוסיף מעבר דרך המאגר; סביב מתודה חוסמת כמו File.ReadAllText זה תופס תהליכון מהמאגר לכל זמן ההמתנה. בצעו await ישירות על מתודת ה-I/O האסינכרונית.
  • התעלמות מה-token. העברת CancellationToken לא עושה כלום אם העבודה לא בודקת אותו.
  • מצב משותף לא מסונכרן בקוד מקבילי. השתמשו ב-Interlocked, ב-lock, או תנו לכל משימה נתונים משלה.

שאלות נפוצות

מה זה Task ב-C#?

Task מייצגת פעולה שעשויה להסתיים בעתיד: הרצת קוד על מאגר התהליכונים (thread pool), טיימר, בקשת I/O. אפשר לבצע עליה await, היא מדווחת אם הסתיימה, נכשלה או בוטלה, ו-Task<T> נושאת גם תוצאה. משימות הן אבן הבניין שמתחת ל-async/await.

מה ההבדל בין Task ל-Thread ב-C#?

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

מתי כדאי להשתמש ב-Task.Run?

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

איך מבטלים Task ב-C#?

צרו CancellationTokenSource, העבירו את ה-Token שלו לעבודה, וקראו ל-Cancel() (או ל-CancelAfter(timeout)) על המקור. העבודה חייבת לשתף פעולה: לבדוק את token.IsCancellationRequested או לקרוא ל-token.ThrowIfCancellationRequested() בלולאה שלה, ולהעביר את ה-token הלאה ל-APIs אסינכרוניים כמו Task.Delay. await על משימה שבוטלה זורק OperationCanceledException.

מה ההבדל בין Task.WhenAll ל-Task.WhenAny?

Task.WhenAll מסתיימת כשכל המשימות הסתיימו ונותנת את כל התוצאות. Task.WhenAny מסתיימת ברגע שהמשימה הראשונה מסתיימת ומחזירה את המשימה הזו. WhenAny היא הדרך המקובלת להוסיף timeout: מריצים את העבודה בתחרות מול Task.Delay.

למה Task.Result זורקת AggregateException?

.Result ו-.Wait() עוטפות כל כישלון ב-AggregateException, כי משימה יכולה באופן כללי להחזיק כמה חריגות. await פורקת את העטיפה וזורקת ישירות את החריגה הפנימית הראשונה, וזו אחת הסיבות להעדיף await. אם חייבים לחסום, גם .GetAwaiter().GetResult() זורקת את החריגה המקורית.

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל