שיטות עבודה מומלצות לרב־תהליכוניות בפועל: מהדורת .NET — מה להחליט לפני שמוסיפים תהליכונים

· · Windows, ריבוי תהליכונים, C#, .NET, יישומים עסקיים, חקירת תקלות, תכנון

«העיבוד היה איטי, אז הקמנו תהליכונים כדי להקביל, ועכשיו סיכומי הצבירה סוטים מדי פעם.» «הוספנו משימת רקע, ועכשיו היישום נתקע פעם בחודש.» «אומרים שזה לא משתחזר תחת מנפה שגיאות, אבל אצל הלקוח זה בהחלט קורה.» — מה שמפחיד בתכנות רב־תהליכוני הוא שהוא נראה נכון ברגע שסיימתם לכתוב. באגי תנאי מרוץ תלויים בעיתוי: הם מחליקים דרך הבדיקות ומראים את פניהם רק בפרודקשן.

באותו זמן, עכשיו שמעבדים מרובי-ליבות הם הנורמה, גם ביישומים עסקיים יש מצבים שאי אפשר להימנע בהם מרב־תהליכוניות — דרישות כמו «להריץ עיבוד כבד בלי להקפיא את ה-UI» או «לעבד כמה התקנים או קבצים במקביל». מה שחשוב הוא להחליט על עקרונות תכנון לפני שמוסיפים תהליכונים. באגי רב־תהליכוניות אינם דבר שמוחקים בניפוי שגיאות; הם דבר שמתכננים כך שלא יישאר להם מקום להיכנס מלכתחילה.

מאמר זה הוא מהדורת .NET של סדרת הרב־תהליכוניות המעשית. מיועד למפתחים שבונים יישומים עסקיים ב-Windows ומגלים שהם צריכים להוסיף רב־תהליכוניות, הוא מסדר עקרונות תכנון שעומדים בפני עצמם בלי קשר לשפה או למערכת ההפעלה, יחד עם כלי העבודה הקונקרטיים ב-C#/.NET, על בסיס מקורות ראשוניים נכון לאוגוסט 2026. העקרונות עצמם אינם משתנים ב-Linux או ב-C++. אם אתם כותבים קוד נייטיב, ראו את מאמרי הלוויין שממפים את אותם עקרונות לכלים של כל שפה — «מהדורת C++» ו«מהדורת C» — ואם אתם כותבים Java, ראו את «מהדורת Java».

1. העיקר קודם

  • שיטת העבודה המומלצת הראשונה היא לא ליצור תהליכונים בעצמכם. עלו על ממשקים ברמה גבוהה יותר — Task, מאגר התהליכונים, המחלקה Parallel — במקום new Thread, והשאירו את ניהול מספר התהליכונים לזמן הריצה.12
  • הדבר הראשון לקצץ כשמקבילים הוא «מצב משתנה משותף». מקומות שבהם כמה תהליכונים כותבים לאותו משתנה הם מקור המרוצים; לפני שפונים למנעולים כדי להגן עליהם, צמצמו את השיתוף עצמו דרך פיצול נתונים, אי-שינוי, ומסירה.3
  • תנו למנעולים משמעת. החליטו, אחד-לאחד, «איזה מנעול מגן על אילו נתונים», ועשו את אובייקט המנעול למופע ייעודי שאינו גלוי מבחוץ. lock(this) ו-lock(typeof(X)) אסורים. מ-.NET 9 ואילך השתמשו בטיפוס הייעודי System.Threading.Lock.4
  • נתבו מסירת נתונים בין תהליכונים דרך תור. מבנה יצרן/צרכן הבנוי על System.Threading.Channels או על אוסף מקבילי פשוט יותר לתכנון מפיזור מנעולים לכל עבר, והוא גם נותן גבול ברור.56
  • תכננו קודם איך זה נעצר. ביטול שיתופי דרך CancellationToken הוא התשובה הנכונה היחידה לעצירה; Thread.Abort זורק חריגת זמן ריצה ב-.NET (הקו מבוסס-Core).78
  • ה-UI שייך בלעדית לתהליכון ה-UI. לא פקדי WinForms ולא רכיבי WPF מותרים למגע מתהליכון אחר מזה שיצר אותם. מתהליכון אחר, בקשו דרך Control.Invoke / Dispatcher.910
  • «מקבילי אומר מהיר יותר» אינו מחזיק תמיד. לולאה שעבודתה לכל איטרציה קטנה עלולה להאט בגלל תקורה של הקבלה. תמיד מדדו לפני שאתם מאמצים.3

2. למה רב־תהליכוניות קשה — תנאי מרוץ וקיפאון

בתמצית, רב־תהליכוניות מביאה שני סוגי בעיות.4

תנאי מרוץ (race condition) הוא באג שבו התוצאה משתנה לפי הסדר שבו כמה תהליכונים מגיעים לקטע קוד מסוים. הדוגמה הקלאסית היא הגדלת מונה משותף: השורה הבודדת count++ מתפרקת בפועל לשלושה שלבים — «קריאה → חיבור → כתיבה חזרה». אם שני תהליכונים מבצעים את שלושת השלבים האלה באותו זמן, הכתיבה חזרה של תהליכון אחד דורסת את החיבור של האחר, וההגדלה אובדת. התוצאה משתנה בכל הרצה, ואיזו תוצאה תקבלו אי אפשר לחזות.4

תנאי מרוץ על מונה משותףתנאי מרוץ קלאסי שבו הגדלה על מונה משותף אובדת. אם תהליכון אחר משתלב בשלושת שלבי count++, הכתיבה החזרה שמתרחשת אחרונה דורסת את האחרתתהליכון Bהמשתנה המשותף countתהליכון Aתהליכון Bהמשתנה המשותף countתהליכון Acount = 10שתי הגדלות קרו, ועדיין count = 11החיבור של תהליכון A אבדקריאה(10)קריאה(10)חיבור מקומי(11)חיבור מקומי(11)כתיבה חזרה(11)כתיבה חזרה(11)

איור 1: תנאי מרוץ קלאסי שבו הגדלה על מונה משותף אובדת. אם תהליכון אחר משתלב בשלושת שלבי count++, הכתיבה החזרה שמתרחשת אחרונה דורסת את האחרת

קיפאון (deadlock) הוא מצב שבו שני תהליכונים כל אחד מחכה למנעול שהאחר מחזיק, ואף אחד אינו יכול להתקדם. תהליכון A מחזיק מנעול 1 ומחכה למנעול 2; תהליכון B מחזיק מנעול 2 ומחכה למנעול 1 — זה לבדו מספיק כדי ששניהם ייעצרו לנצח.4

המתנה מעגלית של קיפאוןההמתנה המעגלית של קיפאון. ברגע שחיצי ההמתנה יוצרים טבעת, כל תהליכון בטבעת הזו נעצר לנצחמחכה לשחרור מנעול 2מחכה לשחרור מנעול 1תהליכון Aמחזיק מנעול 1תהליכון Bמחזיק מנעול 2

איור 2: ההמתנה המעגלית של קיפאון. ברגע שחיצי ההמתנה יוצרים טבעת, כל תהליכון בטבעת הזו נעצר לנצח

מה שהופך את שניהם למביכים הוא שהם תלויי-עיתוי. זה לגמרי נורמלי ששילוב (צירוף מסוים של סדר ביצוע) שפוגע פעם בעשרות אלפי הרצות במכונת פיתוח יקרה כל יום במכונת הלקוח, שבה גם מספר הליבות וגם העיתוי אחרים. «לא משתחזר עם מנפה שגיאות מחובר» ו«נעלם כשהוספתי רישום» קורים כי עצם התצפית משנה את העיתוי — זו התנהגות קלאסית של באג מרוץ.

