שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת .NET — מה להחליט לפני שמוסיפים threads
· עודכן בתאריך: · Go Komura · Windows, multithreading, C#, .NET, יישומים עסקיים, חקירת תקלות, תכנון
היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 2 Aug 2026)
- פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22175824)
מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.
Go Komura (2026). שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת .NET — מה להחליט לפני שמוסיפים threads. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22175824 https://comcomponent.com/he/blog/multithreading-best-practices-dotnet/
- DOI (הגרסה האחרונה)
- 10.5281/zenodo.22175824
- DOI (הגרסה הזו)
- 10.5281/zenodo.22175825
“העיבוד היה איטי, אז הקמנו threads כדי להקביל, ועכשיו סיכומי הצבירה סוטים מדי פעם.” “הוספנו עיבוד ברקע, ועכשיו האפליקציה נתקעת פעם בחודש.” “אומרים שזה לא משתחזר תחת debugger, אבל אצל הלקוח זה בהחלט קורה.” — מה שמפחיד ב-multithreading הוא שהוא נראה נכון ברגע שסיימתם לכתוב. באגים של race condition תלויים בתזמון: הם מחליקים דרך הבדיקות ומופיעים רק ב-production.
באותו זמן, עכשיו שמעבדים מרובי-ליבות הם הנורמה, גם ביישומים עסקיים יש מצבים שאי אפשר להימנע בהם מ-multithreading — דרישות כמו “להריץ עיבוד כבד בלי להקפיא את ה-UI” או “לעבד כמה התקנים או קבצים במקביל”. מה שחשוב הוא להחליט על עקרונות תכנון לפני שמוסיפים threads. באגים של multithreading אינם דבר שמוחקים ב-debugging; הם דבר שמתכננים כך שלא יישאר להם מקום להיכנס מלכתחילה.
המאמר הזה הוא מהדורת .NET של סדרת ה-multithreading המעשית. מיועד למפתחים שבונים יישומים עסקיים ב-Windows ומגלים שהם צריכים להוסיף multithreading, הוא מסדר עקרונות תכנון שעומדים בפני עצמם בלי קשר לשפה או ל-OS, יחד עם כלי העבודה הקונקרטיים ב-C#/.NET, על בסיס מקורות ראשוניים נכון לאוגוסט 2026. העקרונות עצמם אינם משתנים ב-Linux או ב-C++. אם אתם כותבים קוד native, ראו את מאמרי הלוויין שממפים את אותם עקרונות לכלים של כל שפה — “מהדורת C++” ו”מהדורת C” — ואם אתם כותבים Java, ראו את “מהדורת Java”.
1. קודם המסקנה
- שיטת העבודה המומלצת הראשונה היא לא ליצור threads בעצמכם. השתמשו ב-APIs ברמה גבוהה יותר — Task, ה-thread pool, המחלקה
Parallel— במקוםnew Thread, והשאירו את ניהול מספר ה-threads ל-runtime.12 - הדבר הראשון לקצץ כשמקבילים הוא “shared mutable state”. מקומות שבהם כמה threads כותבים לאותו משתנה הם מקור ה-races; לפני שפונים ל-locks כדי להגן עליהם, צמצמו את השיתוף עצמו דרך פיצול נתונים, immutability, והעברה.3
- תנו ל-locks משמעת. החליטו, אחד-לאחד, “איזה lock מגן על אילו נתונים”, ועשו את אובייקט ה-lock ל-instance ייעודי שאינו גלוי מבחוץ.
lock(this)ו-lock(typeof(X))אסורים. מ-.NET 9 ואילך השתמשו בטיפוס הייעודיSystem.Threading.Lock.4 - נתבו מסירת נתונים בין threads דרך תור. מבנה producer/consumer הבנוי על
System.Threading.Channelsאו על concurrent collection פשוט יותר לתכנון מפיזור locks לכל עבר, והוא גם נותן גבול ברור.56 - תכננו קודם איך זה נעצר. cooperative cancellation דרך
CancellationTokenהיא התשובה הנכונה היחידה לעצירה;Thread.Abortזורק runtime exception ב-.NET (הקו מבוסס-Core).78 - ה-UI שייך בלעדית ל-UI thread. לא controls של WinForms ולא אלמנטים של WPF מותרים למגע מ-thread אחר מזה שיצר אותם. מ-thread אחר, בקשו דרך
Control.Invoke/Dispatcher.910 - “מקבילי אומר מהיר יותר” אינו מחזיק תמיד. לולאה שעבודתה לכל iteration קטנה עלולה להאט בגלל overhead של הקבלה. תמיד מדדו לפני שאתם מאמצים.3
ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 28, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle
2. למה multithreading קשה: race condition ו-deadlock
בתמצית, multithreading מביא שני סוגי בעיות.4
race condition הוא באג שבו התוצאה משתנה לפי הסדר שבו כמה threads מגיעים לקטע קוד מסוים. הדוגמה הקלאסית היא הגדלת מונה משותף: השורה הבודדת count++ מתפרקת בפועל לשלושה שלבים — “קריאה → חיבור → כתיבה חזרה”. אם שני threads מבצעים את שלושת השלבים האלה באותו זמן, הכתיבה חזרה של thread אחד דורסת את החיבור של האחר, וההגדלה אובדת. התוצאה משתנה בכל הרצה, ואיזו תוצאה תקבלו אי אפשר לחזות.4
sequenceDiagram
accTitle: race condition על מונה משותף
accDescr: race condition קלאסי שבו הגדלה על מונה משותף אובדת. אם thread אחר נכנס בין שלושת שלבי count++, הכתיבה החזרה שמתרחשת אחרונה דורסת את האחרת
participant A as thread A
participant M as המשתנה המשותף count
participant B as thread B
Note over M: count = 10
A->>M: קריאה(10)
B->>M: קריאה(10)
A->>A: חיבור מקומי(11)
B->>B: חיבור מקומי(11)
A->>M: כתיבה חזרה(11)
B->>M: כתיבה חזרה(11)
Note over M: שתי הגדלות קרו, ועדיין count = 11<br/>ההגדלה של thread A אבדה
איור 1: race condition קלאסי שבו הגדלה על מונה משותף אובדת. אם thread אחר נכנס בין שלושת שלבי count++, הכתיבה החזרה שמתרחשת אחרונה דורסת את האחרת
deadlock הוא מצב שבו שני threads כל אחד מחכה ל-lock שהאחר מחזיק, ואף אחד אינו יכול להתקדם. thread A מחזיק lock 1 ומחכה ל-lock 2; thread B מחזיק lock 2 ומחכה ל-lock 1 — זה לבדו מספיק כדי ששניהם ייעצרו לנצח.4
flowchart LR
accTitle: המתנה מעגלית של deadlock
accDescr: ההמתנה המעגלית של deadlock. ברגע שחיצי ההמתנה יוצרים טבעת, כל thread בטבעת הזו נעצר לנצח
A["thread A<br/>מחזיק lock 1"] -->|"מחכה לשחרור lock 2"| B["thread B<br/>מחזיק lock 2"]
B -->|"מחכה לשחרור lock 1"| A
איור 2: ההמתנה המעגלית של deadlock. ברגע שחיצי ההמתנה יוצרים טבעת, כל thread בטבעת הזו נעצר לנצח
מה שהופך את שניהם לקשים לטיפול הוא שהם תלויי-תזמון. זה לגמרי נורמלי שצירוף מסוים של סדר ביצוע (interleave) שפוגע פעם בעשרות אלפי הרצות במכונת פיתוח יקרה כל יום במכונת הלקוח, שבה גם מספר הליבות וגם התזמון אחרים. “לא משתחזר עם debugger מחובר” ו”נעלם כשהוספתי לוג” קורים כי עצם התצפית משנה את התזמון — זו התנהגות קלאסית של באג race.
לכן בדיוק כל עיקרון מכאן ואילך מצביע לכיוון אחד: לפני “לסנכרן נכון”, לצמצם את המקומות שצריכים סנכרון — זה עמוד השדרה של תכנון multithreading.
3. עיקרון 1: אל תיצרו threads בעצמכם
3.1. להשתמש ב-Task וב-thread pool
יצירת thread ישירות ב-new Thread(...) היא, ב-.NET של היום, מוצא אחרון חריג. מאז .NET Framework 4, האמצעי המומלץ לקוד multithreaded ומקבילי הוא TPL (Task Parallel Library) — כלומר משפחת ה-APIs שמרוכזת סביב Task. ה-TPL מכוונן דינמית את דרגת המקביליות לפי ה-processors הזמינים, ולוקח על עצמו את כל העבודה הנמוכה של חלוקת העבודה, scheduling ל-thread pool, טיפול ב-cancellation, וניהול מצב.1
ה-thread pool הוא תשתית ש-.NET עצמו משתמש בה בהרחבה — להרצת Tasks, להשלמת I/O אסינכרוני, ל-callbacks של timer ועוד — וכל עוד זורקים אליו פיסות עבודה קצרות, המפתחים אינם צריכים לנהל בעצמם את מחזור החיים של ה-threads.2
// חישוב כבד שמשתמש ב-CPU, ברקע
var result = await Task.Run(() => HeavyCalculation(input));
// כמה פעולות עצמאיות במקביל, והמתנה לכולן (כשהמספר קטן)
// * הצורה הזו מניחה ש-ProcessAsync הוא מתודה אסינכרונית שעיקרה I/O.
// WhenAll רק "ממתין ל-Tasks שכבר רצים", לכן אם רוצים להריץ
// עבודה CPU-bound במקביל, עוטפים כל פיסה ב-Task.Run(() => Calc(x)) כדי לשים אותה ב-pool
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));
// שתי נקודות חשובות: לחבר את ה-token של הקורא ל-ParallelOptions
// (שוכחים את זה וה-ct בתוך הגוף הוא תמיד None), ולהעביר את אותו ct
// גם לגוף (לא לזרוק אותו)
יש הסתייגות אחת. Task.WhenAll(items.Select(...)) מתחיל את העיבוד של כל האיברים בבת אחת, ברגע שהוא נספר. זה בסדר לקומץ קבוע עד כמה עשרות פריטים, אבל משתמשים בזה על אוסף גדול ותמצאו את עצמכם ממצה sockets, חיבורי DB וזיכרון בבת אחת. לעבודה שנפחה אינו ניתן לחיזוי, או שמגבילים את דרגת המקביליות כמו ב-Parallel.ForEachAsync למעלה, או ששולטים בזרימה ב-channel bounded, שמתואר בהמשך.
יצירת thread משלכם מוצדקת כמעט רק כשתכונה של ה-thread עצמו היא הדרישה — דברים כמו “צריך message loop ייעודי משלו”, “צריך לציין apartment (STA)”, או “צריך להמשיך לרוץ לכל אורך חיי האפליקציה”.
3.2. ל-data parallelism השתמשו ב-Parallel.For / ForEach
ל-data parallelism — “להחיל אותו עיבוד על כל איבר באוסף כדי להאיץ את הכל” — השתמשו ב-Parallel.For / Parallel.ForEach במקום לחלק את הלולאה ל-threads בעצמכם. ה-TPL מטפל בפיצול מקור הנתונים (partitioning) ובאיזון מחדש של העומס, וללולאה בסיסית אפילו אין צורך ב-locks.11
יש, עם זאת, שתי מלכודות שהתיעוד הרשמי מציין במפורש.3
- אל תניחו שמקבילי תמיד מהיר יותר. לולאה עם מעט iterations, או כזו שעבודתה לכל iteration קלה, עלולה להאט כי overhead של ההקבלה עולה על גוף העבודה. הביצועים תלויים בהרבה גורמים, לכן תמיד מדדו והחליטו מזה.
- אל תגרמו ל-iterations לחכות זו לזו. אין ערובה שכל iteration של
Parallel.Forבאמת רצה במקביל. קוד שבו iteration אחת ממתינה לאירוע ש-iteration אחרת מגדירה יכול להיכנס ל-deadlock, לפי ה-scheduling.
3.3. עבודת “המתנה” שייכת ל-I/O אסינכרוני, לא ל-threads
עבודה שממתינה בעיקר ל-I/O — קבצים, הרשת, מסד נתונים — אינה מועמדת להוספת threads. לקשור thread שלם בזמן שהוא ממתין הוא פשוט בזבוז; I/O אסינכרוני דרך async/await אינו צורך thread בזמן ההמתנה. ההבחנה הזו — להקביל עבודה CPU-bound, ולהפוך עבודה I/O-bound לאסינכרונית — היא הקו הראשון שצריך למתוח בכניסה לתכנון multithreading.
flowchart TB
accTitle: הסתעפויות לפני הקמת thread
accDescr: ההסתעפויות לעבור לפני "להקים thread". רוב העיבוד העסקי נופל לאחת משלוש היציאות העליונות, והגעה ל-new Thread היא המקרה החריג
S["יש עיבוד שרוצים להריץ במקביל"] --> Q1{"מה שולט בעיבוד?"}
Q1 -->|"המתנה ל-I/O שולטת<br/>קבצים, רשת, DB"| ASYNC["I/O אסינכרוני עם async/await<br/>לא מוסיפים threads"]
Q1 -->|"חישוב שמשתמש ב-CPU"| Q2{"מה צורת העבודה?"}
Q2 -->|"להחיל אותו עיבוד<br/>על כל איברי האוסף"| PAR["Parallel.For / ForEach"]
Q2 -->|"גוש עצמאי של<br/>עיבוד רקע"| TASK["Task.Run / Task.WhenAll"]
Q2 -->|"message loop, דרישת STA וכו'<br/>תכונה של ה-thread עצמו היא הדרישה"| TH["new Thread<br/>(מוצא אחרון חריג)"]
איור 3: ההסתעפויות לעבור לפני “להקים thread”. רוב העיבוד העסקי נופל לאחת משלוש היציאות העליונות, והגעה ל-new Thread היא המקרה החריג
קבלת החלטות מעשית ל-async/await מכוסה ב”async/await ב-C#: טבלת החלטה ל-Task.Run ו-ConfigureAwait”, ואיך ה-thread pool ו-I/O אסינכרוני מתחברים מתחת לכל זה מכוסה בפירוט ב”מעמקי ה-I/O של Windows (חלק 3) — I/O Completion Ports (IOCP) וה-thread pool של .NET”.
4. עיקרון 2: לצמצם shared mutable state
race condition קורה רק כש”כמה threads” ו”נתונים משתנים משותפים” שניהם נוכחים. מספר ה-threads נקבע בדרישות, לכן מה שהתכנון יכול לקצץ הוא השיתוף. יש שלושה אמצעים.
4.1. לפצל — שכל thread ייגע רק בנתונים שלו
הגישה הפשוטה והחזקה ביותר היא לפצל את הנתונים לפי thread. לצבירה בלולאה מקבילית, במקום לכתוב למשתנה סכום משותף בכל iteration, השתמשו ב-overload של Parallel.For שמקבל מצב thread-local כדי שכל thread יבנה סכום-ביניים משלו במקום, ומזגו אותם פעם אחת בסוף. הכתיבות למצב המשותף יורדות מ”כל iteration” ל”פעם ל-thread”, וגם עלות הסנכרון וגם חלון ה-race מצטמצמים בסדרי גודל.3
long total = 0;
Parallel.For(0, items.Length,
() => 0L, // ערך התחלה thread-local
(i, state, local) => local + Weigh(items[i]), // כל iteration מוסיפה רק ל-local שלה
local => Interlocked.Add(ref total, local)); // המיזוג קורה פעם אחת לכל thread
flowchart TB
accTitle: צבירה thread-local
accDescr: צבירה thread-local. כי כל thread נוגע רק בנתונים שלו בזמן העיבוד, אין מקום ל-race, וכתיבות למצב המשותף קורות פעם אחת לכל thread, ברגע המיזוג
SRC["מערך נתונים (יעד העיבוד)"] --> T1["thread 1<br/>מעבד את חלקו ו<br/>מוסיף רק לסכום-הביניים המקומי"]
SRC --> T2["thread 2<br/>מעבד את חלקו ו<br/>מוסיף רק לסכום-הביניים המקומי"]
SRC --> T3["thread 3<br/>מעבד את חלקו ו<br/>מוסיף רק לסכום-הביניים המקומי"]
T1 --> M["מיזוג: Interlocked.Add משקף<br/>פעם אחת לכל thread לסכום"]
T2 --> M
T3 --> M
איור 4: צבירה thread-local. כי כל thread נוגע רק בנתונים שלו בזמן העיבוד, אין מקום ל-race, וכתיבות למצב המשותף קורות פעם אחת לכל thread, ברגע המיזוג
4.2. לעשות immutable — מה שלא נכתב מחדש מותר לשתף בחופשיות
נתונים שנקראים בלבד בטוחים לקריאה במקביל מכל מספר threads. ערכי תצורה, נתוני אב, קלטי חישוב וכדומה אפשר לשתף בחופשיות בלי סנכרון אם הופכים אותם ל-immutable — לעולם לא נכתבים מחדש אחרי הבנייה. ב-C#, טיפוסי record ומאפייני init תומכים בתכנון הזה. עצם ההחלטה ש”כשצריך שינוי, בונים instance חדש ומחליפים במקום לשכתב את הקיים” מסירה עוד פיסת מצב משתנה שהייתם צריכים להגן עליה.
אבל “נראה לקריאה בלבד” ו”immutable” הם דברים שונים. ממשק לקריאה בלבד כמו IReadOnlyList<T> אומר רק “אי אפשר לשכתב דרך הממשק הזה” — הוא אינו מונע מה-List<T> שמתחת להישכתב דרך הפניה אחרת. גם הערובה של record / init רדודה: היא אינה מגנה על האובייקטים שמאפיין מצביע עליהם. לנתונים שרוצים באמת לשתף בבטחה בין threads, או שמשתמשים ב-immutable collection מ-System.Collections.Immutable כמו ImmutableArray<T>, או שמעבירים עותק בנקודת השיתוף, וחותכים את נתיב השכתוב לגמרי. במקרה הזה, התנאי הוא שגם טיפוס האיבר T עצמו חייב להיות immutable. immutable collection מגן רק על “הסדר” — הפניות לאובייקטי איברים משתנים עדיין משותפות כפי שהן, כך שאם תוכן איבר יכול להישכתב בנתיב אחר, ה-race נשאר. או שהופכים את גרף האובייקטים ל-immutable עד העלים, או שמעבירים deep copy.
4.3. להעביר — לשלוח בתור במקום לשתף
גם אז, עדיין צריך להזיז נתונים בין threads. כשעושים זאת, במקום “שני הצדדים נוגעים במשתנה משותף”, השתמשו במבנה producer/consumer שבו צד אחד כותב והאחר קורא, עם תור באמצע.
הבחירה הראשונה ב-.NET היא System.Threading.Channels. זה FIFO שאליו ה-producer כותב נתונים באופן אסינכרוני וה-consumer קורא אותם באופן אסינכרוני, וה-channel עצמו מנהל את כל עבודת הסנכרון.5
var channel = Channel.CreateBounded<WorkItem>(100); // קיבולת 100 — מפעיל backpressure
// צד ה-producer
await channel.Writer.WriteAsync(item, ct); // אם מלא, ממתין עד שמתפנה מקום
// …ברגע שכל ה-producers סיימו לכתוב:
channel.Writer.Complete(); // מכריז "לא יגיע יותר". בלי זה לולאת הקורא לעולם לא תסתיים
// צד ה-consumer
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
Process(item);
}
flowchart LR
accTitle: מבנה producer/consumer סביב channel
accDescr: מבנה producer/consumer סביב channel. אף צד אינו נוגע במשתנה משותף ישירות; גם ההמתנה וגם בקרת הקיבולת נמסרות ל-channel
P1["producer 1<br/>WriteAsync"] --> CH["channel bounded (קיבולת 100)<br/>תור FIFO<br/>הסנכרון מנוהל על ידי ה-channel"]
P2["producer 2<br/>WriteAsync"] --> CH
CH --> C1["consumer 1<br/>ReadAllAsync"]
CH --> C2["consumer 2<br/>ReadAllAsync"]
CH -.->|"אם מלא, גורם לכותבים לחכות<br/>(backpressure)"| P1
CH -.->|"אם ריק, גורם לקוראים לחכות"| C1
איור 5: מבנה producer/consumer סביב channel. אף צד אינו נוגע במשתנה משותף ישירות; גם ההמתנה וגם בקרת הקיבולת נמסרות ל-channel
מה שחשוב בפועל הוא לבחור channel עם קיבולת מוגבלת (bounded). התנהגות ברירת המחדל בהגעה לתקרה היא “הכותב ממתין למקום”, וזה הופך ל-backpressure טבעי. משתמשים בתור בלי תקרה במערך שבו הייצור עוקף את הצריכה, ומקבלים מצב שבו המערכת ממשיכה לרוץ בזמן שהזיכרון גדל בלי גבול.5
בעולם הסינכרוני, את התפקיד ש-channel bounded ממלא ממלא BlockingCollection<T> עם קיבולת שצוינה. הוא משלב חסימה עם בקרת קיבולת: מגבלת הקיבולת מונעת מה-producer להתרחק יותר מדי מה-consumer, והוא חוסם וגורם ל-consumer לחכות כשהוא ריק.12 לעומת זאת ConcurrentQueue<T> / ConcurrentStack<T> הם אוספים מהירים שמשיגים thread-safety ב-Interlocked בלבד, בלי locks6, אבל הם תורי thread-safety פשוטים, בלי מגבלת קיבולת ובלי מנגנון “חכה כשריק”. חשבו עליהם כרכיב, לא ככוכב של תכנון ההעברה. שימו לב גם ש-BlockingCollection<T> לא תוכנן עם גישה אסינכרונית בראש, לכן אם משלבים אותו עם async/await, בחרו Channel<T> במקום.12
הסתייגות אחת: היזהרו מההנחה ש”החלפת מילון ל-ConcurrentDictionary הופכת אותו ל-thread-safe”. גם כשפעולות בודדות thread-safe, פעולות מורכבות כמו “בדוק אם קיים, ואז הוסף” עדיין נכנסות ל-race condition (השתמשו במתודות שנבנו לפעולות מורכבות, כמו GetOrAdd). וגם ל-GetOrAdd עצמו יש הסתייגות משלו: אף שהערך שבסוף מאוחסן מובטח להיות אחד, פונקציית ה-factory שבונה את הערך יכולה להיקרא יותר מפעם אחת תחת contention. שמים side effect ב-factory — פתיחת חיבור, יצירת קובץ וכן הלאה — והביצוע הכפול מדליף אותה, לכן או שהופכים את ה-factory לחסר side effects, או שלאתחול שצריך לקרות בדיוק פעם אחת מאחסנים Lazy<T> כערך. החלפת טיפוס האוסף אינה תחליף לצמצום shared mutable state.
5. עיקרון 3: תנו ל-locks משמעת
גם אחרי צמצום shared mutable state, לעיתים קרובות אי אפשר להגיע לאפס. השתמשו ב-mutual exclusion (lock) למה שנשאר משותף, אבל lock אינו כלי ל”לעטוף ב-lock כל מקום שנראה חשוד, למקרה ש”. יש ארבע נקודות משמעת.
5.1. להחליט “על מה מגינים”, ולנעול באובייקט ייעודי
חשבו על יחידת הנעילה כ”נתונים”, לא כ”קטע קוד”. הקצו אובייקט lock אחד לכל קבוצת נתונים משתנים שרוצים להגן עליה, וקחו את אותו lock בכל מקום שנוגע בנתונים האלה — באג race, בפועל, הוא מה שמקבלים כשטבלת ההתאמה הזו התפרקה.
עשו את אובייקט ה-lock ל-instance ייעודי שאינו נחשף החוצה. lock(this) חולק את ה-lock עם כל קוד חיצוני שיכול להפנות ל-instance שלכם, ו-lock(typeof(X)) חולק אותו עם כל ה-application domain — בשני המקרים זה פותח פתח ל-deadlocks. מ-.NET 9 / C# 13 ואילך, שימוש ב-instance של הטיפוס הייעודי System.Threading.Lock כאובייקט ה-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# מבטיח שה-lock ישוחרר גם כשנזרקת exception. אופן ההרחבה תלוי בטיפוס אובייקט ה-lock: לאובייקט רגיל זה הופך לקריאה ל-Monitor.Exit בבלוק finally, ולטיפוס Lock זה הופך לקריאה ל-EnterScope() ולסילוקו.413 כלומר שדה מסוג Lock הוא מנגנון אחר מ-Monitor, ואם רק חלק מהקוד כותב ביד Monitor.Enter(_gate), ה-mutual exclusion עם lock (_gate) אינו מחזיק. עם כל אחד מהטיפוסים, בטוח יותר להפסיק לכתוב ביד Monitor.Enter / Exit ולאחד לגמרי על תחביר lock.4
5.2. לא לעשות דבר איטי או חיצוני בזמן שמחזיקים lock
ככל שמחזיקים lock לזמן קצר יותר, כך טוב יותר; הדבר היחיד שמותר לעשות בזמן שמחזיקים אותו הוא לקרוא ולכתוב את הנתונים שהוא מגן. כתיבת קוד שמבצע I/O בזמן שמחזיקים lock, או שקורא לקוד חיצוני דרך event או callback, לא רק מאריכה את זמן ההחזקה — היא פותחת נתיב שבו הקוד שנקרא מנסה לקחת lock אחר ונכנס ל-deadlock. להכין מחוץ ל-lock, ובתוכו לעשות רק את ההחלפה — זו הצורה הבסיסית.
שימו לב שאי אפשר לעשות await בתוך lock (זו שגיאת קומפילציה). זו הגנה, לא רק מגבלה: ל-Monitor יש thread affinity — ה-thread שלקח את ה-lock חייב להיות זה שמשחרר אותו — וזה אינו מתיישב עם קוד אסינכרוני שבו ה-thread המבצע יכול להשתנות משני צידי await. ל-mutual exclusion בקוד אסינכרוני, השתמשו ב-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. תמיד לרכוש כמה locks באותו סדר
כשיש שני locks או יותר, דפוס ה-deadlock הקלאסי הוא שסדר הרכישה מתהפך מ-thread ל-thread. התיקון פשוט: עשו לכלל שכל thread רוכש את ה-locks באותו סדר. במקום שאי אפשר להבטיח את הסדר, השתמשו ב-overload עם timeout של Monitor.TryEnter, ואם אי אפשר לקבל את ה-lock, נסוגו ונסו שוב (או רשמו את החריגה) — זה הופך hang שהיה נמשך לנצח לכישלון שניתן לגלות.4
5.4. Interlocked לעדכונים פשוטים, ReaderWriterLockSlim כשהקריאות שולטות
לעדכונים אטומיים של משתנה בודד — הגדלה או הקטנה של מונה, החלפת דגל — המחלקה Interlocked (Increment / Add / CompareExchange) מהירה מ-lock. בלי contention, זה יכול לעלות כמו קידומת הוראת CPU אחת.4 מצד שני, עד כאן מגיע Interlocked; הוא אינו יכול לשמור כמה משתנים עקביים יחד. מבנה lock-free שנכתב ביד בשילוב עם volatile הוא כלי למומחים שדורש הבנה עמוקה של מודל הזיכרון, וזה אינו דבר שצריך לכתוב ביישום עסקי.
לנתונים משותפים שבהם “הקריאות תכופות אבל הכתיבות נדירות”, יש גם את האפשרות של ReaderWriterLockSlim, שמוציא ל-exclusion רק כתיבות ומאפשר לקריאות לעבור במקביל.13
6. עיקרון 4: תכננו קודם איך זה נעצר
השאלה הראשונה לשאול בסקירת תכנון multithreading היא “איך זה נעצר”. אפשר לכתוב קוד שמתחיל לרוץ בלי לחשוב על זה, אבל קוד שנעצר בבטחה אינו בא לעולם אלא אם מתכננים אותו.
6.1. cooperative cancellation (CancellationToken) היא התשובה הנכונה היחידה
מודל העצירה של .NET מאוחד סביב cooperative cancellation. הצד שרוצה לעצור משהו יוצר CancellationTokenSource ומעביר את ה-Token שלו לכל פיסת עיבוד. כשהוא רוצה לעצור, הוא קורא ל-Cancel(). צד העיבוד עוקב אחרי ה-token, ובנקודה נוחה שהוא בוחר, מנקה ומסיים — כי זו שיתופיות ולא כפייה, צד העיבוד יכול להסתיים תוך שמירה על מצב עקבי לכל אורך הדרך.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) // לעקוב ב-polling
{
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 שנרשם על ה-token יכול לזרוק
catch (Exception ex) { cancelFailure = ex; } // להחזיק אותו ולדווח אחרי ה-join
try
{
try { await worker; } // תמיד לעשות join בלי קשר אם Cancel הצליח, ולצפות בכל כישלון באמצע
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{ } // להתייחס כ"תקין" רק ל-cancellation שאנחנו עצמנו ביקשנו
catch (Exception ex) when (cancelFailure is not null)
{
throw new AggregateException(cancelFailure, ex); // לא לאבד אף אחד משני הכשלונות
}
}
finally
{
cts.Dispose(); // לסלק את ה-source אחרי join (משחרר משאבי מערכת כמו WaitHandle).
if (ReferenceEquals(_cts, cts))
{
_cts = null; // לא לתת ל-StopAsync הבא להשתמש ב-source שכבר סולק
_worker = null;
}
}
if (cancelFailure is not null)
throw new AggregateException(cancelFailure);
}
שימו לב ש-Start / StopAsync האלה הם מבנה מינימלי שמניח שהוא נקרא בסדר מ-thread יחיד (ה-UI thread, למשל). אם כמה threads עלולים לתפעל את מחזור החיים בו-זמנית, בצעו את Start / StopAsync עצמם בטור עם משהו כמו SemaphoreSlim — זה יחטיא את המטרה אם פעולות ניהול מחזור החיים עצמן ייכנסו ל-race, עוד לפני שמגינים על ה-worker.
גם במדגם הקטן הזה בנויות כמה התאמות שמשתלמות בפועל. ראשית, Start דוחה קריאה כפולה בזמן שהוא רץ. דריסה לא מותנית של _cts ושל _worker מאבדת את ההפניה ל-worker הקודם, ומשאירה “thread יתום” שרץ בצד — אחד שאי אפשר לעצור ואי אפשר לעשות לו join. זה סטנדרטי שממשק מחזור חיים (Start/Stop) אוכף על עצמו “רק אחד בכל רגע”. מעבר לזה, שלוש נקודות נוספות. ראשונה, ממשק העצירה ממתין להשלמה. Cancel() רק “מבקש” cancellation; ברגע שהוא חוזר, ה-worker עדיין יכול להיות באמצע ProcessNextItem. עושים Stop() שמבקש וחוזר, ויוצרים race חדש שבו הקורא מתחיל את הניקוי שלו בזמן שה-worker עדיין רץ. שנייה, מחזיקים את ה-Task במקום לזרוק אותו. זורקים אותו ב-_ = Task.Run(...) ואף אחד לא ישים לב אם ה-worker מת מ-exception. שלישית, לוכדים את ה-token למשתנה מקומי לפני שמעבירים אותו, במקום להפנות ל-_cts.Token בתוך ה-lambda. הפניה בתוך ה-lambda פירושה שההערכה היא בזמן הביצוע, ואם Start מחדש קורה מיד אחרי עצירה, מקבלים בלבול שבו ה-worker הישן תופס את ה-token החדש. העברת אותו token גם כארגומנט השני ל-Task.Run פירושה שכשצד העיבוד מסתיים דרך ThrowIfCancellationRequested או דרך OperationCanceledException מ-API שמכיר cancellation, ה-Task מסווג כ”Cancelled” ולא כ”Faulted” (בדוגמה הזו, יציאה רגילה דרך תנאי הלולאה, כמו שמוצג, עדיין נספרת כהשלמה מוצלחת). עוד דבר: ה-catch ב-StopAsync משתמש במסנן when כדי לתפוס רק cancellation שמקורו ב-token שלו. בליעה לא מותנית של OperationCanceledException הייתה גורמת אפילו לכישלון אמיתי שנזרק מ-token אחר בתוך העיבוד — timeout לכל איבר, למשל — להיראות כמו “נעצר, אז הכל בסדר”. שימו לב שהזיהוי לפי התאמת token מתפרק אם WorkLoop משתמש בפנים בlinked token (הרכבת הקישור מסעיף 6.1), כי ה-exception שעף החוצה נושא את ה-token שבצד המקושר. במבנה הזה, בחרו במפורש, כהחלטת תכנון, או לקרוא ל-ct.ThrowIfCancellationRequested() ביציאה מ-WorkLoop כדי “לתרגם” בחזרה ל-token החיצוני לפני היציאה, או להרפות את המסנן ל-when (cts.IsCancellationRequested) ולקבל “cancellation בזמן שנתבקשה עצירה נספר כתקין”.
flowchart TB
accTitle: מבנה ה-cooperative cancellation
accDescr: מבנה ה-cooperative cancellation. הצד שעוצר רק קורא ל-Cancel(); כל פיסת עיבוד מחליטה בעצמה "מתי ואיך" היא מסתיימת. לכן אפשר לעצור תוך שמירה על מצב עקבי
OWNER["הצד שעוצר"] -->|"קורא ל-Cancel() פעם אחת"| CTS["CancellationTokenSource"]
CTS -->|"מוסר את ה-Token"| W1["עיבוד worker 1"]
CTS -->|"מוסר את ה-Token"| W2["עיבוד worker 2"]
CTS -->|"מוסר את ה-Token"| W3["API של ספרייה<br/>שמכיר cancellation"]
W1 -->|"בודק IsCancellationRequested<br/>מנקה ומסיים בעצמו"| E1["סיום תקין"]
W2 -->|"ThrowIfCancellationRequested"| E2["OperationCanceledException<br/>= מטופל כהשלמת cancellation"]
W3 -->|"מפסיק מיד גם באמצע המתנה"| E3["ה-cancellation הושלם"]
איור 6: מבנה ה-cooperative cancellation. הצד שעוצר רק קורא ל-Cancel(); כל פיסת עיבוד מחליטה בעצמה “מתי ואיך” היא מסתיימת. לכן אפשר לעצור תוך שמירה על מצב עקבי
יש גם מוסכמה קבועה בצד הספרייה. פעולה שניתנת ל-cancellation צריכה לספק מתודה ציבורית שמקבלת CancellationToken, ובתוך לולאת חישוב, או לבדוק IsCancellationRequested מעת לעת או לקרוא ל-ThrowIfCancellationRequested(). האחרון זורק OperationCanceledException, ש-Task מתייחס אליו כ”השלמת cancellation” ולא כ”כישלון”. כשרוצים לעצור גם מ-token שסופק מבחוץ וגם מדאגה פנימית (timeout, למשל), מרכיבים אותם ב-linked token.7
6.2. התייחסו ל-Thread.Abort כאילו אינו קיים
Thread.Abort — “להרוג מבחוץ thread שאינו מקשיב” — פשוט זורק PlatformNotSupportedException ב-.NET Core / .NET 5 ואילך; אי אפשר להשתמש בו יותר בכלל. זריקת exception לתוך thread בלי לדעת היכן הוא מבצע כרגע מסכנת הפרעה לניקוי משאבים והשחתת מצב. אם צריך לסיים בכוח קוד צד שלישי שאינו מגיב ל-cooperative cancellation (או שאי אפשר לשכתב כך שיגיב), ההנחיה הרשמית היא להריץ אותו ב-process נפרד ולעצור אותו ב-Process.Kill.8
6.3. כשממתינים, השתמשו ב-wait handle ולא ב-polling
כתיבת “לחכות בלולאת Sleep(100) עד שדגל עולה” מבזבזת גם CPU וגם היענות. יש primitives של סנכרון כמו ManualResetEventSlim ו-SemaphoreSlim לאיתות בין threads, שמכניסים thread לשינה נכון עד שהוא מסומן.13 הבחירה בין דיוק timer להמתנות לאירוע ב-Windows מכוסה בפירוט ב”למה Sleep(1) ב-Windows לא מדויק, ולמה עדיף event wait”.
7. המקרה המיוחד של ה-UI thread — הכלל ביישומי desktop של Windows
ליישומי desktop של Windows יש עוד אילוץ חזק מעל העקרונות הכלליים. החוק שרק ה-thread שיצר את ה-UI (ה-UI thread) רשאי לגעת בו.
controls של WinForms אינם thread-safe; תפעול שלהם מכמה threads דוחף control למצב לא עקבי וגורם ל-races, ל-deadlocks ולהקפאות. Windows דורש מיישום thread ייעודי אחד שמקבל הודעות מערכת, ויצירה ותפעול של ה-UI חייבים להתרכז באותו thread.9 ל-WPF יש בדיוק אותו מבנה: רק ה-UI thread יכול לשנות אלמנטי UI.10
כשרוצים לעדכן את ה-UI מ-thread אחר, אל תיגעו בו ישירות — הפכו זאת ל”בקשה ל-UI thread”.
flowchart LR
accTitle: המרת עדכון UI לבקשה
accDescr: להמיר עדכון UI ל"בקשה". עבודת ה-background thread נעצרת בהעלאת העבודה לתור ההודעות — תמיד ה-UI thread עצמו הוא שנוגע ב-control
OS["Windows<br/>עכבר, מקלדת, ציור מחדש"] --> Q["תור ההודעות<br/>של ה-UI thread"]
BG["background thread<br/>(עיבוד כבד, תקשורת)"] -->|"בקשה דרך Control.Invoke /<br/>Dispatcher.InvokeAsync"| Q
Q --> UI["UI thread<br/>ה-thread היחיד שרשאי לגעת ב-controls"]
BG -.->|"נגיעה ישירה ב-control"| NG["אסור<br/>גורם ל-race, deadlock, הקפאה"]
איור 7: להמיר עדכון UI ל”בקשה”. עבודת ה-background thread נעצרת בהעלאת העבודה לתור ההודעות — תמיד ה-UI thread עצמו הוא שנוגע ב-control
| מסגרת | אמצעי הבקשה |
|---|---|
| WinForms | Control.Invoke (סינכרוני) / Control.BeginInvoke (אסינכרוני) / מ-.NET 9 ואילך, Control.InvokeAsync9 |
| WPF | Dispatcher.Invoke (סינכרוני) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (אסינכרוני)10 |
מבין אלה, הצורות הסינכרוניות (Control.Invoke / Dispatcher.Invoke) דורשות זהירות. אם ה-UI thread ממתין באופן סינכרוני שאותו worker יסתיים, וה-worker קורא ל-Invoke, מקבלים deadlock שבו כל אחד מחכה לשני (בדיוק ההמתנה המעגלית מסעיף 2). עשו את הצורות האסינכרוניות (BeginInvoke / InvokeAsync) לברירת המחדל להודעות ולדיווחי התקדמות מ-background thread, והגבילו את הצורות הסינכרוניות למצבים שבהם אפשר להיות בטוחים שה-UI thread אינו ממתין לכם.
בפועל יש תשובה טובה עוד יותר. כותבים עיבוד שהתחיל על ה-UI thread ב-async/await, ו-await לוכד את SynchronizationContext של ה-UI thread ומחדש אוטומטית את ההמשך על ה-UI thread, מה שמקטין מאוד את מספר המקומות שבהם צריך לכתוב Invoke ביד בכלל. זו, עם זאת, אינה תכונה לא מותנית. קוד שנכנס מ-callback ברקע, או המשך אחרי ConfigureAwait(false), אינו חוזר ל-UI thread, לכן dispatch מפורש עדיין נחוץ אם נוגעים ב-UI בנתיב הזה. להתייצב על הצורה “עבודה כבדה הולכת ל-Task.Run או ל-I/O אסינכרוני, והשתקפות התוצאה על המסך קורה בהמשך אחרי await” היא הצורה הבסיסית של יישום Windows מודרני. הקשר בין ה-UI thread ל-async/await מסוכם בתרשים אחד ב”async/await וה-UI thread ב-WPF וב-WinForms”.
גם, כש-COM מעורב — שילוב Office, רכיבים ישנים וכן הלאה — מתווספת שכבה נוספת: מודל ה-threading של COM עצמו (STA/MTA). תקריות כמו “יצרנו את אובייקט ה-COM על ה-UI thread אבל קראנו לו מ-thread אחר והוא נתקע” שייכות לשכבה הזו, ומפורטות ב”STA ו-MTA ב-COM — threading model, ואיך נמנעים מ-hang”.
8. כשכותבים native (C++/C)
העקרונות עד כאן — לא ליצור threads ישירות, לצמצם shared mutable state, משמעת locks, לתכנן איך עוצרים — חלים ישירות גם על קוד native. מה שמשתנה הוא כלי העבודה. ב-C++, המקבילים הם RAII יחד עם std::jthread / std::mutex / std::atomic; ב-C, הם _beginthreadex של Win32 API, SRW locks, condition variables, ודפוס stop event. כל אחד מכוסה, כולל מלכודות ייחודיות לשפה (ה-destructor של std::thread, הסכנות של TerminateThread, DllMain ו-loader lock, ועוד), ב”מהדורת C++” וב”מהדורת C” של הסדרה הזו.
9. אימות ו-debugging — להתכונן מתוך הנחה שזה לא ישתחזר
אי אפשר לצפות שבאגים של multithreading יימצאו בבדיקות. בדיקת יחידה רגילה סופרת הרצה שבה ה-race פשוט לא קרה כהצלחה. חשבו את ההכנה בשלוש שכבות.
קו ההגנה הראשון הוא עקרונות התכנון שכוסו עד כאן, בדיוק כפי שהם. בין יישום עם חמש פיסות shared mutable state ליישום עם חמישים, מספר המקומות שצריך לחשוד בהם שונה פי עשרה. בסקירה, בדקו בטבלה: “אילו נתונים משתנים משותפים”, “איזה lock מגן על כל אחד”, “האם סדר רכישת ה-locks ייחודי”, ו”איפה נתיבי העצירה”. תכנון שאי אפשר לכתוב את הטבלה הזו עבורו אינו גמור עדיין, גם אם הוא רץ.
שנית, עשו exceptions ניתנות לצפייה במקום להסתיר אותן. גלו חריגות המתנה ל-lock עם timeout של Monitor.TryEnter ורשמו אותן,4 רשמו במקום לבלוע exceptions לא נצפות מעבודה שנזרקה ל-thread pool, והיו ערוכים ללכוד dump מלא ב-hang כדי שתוכלו לבדוק את ה-stack של כל thread — המאבק בבאג ש”קורה רק מדי פעם” מוכרע לפי כמה מידע אפשר לחלץ מהפעם האחת שזה כן קורה. הקמת dump ורישום מכוסה ב”תכנון שמירת יומנים ו-dump בקריסת יישום Windows”.
שלישית, נערו תחת עומס. מבחן לחץ שמקל לפגוע בצירוף ביש-מזל במכונת פיתוח — ריצה עם מקביליות גדולה ממספר הליבות לזמן ארוך, ערבוב סדר עיבוד, הזרקת השהיות מלאכותיות וכן הלאה — הוא דרך מציאותית לשלוף races לפני המשלוח. באג שנעלם תחת debugger לעיתים קרובות משתחזר תחת גרסת release ועוד עומס כבד.
10. סיכום — checklist לפני שמוסיפים threads
בתמצית, שיטת העבודה המומלצת ל-multithreading אינה “המיומנות לכתוב סנכרון נכון” אלא “תכנון שמאפשר להימנע מלכתוב סנכרון בכלל”. אם אפשר לענות על שמונת השאלות הבאות לפני שמתחילים, אפשר למנוע כמעט כל תקרית גדולה.
- העבודה הזו CPU-bound או I/O-bound (אם האחרון, התשובה היא async/await, לא thread)?
- אתם עומדים לכתוב
new Thread(אפשר לבטא זאת ב-Task, ב-Parallel, או ב-thread pool במקום)? - אילו נתונים משתנים משותפים בין threads — אפשר למנות אותם?
- אפשר לבטל את השיתוף הזה דרך פיצול, immutability, או העברה דרך תור?
- לכל פיסת נתונים משותפים שנשארה, יש בדיוק lock אחד מתאים שהוחלט עליו?
- סדר רכישת ה-locks ייחודי בכל ה-threads, ואתם נמנעים מקריאות חיצוניות בזמן שמחזיקים lock?
CancellationTokenמועבר לכל פעולה ארוכה, ואפשר להסביר את נתיב העצירה?- קוד שנוגע ב-UI מרוכז ב-UI thread?
באגים של multithreading אינם מופיעים ביום שבו כותבים את הקוד — הם מתגלים אצל לקוח הרבה אחרי ששכחתם אותם. להפך, אם עוברים על ה-checklist הזו בשלב התכנון, אפשר לתלוש את סוג התקלה היקר ביותר — “קורס מדי פעם”, “נתקע פעם בחודש” — לפני שכותבים שורת קוד.
מאמרים קשורים
- שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת C++
- שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת C
- Best practices לריבוי threads בפועל: Java
- async/await ב-C#: טבלת החלטה ל-Task.Run ו-ConfigureAwait
- async/await וה-UI thread ב-WPF וב-WinForms
- מעמקי ה-I/O של Windows (חלק 3) — I/O Completion Ports (IOCP) וה-thread pool של .NET: המרתף מתחת ל-async/await
- STA ו-MTA ב-COM — threading model, ואיך נמנעים מ-hang
- למה Sleep(1) ב-Windows לא מדויק, ולמה עדיף event wait
- מלכודות של shared memory ו-best practices מעשיים
תחומי ייעוץ קשורים
KomuraSoft LLC מטפלת בסקירות תכנון של יישומים עסקיים שכוללים multithreading, בחקירת שורש של תקלות קשות לשחזור כמו “קורס/נתקע מדי פעם” — ניתוח dump ואיתור races — ובייעוץ טכני על הקבלה או הפיכה לאסינכרוני של יישומים קיימים. נשמח להיכנס כבר בשלב “בדקו בבקשה אם התכנון הזה יכול לרוץ”.
מקורות
-
Microsoft Learn, Task Parallel Library (TPL). על כך שה-TPL הוא האמצעי המומלץ לקוד multithreaded ומקבילי מאז .NET Framework 4; על כך שהוא מכוונן דינמית את דרגת המקביליות לפי ה-processors הזמינים; על כך שהוא לוקח על עצמו את חלוקת העבודה, ה-scheduling ל-thread pool, טיפול ב-cancellation וניהול מצב; על כך שלולאה שעבודתה לכל iteration קטנה יכולה להאט מ-overhead של הקבלה; ועל כך שעדיין מומלצת הבנה בסיסית של locks, deadlocks ו-race conditions גם כשמשתמשים ב-TPL. ↩ ↩2
-
Microsoft Learn, The managed thread pool. על כך שמחלקת ThreadPool מספקת pool של worker threads בניהול המערכת, ומאפשרת למפתחים להתמקד במשימות היישום במקום בניהול threads; ועל כך ש-.NET משתמש ב-thread pool בהרחבה לפעולות TPL, השלמת I/O אסינכרוני, callbacks של timer, המתנות רשומות, חיבורי socket ועוד. ↩ ↩2
-
Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. על כך שלולאה מקבילית לפעמים איטית מסדרתית ותמיד צריך למדוד; על הימנעות מכתיבות לזיכרון משותף בתוך לולאה מקבילית, עם המלצה על overload שמקבל מצב thread-local; ועל כך שאין ערובה שכל iteration של For/ForEach באמת רצה במקביל, כך שקוד שממתין בין iterations יכול להיכנס ל-deadlock. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Managed threading best practices. על הגדרות race condition (דוגמה שבה הגדלת מונה מתפרקת לקריאה, חיבור וכתיבה חזרה, ונדרסת ואובדת) ו-deadlock; על שימוש ב-cooperative cancellation במקום Thread.Abort; על כך שאסור להשתמש בטיפוס או ב-
thisכאובייקט lock, ושמ-.NET 9 / C# 13 ואילך יש להשתמש ב-instance ייעודי שלSystem.Threading.Lock; על כך שמשפט ה-lockשל C# מבטיחMonitor.Exitבבלוקfinally; על גילוי deadlocks עם timeout שלMonitor.TryEnter; על כך שהמחלקהInterlockedמהירה יותר לשינויי מצב פשוטים; ועל הנחיית התכנון שנתונים סטטיים צריכים להיות thread-safe כברירת מחדל ונתוני instance לא צריכים להיות thread-safe כברירת מחדל. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 -
Microsoft Learn, System.Threading.Channels library. על כך ש-channel הוא FIFO למודל producer/consumer שמנהל סנכרון בפנים; על כך שאפשר ליצור channel עם מגבלת קיבולת ב-CreateBounded; על כך שהתנהגות ברירת המחדל בהגעה לתקרה היא שהכותב ממתין, עם FullModes אחרים כמו DropOldest שגם הם ניתנים לבחירה; ועל כך שמופעל backpressure כשהכתיבה עוקפת את הקריאה. ↩ ↩2 ↩3
-
Microsoft Learn, Thread-safe collections. על כך שהאוספים תחת System.Collections.Concurrent משיגים thread-safety דרך fine-grained locking או מנגנונים lock-free; ועל כך ש-ConcurrentQueue ו-ConcurrentStack ממומשים בלי locks, בפעולות Interlocked, כך שהם מחזיקים מעמד תחת הוספה והסרה תכופות מכמה threads. ↩ ↩2
-
Microsoft Learn, Cancellation in Managed Threads. על הליך ה-cooperative cancellation באמצעות CancellationTokenSource ו-CancellationToken; על כך ש-cancellation הוא שיתופי ולא כפוי, והמאזין מחליט איך לעצור; על שלושת גישות המעקב של polling, רישום callback, ו-wait handles; על כך ש-ThrowIfCancellationRequested זורק OperationCanceledException, ש-Task מתייחס אליו כהשלמת cancellation; על הרכבת כמה tokens ב-linked token; ועל כך שספרייה צריכה לספק מתודות ציבוריות שמקבלות CancellationToken. ↩ ↩2 ↩3
-
Microsoft Learn, Using threads and threading. על כך ש-CancellationToken הוא הדרך הנכונה לעצור thread; על כך ש-Thread.Abort זורק PlatformNotSupportedException ב-.NET Core וב-.NET 5 ואילך, עם אזהרת deprecation בזמן קומפילציה (SYSLIB0006) מ-.NET 5 ואילך גם כן; ועל כך שסיום בכוח של קוד צד שלישי שאינו מגיב ל-cooperative cancellation דורש להריץ אותו ב-process נפרד ולעצור אותו ב-Process.Kill. ↩ ↩2
-
Microsoft Learn, How to handle cross-thread operations with controls. על כך שגישה ל-controls של WinForms אינה thread-safe, ותפעול מכמה threads מוביל למצב לא עקבי, ל-races, ל-deadlocks ולהקפאות; על כך שכל control צריך להיווצר ולהיות נגיש באותו thread, ו-Windows דורש UI thread ייעודי למסירת הודעות מערכת; ועל קריאה בטוחה מ-thread אחר באמצעות Control.Invoke, Control.InvokeAsync מ-.NET 9 ואילך, או BackgroundWorker. ↩ ↩2 ↩3
-
Microsoft Learn, Threading model (WPF). על כך ששינויי UI ב-WPF מוגבלים ל-thread יחיד, ו-background thread רושם work items אצל ה-Dispatcher של ה-UI thread כדי לבקש אותם; על כך ש-Dispatcher.Invoke סינכרוני בעוד InvokeAsync ו-BeginInvoke אסינכרוניים; ועל כך שה-Dispatcher מעבד עבודה כתור עדיפויות. ↩ ↩2 ↩3
-
Microsoft Learn, Data Parallelism (Task Parallel Library). על כך ש-
Parallel.For/Parallel.ForEachמספקים data parallelism כמעט באותה תחושה של כתיבת לולאת for; על כך שאין צורך ליצור threads או להכניס work items לתור, ואין צורך ב-locks בלולאה בסיסית; ועל כך שה-TPL מפצל את מקור הנתונים בין כמה threads ומאזן מחדש את העומס אם הוא נעשה לא אחיד. ↩ -
Microsoft Learn, BlockingCollection<T> Class. על כך ש-BlockingCollection הוא מימוש producer/consumer עם חסימה ומגבלות קיבולת; על כך שמגבלת הקיבולת מונעת מה-producer להתרחק יותר מדי מה-consumer; ועל כך שהוא לא תוכנן לגישה אסינכרונית, עם המלצה על Channel<T> ל-producer/consumer אסינכרוני. ↩ ↩2
-
Microsoft Learn, Overview of synchronization primitives. על כך ש-Monitor מספק mutual exclusion דרך אובייקט lock ויש לו thread affinity; על כך שמצופה מקוד C# להשתמש במשפט
lockבמקום ב-Monitor ישירות; על כך ש-ReaderWriterLockSlim מוציא כתיבות ל-exclusion ומאפשר קריאות מקביליות; ועל כך ש-SemaphoreSlim הוא semaphore קל לשימוש בתוך process יחיד, בעוד Semaphore הוא בעל שם ואפשר להשתמש בו לסנכרון בין processes. ↩ ↩2 ↩3 -
Microsoft Learn, Async semaphores, locks, and reader/writer coordination. על כך שלמשפט ה-
lockשל C# ולטיפוסLockיש thread affinity ולכן אי אפשר להשתמש בהם מעבר ל-await(כי ה-thread שמבצע את ההמשך יכול להשתנות לפני ואחרי ה-await); על שימוש ב-SemaphoreSlimעם ספירה 1, דרךWaitAsyncו-Releaseב-finally, ל-mutual exclusion בקוד אסינכרוני; ועל כך ש-Channelbounded הוא חלופה למטרות throttling. ↩
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת C — כתיבה בטוחה לפי Win32 API
הגישה המבוססת ל-multithreading ב-C מול Win32 היא יצירת thread עם _beginthreadex, SRW lock ו-condition variable, Interlocked, ו-stop event...
שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת C++ — לבטל תאונות במבנה עם RAII ו-jthread
ב-C++, multithreading הוא עולם שבו data race הוא undefined behavior. המאמר עובר על מלכודת ה-destructor של std::thread, תכנון עצירה עם jth...
Spurious wakeup — למה condition variable מתעורר בלי notify, ואיך לחכות נכון ב-Windows
Wait של condition variable יכול להתעורר בלי notify (spurious wakeup). למה Windows מתיר זאת, וצורת ה-wait הנכונה עם while ו-predicate ב-Wi...
Best practices לריבוי threads בפועל: Java — המוסכמות בעידן ה-virtual threads
ב-Java הנוהג המבוסס ב-multithreading הוא לא ליצור threads ישירות אלא לבנות על ExecutorService ועל virtual threads. המאמר מציג את העקרונות...
מעמקי ה-I/O של Windows (חלק 1) — כל קריאה וכתיבה הופכת ל-IRP: התמונה הכוללת של מערכת ה-I/O
חלק 1 בסדרה על ה-I/O של Windows מהיסוד: מרחב השמות של ה-Object Manager, ה-driver ואובייקטי ה-device וה-file, מחזור החיים של IRP, ומה Clos...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
יישומים עסקיים, חיבור התקנים וכלי תקשורת, מהגדרת הדרישות ועד הפיתוח.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- למה להימנע מ-lock(this) או מ-lock(typeof(MyClass))?
- כי האובייקט שעליו נועלים גלוי גם לקוד שמחוץ לשלכם. this הוא ה-instance עצמו, כך שכל קוד חיצוני שיכול להפנות לאותו instance יכול לנעול על אותו אובייקט ולגרום ל-contention לא מכוון או ל-deadlock. typeof(MyClass) מסוכן עוד יותר: יש רק אובייקט Type אחד לכל application domain, ולכן אתם חולקים את ה-lock עם קוד שאין לו שום קשר אליכם. השתמשו באובייקט ייעודי שלעולם אינו נחשף החוצה כיעד הנעילה. מ-.NET 9 / C# 13 ואילך ההמלצה היא להשתמש ב-instance של הטיפוס הייעודי System.Threading.Lock כאובייקט ה-lock.
- כמה threads מותר ליצור? מה מספר ה-threads האופטימלי?
- «אל תחליטו בעצמכם על מספר ה-threads» היא התשובה המודרנית. השתמשו ב-Task ובמחלקה Parallel, וה-thread pool מכוונן אוטומטית את דרגת המקביליות לפי מספר ליבות ה-CPU והעומס הנוכחי. תכנון שקורא שוב ושוב ל-new Thread ביד נוטה לספק יותר מדי או פחות מדי במכונות לקוח עם מספר ליבות אחר. מה שצריך לשים לב אליו אינו המספר אלא סוג העבודה: חישוב שממצה את ה-CPU אינו מאיץ כשמקבילים מעבר למספר הליבות, ועיבוד שממתין בעיקר ל-I/O כלל לא צריך threads נוספים — הצעד הנכון שם הוא I/O אסינכרוני עם async/await.
- האם הוספת volatile הופכת משהו ל-thread-safe?
- לא. מה ש-volatile מבטיח הוא סדר — שגישה לשדה הזה אינה מסודרת מחדש ביחס לפעולות זיכרון סובבות (סמנטיקת acquire/release) — לא אטומיות של פעולה מורכבת כמו «קרא, חשב, כתוב בחזרה». לדוגמה, גם אם כמה threads עושים ++ על מונה volatile int, הגדלות עדיין הולכות לאיבוד. השתמשו במחלקה Interlocked להגדלה/הקטנה של מונה או ל-compare-and-swap, והשתמשו ב-lock כשצריך להגן על כמה משתנים יחד כקבוצה. volatile ראוי לשקול כמעט רק במקרה הפשוט של דגל עצירה — thread אחד כותב והאחרים רק קוראים — וגם את הדגל הזה נהוג כיום לבטא כ-CancellationToken.
- איך לדעת אם באג שקורה רק מדי פעם נגרם מ-multithreading?
- שלושת הסימנים שכדאי לחשוד בהם הם: «אותה פעולה משתחזרת לפעמים ולא בפעמים אחרות», «השחזור נעלם ברגע שמחברים debugger או מוסיפים לוג», ו«זה קורה רק תחת עומס כבד או מיד אחרי ההפעלה». באג תלוי-תזמון מאופיין בכך שהתוצאה משתנה בכל הרצה — זו בדיוק הגדרת race condition. כדי לצמצם, קודם מנו כל פיסת נתונים משתנים שאתם חולקים, ולכל אחת רשמו בטבלה איזה lock מגן עליה. אפילו גישה אחת לא מוגנת היא חשודה. ל-hang, לכדו את ה-stacks של כל thread ובדקו אם הן יוצרות מעגל של המתנה ל-locks זה של זה. עצרו ב-debugger של Visual Studio והסתכלו על Parallel Stacks, או, ב-production, לכדו dump ונתחו אותו.