פרקטיקות מומלצות לריבוי תהליכונים: מהדורת C — כתיבה בטוחה בדרך של Win32 API
· Go Komura · Windows, ריבוי תהליכונים, C, Win32 API, יישומים עסקיים, חקירת תקלות, תכנון
«אני כותב תהליך תושב לבקרת ציוד ב-C.» «נאלצנו להוסיף תהליכונים ליישום C בן עשרים שנה.» «אנחנו עוצרים תהליכונים ב-TerminateThread, אבל מדי פעם כל התהליך נתקע.» — ריבוי תהליכונים ב-C הוא העולם שבו השפה עוזרת הכי פחות. אין חריגות, אין RAII, אין תבניות; נכונות הסנכרון מונחת כולה על אילו ממשקים אתם בוחרים ועל כמה ממושמעים אתם בקריאה להם.
מאמר זה הוא מהדורת C של סדרת ריבוי התהליכונים המעשית. מיועד למפתחים שכותבים C מול Win32 API, הוא לוקח את עקרונות עיצוב ריבוי התהליכונים — להפסיק לייצר תהליכונים בהמוניהם, לצמצם מצב משתנה משותף, לשמור משמעת מנעולים, ולעצב איך עוצרים לפני שעוצבים איך מתחילים — וממפה אותם לכלי Win32: איך ליצור תהליכונים (_beginthreadex), איך לבחור אובייקטי סנכרון, איך לעצב נתיב עצירה בלי TerminateThread, ואילוצי DllMain, מאורגנים סביב מקורות ראשוניים נכון לאוגוסט 2026. נכתב כדי להיקרא לבדו. אותם עקרונות, מפורטים לשפות אחרות, מופיעים גם במהדורת .NET, במהדורת C++ ובמהדורת Java.
1. השורה התחתונה קודם
- צרו תהליכונים ב-
_beginthreadex, לא ב-CreateThread. אם תהליכון שקורא ל-CRT נוצר ב-CreateThread, ה-CRT יכול לסיים את התהליך כשהזיכרון דל.12 - המנעול כברירת מחדל בתוך תהליך הוא מנעול SRW; השתמשו ב-CRITICAL_SECTION רק כשצריך רכישה רקורסיבית. שימוש ב-Mutex להדרה בתוך תהליך הוא «טעות נפוצה» שתמיד כרוכה במעבר למצב ליבה.3
- עדכנו משתנים בודדים במשפחת Interlocked.
volatileלא מבטיח לא אטומיות ולא סדר. רוב פונקציות Interlocked נושאות מחסום זיכרון מלא.4 - המתינו במשתנה תנאי (משפחת
SleepConditionVariableCS) או באירוע ועוד פונקציית המתנה. לולאת סקר מבוססתSleepמבזבזת גם מעבד וגם היענות.5 - לעולם אל תשתמשו ב-
TerminateThread. זו פונקציה מסוכנת שמשבשת מנעולים, ערימה ומצב DLL, והיא יעד אזהרת ניתוח הקוד C6258. עצבו עצירה ככיבוי שיתופי: אירוע עצירה ועודWaitForMultipleObjects.67 - העבירו עבודות קצרות למאגר התהליכונים של Windows (
CreateThreadpoolWork) במקום לבצע אותן במקביל בתהליכונים שלכם. לעולם אל תסיימו תהליכוני מאגר ב-ExitThread/TerminateThread.89 - אל תיצרו תהליכונים, אל תסנכרנו ואל תמתינו לסיום תהליכונים בתוך DllMain. היא נקראת כשמנעול הטוען מוחזק, והופכת לחממה לקיפאונות.10
<threads.h>של C11 שמיש מ-VS 2022 17.8 ואילך, אבל<stdatomic.h>עדיין ניסיוני. לבסיס קוד ל-Windows בלבד, גישת Win32 היא הבחירה המציאותית.11
2. למה ריבוי תהליכונים קשה? — תנאי מרוץ וקיפאונות
תמצתו את הבעיות שריבוי תהליכונים מכניס, בלי קשר לשפה, ונשארים שני סוגים.
תנאי מרוץ (race condition) הוא באג שבו התוצאה משתנה לפי הסדר שבו כמה תהליכונים מגיעים לקטע קוד. הדוגמה הקלאסית היא מונה משותף: הביטוי הבודד count++ מתפרק ברמת קוד מכונה לשלושה שלבים — קריאה, חיבור, כתיבה בחזרה. אם שני תהליכונים נכנסים לשלושת השלבים האלה באותו רגע, החיבור של אחד נדרס ואובד כשהשני כותב בחזרה.4 התוצאה משתנה מהרצה להרצה, ואיזו תוצאה תקבלו אינה ניתנת לחיזוי.
sequenceDiagram
participant A as תהליכון A
participant M as משתנה משותף count
participant B as תהליכון 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/>ההגדלה של תהליכון A אבדה
איור 1: תנאי המרוץ הקלאסי שבו מונה משותף מאבד הגדלה. אם תהליכון אחר משתלב בשלושת שלבי count++, הכתיבה בחזרה המאוחרת יותר דורסת את האחרת
קיפאון (deadlock) הוא מצב שבו שני תהליכונים ממתינים כל אחד למנעול שהשני מחזיק, ואף אחד לא יכול להתקדם. תהליכון A מחזיק מנעול 1 וממתין למנעול 2; תהליכון B מחזיק מנעול 2 וממתין למנעול 1 — די בזה כדי ששניהם ייעצרו לנצח.
flowchart LR
A["תהליכון A<br/>מחזיק מנעול 1"] -->|"ממתין לשחרור מנעול 2"| B["תהליכון B<br/>מחזיק מנעול 2"]
B -->|"ממתין לשחרור מנעול 1"| A
איור 2: המתנה מעגלית של קיפאון. ברגע שחצי ההמתנה יוצרים טבעת, כל תהליכון בטבעת ההיא נעצר לנצח
שניהם תלויי תזמון: צירוף סדר ביצוע שמופיע פעם בעשרות אלפי הרצות במכונת פיתוח יכול לקרות כל יום במכונת לקוח עם מספר ליבות ותזמון שונים. «לא משתחזר כשמחברים מנפה שגיאות» קורה כי עצם התצפית משנה את התזמון — התנהגות טיפוסית של באגי מרוץ. לכן כל עיקרון במאמר מצביע לכיוון אחד: לצמצם את המקומות שצריכים סנכרון, לפני שדואגים לסנכרן נכון.
2.1. הנחות ייחודיות ל-C — השפה לא מגנה עליכם משום דבר
ב-C, כי לשפה אין מנגנון לאכוף את העקרונות האלה, צריך לכתוב אותם במפורש כמשמעת.
ראשית, בנו את ערבות השחרור לתוך המבנה. בלי מקבילה ל-RAII של C++, שחרור מנעולים וקריאה ל-CloseHandle על ידיות צריכים להיות מוגנים בדפוס goto cleanup שמעביר כל יציאה מהפונקציה במקום אחד, או במוסכמת קידוד שמצמידה כל רכישה לשחרור. הוספת return מוקדם ודליפת מנעול כתוצאה היא תאונה קלאסית של C.
שנית, התייחסו למרוצי נתונים כמו ב-C++. קריאה או כתיבה פשוטה של משתנה 32 סיביות מיושר כראוי היא אטומית ב-Windows, אבל שום דבר מעבר — משתני 64 סיביות ב-Windows של 32 סיביות, פעולות מורכבות או עקביות בין כמה משתנים — אינו מובטח כלל.12 קוד ש«במקרה עובד» נשבר ברגע שהמהדר או רמת האופטימיזציה משתנים.
שלישית, החליטו בעלות. תרבות לפרט, בהערה לפונקציה, «איזה תהליכון כותב את המאגר הזה, ומאימתי הוא שייך למי» משתלמת בריבוי תהליכונים ב-C לא פחות מבחירת פרימיטיב הסנכרון.
3. איך ליצור תהליכונים — _beginthreadex, ושום דבר אחר
3.1. למה CreateThread היא הבחירה הלא נכונה
ה-API הילידי של Win32 הוא CreateThread, אבל ההנחיה הרשמית היא שכל תהליכון שקורא לפונקציות CRT (זמן ריצה של C) חייב להיווצר ב-_beginthreadex. _beginthreadex מאתחל את הנתונים הפנימיים לכל תהליכון שה-CRT זקוק להם לפני שהוא מתחיל את התהליכון. אם תהליכון שנוצר ב-CreateThread קורא לפונקציית CRT, ה-CRT יכול לסיים את התהליך בתנאי זיכרון נמוך.12 כי printf, malloc ו-strtok כולן פונקציות CRT, הכלל המעשי: «תהליכונים שנכתבים ב-C תמיד משתמשים ב-_beginthreadex».
הימנעו גם מ-_beginthread (בלי ex). יש בו מלכודת: אם התהליכון שהוא יוצר מסתיים מוקדם, הידית המוחזרת כבר יכולה להיות לא תקפה — או אפילו להצביע לתהליכון אחר — בעוד _beginthreadex, שאפשר להעביר את הידית שלו בבטחה לממשקי סנכרון, היא הבחירה הבטוחה יותר. הקורא סוגר את הידית ש-_beginthreadex מחזיר ב-CloseHandle.13
#include <process.h>
static unsigned __stdcall WorkerMain(void* arg)
{
WorkerContext* ctx = (WorkerContext*)arg;
/* ... לולאת ההמתנה מפרקים 5 ו-6 ... */
return 0;
}
HANDLE hThread = (HANDLE)_beginthreadex(
NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* טיפול בכשל */ }
/* ...אחרי בקשת העצירה... */
WaitForSingleObject(hThread, INFINITE); /* הצטרפות */
CloseHandle(hThread);
3.2. עבודות קצרות הולכות למאגר התהליכונים של Windows
אם אתם רוצים «לזרוק מספר גדול של עבודות קטנות על משהו» או מוצאים את עצמכם «יוצרים והורסים תהליכונים קצרי חיים שוב ושוב», השתמשו במאגר התהליכונים של Windows (ממשק המאגר הזמין מוויסטה ואילך) במקום בתהליכונים שלכם. צרו אובייקט עבודה ב-CreateThreadpoolWork והגישו ב-SubmitThreadpoolWork, ותהליכוני העובד של המאגר מבצעים את הקריאה החוזרת במקביל.8 ניהול מספר התהליכונים נשאר למערכת ההפעלה, ועלות היצירה וההריסה נעלמת. זו תשובת C לעיקרון «אל תיצרו תהליכונים משלכם».
המשמעת לשימוש במאגר גם היא מתועדת רשמית: לעולם אל תסיימו תהליכון מאגר ב-TerminateThread / ExitThread; שחזרו כל מצב ששיניתם בתוך קריאה חוזרת (TLS, עדיפות תהליכון וכן הלאה) לפני שחוזרים; והשאירו ידיות המתנה בחיים עד שהמאגר סיים איתן.9 הערה מעשית נוספת: המאגר מגביל רק את מספר תהליכוני העובד — מספר הקריאות החוזרות שטרם בוצעו שתורות דרך SubmitThreadpoolWork יכול להיערם בלי גבול. בתצורה ארוכת ריצה שבה ההגשות ממשיכות להקדים את העיבוד, שימו מגביל כניסה כמו סמפור, או תור בעל קיבולת, בצד היישום, כך שצד המגיש ממתין או נדחה כשהוא מלא (זה לחץ נגדי כדי שעומס יתר לא יהפוך לבעיית זיכרון, ואותו עיקרון כמו עיצוב התור בפרק 5).
4. צמצמו מצב משתנה משותף — חלוקה, קריאה בלבד והעברה
מרוץ קורה רק כש«כמה תהליכונים» ו«נתונים משתנים משותפים» שניהם נוכחים. לפני שבוחרים פרימיטיב סנכרון (הפרק הבא), חשבו אם אפשר לצמצם את השיתוף מלכתחילה. יש שלוש משפחות טכניקה.
חלקו. לצבירה מקבילית, במקום שכל תהליכון יכתוב למונה משותף, בנו סיכום ביניים במשתנה מקומי לכל תהליכון (או מאגר שהוקצה לכל תהליכון), ומזגו אותם פעם אחת בסוף במשהו כמו InterlockedAdd. כתיבות למצב משותף יורדות מ«כל איטרציה» ל«פעם לכל תהליכון», וגם עלות הסנכרון וגם חלון התחרות מצטמצמים בסדרי גודל. משמעת הבעלות מפרק 2.1 — «לאיזה תהליכון שייך המאגר הזה» — הופכת לתרשים איך מחלקים.
הפכו לקריאה בלבד. תצורה וטבלאות שנבנות בהפעלה ולא משתנות אחר כך בטוחות לקריאה מכל מספר תהליכונים אחרי שסתיימה האתחול. או סיימו את כל האתחול לפני שתהליכונים מתחילים, או, אם נדרש אתחול עציל, השתמשו באתחול חד-פעמי של Win32 (InitOnceExecuteOnce), והפכו את הגבול — «מאימתי זה הופך לקריאה בלבד» — למפורש בקוד.3
העבירו. במקום ששני הצדדים ייגעו במשתנה משותף, נתבו את זרימת הנתונים בין תהליכונים דרך תור יצרן/צרכן. ב-C, המימוש הוא בדיוק דפוס משתנה התנאי מפרק 5 (מאגר מעגלי בעל קיבולת ועוד SleepConditionVariableCS), שהוא הדוגמה הרשמית המעובדת; מאגר עם גבול קיבולת גם נותן לחץ נגדי טבעי — הייצור ממתין ברגע שהוא מקדים את הצריכה.5
5. בחירת אובייקטי סנכרון ומשמעת מנעולים
ל-Win32 יש סוגים רבים של פרימיטיבי סנכרון, ובחירה שגויה עולה לכם גם בביצועים וגם בנכונות. הנה ההנחיה הרשמית מסוכמת בתמונה אחת.3
flowchart TB
S{"צריך סנכרון<br/>בין תהליכים?"} -->|"כן"| Q2{"לשם מה?"}
Q2 -->|"הדרה הדדית"| MTX["Mutex בעל שם"]
Q2 -->|"הגבלת מספר גישות במקביל"| SEM["סמפור בעל שם"]
Q2 -->|"הודעת אירוע"| EVT["אירוע בעל שם"]
S -->|"לא - בתוך התהליך"| Q3{"צריך רכישה רקורסיבית<br/>מאותו תהליכון?"}
Q3 -->|"כן"| CS["CRITICAL_SECTION"]
Q3 -->|"לא"| Q4{"קוד C++ נייד<br/>בעדיפות?"}
Q4 -->|"כן"| STD["std::mutex /<br/>std::shared_mutex"]
Q4 -->|"לא"| SRW["מנעול SRW - בחירת ברירת המחדל"]
איור 3: איך לבחור פרימיטיב סנכרון Win32. הענף הראשון הוא «האם זה חוצה תהליכים» — הנקודה היא לא לשלוח יד לאובייקט ליבה (Mutex) כשזה לא
| פרימיטיב | היקף | מאפיינים | איפה להשתמש |
|---|---|---|---|
| מנעול SRW | בתוך התהליך | מהיר (בדרך כלל נשאר כולו במצב משתמש), בגודל מצביע, לא רקורסיבי | ברירת המחדל לקוד חדש. AcquireSRWLockShared גם מאפשר גישת קריאה משותפת |
| CRITICAL_SECTION | בתוך התהליך | מהיר (מסתובב, ואז נופל להמתנת ליבה), רקורסיבי | כשאותו תהליכון צריך רכישה רקורסיבית |
| Mutex | בתוך התהליך / בין תהליכים | תמיד אובייקט ליבה, ולכן איטי יותר | הדרה בין תהליכים (בעל שם), או בשילוב עם WaitForMultipleObjects |
| סמפור | בתוך התהליך / בין תהליכים | אובייקט ליבה | הגבלת גישה במקביל למאגר משאבים |
| אירוע | בתוך התהליך / בין תהליכים | אובייקט ליבה | להודיע ש«משהו קרה» (לא להגנת נתונים) |
| פונקציות Interlocked | בתוך התהליך (וגם בין תהליכים, על זיכרון משותף) | פעולות אטומיות בלי מנעול | מונים, דגלים, החלפת מצביעים4 |
הערת שוליים לטבלה ולתרשים. אובייקטי ליבה כמו אירועים, סמפורים ומנעולים עובדים מצוין לסנכרון בתוך תהליך כשנוצרים בלי שם (אירוע העצירה בפרק 6 הוא בדיוק אירוע בלי שם). אובייקט ליבה לא אומר רק בין תהליכים. ולהפך, «בלי שם» לא אומר בקפדנות «כלוא בתהליך אחד» — אם תתנו לתהליך בן לרשת את הידית, או תכפילו אותה לתהליך אחר ב-DuplicateHandle, אפשר להשתמש באותו אובייקט ליבה מכמה תהליכים גם בלי שם. האופן המדויק לומר זאת הוא שמתן שם הוא דרך מייצגת לאפשר לתהליכים לפתוח מחדש את אותו אובייקט. הענף באיור 3 תופס את נקודת הבחירה כ«אל תבחרו אובייקט ליבה למנעול בתוך תהליך» — להודעה בתוך תהליך (אירועים) או להגבלת מקביליות (סמפורים), אובייקט ליבה בלי שם עדיין התשובה הנכונה.
משפחת Interlocked מקבילה למחלקת Interlocked במהדורת .NET ול-std::atomic במהדורת C++. InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange מבצעות פעולות על משתנה בודד באופן שאינו ניתן לפיצול, וכי רוב הפונקציות האלה נושאות מחסום זיכרון מלא, אתם מקבלים גם הבטחת סדר.4 «זה בסדר כי סימנתי volatile» הוא תפיסה שגויה — volatile לא מבטיח לא אטומיות ולא סדר (ראו שאלות נפוצות). יש עוד תנאי מוקדם: יישור. המשתנה שמכוונת אליו פונקציית Interlocked חייב להיות מיושר על גבול טבעי (גבול 4 בתים לערך 32 סיביות, גבול 8 בתים לערך 64 סיביות); אם לא, ההתנהגות אינה ניתנת לחיזוי.12 לעולם אל תכוונו שדה בתוך מבנה #pragma pack, או שדה במאגר שממופה ישירות לפורמט קו, בפונקציית Interlocked. הגבילו מונים ודגלים למשתנים שהוצהרו כרגיל — אלה שהמהדר מיישר בשבילכם. יש גם אזהרה ספציפית סביב החלפת מצביעים במשהו כמו InterlockedExchangePointer: רק ההחלפה עצמה אינה ניתנת לפיצול, ואף אחד לא מבטיח את חיי הבלוק הישן אחרי ההחלפה. אם קורא טוען את המצביע הישן בדיוק כשכותב מחליף אותו וקורא ל-free, זו גישה לזיכרון ששוחרר. עיצוב שמעדכן נתונים משותפים בהחלפת מצביעים עובד רק כשהוא מצומד לפרוטוקול תביעה — מנעול, ספירת הפניות או דומה (אם יש ספק, להגן במנעול SRW הוא ברירת המחדל הבטוחה).
להמתנה למשהו, יש משתני תנאי. צרו אחד ב-InitializeConditionVariable; הצרכן ישן על SleepConditionVariableCS (בזוג עם CRITICAL_SECTION), והיצרן מעיר אותו ב-WakeConditionVariable — זו בדיוק צורת הדוגמה הרשמית של תור יצרן/צרכן על מאגר בעל קיבולת.5 המשמעת החשובה: בהתעוררות, תמיד בדקו מחדש את התנאי (האם התור אינו ריק) בתוך המנעול, וחזרו להמתנה אם הוא שקרי. משתני תנאי יכולים לסבול התעוררויות שווא בלי הודעה כלל, ועד שאתם מתעוררים צרכן אחר כבר יכול היה לקחת את הפריט ראשון — כך ש«העירו אותי» לא בהכרח אומר «התנאי מתקיים». זה הכלי של C לבנות את אותה צורה כמו הערוץ במהדורת .NET פרק 4.3 ו-BlockingQueue במהדורת C++ פרק 4. כשמצמידים למנעול SRW, השתמשו ב-SleepConditionVariableSRW.
5.1. משמעת מנעולים — שלושה עקרונות שעומדים בלי קשר לאיזה פרימיטיב בחרתם
בחירת הפרימיטיב הנכון אינה מספיקה לבדה — בלי שימוש ממושמע, עדיין לא תמנעו מרוצים.
- החליטו, אחד לאחד, איזה מנעול מגן על אילו נתונים. הקצו בדיוק מנעול אחד (מנעול SRW או CRITICAL_SECTION) לכל קבוצת נתונים משתנים שרוצים להגן עליהם, וקחו את אותו מנעול בכל מקום שנוגע בנתונים האלה. ב-C במיוחד, משתלם לפרט זאת בהערת כותרת — «המבנה הזה מוגן ב-
g_lockFoo». - אל תעשו שום דבר איטי או חיצוני כשאתם מחזיקים מנעול. הדבר היחיד שמותר כשמחזיקים מנעול הוא לקרוא או לכתוב את הנתונים שהוא מגן. קלט/פלט קבצים, קריאות רשת או הפעלות קריאה חוזרת שעדיין מחזיקות את המנעול מאריכות כמה זמן מחזיקים ומסכנות שהנקרא ינסה לקחת מנעול אחר, וליצור את ההמתנה המעגלית מאיור 2.
- קבעו את סדר הרכישה למנעולים מרובים. בכל מקום שלוקחים שני מנעולים או יותר, הפכו לכלל שכל תהליכון לוקח אותם באותו סדר (היררכיית מנעולים). תיעוד הפרקטיקות המומלצות של DLL קובע במפורש שהיפוך הסדר הזה (היפוך סדר מנעולים) מייצר קיפאונות שקשה לנפות, ושצריך להגדיר היררכיה ולעקוב אחריה בעקביות.10
6. עיצוב איך עוצרים — לעולם לא TerminateThread
6.1. מה TerminateThread שובר
TerminateThread מוחק את תהליכון היעד בלי לתת לו לבצע שום קוד במצב משתמש. התוצאות שהתיעוד הרשמי מונה חמורות. אם תהליכון היעד החזיק מקטע קריטי, הוא לעולם לא ישוחרר; אם היה באמצע פעולת ערימה, מנעול הערימה נשאר מוחזק (וכל תהליכון הבא שקורא ל-malloc נתקע); ואם שינה את המצב הגלובלי של DLL, המצב הזה נשאר משובש. העמדה הרשמית היא שזו «פונקציה מסוכנת שיש להשתמש בה רק במקרים הקיצוניים ביותר», וניתוח הקוד מסמן אותה כאזהרת C6258.67
למצוא TerminateThread בחקירת יישום ש«מדי פעם נתקע לגמרי» הוא באמת מחזה נפוץ בפועל. אם מצאתם, התייחסו לזה כמשהו שצריך לתקן.
6.2. הדפוס הנכון: אירוע עצירה ועוד WaitForMultipleObjects
הדפוס המבוסס לכיבוי שיתופי ב-C הוא ליצור אירוע עצירה באיפוס ידני אחד, ולתת לכל תהליכון עובד להמתין ל«אות עבודה» ול«אות עצירה» באותו זמן. תיעוד אזהרת C6258 עצמו מצביע בדיוק על הדפוס הזה — צרו אירוע, תנו לכל תהליכון לעקוב אחריו ב-WaitForSingleObject, ותנו לתהליכון לסיים את עצמו — כדרך הסיום הנכונה.7
HANDLE hStopEvent; /* CreateEvent(NULL, TRUE, FALSE, NULL): איפוס ידני */
HANDLE hWorkEvent; /* CreateEvent(NULL, FALSE, FALSE, NULL): איפוס אוטומטי.
חוזר מעצמו ללא-מסומן כשמתקבל
(אירוע איפוס ידני היה מעביר המתנות ישר
אחרי שסומן פעם אחת, והופך ללולאה עסוקה
שמסתובבת על תור ריק) */
static unsigned __stdcall WorkerMain(void* arg)
{
HANDLE waits[2] = { hStopEvent, hWorkEvent };
for (;;) {
DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
if (r == WAIT_FAILED) { /* למשל ידית לא תקפה. אם לא מטופל, מסתובב במלוא הכוח */
LogLastError(); /* רשמו GetLastError() וצאו */
break;
}
if (r == WAIT_OBJECT_0) /* התבקשה עצירה */
break;
if (r == WAIT_OBJECT_0 + 1) { /* יש עבודה */
/* העבירו גם את אירוע העצירה ל-ProcessNextItem: אם הוא ממתין זמן רב
בפנים לפריט אחד, ולא יכול לראות את העצירה גם שם,
הכיבוי נעשה בן ערובה של הפריט ההוא */
while (ProcessNextItem(hStopEvent)) { /* עיבוד פריט אחד מהתור; FALSE אם ריק */
/* בדקו את בקשת העצירה גם בזמן הריקון. דלגו על זה ולא
תוכלו לעצור כל עוד עבודה ממשיכה להיערם (רעב עצירה) */
if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
break;
}
}
}
Cleanup(); /* נקו בעצמכם */
return 0; /* סיימו בעצמכם */
}
BOOL StopWorkers(HANDLE* threads, DWORD count)
{
BOOL ok = TRUE;
if (!SetEvent(hStopEvent)) { /* אם בקשת העצירה לא עברה, */
LogLastError(); /* אל תמשיכו להצטרפות בלי גבול */
return FALSE;
}
/* עכשיו לכולם בקשת העצירה מורמת בבת אחת */
for (DWORD i = 0; i < count; i++) {
if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
CloseHandle(threads[i]); /* סגרו רק ידיות שאישרנו שהצטרפו */
} else {
LogLastError(); /* WAIT_FAILED: למשל ידית לא תקפה */
ok = FALSE; /* אל תדווחו «כולם נעצרו» */
}
}
return ok; /* אם FALSE, אל תמשיכו לשחרור משאבים משותפים */
}
יש סיבה שהצד העוצר מצטרף לתהליכון אחד בכל פעם ב-WaitForSingleObject. WaitForMultipleObjects יכול להמתין לכל היותר ל-MAXIMUM_WAIT_OBJECTS (64) ידיות בבת אחת; מסרו לו מערך גדול יותר וההמתנה עצמה נכשלת ב-WAIT_FAILED, ומשאירה אתכם סוגרים ידיות מתוך אמונה שהמתנתם לכולם כשלמעשה לא המתנתם לאף אחד. אם כל מה שצריך הוא להמתין שכולם יסיימו, לולאה אחת-אחת בלי תקרה עליונה היא הבחירה הבטוחה.
flowchart TB
OWNER["הצד העוצר"] -->|"SetEvent(hStopEvent)"| SE["אירוע עצירה<br/>איפוס ידני - גלוי לכולם בבת אחת"]
SE --> W1["עובד 1 - ממתין לעצירה ולעבודה<br/>יחד דרך WaitForMultipleObjects"]
SE --> W2["עובד 2 - ממתין לעצירה ולעבודה<br/>יחד דרך WaitForMultipleObjects"]
W1 --> C1["מנקה וחוזר בעצמו"]
W2 --> C2["מנקה וחוזר בעצמו"]
C1 --> J["הצד העוצר ממתין לידיות התהליכונים ומצטרף<br/>רק עכשיו אפשר לקרוא לזה עצור"]
C2 --> J
איור 4: דפוס אירוע העצירה. שימוש באירוע איפוס ידני לאות העצירה אומר ש-SetEvent אחד מעיר כל עובד ממתין בבת אחת. כל תהליכון מחליט בעצמו איך הוא מסיים, והעצירה נחשבת שלמה רק אחרי שההצטרפות הסתיימה
יש שלוש נקודות מפתח. הפכו את אירוע העצירה לאיפוס ידני (כך ש-SetEvent אחד גלוי לכל עובד); שימו את אירוע העצירה ראשון במערך ההמתנה (כך שאם שניהם מסומנים יחד, לעצירה יש עדיפות); והצד העוצר חייב תמיד להצטרף לידיות התהליכונים לפני שסוגר אותן.
שתי הסתייגויות לגבי היקף. ראשית, דפוס «אירוע ועוד רוקן-הכול» הזה הוא לתצורה של עובד יחיד. כמה שלא תקראו ל-SetEvent על אירוע איפוס אוטומטי, הוא יכול לבטא רק «יש מצב מסומן אחד» (אותות רצופים מתמזגים), כך שעם כמה עובדים רק אחד מתעורר ומסיים לעבד את כל ההתפרצות בטור. אם כמה עובדים חולקים תור, החליפו את אות העבודה לסמפור, והגדילו את הספירה ב-ReleaseSemaphore(hSem, 1, NULL) בכל פעם שפריט נכנס לתור. המתנת סמפור מוצלחת צורכת ספירה אחת, ונותנת את ההתאמה הנכונה: בדיוק כמספר העובדים הממתינים מתעוררים, אחד בכל פעם, כמספר הפריטים שבתור (שימוש זה נמצא בלב תחום הסמפור, כמו «הגבלת גישה במקביל למאגר משאבים» בטבלת איור 3). אבל כשעוברים לסמפור, שנו גם את צד הצרכן כך שהמתנה מוצלחת אחת שווה לעיבוד בדיוק פריט אחד מהתור. השאירו את לולאת רוקן-הכול מהדוגמה למעלה כפי שהיא, והמתנה אחת — שצורכת היתר אחד בלבד — תרוקן את כל התור, תקלקל את החשבון: עובדים אחרים יתעוררו לתור ריק על ההיתרים שנשארו, ו-ReleaseSemaphore של היצרן יתחיל להיכשל מחריגת הספירה המרבית. שמירת ההתאמה «היתר אחד שווה עבודה אחת» היא תנאי מוקדם לגישת הסמפור. שנית, העבירו את נתיב העצירה גם דרך עיבוד פריט בודד. אם ProcessNextItem ממתין בפנים המתנה חוסמת ארוכה, או העבירו גם לשם את אירוע העצירה והמתינו לשניהם יחד, או צרפו פסק זמן סופי. בדיקה רק בין פריטים משאירה חור שבו «הכיבוי ממתין לנצח כי פריט אחד לא נגמר». זה אומר בדיוק מה שאומרים StopAsync במהדורת .NET ו-jthread ועוד הצטרפות במהדורת C++.
תהליכון שממתין על קלט/פלט חוסם (צינור, שקע, יציאה טורית) לא יכול לחזור לבדוק את האירוע, ולכן צד הקלט/פלט צריך עיצוב משלו — או קלט/פלט OVERLAPPED בשילוב אירוע שממתינים לו יחד, או העיר את הקלט/פלט ב-CancelIoEx (לדוגמה טורית מוחשית, ראו «המלכודות של יישום תקשורת טורית - עד לתכנון חיבור מחדש ויומן»).
7. DllMain ומנעול הטוען — שדה מוקשים לכותבי DLL
רכיבים משותפים שנכתבים ב-C לעיתים קרובות הופכים ל-DLL, ול-DLL יש אילוץ משלהם: מנעול הטוען. טוען מערכת ההפעלה קורא ל-DllMain כשהוא מחזיק את מנעול הטוען, כך שכל אחד מהבאים בתוכו הופך למקור קיפאון או קריסה.10
- סנכרון עם תהליכונים אחרים (רכישת מנעולים, המתנה לסיום תהליכון)
- קריאה ל-
LoadLibrary/FreeLibrary, ישירות או בעקיפין - יצירת תהליכונים (מסוכן אם כרוך בסנכרון), או קריאה ל-
ExitThread
«להמתין בתוך DllMain לסיום תהליכון עובד כשה-DLL נפרק» נראה סביר לגמרי אבל הוא קיפאון קלאסי: התהליכון שמסתיים מנסה לקחת את מנעול הטוען כדי למסור DLL_THREAD_DETACH, ושני הצדדים מסיימים בהמתנה זה לזה. DLL עם תהליכונים משלו צריך לחשוף פונקציות אתחול וכיבוי מפורשות — משהו כמו MyLib_Init / MyLib_Shutdown — ולעשות שם את התחלת התהליכונים וההצטרפות. DllMain האידיאלי קרוב למתאר ריק.10
8. אפשרות תהליכוני C11 — איפה אנחנו עומדים
אם רוצים לכתוב C נייד שאינו תלוי ב-Win32, האפשרות היא <threads.h> של C11 (thrd_create / mtx_lock / cnd_wait) ו-<stdatomic.h>. לפי טבלת ההתאמה הרשמית, התמיכה של MSVC עומדת כך: <threads.h> נתמך מ-Visual Studio 2022 17.8 (דורש /std:c11 ו-Windows SDK תואם), בעוד <stdatomic.h> עדיין ניסיוני, בשלב שדורש את האפשרות /experimental:c11atomics.11
אם שיתוף קוד עם Linux הוא דרישה, לתהליכוני C11 (או עטיפת pthread) יש ערך אמיתי, אבל לבסיס קוד ל-Windows בלבד, גישת Win32 שמאמר זה מכסה יש יתרון בנפח המידע הזמין, ברקורד ובקלות הניפוי. מה שלא תבחרו, עקרונות העיצוב שכוסו עד כאן — צמצום שיתוף, ההתאמה בין מנעולים לנתונים, וכיבוי שיתופי — לא משתנים.
9. אימות וניפוי — להתכונן ל«לא משתחזר»
אי אפשר לצפות שבדיקות יתפסו באגי מרוץ. בדיקות רגילות סופרות הרצה שבה המרוץ «במקרה לא הופעל» כהצלחה. חשבו על ההגנות בשלוש שכבות.
קו ההגנה הראשון הוא העיצוב. בסקירה, אשרו בטבלה: אילו נתונים משתנים משותפים, איזה מנעול מגן על כל חלק (ההתאמה מפרק 5.1), האם סדר רכישת המנעולים חד-משמעי, והאם אירוע העצירה מגיע לכל עובד. עיצוב שלא יכול למלא את הטבלה הזאת עדיין לא גמור, גם אם כרגע עובד.
שנית, הפכו חריגות לצפיות. במקום להמתין ללא תנאי ב-INFINITE, צרפו פסק זמן בנקודות מפתח ורשמו את פסק הזמן כשהוא נורה, והפכו תקיעה שהייתה נמשכת לנצח לכשל שניתן לגלות. כשתקיעה קורה בשטח, לכדו dump, בדקו את המחסנית של כל תהליכון, וחפשו מעגל מי ממתין למנעול של מי. בדיקה ב-Application Verifier מומלצת רשמית לשגיאות סביב DLL.10 בניית dump ורישום מכוסה ב«תכנון שמירת יומנים ו-dump בקריסת יישום Windows».
שלישית, נערו בעומס. בדיקות מאמץ — ריצה עם יותר תהליכונים ממספר הליבות, ערבוב סדר עיבוד, הכנסת השהיות מלאכותיות — הן דרך מעשית להעלות את הסיכוי שתפגעו ב«זכייה» של מרוץ במכונת פיתוח. אל תשכחו גם לבדוק שחזור בבניית הפצה ממוטבת תחת עומס כבד.
10. סיכום — רשימת הבדיקה של מהדורת C
- האם כל תהליכון נוצר ב-
_beginthreadex(בלי ערבובCreateThread/_beginthread)? - האם אתם מצטרפים לידיות התהליכונים (
WaitForSingleObject) לפני קריאה ל-CloseHandle? - האם אתם מייצרים בהמוניהם תהליכונים משלכם לעבודות קצרות חיים (אפשר להעביר אותן לממשק מאגר התהליכונים)?
- האם ההדרה בתוך התהליך משתמשת במנעול SRW / CRITICAL_SECTION (ולא מנצלת לרעה Mutex)?
- האם מונים ודגלים משותפים מתעדכנים בפונקציות Interlocked במקום להסתמך על
volatile? - האם נשאר סקר
Sleep(האם הוחלף במשתנה תנאי או בהמתנת אירוע)? - האם
TerminateThread(הריגת תהליכון אחר בכוח) נעדר בכל מקום? האם עובדים מסתיימים ב-returnמפונקציית התהליכון במקום לקרוא ל-ExitThread(כדי שניקוי CRT ירוץ נכון דרך_endthreadex)? - האם לכל עובד יש נתיב עצירה דרך אירוע עצירה ועוד
WaitForMultipleObjects, והאם אפשר גם להעיר תהליכונים שחסומים על קלט/פלט? - האם שחרור מנעולים וידיות מובטח בכל נתיב חזרה (משמעת
goto cleanup)? - האם
DllMainנמנע מיצירת תהליכונים, מסנכרון או מהמתנה לסיום תהליכונים?
בתמורה לכך שאין עזרה מהשפה, איכות ריבוי התהליכונים ב-C היא בדיוק מה שבחירות ה-API והמשמעת שלכם עושות ממנה. הפכו את _beginthreadex, מנעולי SRW, פונקציות Interlocked ואירוע העצירה לערכת ארבעת החלקים כברירת מחדל, ואפילו ב-C אפשר לעצב הרחק מ«מדי פעם נתקע».
מאמרים קשורים
- שיטות מומלצות לריבוי תהליכונים בפועל: מהדורת .NET — מה להחליט לפני שמוסיפים עוד תהליכונים
- פרקטיקות מומלצות לריבוי תהליכונים: מהדורת C++ — ביטול תאונות לפי מבנה עם RAII ו-jthread
- פרקטיקות מומלצות לריבוי תהליכונים: מהדורת Java — מוסכמות לעידן התהליכונים הווירטואליים
- המלכודות של הזיכרון המשותף ושיטות העבודה המומלצות בפועל
- למה עדיף המתנה מונחית אירועים על פני Sleep(1) ב-Windows
- המלכודות של יישום תקשורת טורית - עד לתכנון חיבור מחדש ויומן
- תכנון שמירת יומנים ו-dump בקריסת יישום Windows
תחומי ייעוץ קשורים
KomuraSoft LLC מטפלת בסקירת עיצוב ריבוי תהליכונים לתהליכים תושבים, יישומי בקרת ציוד ו-DLL שנכתבים ב-C; בחקירת שורש (ניתוח dump) של תקיעות וקריסות שנגרמו מ-TerminateThread או מנעולים שדלפו; ובייעוץ טכני על הוספת תהליכונים לקוד C ישן.
מקורות
-
Microsoft Learn, CreateThread function. על כך שתהליכונים בתוך קובץ הרצה שקורא ל-CRT צריכים להיות מנוהלים ב-_beginthreadex / _endthreadex ולא ב-CreateThread / ExitThread, ועל כך שה-CRT יכול לסיים את התהליך בתנאי זיכרון נמוך כשתהליכון שנוצר ב-CreateThread קורא ל-CRT. ↩ ↩2
-
Microsoft Learn, Multithreading with C and Win32. על כך שתוכניות שקוראות לספריית CRT צריכות להתחיל תהליכונים ב-_beginthread / _beginthreadex ולא ב-CreateThread / ExitThread של Win32; על כך שמשפחת _beginthread מאתחלת את משתני ה-CRT לכל תהליכון; ועל כך ש-SuspendThread יכול לעצור תהליכון בזמן שהוא ניגש למבני נתונים פנימיים של ה-CRT, מה שעלול להוביל לקיפאון. ↩ ↩2
-
Microsoft Learn, About Synchronization. על הנחיית בחירת פרימיטיבי סנכרון Win32: מנעולי SRW כברירת מחדל לקוד חדש, בגודל מצביע ובדרך כלל נשארים במצב משתמש; CRITICAL_SECTION למקרים שצריכים רכישה רקורסיבית; Mutex תמיד אובייקט ליבה, משמש לסנכרון בין תהליכים בעל שם ובשילוב עם WaitForMultipleObjects; שימוש ב-Mutex לסנכרון בתוך תהליך הוא «טעות נפוצה» איטית בהרבה תחת פעולות תכופות; וסמפורים משמשים להגבלת גישה במקביל למאגר משאבים, אירועים להודעה. ↩ ↩2 ↩3
-
Microsoft Learn, Interlocked Variable Access. על כך שפונקציות Interlocked מסנכרנות גישה למשתנה משותף בין כמה תהליכונים ומבצעות את הפעולה באופן שאינו ניתן לפיצול; על כך ש-InterlockedIncrement / Decrement אוגדות קריאה, חיבור וכתיבה בחזרה לפעולה אטומית אחת, כי בלי סנכרון הגדלה בו-זמנית משני תהליכונים יכולה לאבד אחת מההגדלות; על משפחת InterlockedExchange / InterlockedCompareExchange; על שימוש בין תהליכונים בתהליכים שונים כשהמשתנה בזיכרון משותף; ועל כך שרוב פונקציות Interlocked מספקות מחסום זיכרון מלא, עם וריאנטי Acquire / Release לבחירת סמנטיקת סדר. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Using Condition Variables. על הדוגמה המעובדת של תור יצרן/צרכן הממומש על מאגר מעגלי בעל קיבולת המוגן ב-CRITICAL_SECTION; על המבנה שבו InitializeConditionVariable יוצר משתנה תנאי, הצרכן ממתין ב-SleepConditionVariableCS, והיצרן מעיר ב-WakeConditionVariable; ועל תמיכה במשתני תנאי מוויסטה ואילך. ↩ ↩2 ↩3
-
Microsoft Learn, TerminateThread function. על כך ש-TerminateThread מסיים את תהליכון היעד בלי לתת לו לבצע קוד במצב משתמש; על כך שמקטע קריטי של היעד אינו משוחרר אם הוא החזיק אחד; על כך שמנעול הערימה אינו משוחרר אם התהליכון הקצה זיכרון מהערימה; על כך שמצב kernel32 או מצב גלובלי של DLL עלולים להשתבש; ועל כך שזו «פונקציה מסוכנת שיש להשתמש בה רק במקרים הקיצוניים ביותר», שאין לקרוא לה אלא אם אתם יודעים ושולטים לגמרי בכל נתיב קוד שתהליכון היעד יכול היה לבצע. ↩ ↩2
-
Microsoft Learn, Warning C6258. על כך שאזהרת ניתוח הקוד C6258 מגלה שימוש ב-TerminateThread; על כך ש-TerminateThread אינו יכול לבצע ניקוי תהליכון נאות; ועל הליך הסיום הנכון שמוצג כיוצרים אירוע ב-CreateEvent, נותנים לכל תהליכון לעקוב אחרי מצב האירוע ב-WaitForSingleObject, ונותנים לתהליכון לסיים את ביצועו בעצמו ברגע שהאירוע נעשה מסומן. ↩ ↩2 ↩3
-
Microsoft Learn, CreateThreadpoolWork function. על יצירת אובייקט עבודה ב-CreateThreadpoolWork ומתן לתהליכון עובד במאגר לבצע את הקריאה החוזרת בכל קריאה ל-SubmitThreadpoolWork; על האפשרות לציין סביבת ביצוע דרך סביבת קריאה חוזרת (TP_CALLBACK_ENVIRON); ועל זמינות מוויסטה ואילך. ↩ ↩2
-
Microsoft Learn, Thread Pools. על כך שמאגר תהליכונים מתאים ליישומים שמבצעים מספרים גדולים של עבודות אסינכרוניות קצרות, או שיוצרים לעיתים קרובות תהליכונים קצרי חיים; על רכיבי ממשק מאגר התהליכונים החדש שעוצב מחדש בוויסטה; על הפרקטיקות המומלצות לעולם לא לסיים תהליכון מאגר ב-TerminateThread או לקרוא ל-ExitThread מתוך קריאה חוזרת, לנקות כל מצב שנוצר בקריאה חוזרת לפני שחוזרים, ולהשאיר ידיות המתנה בחיים עד שהמאגר סיים איתן. ↩ ↩2
-
Microsoft Learn, Dynamic-Link Library Best Practices. על כך ש-DllMain נקראת כשמנעול הטוען מוחזק, מה שמטיל הגבלות חמורות על אילו ממשקים בטוח לקרוא; על כך שסנכרון עם תהליכונים אחרים בתוך DllMain מוביל לקיפאון; על כך שקריאה ל-LoadLibrary ברשימת הפעולות האסורות; על הדפוס שבו המתנה לסיום תהליכון בתוך DllMain בפריקת DLL נכנסת לקיפאון מול ניסיון התהליכון עצמו לרכוש את מנעול הטוען כדי למסור DLL_THREAD_DETACH; על כך ש-DllMain האידיאלי קרוב למתאר ריק, עם אתחול שנדחה ככל האפשר; ועל הגדרת היררכיית מנעולים עם מנעול הטוען בראש. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. על טבלת ההתאמה של ספריית התקן של C, שמראה תהליכוני C11 (threads.h) נתמכים מ-Visual Studio 2022 17.8; stdatomic.h מטופל כניסיוני (מאחורי האפשרות /experimental:c11atomics); ותמיכת המהדר ב-C11 / C17 דורשת Visual Studio 2019 16.8 ואילך יחד עם Windows SDK תואם. ↩ ↩2
-
Microsoft Learn, Interlocked Variable Access. על כך שקריאה או כתיבה פשוטה של משתנה 32 סיביות מיושר כראוי היא אטומית, אבל סנכרון (סדר) הגישה אינו מובטח; על כך שקריאה או כתיבה פשוטה של משתנה 64 סיביות היא אטומית ב-Windows של 64 סיביות אבל אינה מובטחת ב-Windows של 32 סיביות; ועל כך שמשתנים בגדלים אחרים אינם מובטחים כאטומיים באף פלטפורמה. ↩ ↩2
-
Microsoft Learn, _beginthread, _beginthreadex. על למה _beginthreadex בטוח יותר מ-_beginthread: תהליכון שנוצר ב-_beginthread יכול להשאיר את הידית המוחזרת לא תקפה (או מצביעה לתהליכון אחר) אם הוא מסתיים מוקדם; הידית מ-_beginthreadex חייבת להיסגר על ידי הקורא ב-CloseHandle ותקפותה מובטחת; _beginthreadex מאפשר להעביר את הידית לממשקי סנכרון; פונקציית התהליכון מחזירה קוד יציאת תהליכון לפי מוסכמת הקריאה __stdcall; ונדרש קישור ל-CRT רב-תהליכוני. ↩
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
שיטות עבודה מומלצות לרב־תהליכוניות בפועל: מהדורת C++ — ביטול תאונות במבנה עם RAII ו-jthread
ב-C++, רב־תהליכוניות היא עולם שבו מרוץ נתונים הוא התנהגות לא מוגדרת. המאמר עובר על מלכודת המפרק של std::thread, תכנון עצירה עם jthread ו-...
יישומים שנשברים בחזרה משינה — איך אירועי צריכת החשמל של Windows עובדים ואיך לבנות יישומים עסקיים ששורדים אותם
פתחתם את המחשב הנייד והחיבורים של היישום העסקי היו מתים — הסיבה היא תכנון שלא חשב על שינה. המאמר מכסה את זרימת ההודעות WM_POWERBROADCAST,...
DllMain ונעילת הטוען — הסיבה האמיתית שאומרים לכם "לא לעשות כלום באתחול DLL"
למה אסור לקרוא ל-LoadLibrary או להסתנכרן עם תהליכונים אחרים מתוך DllMain. המאמר מסביר ממקורות ראשוניים איך נעילת הטוען מסדרת כל הודעת DLL...
מה "לא מגיב" באמת — איך Windows מחליט שיישום נתקע, ואיך לתכנן יישומים שלא
"לא מגיב" של Windows הוא מנגנון שבו מערכת ההפעלה קובעת שחלון לא שלף הודעה במשך 5 שניות ומחליפה אותו בחלון רפאים. המאמר מכסה את פנים השיפו...
התעוררויות ספונטניות — למה משתני תנאי מתעוררים "בלי שהודיעו להם" ואיך להמתין נכון ב-Windows
המתנה על משתנה תנאי יכולה לחזור גם כשלא הגיעה הודעה (התעוררות ספונטנית). המאמר מסביר ממימוש Windows למה המפרט מתיר זאת, ומציג את צורת ההמ...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
יישומים עסקיים, חיבור התקנים וכלי תקשורת, מהגדרת הדרישות ועד הפיתוח.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- האם להשתמש ב-CreateThread או ב-_beginthreadex?
- השתמשו ב-_beginthreadex לכל תהליכון שקורא לפונקציות בספריית זמן הריצה של C (CRT). _beginthreadex מאתחל את הנתונים הפנימיים לכל תהליכון שה-CRT זקוק להם לפני שהוא מתחיל את התהליכון. התיעוד הרשמי קובע במפורש שאם תהליכון שנוצר ב-CreateThread קורא לפונקציית CRT, ה-CRT יכול לסיים את התהליך כשהזיכרון דל. בפועל, תהליכונים ביישום C כמעט תמיד קוראים לפונקציית CRT איפשהו (printf, malloc, strtok וכן הלאה), כך שאין נזק לזכור את הכלל כ«תמיד _beginthreadex». הימנעו גם מ-_beginthread (בלי ex) — יש בו מלכודת שבה הידית המוחזרת עלולה להיעשות לא תקפה אם התהליכון שנוצר מסתיים מוקדם, ולכן _beginthreadex, שאפשר להעביר את הידית שלו לממשקי סנכרון, הוא הבחירה.
- אסור לי לעצור תהליכון ב-TerminateThread?
- לא, לא כדאי. TerminateThread מוחק את תהליכון היעד בלי לתת לו לבצע שום קוד במצב משתמש, כך שאם התהליכון החזיק מקטע קריטי הוא לעולם לא ישוחרר, אם הקצה זיכרון מהערימה מנעול הערימה נשאר מוחזק, ואם שינה את המצב הגלובלי של DLL המצב הזה משתבש. התיעוד הרשמי קובע במפורש שזו «פונקציה מסוכנת שיש להשתמש בה רק במקרים הקיצוניים ביותר», וניתוח הקוד גם מסמן אותה כאזהרת C6258. דרך העצירה הנכונה היא כיבוי שיתופי: צרו אירוע עצירה, תנו לכל תהליכון לעקוב אחריו ב-WaitForSingleObject / WaitForMultipleObjects, ותנו לכל תהליכון לנקות אחרי עצמו ולהסתיים בעצמו.
- השתמשתי ב-Mutex להדרה בתוך תהליך. מה רע בזה?
- זה עובד, אבל עולה לכם הרבה ביצועים. Mutex של Win32 הוא תמיד אובייקט ליבה, כך שכל רכישה ושחרור מפעילים מעבר למצב ליבה. להדרה בתוך תהליך אחד, מנעול SRW או CRITICAL_SECTION — שנשארים במצב משתמש ונופלים להמתנת ליבה רק תחת תחרות — מהירים בהרבה, והתיעוד הרשמי קורא במפורש לשימוש ב-Mutex לסנכרון בתוך תהליך «טעות נפוצה». Mutex מצדיק את עצמו כשצריך הדרה בין תהליכים כאובייקט בעל שם, או כשרוצים להמתין לו יחד עם אובייקטי ליבה אחרים ב-WaitForMultipleObjects.
- אפשר להשתמש ב-threads.h וב-stdatomic.h של C11 ב-Windows?
- ב-MSVC, תהליכוני C11 (threads.h) נתמכים מאז Visual Studio 2022 17.8 (דורשים /std:c11 ו-Windows SDK תואם). stdatomic.h, לעומת זאת, עדיין מטופל כניסיוני ודורש את האפשרות /experimental:c11atomics (לפי טבלת ההתאמה הרשמית נכון לאוגוסט 2026). זו אפשרות סבירה אם ניידות היא העדיפות העליונה, אבל לבסיס קוד ל-Windows בלבד, כתיבה ל-Win32 API (_beginthreadex, מנעולי SRW, משתני תנאי, פונקציות Interlocked) היא הבחירה המציאותית לאור הרקורד ונפח המידע הזמין.
- האם הוספת volatile הופכת דגל משותף לבטוח?
- לא. volatile של C רק מדכא אופטימיזציות מהדר כמו שמירת ערך באוגר — הוא לא מבטיח לא אטומיות של פעולה ולא סדר זיכרון בין מעבדים. קריאה או כתיבה פשוטה של משתנה 32 סיביות מיושר כראוי היא אטומית כשלעצמה ב-Windows, אבל «קרא, הוסף וכתוב בחזרה» מתפצל לשלבים נפרדים, ואין גם הבטחה לגבי סדרו ביחס לפעולות זיכרון סובבות. השתמשו במשפחת Interlocked לעדכון מונה או דגל משותף. רוב פונקציות Interlocked נושאות מחסום זיכרון מלא, כך שאתם מקבלים גם הבטחת סדר. כשצריך להגן על כמה משתנים יחד, השתמשו במנעול SRW או ב-CRITICAL_SECTION.