לכן בדיוק כל עיקרון מכאן ואילך מצביע לכיוון אחד: לפני «לסנכרן נכון», לצמצם את המקומות שצריכים סנכרון — זה עמוד השדרה של תכנון רב־תהליכוני.

3. עיקרון 1: אל תיצרו תהליכונים בעצמכם

3.1. לעלות על Task ועל מאגר התהליכונים

יצירת תהליכון ישירות ב-new Thread(...) היא, ב-.NET של היום, מוצא אחרון חריג. מאז .NET Framework 4, האמצעי המומלץ לקוד רב־תהליכוני ומקבילי הוא TPL (Task Parallel Library) — כלומר משפחת הממשקים שמרוכזת סביב Task. ה-TPL מכוונן דינמית את דרגת המקביליות לפי המעבדים הזמינים, ולוקח על עצמו את כל העבודה הנמוכה של חלוקת העבודה, תזמון למאגר התהליכונים, טיפול בביטול, וניהול מצב.1

מאגר התהליכונים הוא תשתית ש-.NET עצמו משתמש בה בהרחבה — להרצת Tasks, להשלמת I/O אסינכרוני, לקריאות חוזרות של טיימר ועוד — וכל עוד זורקים אליו פיסות עבודה קצרות, המפתחים אינם צריכים לנהל בעצמם את מחזור החיים של התהליכונים.2

// חישוב כבד שמשתמש במעבד, ברקע
var result = await Task.Run(() => HeavyCalculation(input));

// כמה פעולות עצמאיות במקביל, והמתנה לכולן (כשהמספר קטן)
// * הצורה הזו מניחה ש-ProcessAsync הוא מתודה אסינכרונית שעיקרה I/O.
//   WhenAll רק «ממתין ל-Tasks שכבר רצים», לכן אם רוצים להריץ
//   עבודה CPU-bound במקביל, עוטפים כל פיסה ב-Task.Run(() => Calc(x)) כדי לשים אותה במאגר
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));

// אם יש הרבה פריטים, מגבילים את דרגת המקביליות
await Parallel.ForEachAsync(items,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
    async (x, ct) => await ProcessAsync(x, ct));
// שתי נקודות חשובות: לחבר את האסימון של הקורא ל-ParallelOptions
// (שוכחים את זה וה-ct בתוך הגוף הוא תמיד None), ולהעביר את אותו ct
// גם לגוף (לא לזרוק אותו)

יש הסתייגות אחת. Task.WhenAll(items.Select(...)) מתחיל את העיבוד של כל האיברים בבת אחת, ברגע שהוא נספר. זה בסדר לקומץ קבוע עד כמה עשרות פריטים, אבל משתמשים בזה על אוסף גדול ותמצאו את עצמכם ממצה שקעים, חיבורי DB וזיכרון בבת אחת. לעבודה שנפחה אינו ניתן לחיזוי, או שמגבילים את דרגת המקביליות כמו ב-Parallel.ForEachAsync למעלה, או ששולטים בזרימה בערוץ bounded, שמתואר בהמשך.

יצירת תהליכון משלכם מוצדקת כמעט רק כשתכונה של התהליכון עצמו היא הדרישה — דברים כמו «צריך לולאת הודעות ייעודית משלו», «צריך לציין דירת שרשור (STA)», או «צריך להמשיך לרוץ לכל אורך חיי היישום».

3.2. למקביליות נתונים השתמשו ב-Parallel.For / ForEach

למקביליות נתונים — «להחיל אותו עיבוד על כל איבר באוסף כדי להאיץ את הכל» — השתמשו ב-Parallel.For / Parallel.ForEach במקום לחלק את הלולאה לתהליכונים בעצמכם. ה-TPL מטפל בפיצול מקור הנתונים (partitioning) ובאיזון מחדש של העומס, וללולאה בסיסית אפילו אין צורך במנעולים.11

יש, עם זאת, שתי מלכודות שהתיעוד הרשמי מציין במפורש.3

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

3.3. עבודת «המתנה» שייכת לקלט/פלט אסינכרוני, לא לתהליכונים

עבודה שממתינה בעיקר ל-I/O — קבצים, הרשת, מסד נתונים — אינה מועמדת להוספת תהליכונים. לקשור תהליכון שלם בזמן שהוא ממתין הוא פשוט בזבוז; I/O אסינכרוני דרך async/await אינו צורך תהליכון בזמן ההמתנה. ההבחנה הזו — להקביל עבודה CPU-bound, ולהפוך עבודה I/O-bound לאסינכרונית — היא הקו הראשון שצריך למתוח בכניסה לתכנון רב־תהליכוני.

הסתעפויות לפני הקמת תהליכוןההסתעפויות לעבור לפני «להקים תהליכון». רוב העיבוד העסקי נופל לאחת משלוש היציאות העליונות, והגעה ל-new Thread היא המקרה החריגהמתנה ל-I/O שולטתקבצים, רשת, DBחישוב שמשתמש במעבדלהחיל אותו עיבודעל כל איברי האוסףגוש עצמאי שלעיבוד רקעלולאת הודעות, דרישת STA וכו'תכונה של התהליכון עצמו היא הדרישהיש עיבוד שרוצים להריץ במקבילמה שולט בעיבוד?I/O אסינכרוני עם async/awaitלא מוסיפים תהליכוניםמה צורת העבודה?Parallel.For / ForEachTask.Run / Task.WhenAllnew Thread(מוצא אחרון חריג)

איור 3: ההסתעפויות לעבור לפני «להקים תהליכון». רוב העיבוד העסקי נופל לאחת משלוש היציאות העליונות, והגעה ל-new Thread היא המקרה החריג

קבלת החלטות מעשית ל-async/await מכוסה ב«טבלת החלטה ל-async/await ב-C# — Task.Run ו-ConfigureAwait», ואיך מאגר התהליכונים ו-I/O אסינכרוני מתחברים מתחת לכל זה מכוסה בפירוט ב«מעמקי ה-I/O של Windows (חלק 3) — פורטי השלמת I/O ‏(IOCP) ומאגר התהליכונים של .NET».

4. עיקרון 2: מזעור מצב משתנה משותף

תנאי מרוץ קורה רק כש«כמה תהליכונים» ו«נתונים משתנים משותפים» שניהם נוכחים. מספר התהליכונים נקבע בדרישות, לכן מה שהתכנון יכול לקצץ הוא השיתוף. יש שלושה אמצעים.

4.1. לפצל — שכל תהליכון ייגע רק בנתונים שלו

הגישה הפשוטה והחזקה ביותר היא לפצל את הנתונים לפי תהליכון. לצבירה בלולאה מקבילית, במקום לכתוב למשתנה סכום משותף בכל איטרציה, השתמשו בעומס-היתר של Parallel.For שמקבל מצב מקומי לתהליכון כדי שכל תהליכון יבנה סכום-ביניים משלו במקום, ומזגו אותם פעם אחת בסוף. הכתיבות למצב המשותף יורדות מ«כל איטרציה» ל«פעם לתהליכון», וגם עלות הסנכרון וגם חלון המרוץ מצטמצמים בסדרי גודל.3

long total = 0;
Parallel.For(0, items.Length,
    () => 0L,                                     // ערך התחלה מקומי לתהליכון
    (i, state, local) => local + Weigh(items[i]),  // כל איטרציה מוסיפה רק ל-local שלה
    local => Interlocked.Add(ref total, local));   // המיזוג קורה פעם אחת לכל תהליכון
צבירה מקומית לתהליכוןצבירה מקומית לתהליכון. כי כל תהליכון נוגע רק בנתונים שלו בזמן העיבוד, אין מקום למרוץ, וכתיבות למצב המשותף קורות פעם אחת לכל תהליכון, ברגע המיזוגמערך נתונים (יעד העיבוד)תהליכון 1מעבד את חלקו ומוסיף רק לסכום-הביניים המקומיתהליכון 2מעבד את חלקו ומוסיף רק לסכום-הביניים המקומיתהליכון 3מעבד את חלקו ומוסיף רק לסכום-הביניים המקומימיזוג: Interlocked.Add משקףפעם אחת לכל תהליכון לסכום

איור 4: צבירה מקומית לתהליכון. כי כל תהליכון נוגע רק בנתונים שלו בזמן העיבוד, אין מקום למרוץ, וכתיבות למצב המשותף קורות פעם אחת לכל תהליכון, ברגע המיזוג

4.2. להפוך לבלתי-משתנה — מה שלא נכתב מחדש מותר לשתף בחופשיות

נתונים שנקראים בלבד בטוחים לקריאה במקביל מכל מספר תהליכונים. ערכי תצורה, נתוני אב, קלטי חישוב וכדומה אפשר לשתף בחופשיות בלי סנכרון אם הופכים אותם לבלתי-משתנים — לעולם לא נכתבים מחדש אחרי הבנייה. ב-C#, טיפוסי record ומאפייני init תומכים בתכנון הזה. עצם ההחלטה ש«כשצריך שינוי, בונים מופע חדש ומחליפים במקום לשכתב את הקיים» מסירה עוד פיסת מצב משתנה שהייתם צריכים להגן עליה.

אבל «נראה לקריאה בלבד» ו«בלתי-משתנה» הם דברים שונים. ממשק לקריאה בלבד כמו IReadOnlyList<T> אומר רק «אי אפשר לשכתב דרך הממשק הזה» — הוא אינו מונע מה-List<T> שמתחת להישכתב דרך הפניה אחרת. גם הערובה של record / init רדודה: היא אינה מגנה על האובייקטים שמאפיין מצביע עליהם. לנתונים שרוצים באמת לשתף בבטחה בין תהליכונים, או שמשתמשים באוסף בלתי-משתנה מ-System.Collections.Immutable כמו ImmutableArray<T>, או שמעבירים עותק בנקודת השיתוף, וחותכים את נתיב השכתוב לגמרי. במקרה הזה, התנאי הוא שגם טיפוס האיבר T עצמו חייב להיות בלתי-משתנה. אוסף בלתי-משתנה מגן רק על «הסדר» — הפניות לאובייקטי איברים משתנים עדיין משותפות כפי שהן, כך שאם תוכן איבר יכול להישכתב בנתיב אחר, המרוץ נשאר. או שהופכים את גרף האובייקטים לבלתי-משתנה עד העלים, או שמעבירים עותק עמוק.

4.3. למסור — לשלוח בתור במקום לשתף

גם אז, עדיין צריך להזיז נתונים בין תהליכונים. כשעושים זאת, במקום «שני הצדדים נוגעים במשתנה משותף», השתמשו במבנה יצרן/צרכן שבו צד אחד כותב והאחר קורא, עם תור באמצע.

הבחירה הראשונה ב-.NET היא System.Threading.Channels. זה FIFO שאליו היצרן כותב נתונים באופן אסינכרוני והצרכן קורא אותם באופן אסינכרוני, והערוץ עצמו מנהל את כל עבודת הסנכרון.5

var channel = Channel.CreateBounded<WorkItem>(100); // קיבולת 100 — מפעיל לחץ נגדי

// צד היצרן
await channel.Writer.WriteAsync(item, ct); // אם מלא, ממתין עד שמתפנה מקום
// …ברגע שכל היצרנים סיימו לכתוב:
channel.Writer.Complete();   // מכריז «לא יגיע יותר». בלי זה לולאת הקורא לעולם לא תסתיים

// צד הצרכן
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
    Process(item);
}
מבנה יצרן/צרכן סביב ערוץמבנה יצרן/צרכן סביב ערוץ. אף צד אינו נוגע במשתנה משותף ישירות; גם ההמתנה וגם בקרת הקיבולת נמסרות לערוץאם מלא, גורם לכותבים לחכות(לחץ נגדי)אם ריק, גורם לקוראים לחכותיצרן 1WriteAsyncערוץ bounded (קיבולת 100)תור FIFOהסנכרון מנוהל על ידי הערוץיצרן 2WriteAsyncצרכן 1ReadAllAsyncצרכן 2ReadAllAsync

איור 5: מבנה יצרן/צרכן סביב ערוץ. אף צד אינו נוגע במשתנה משותף ישירות; גם ההמתנה וגם בקרת הקיבולת נמסרות לערוץ

מה שחשוב בפועל הוא לבחור ערוץ עם קיבולת מוגבלת (bounded). התנהגות ברירת המחדל בהגעה לתקרה היא «הכותב ממתין למקום», וזה הופך ללחץ נגדי טבעי. משתמשים בתור בלי תקרה במערך שבו הייצור עוקף את הצריכה, ומקבלים פצצת זמן שממשיכה לרוץ בזמן שהזיכרון גדל.5

בעולם הסינכרוני, את התפקיד שערוץ bounded ממלא ממלא BlockingCollection<T> עם קיבולת שצוינה. הוא משלב חסימה עם בקרת קיבולת: מגבלת הקיבולת מונעת מהיצרן להתרחק יותר מדי מהצרכן, והוא חוסם וגורם לצרכן לחכות כשהוא ריק.12 לעומת זאת ConcurrentQueue<T> / ConcurrentStack<T> הם אוספים מהירים שמשיגים בטיחות לתהליכונים ב-Interlocked בלבד, בלי מנעולים6, אבל הם תורי בטיחות לתהליכונים פשוטים, בלי מגבלת קיבולת ובלי מנגנון «חכה כשריק». חשבו עליהם כרכיב, לא ככוכב של תכנון המסירה. שימו לב גם ש-BlockingCollection<T> לא תוכנן עם גישה אסינכרונית בראש, לכן אם משלבים אותו עם async/await, בחרו Channel<T> במקום.12

הסתייגות אחת: היזהרו מההנחה ש«החלפת מילון ל-ConcurrentDictionary הופכת אותו לבטוח לתהליכונים». גם כשפעולות בודדות בטוחות לתהליכונים, פעולות מורכבות כמו «בדוק אם קיים, ואז הוסף» עדיין נכנסות לתנאי מרוץ (השתמשו במתודות שנבנו לפעולות מורכבות, כמו GetOrAdd). וגם ל-GetOrAdd עצמו יש הסתייגות משלו: אף שהערך שבסוף מאוחסן מובטח להיות אחד, פונקציית הייצור שבונה את הערך יכולה להיקרא יותר מפעם אחת תחת תחרות. שמים תופעת לוואי בייצור — פתיחת חיבור, יצירת קובץ וכן הלאה — והביצוע הכפול מדליף אותה, לכן או שהופכים את הייצור לחסר תופעות לוואי, או שלאתחול שצריך לקרות בדיוק פעם אחת מאחסנים Lazy<T> כערך. החלפת טיפוס האוסף אינה תחליף לצמצום מצב משתנה משותף.

5. עיקרון 3: תנו למנעולים משמעת

גם אחרי צמצום מצב משתנה משותף, לעיתים קרובות אי אפשר להגיע לאפס. השתמשו בהדרה (נעילה) למה שנשאר משותף, אבל מנעול אינו כלי ל«לעטוף ב-lock כל מקום שנראה חשוד, למקרה ש». יש ארבע נקודות משמעת.

5.1. להחליט «על מה מגינים», ולנעול באובייקט ייעודי

חשבו על יחידת הנעילה כ«נתונים», לא כ«קטע קוד». הקצו אובייקט מנעול אחד לכל קבוצת נתונים משתנים שרוצים להגן עליה, וקחו את אותו מנעול בכל מקום שנוגע בנתונים האלה — באג מרוץ, בפועל, הוא מה שמקבלים כשטבלת ההתאמה הזו התפרקה.

עשו את אובייקט המנעול למופע ייעודי שאינו נחשף החוצה. lock(this) חולק את המנעול עם כל קוד חיצוני שיכול להפנות למופע שלכם, ו-lock(typeof(X)) חולק אותו עם כל תחום היישום — בשני המקרים זה כר לגידול קיפאונות. מ-.NET 9 / C# 13 ואילך, שימוש במופע של הטיפוס הייעודי System.Threading.Lock כאובייקט המנעול הוא המומלץ.4

public class OrderBook
{
    private readonly Lock _gate = new();          // .NET 9+ (readonly object לפני כן)
    private readonly List<Order> _orders = [];    // הנתונים ש-_gate מגן עליהם

    public void Add(Order order)
    {
        lock (_gate) { _orders.Add(order); }
    }
}

משפט ה-lock של C# מבטיח שהמנעול ישוחרר גם כשנזרקת חריגה. אופן ההרחבה תלוי בטיפוס אובייקט המנעול: לאובייקט רגיל זה הופך לקריאה ל-Monitor.Exit בבלוק finally, ולטיפוס Lock זה הופך לקריאה ל-EnterScope() ולסילוקו.413 כלומר שדה מסוג Lock הוא מנגנון אחר מ-Monitor, ואם רק חלק מהקוד כותב ביד Monitor.Enter(_gate), ההדרה ההדדית עם lock (_gate) אינה מחזיקה. עם כל אחד מהטיפוסים, בטוח יותר להפסיק לכתוב ביד Monitor.Enter / Exit ולאחד לגמרי על תחביר lock.4

5.2. לא לעשות דבר איטי או חיצוני בזמן שמחזיקים מנעול

ככל שמחזיקים מנעול לזמן קצר יותר, כך טוב יותר; הדבר היחיד שמותר לעשות בזמן שמחזיקים אותו הוא לקרוא ולכתוב את הנתונים שהוא מגן. כתיבת קוד שמבצע I/O בזמן שמחזיקים מנעול, או שקורא לקוד חיצוני דרך אירוע או callback, לא רק מאריכה את זמן ההחזקה — היא פותחת נתיב שבו הקוד שנקרא מנסה לקחת מנעול אחר ונכנס לקיפאון. להכין מחוץ למנעול, ובתוכו לעשות רק את ההחלפה — זו הצורה הבסיסית.

שימו לב שאי אפשר לעשות await בתוך lock (זו שגיאת הידור). זו הגנה, לא רק מגבלה: ל-Monitor יש זיקה לתהליכון — התהליכון שלקח את המנעול חייב להיות זה שמשחרר אותו — וזה אינו מתיישב עם קוד אסינכרוני שבו התהליכון המבצע יכול להשתנות משני צידי await. להדרה בקוד אסינכרוני, השתמשו ב-SemaphoreSlim עם ספירה התחלתית 1.14

private readonly SemaphoreSlim _asyncGate = new(1, 1);

public async Task SaveAsync(Data data, CancellationToken ct)
{
    await _asyncGate.WaitAsync(ct);
    try   { await WriteToFileAsync(data, ct); }
    finally { _asyncGate.Release(); }
}

5.3. תמיד לרכוש כמה מנעולים באותו סדר

כשיש שני מנעולים או יותר, דפוס הקיפאון הקלאסי הוא שסדר הרכישה מתהפך מתהליכון לתהליכון. התיקון פשוט: עשו לכלל שכל תהליכון רוכש את המנעולים באותו סדר. במקום שאי אפשר להבטיח את הסדר, השתמשו בעומס-היתר עם פסק זמן של Monitor.TryEnter, ואם אי אפשר לקבל את המנעול, נסוגו ונסו שוב (או רשמו את החריגה) — זה הופך תקיעה שהייתה נמשכת לנצח לכישלון שניתן לגלות.4

5.4. Interlocked לעדכונים פשוטים, ReaderWriterLockSlim כשהקריאות שולטות

לעדכונים אטומיים של משתנה בודד — הגדלה או הקטנה של מונה, החלפת דגל — המחלקה Interlocked (Increment / Add / CompareExchange) מהירה מ-lock. בלי תחרות, זה יכול לעלות כמו קידומת הוראת מעבד אחת.4 מצד שני, עד כאן מגיע Interlocked; הוא אינו יכול לשמור כמה משתנים עקביים יחד. מבנה נטול-מנעולים שנכתב ביד בשילוב עם volatile הוא כלי למומחים שדורש הבנה עמוקה של מודל הזיכרון, וזה אינו דבר שצריך לכתוב ביישום עסקי.

לנתונים משותפים שבהם «הקריאות תכופות אבל הכתיבות נדירות», יש גם את האפשרות של ReaderWriterLockSlim, שמוציא להדרה רק כתיבות ומאפשר לקריאות לעבור במקביל.13

6. עיקרון 4: תכננו קודם איך זה נעצר

השאלה הראשונה לשאול בסקירת תכנון רב־תהליכוני היא «איך זה נעצר». אפשר לכתוב קוד שמתחיל לרוץ בלי לחשוב על זה, אבל קוד שנעצר בבטחה אינו בא לעולם אלא אם מתכננים אותו.

6.1. ביטול שיתופי (CancellationToken) הוא התשובה הנכונה היחידה

מודל העצירה של .NET מאוחד סביב ביטול שיתופי. הצד שרוצה לעצור משהו יוצר CancellationTokenSource ומעביר את ה-Token שלו לכל פיסת עיבוד. כשהוא רוצה לעצור, הוא קורא ל-Cancel(). צד העיבוד עוקב אחרי האסימון, ובנקודה נוחה שהוא בוחר, מנקה ומסיים — כי זו שיתופיות ולא כפייה, צד העיבוד יכול להסתיים תוך שמירה על מצב עקבי לכל אורך הדרך.7

private CancellationTokenSource? _cts;
private Task? _worker;

public void Start()
{
    if (_worker is { IsCompleted: false })    // לדחות Start כפול בזמן שכבר רץ
        throw new InvalidOperationException("העובד כבר רץ.");
    if (_worker is { IsFaulted: true })       // לא לבנות מחדש מעל כישלון קודם שנבלע
        throw new InvalidOperationException("העובד הקודם נכשל.", _worker.Exception);
    _cts = new CancellationTokenSource();
    var token = _cts.Token;   // ללכוד למשתנה מקומי קודם, כדי שלא ירוץ מול Start מחדש אחרי עצירה
    _worker = Task.Run(() => WorkLoop(token), token);
}

private void WorkLoop(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)   // לעקוב בסריקה
    {
        ProcessNextItem(ct);              // להעביר ct לכל קריאה חוסמת, להפסקה מיידית
    }
}

public async Task StopAsync()
{
    var cts = _cts;          // לנעוץ למשתנים מקומיים, כך שגם אם השדות
    var worker = _worker;    // מוחלפים בזמן שאנחנו ממתינים, לא נעצור את היעד הלא נכון
    if (cts is null || worker is null) return;

    Exception? cancelFailure = null;
    try { cts.Cancel(); }    // callback שנרשם על האסימון יכול לזרוק
    catch (Exception ex) { cancelFailure = ex; }   // להחזיק אותו ולדווח אחרי המפגש

    try
    {
        try { await worker; }    // תמיד לעשות join בלי קשר אם Cancel הצליח, ולצפות בכל כישלון באמצע
        catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
        { }                      // להתייחס כ«תקין» רק לביטול שאנחנו עצמנו ביקשנו
        catch (Exception ex) when (cancelFailure is not null)
        {
            throw new AggregateException(cancelFailure, ex);  // לא לאבד אף אחד משני הכשלונות
        }
    }
    finally
    {
        cts.Dispose();           // לסלק את המקור אחרי join (משחרר משאבי מערכת כמו WaitHandle).
        if (ReferenceEquals(_cts, cts))
        {
            _cts = null;         // לא לתת ל-StopAsync הבא להשתמש במקור שכבר סולק
            _worker = null;
        }
    }
    if (cancelFailure is not null)
        throw new AggregateException(cancelFailure);
}

שימו לב ש-Start / StopAsync האלה הם מבנה מינימלי שמניח שהוא נקרא בסדר מתהליכון יחיד (תהליכון ה-UI, למשל). אם כמה תהליכונים עלולים לתפעל את מחזור החיים בו-זמנית, בצעו את Start / StopAsync עצמם בטור עם משהו כמו SemaphoreSlim — זה יחטיא את המטרה אם פעולות ניהול מחזור החיים עצמן ייכנסו למרוץ, עוד לפני שמגינים על העובד.

גם במדגם הקטן הזה בנויות כמה התאמות שמשתלמות בפועל. ראשית, Start דוחה קריאה כפולה בזמן שהוא רץ. דריסה לא מותנית של _cts ושל _worker מאבדת את ההפניה לעובד הקודם, ומשאירה «תהליכון תועה» שרץ לצד — אחד שאי אפשר לעצור ואי אפשר לעשות לו join. זה סטנדרטי שממשק מחזור חיים (Start/Stop) אוכף על עצמו «רק אחד בכל רגע». מעבר לזה, שלוש נקודות נוספות. ראשונה, ממשק העצירה ממתין להשלמה. Cancel() רק «מבקש» ביטול; ברגע שהוא חוזר, העובד עדיין יכול להיות באמצע ProcessNextItem. עושים Stop() שמבקש וחוזר, ויוצרים מרוץ חדש שבו הקורא מתחיל את הניקוי שלו בזמן שהעובד עדיין רץ. שנייה, מחזיקים את ה-Task במקום לזרוק אותו. זורקים אותו ב-_ = Task.Run(...) ואף אחד לא ישים לב אם העובד מת מחריגה. שלישית, לוכדים את האסימון למשתנה מקומי לפני שמעבירים אותו, במקום להפנות ל-_cts.Token בתוך הלמבדה. הפניה בתוך הלמבדה פירושה שההערכה היא בזמן הביצוע, ואם Start מחדש קורה מיד אחרי עצירה, מקבלים בלבול שבו העובד הישן תופס את האסימון החדש. העברת אותו אסימון גם כארגומנט השני ל-Task.Run פירושה שכשצד העיבוד מסתיים דרך ThrowIfCancellationRequested או דרך OperationCanceledException מממשק שמכיר ביטול, ה-Task מסווג כ«Cancelled» ולא כ«Faulted» (בדוגמה הזו, יציאה רגילה דרך תנאי הלולאה, כמו שמוצג, עדיין נספרת כהשלמה מוצלחת). עוד דבר: ה-catch ב-StopAsync משתמש במסנן when כדי לתפוס רק ביטול שמקורו באסימון שלו. בליעה לא מותנית של OperationCanceledException הייתה גורמת אפילו לכישלון אמיתי שנזרק מאסימון אחר בתוך העיבוד — פסק זמן לכל איבר, למשל — להיראות כמו «נעצר, אז הכל בסדר». שימו לב שהזיהוי לפי התאמת אסימון מתפרק אם WorkLoop משתמש בפנים באסימון מקושר (הרכבת הקישור מסעיף 6.1), כי החריגה שעפה החוצה נושאת את האסימון שבצד המקושר. במבנה הזה, בחרו במפורש, כהחלטת תכנון, או לקרוא ל-ct.ThrowIfCancellationRequested() ביציאה מ-WorkLoop כדי «לתרגם» בחזרה לאסימון החיצוני לפני היציאה, או להרפות את המסנן ל-when (cts.IsCancellationRequested) ולקבל «ביטול בזמן שנתבקשה עצירה נספר כתקין».

מבנה הביטול השיתופימבנה הביטול השיתופי. הצד שעוצר רק קורא ל-Cancel(); כל פיסת עיבוד מחליטה בעצמה «מתי ואיך» היא מסתיימת. לכן אפשר לעצור תוך שמירה על מצב עקביקורא ל-Cancel() פעם אחתמוסר את ה-Tokenמוסר את ה-Tokenמוסר את ה-Tokenבודק IsCancellationRequestedמנקה ומסיים בעצמוThrowIfCancellationRequestedמפסיק מיד גם באמצע המתנההצד שעוצרCancellationTokenSourceעיבוד עובד 1עיבוד עובד 2ממשק של ספרייהשמכיר ביטולסיום תקיןOperationCanceledException= מטופל כהשלמת ביטולהביטול הושלם

איור 6: מבנה הביטול השיתופי. הצד שעוצר רק קורא ל-Cancel(); כל פיסת עיבוד מחליטה בעצמה «מתי ואיך» היא מסתיימת. לכן אפשר לעצור תוך שמירה על מצב עקבי

יש גם מוסכמה קבועה בצד הספרייה. פעולה שניתנת לביטול צריכה לספק מתודה ציבורית שמקבלת CancellationToken, ובתוך לולאת חישוב, או לבדוק IsCancellationRequested מעת לעת או לקרוא ל-ThrowIfCancellationRequested(). האחרון זורק OperationCanceledException, ש-Task מתייחס אליו כ«השלמת ביטול» ולא כ«כישלון». כשרוצים לעצור גם מאסימון שסופק מבחוץ וגם מדאגה פנימית (פסק זמן, למשל), מרכיבים אותם באסימון מקושר.7

6.2. התייחסו ל-Thread.Abort כאילו אינו קיים

Thread.Abort — «להרוג מבחוץ תהליכון שאינו מקשיב» — פשוט זורק PlatformNotSupportedException ב-.NET Core / .NET 5 ואילך; אי אפשר להשתמש בו יותר בכלל. זריקת חריגה לתוך תהליכון בלי לדעת היכן הוא מבצע כרגע מסכנת הפרעה לניקוי משאבים והשחתת מצב. אם צריך לסיים בכוח קוד צד שלישי שאינו מגיב לביטול שיתופי (או שאי אפשר לשכתב כך שיגיב), ההנחיה הרשמית היא להריץ אותו בתהליך נפרד ולעצור אותו ב-Process.Kill.8

6.3. כשממתינים, השתמשו ב-wait handle ולא בסריקה

כתיבת «לחכות בלולאת Sleep(100) עד שדגל עולה» מבזבזת גם מעבד וגם היענות. יש פרימיטיבי סנכרון כמו ManualResetEventSlim ו-SemaphoreSlim לאיתות בין תהליכונים, שמכניסים תהליכון לשינה נכון עד שהוא מסומן.13 הבחירה בין דיוק טיימר להמתנות לאירוע ב-Windows מכוסה בפירוט ב«למה עדיף המתנה מונחית אירועים על פני ‏Sleep(1) ב-Windows».

7. המקרה המיוחד של תהליכון ה-UI — חוק יישומי שולחן העבודה של Windows

ליישומי שולחן עבודה של Windows יש עוד אילוץ חזק מעל העקרונות הכלליים. החוק שרק התהליכון שיצר את ה-UI (תהליכון ה-UI) רשאי לגעת בו.

פקדי WinForms אינם בטוחים לתהליכונים; תפעול שלהם מכמה תהליכונים דוחף פקד למצב לא עקבי וגורם למרוצים, לקיפאונות ולהקפאות. Windows דורש מיישום תהליכון ייעודי אחד שמקבל הודעות מערכת, ויצירה ותפעול של ה-UI חייבים להתרכז באותו תהליכון.9 ל-WPF יש בדיוק אותו מבנה: רק תהליכון ה-UI יכול לשנות רכיבי UI.10

כשרוצים לעדכן את ה-UI מתהליכון אחר, אל תיגעו בו ישירות — הפכו זאת ל«בקשה לתהליכון ה-UI».

המרת עדכון UI לבקשהלהמיר עדכון UI ל«בקשה». עבודת תהליכון הרקע נעצרת בהעלאת העבודה לתור ההודעות — תמיד תהליכון ה-UI עצמו הוא שנוגע בפקדבקשה דרך Control.Invoke /Dispatcher.InvokeAsyncנגיעה ישירה בפקדWindowsעכבר, מקלדת, ציור מחדשתור ההודעותשל תהליכון ה-UIתהליכון רקע(עיבוד כבד, תקשורת)תהליכון ה-UIהתהליכון היחיד שרשאי לגעת בפקדיםאסורגורם למרוץ, קיפאון, הקפאה

איור 7: להמיר עדכון UI ל«בקשה». עבודת תהליכון הרקע נעצרת בהעלאת העבודה לתור ההודעות — תמיד תהליכון ה-UI עצמו הוא שנוגע בפקד

מסגרת אמצעי הבקשה
WinForms Control.Invoke (סינכרוני) / Control.BeginInvoke (אסינכרוני) / מ-.NET 9 ואילך, Control.InvokeAsync9
WPF Dispatcher.Invoke (סינכרוני) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (אסינכרוני)10

מבין אלה, הצורות הסינכרוניות (Control.Invoke / Dispatcher.Invoke) דורשות זהירות. אם תהליכון ה-UI ממתין באופן סינכרוני שאותו עובד יסתיים, והעובד קורא ל-Invoke, מקבלים קיפאון שבו כל אחד מחכה לשני (בדיוק ההמתנה המעגלית מסעיף 2). עשו את הצורות האסינכרוניות (BeginInvoke / InvokeAsync) לברירת המחדל להודעות ולדיווחי התקדמות מתהליכון רקע, והגבילו את הצורות הסינכרוניות למצבים שבהם אפשר להיות בטוחים שתהליכון ה-UI אינו ממתין לכם.

בפועל יש תשובה טובה עוד יותר. כותבים עיבוד שהתחיל על תהליכון ה-UI ב-async/await, ו-await לוכד את SynchronizationContext של תהליכון ה-UI ומחדש אוטומטית את ההמשך על תהליכון ה-UI, מה שמקטין מאוד את מספר המקומות שבהם צריך לכתוב Invoke ביד בכלל. זו, עם זאת, אינה תכונה לא מותנית. קוד שנכנס מקריאה חוזרת ברקע, או המשך אחרי ConfigureAwait(false), אינו חוזר לתהליכון ה-UI, לכן דיספאץ’ מפורש עדיין נחוץ אם נוגעים ב-UI בנתיב הזה. להתייצב על הצורה «עבודה כבדה הולכת ל-Task.Run או ל-I/O אסינכרוני, והשתקפות התוצאה על המסך קורה בהמשך אחרי await» היא הצורה הבסיסית של יישום Windows מודרני. הקשר בין תהליכון ה-UI ל-async/await מסוכם בתרשים אחד ב«async ות’רד ה-UI ב-WPF/‏WinForms בדף אחד».

גם, כש-COM מעורב — שילוב Office, רכיבים ישנים וכן הלאה — מתווספת שכבה נוספת: מודל השרשור של COM עצמו (STA/MTA). תקריות כמו «יצרנו את אובייקט ה-COM על תהליכון ה-UI אבל קראנו לו מתהליכון אחר והוא נתקע» שייכות לשכבה הזו, ומפורטות ב«ידע בסיסי על STA/MTA ב-COM — מודל השרשור ואיך נמנעים מתקיעה».

8. כשכותבים בקוד נייטיב (C++/C)

העקרונות עד כאן — לא ליצור תהליכונים ישירות, לצמצם מצב משתנה משותף, משמעת מנעולים, לתכנן איך עוצרים — חלים ישירות גם על קוד נייטיב. מה שמשתנה הוא כלי העבודה. ב-C++, המקבילים הם RAII יחד עם std::jthread / std::mutex / std::atomic; ב-C, הם _beginthreadex של Win32 API, מנעולי SRW, משתני תנאי, ודפוס אירוע העצירה. כל אחד מכוסה, כולל מלכודות ייחודיות לשפה (המפרק של std::thread, הסכנות של TerminateThread, DllMain ומנעול הטוען, ועוד), ב«מהדורת C++» וב«מהדורת C» של הסדרה הזו.

9. אימות וניפוי שגיאות — להתכונן מתוך הנחה שזה לא ישתחזר

אי אפשר לצפות שבאגי רב־תהליכוניות יימצאו בבדיקות. בדיקת יחידה רגילה סופרת הרצה שבה המרוץ פשוט לא קרה כהצלחה. חשבו את ההכנה בשלוש שכבות.

קו ההגנה הראשון הוא עקרונות התכנון שכוסו עד כאן, בדיוק כפי שהם. בין יישום עם חמש פיסות מצב משתנה משותף ליישום עם חמישים, מספר המקומות שצריך לחשוד בהם שונה פי עשרה. בסקירה, בדקו בטבלה: «אילו נתונים משתנים משותפים», «איזה מנעול מגן על כל אחד», «האם סדר רכישת המנעולים ייחודי», ו«איפה נתיבי העצירה». תכנון שאי אפשר לכתוב את הטבלה הזו עבורו אינו גמור עדיין, גם אם הוא רץ.

שנית, עשו חריגות ניתנות לצפייה במקום להסתיר אותן. גלו חריגות המתנה למנעול עם פסק זמן של Monitor.TryEnter ורשמו אותן,4 רשמו במקום לבלוע חריגות לא נצפות מעבודה שנזרקה למאגר התהליכונים, והיו ערוכים ללכוד dump מלא בתקיעה כדי שתוכלו לבדוק את המחסנית של כל תהליכון — המאבק בבאג ש«קורה רק מדי פעם» מוכרע לפי כמה מידע אפשר לחלץ מהפעם האחת שזה כן קורה. הקמת dump ורישום מכוסה ב«תכנון שמירת יומנים ו-dump בקריסת יישום Windows».

שלישית, נערו תחת עומס. מבחן לחץ שמקל לפגוע בשילוב ביש-מזל במכונת פיתוח — ריצה עם מקביליות גדולה ממספר הליבות לזמן ארוך, ערבוב סדר עיבוד, הזרקת השהיות מלאכותיות וכן הלאה — הוא דרך מציאותית לשלוף מרוצים לפני המשלוח. באג שנעלם תחת מנפה שגיאות לעיתים קרובות משתחזר תחת גרסת הפצה ועוד עומס כבד.

10. סיכום — רשימת בדיקה לפני שמוסיפים תהליכונים

בתמצית, שיטת העבודה המומלצת לתכנות רב־תהליכוני אינה «המיומנות לכתוב סנכרון נכון» אלא «תכנון שמאפשר להימנע מלכתוב סנכרון בכלל». אם אפשר לענות על שמונת השאלות הבאות לפני שמתחילים, אפשר למנוע כמעט כל תקרית גדולה.

  1. העבודה הזו CPU-bound או I/O-bound (אם האחרון, התשובה היא async/await, לא תהליכון)?
  2. אתם עומדים לכתוב new Thread (אפשר לבטא זאת ב-Task, ב-Parallel, או במאגר התהליכונים במקום)?
  3. אילו נתונים משתנים משותפים בין תהליכונים — אפשר למנות אותם?
  4. אפשר לבטל את השיתוף הזה דרך פיצול, אי-שינוי, או מסירה דרך תור?
  5. לכל פיסת נתונים משותפים שנשארה, יש בדיוק מנעול אחד מתאים שהוחלט עליו?
  6. סדר רכישת המנעולים ייחודי בכל התהליכונים, ואתם נמנעים מקריאות חיצוניות בזמן שמחזיקים מנעול?
  7. CancellationToken מועבר לכל פעולה ארוכה, ואפשר להסביר את נתיב העצירה?
  8. קוד שנוגע ב-UI מרוכז בתהליכון ה-UI?

באגי רב־תהליכוניות אינם מופיעים ביום שבו כותבים את הקוד — הם חושפים שיניים אצל לקוח הרבה אחרי ששכחתם אותם. להפך, אם עוברים על רשימת הבדיקה הזו בשלב התכנון, אפשר לתלוש את סוג התקלה היקר ביותר — «קורס מדי פעם», «נתקע פעם בחודש» — לפני שכותבים שורת קוד.

מאמרים קשורים

תחומי ייעוץ קשורים

KomuraSoft LLC מטפלת בסקירות תכנון של יישומים עסקיים שכוללים רב־תהליכוניות, בחקירת שורש של תקלות קשות לשחזור כמו «קורס/נתקע מדי פעם» — ניתוח dump ואיתור מרוצים — ובייעוץ טכני על הקבלה או הפיכה לאסינכרוני של יישומים קיימים. נשמח להיכנס כבר בשלב «בדקו בבקשה אם התכנון הזה יכול לרוץ».

מקורות

  1. Microsoft Learn, Task Parallel Library (TPL). על כך שה-TPL הוא האמצעי המומלץ לקוד רב־תהליכוני ומקבילי מאז .NET Framework 4; על כך שהוא מכוונן דינמית את דרגת המקביליות לפי המעבדים הזמינים; על כך שהוא לוקח על עצמו את חלוקת העבודה, התזמון למאגר התהליכונים, טיפול בביטול וניהול מצב; על כך שלולאה שעבודתה לכל איטרציה קטנה יכולה להאט מתקורה של הקבלה; ועל כך שעדיין מומלצת הבנה בסיסית של מנעולים, קיפאונות ותנאי מרוץ גם כשמשתמשים ב-TPL.  2

  2. Microsoft Learn, The managed thread pool. על כך שמחלקת ThreadPool מספקת מאגר של תהליכוני עובד בניהול המערכת, ומאפשרת למפתחים להתמקד במשימות היישום במקום בניהול תהליכונים; ועל כך ש-.NET משתמש במאגר התהליכונים בהרחבה לפעולות TPL, השלמת I/O אסינכרוני, קריאות חוזרות של טיימר, המתנות רשומות, חיבורי שקע ועוד.  2

  3. Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. על כך שלולאה מקבילית לפעמים איטית מסדרתית ותמיד צריך למדוד; על הימנעות מכתיבות לזיכרון משותף בתוך לולאה מקבילית, עם המלצה על עומס-היתר שמקבל מצב מקומי לתהליכון; ועל כך שאין ערובה שכל איטרציה של For/ForEach באמת רצה במקביל, כך שקוד שממתין בין איטרציות יכול להיכנס לקיפאון.  2 3 4

  4. Microsoft Learn, Managed threading best practices. על הגדרות תנאי מרוץ (דוגמה שבה הגדלת מונה מתפרקת לקריאה, חיבור וכתיבה חזרה, ונדרסת ואובדת) וקיפאון; על שימוש בביטול שיתופי במקום Thread.Abort; על כך שאסור להשתמש בטיפוס או ב-this כאובייקט מנעול, ושמ-.NET 9 / C# 13 ואילך יש להשתמש במופע ייעודי של System.Threading.Lock; על כך שמשפט ה-lock של C# מבטיח Monitor.Exit בבלוק finally; על גילוי קיפאונות עם פסק זמן של Monitor.TryEnter; על כך שהמחלקה Interlocked מהירה יותר לשינויי מצב פשוטים; ועל הנחיית התכנון שנתונים סטטיים צריכים להיות בטוחים לתהליכונים כברירת מחדל ונתוני מופע לא צריכים להיות בטוחים לתהליכונים כברירת מחדל.  2 3 4 5 6 7 8 9 10

  5. Microsoft Learn, System.Threading.Channels library. על כך שערוץ הוא FIFO למודל יצרן/צרכן שמנהל סנכרון בפנים; על כך שאפשר ליצור ערוץ עם מגבלת קיבולת ב-CreateBounded; על כך שהתנהגות ברירת המחדל בהגעה לתקרה היא שהכותב ממתין, עם FullModes אחרים כמו DropOldest שגם הם ניתנים לבחירה; ועל כך שמופעל לחץ נגדי כשהכתיבה עוקפת את הקריאה.  2 3

  6. Microsoft Learn, Thread-safe collections. על כך שהאוספים תחת System.Collections.Concurrent משיגים בטיחות לתהליכונים דרך נעילה דקת-גרעין או מנגנונים נטולי-מנעול; ועל כך ש-ConcurrentQueue ו-ConcurrentStack ממומשים בלי מנעולים, בפעולות Interlocked, כך שהם מחזיקים מעמד תחת הוספה והסרה תכופות מכמה תהליכונים.  2

  7. Microsoft Learn, Cancellation in Managed Threads. על הליך הביטול השיתופי באמצעות CancellationTokenSource ו-CancellationToken; על כך שביטול הוא שיתופי ולא כפוי, והמאזין מחליט איך לעצור; על שלושת גישות המעקב של סריקה, רישום callback, ו-wait handles; על כך ש-ThrowIfCancellationRequested זורק OperationCanceledException, ש-Task מתייחס אליו כהשלמת ביטול; על הרכבת כמה אסימונים באסימון מקושר; ועל כך שספרייה צריכה לספק מתודות ציבוריות שמקבלות CancellationToken.  2 3

  8. Microsoft Learn, Using threads and threading. על כך ש-CancellationToken הוא הדרך הנכונה לעצור תהליכון; על כך ש-Thread.Abort זורק PlatformNotSupportedException ב-.NET Core וב-.NET 5 ואילך, עם אזהרת הוצאה משימוש בזמן הידור (SYSLIB0006) מ-.NET 5 ואילך גם כן; ועל כך שסיום בכוח של קוד צד שלישי שאינו מגיב לביטול שיתופי דורש להריץ אותו בתהליך נפרד ולעצור אותו ב-Process.Kill.  2

  9. Microsoft Learn, How to handle cross-thread operations with controls. על כך שגישה לפקדי WinForms אינה בטוחה לתהליכונים, ותפעול מכמה תהליכונים מוביל למצב לא עקבי, למרוצים, לקיפאונות ולהקפאות; על כך שכל פקד צריך להיווצר ולהיות נגיש באותו תהליכון, ו-Windows דורש תהליכון UI ייעודי למסירת הודעות מערכת; ועל קריאה בטוחה מתהליכון אחר באמצעות Control.Invoke, Control.InvokeAsync מ-.NET 9 ואילך, או BackgroundWorker.  2 3

  10. Microsoft Learn, Threading model (WPF). על כך ששינויי UI ב-WPF מוגבלים לתהליכון יחיד, ותהליכון רקע רושם פריטי עבודה אצל ה-Dispatcher של תהליכון ה-UI כדי לבקש אותם; על כך ש-Dispatcher.Invoke סינכרוני בעוד InvokeAsync ו-BeginInvoke אסינכרוניים; ועל כך שה-Dispatcher מעבד עבודה כתור עדיפויות.  2 3

  11. Microsoft Learn, Data Parallelism (Task Parallel Library). על כך ש-Parallel.For / Parallel.ForEach מספקים מקביליות נתונים כמעט באותה תחושה של כתיבת לולאת for; על כך שאין צורך ליצור תהליכונים או להכניס פריטי עבודה לתור, ואין צורך במנעולים בלולאה בסיסית; ועל כך שה-TPL מפצל את מקור הנתונים בין כמה תהליכונים ומאזן מחדש את העומס אם הוא נעשה לא אחיד. 

  12. Microsoft Learn, BlockingCollection<T> Class. על כך ש-BlockingCollection הוא מימוש יצרן/צרכן עם חסימה ומגבלות קיבולת; על כך שמגבלת הקיבולת מונעת מהיצרן להתרחק יותר מדי מהצרכן; ועל כך שהוא לא תוכנן לגישה אסינכרונית, עם המלצה על Channel<T> ליצרן/צרכן אסינכרוני.  2

  13. Microsoft Learn, Overview of synchronization primitives. על כך ש-Monitor מספק הדרה הדדית דרך אובייקט מנעול ויש לו זיקה לתהליכון; על כך שמצופה מקוד C# להשתמש במשפט lock במקום ב-Monitor ישירות; על כך ש-ReaderWriterLockSlim מוציא כתיבות להדרה ומאפשר קריאות מקביליות; ועל כך ש-SemaphoreSlim הוא סמפור קל לשימוש בתוך תהליך יחיד, בעוד Semaphore הוא בעל שם ואפשר להשתמש בו לסנכרון בין תהליכים.  2 3

  14. Microsoft Learn, Async semaphores, locks, and reader/writer coordination. על כך שלמשפט ה-lock של C# ולטיפוס Lock יש זיקה לתהליכון ולכן אי אפשר להשתמש בהם מעבר ל-await (כי התהליכון שמבצע את ההמשך יכול להשתנות לפני ואחרי ה-await); על שימוש ב-SemaphoreSlim עם ספירה 1, דרך WaitAsync ו-Release ב-finally, להדרה הדדית בקוד אסינכרוני; ועל כך ש-Channel bounded הוא חלופה למטרות האטה. 

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

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

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

שאלות נפוצות

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

למה להימנע מ-lock(this) או מ-lock(typeof(MyClass))?
כי האובייקט שעליו נועלים גלוי גם לקוד שמחוץ לשלכם. this הוא המופע עצמו, כך שכל קוד חיצוני שיכול להפנות למופע הזה יכול לנעול על אותו אובייקט ולגרום לתחרות לא מכוונת או לקיפאון. typeof(MyClass) מסוכן עוד יותר: יש רק אובייקט Type אחד לכל תחום יישום, ולכן אתם חולקים את המנעול עם קוד שאין לו שום קשר אליכם. השתמשו באובייקט ייעודי שלעולם אינו נחשף החוצה כיעד הנעילה. מ-.NET 9 / C# 13 ואילך ההמלצה היא להשתמש במופע של הטיפוס הייעודי System.Threading.Lock כאובייקט המנעול.
כמה תהליכונים מותר ליצור? מה מספר התהליכונים האופטימלי?
«אל תחליטו בעצמכם על מספר התהליכונים» היא התשובה המודרנית. השתמשו ב-Task ובמחלקה Parallel, ומאגר התהליכונים מכוונן אוטומטית את דרגת המקביליות לפי מספר ליבות המעבד והעומס הנוכחי. תכנון שקורא שוב ושוב ל-new Thread ביד נוטה לספק יותר מדי או פחות מדי במכונות לקוח עם מספר ליבות אחר. מה שצריך לשים לב אליו אינו מספר אלא סוג העבודה: חישוב שממצה את המעבד אינו מאיץ כשמקבילים מעבר למספר הליבות, ועיבוד שממתין בעיקר ל-I/O כלל לא צריך תהליכונים נוספים — הצעד הנכון שם הוא I/O אסינכרוני עם async/await.
האם הוספת volatile הופכת משהו לבטוח לתהליכונים?
לא. מה ש-volatile מבטיח הוא סדר — שגישה לשדה הזה אינה מסודרת מחדש ביחס לפעולות זיכרון סובבות (סמנטיקת acquire/release) — לא אטומיות של פעולה מורכבת כמו «קרא, חשב, כתוב בחזרה». לדוגמה, גם אם כמה תהליכונים עושים ++ על מונה volatile int, הגדלות עדיין הולכות לאיבוד. השתמשו במחלקה Interlocked להגדלה/הקטנה של מונה או להחלפה מותנית, והשתמשו ב-lock כשצריך להגן על כמה משתנים יחד כקבוצה. volatile ראוי לשקול כמעט רק במקרה הפשוט של דגל עצירה — תהליכון אחד כותב והאחרים רק קוראים — וגם את הדגל הזה נהוג כיום לבטא כ-CancellationToken.
איך לדעת אם באג שקורה רק מדי פעם נגרם מרב־תהליכוניות?
שלושת הסימנים שכדאי לחשוד בהם הם: «אותה פעולה משתחזרת לפעמים ולא בפעמים אחרות», «השחזור נעלם ברגע שמחברים מנפה שגיאות או מוסיפים רישום», ו«זה קורה רק תחת עומס כבד או מיד אחרי ההפעלה». באג תלוי-עיתוי מאופיין בכך שהתוצאה משתנה בכל הרצה — זו בדיוק הגדרת תנאי מרוץ. כדי לצמצם, קודם מנו כל פיסת נתונים משתנים שאתם חולקים, ולכל אחת רשמו בטבלה איזה מנעול מגן עליה. אפילו גישה אחת לא מוגנת היא חשודה. לתקיעה, לכדו את המחסניות של כל תהליכון ובדקו אם הן יוצרות מעגל של המתנה למנעולים זה של זה. עצרו במנפה של Visual Studio והסתכלו על Parallel Stacks, או, בפרודקשן, לכדו dump ונתחו אותו.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג