שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת C — כתיבה בטוחה לפי Win32 API

· עודכן בתאריך: · · Windows, multithreading, C, Win32 API, יישומים עסקיים, חקירת תקלות, תכנון

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 2 Aug 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22175893)

מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.

Go Komura (2026). שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת C — כתיבה בטוחה לפי Win32 API. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22175893 https://comcomponent.com/he/blog/multithreading-best-practices-c/

DOI (הגרסה האחרונה)
10.5281/zenodo.22175893
DOI (הגרסה הזו)
10.5281/zenodo.22175894

«כותבים resident process לבקרת ציוד ב-C.» «הוסיפו threads ליישום C בן עשרים שנה.» «עוצרים עם TerminateThread, ומדי פעם כל ה-process נתקע.» — multithreading ב-C הוא העולם שבו השפה עוזרת הכי פחות. אין exceptions, אין RAII, אין templates; הנכונות של הסנכרון יושבת כולה על איזה API בוחרים ועל המשמעת בקריאה אליו.

המאמר הזה הוא מהדורת C בסדרת ה-multithreading המעשית. הוא מיועד למי שכותב C מול Win32 API, ומוריד את עקרונות התכנון — להפסיק לייצר threads בהמונים, לצמצם shared mutable state, לשמור משמעת של locks, ולתכנן קודם איך עוצרים — לכלים של Win32: איך יוצרים thread (_beginthreadex), איך בוחרים synchronization object, איך מתכננים עצירה בלי TerminateThread, ואילוצי DllMain, על בסיס מקורות ראשוניים נכון לאוגוסט 2026. נכתב כדי שאפשר לקרוא אותו לבד. אותם עקרונות בשפות אחרות מופיעים במהדורת .NET, במהדורת C++ ובמהדורת Java.

1. קודם המסקנה

  • יוצרים thread ב-_beginthreadex, לא ב-CreateThread. Thread שקורא ל-CRT ונוצר ב-CreateThread עלול לגרום ל-CRT לסיים את ה-process כשהזיכרון נגמר.12
  • ה-lock כברירת מחדל בתוך process הוא SRW lock; CRITICAL_SECTION רק כשצריך recursive acquire. Mutex ל-exclusion בתוך process הוא «טעות נפוצה» שתמיד עוברת ל-kernel.3
  • עדכון של משתנה בודד — משפחת Interlocked. volatile לא מבטיח atomicity ולא סדר. רוב פונקציות Interlocked כוללות full memory barrier.4
  • המתנה היא condition variable (משפחת SleepConditionVariableCS) או event יחד עם wait function. לולאת polling עם Sleep מבזבזת גם CPU וגם תגובתיות.5
  • לא משתמשים ב-TerminateThread. זו פונקציה מסוכנת ששוברת locks, heap ומצב DLL, והיא יעד של אזהרת code analysis C6258. עצירה מתכננים כ-cooperative cancellation: stop event יחד עם WaitForMultipleObjects.67
  • עבודה קצרה במקביל לא holכים ל-thread עצמי — זורקים ל-Windows thread pool (CreateThreadpoolWork). אסור לסיים thread של ה-pool ב-ExitThread / TerminateThread.89
  • ב-DllMain לא יוצרים thread, לא מסנכרנים ולא מחכים ש-thread יסתיים. הקריאה מגיעה כשה-loader lock מוחזק, וזה חממה ל-deadlock.10
  • <threads.h> של C11 זמין מ-VS 2022 17.8, אבל <stdatomic.h> עדיין experimental. ל-codebase של Windows בלבד, הגישה של Win32 API היא הבחירה המציאותית.11

2. למה multithreading קשה — race condition ו-deadlock

אם מצמצמים את הבעיות ש-multithreading מכניס, בלי קשר לשפה, נשארים שני סוגים.

race condition הוא באג שבו התוצאה משתנה לפי הסדר שבו כמה threads מגיעים לקטע קוד. הדוגמה הקלאסית היא counter משותף: הביטוי count++ מתפרק ברמת machine code לשלושה שלבים — קריאה, חיבור, כתיבה חזרה. אם שני threads נכנסים לשלושת השלבים האלה באותו זמן, החיבור של אחד נדרס ונעלם כשהשני כותב חזרה.4 התוצאה משתנה מהרצה להרצה, ואי אפשר לחזות איזו תוצאה תתקבל.

thread Bמשתנה משותף countthread Athread Bמשתנה משותף countthread Acount = 10count = 11 אחרי שתי הגדלותההגדלה של thread A אבדהקריאה (10)קריאה (10)חיבור מקומי (11)חיבור מקומי (11)כתיבה חזרה (11)כתיבה חזרה (11)

איור 1: ה-race condition הקלאסי שבו counter משותף מאבד הגדלה. אם thread אחר נכנס בין שלושת שלבי count++, הכתיבה חזרה המאוחרת דורסת את השנייה

deadlock הוא מצב שבו שני threads מחכים כל אחד ל-lock שהשני מחזיק, ואף אחד לא מתקדם. thread A מחזיק lock 1 ומחכה ל-lock 2; thread B מחזיק lock 2 ומחכה ל-lock 1 — די בזה כדי ששניהם ייעצרו לתמיד.

מחכה ש-lock 2 ישתחררמחכה ש-lock 1 ישתחררthread Aמחזיק lock 1thread Bמחזיק lock 2

איור 2: המתנה מעגלית של deadlock. ברגע שחצי ההמתנה סוגרים טבעת, כל thread בטבעת נעצר לתמיד

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

2.1. הנחות ייחודיות ל-C — השפה לא שומרת עליכם

ב-C אין מנגנון בשפה שכופה את העקרונות האלה, ולכן צריך לכתוב אותם במפורש כמשמעת.

ראשית, לבנות את ערבות השחרור לתוך המבנה. בלי מקבילה ל-RAII של C++, שחרור lock ו-CloseHandle על handle נשמרים בדפוס goto cleanup שמעביר את כל היציאות מהפונקציה במקום אחד, או במוסכמה שמצמידה כל acquire ל-release. return מוקדם שדולף lock הוא תאונה קלאסית ב-C.

שנית, data race מטופל כמו ב-C++. קריאה או כתיבה פשוטה של משתנה 32-bit שמיושר נכון היא atomic ב-Windows, אבל מעבר לזה — משתנה 64-bit ב-Windows של 32-bit, פעולה מורכבת, עקביות בין כמה משתנים — אין שום הבטחה.12 קוד ש«במקרה עובד» נשבר כשהקומפיילר או רמת האופטימיזציה משתנים.

שלישית, להחליט בעלות. תרבות לכתוב בהערת פונקציה «איזה thread כותב ל-buffer הזה, ומאיזה רגע הוא שייך למי» עוזרת ב-multithreading ב-C לא פחות מבחירת synchronization primitive.

3. איך יוצרים thread — רק _beginthreadex

3.1. למה CreateThread היא הבחירה הלא נכונה

ה-API הילידי של Win32 הוא CreateThread, אבל ההנחיה הרשמית היא שthread שקורא לפונקציות CRT נוצר ב-_beginthreadex. _beginthreadex מאתחל את הנתונים הפנימיים ש-CRT צריך לכל thread, ורק אז מתחיל אותו. אם thread שנוצר ב-CreateThread קורא לפונקציית CRT, ה-CRT עלול לסיים את ה-process במצב של זיכרון נמוך.12 printf, malloc ו-strtok כולן CRT, אז הכלל המעשי: «thread שנכתב ב-C תמיד נוצר ב-_beginthreadex».

גם מ-_beginthread (בלי ex) נמנעים. יש מלכודת: אם ה-thread שהוא יוצר מסתיים מוקדם, ה-handle שחזר כבר עלול להיות לא תקף — ואפילו להצביע ל-thread אחר. _beginthreadex, שאפשר להעביר את ה-handle שלו בבטחה ל-API של סנכרון, בטוח יותר. מי שקורא סוגר את ה-handle ש-_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);  /* join */
CloseHandle(hThread);

3.2. עבודה קצרה הולכת ל-Windows thread pool

אם רוצים «לזרוק הרבה עבודות קטנות» או מוצאים את עצמכם «יוצרים והורסים threads קצרי-חיים שוב ושוב», משתמשים בWindows thread pool (ממשק ה-thread pool מ-Vista ואילך) במקום ב-thread עצמי. יוצרים work object ב-CreateThreadpoolWork ומגישים ב-SubmitThreadpoolWork, ו-worker threads של ה-pool מריצים את ה-callback במקביל.8 ניהול מספר ה-threads נשאר ל-OS, ועלות היצירה וההריסה נעלמת. זו תשובת C לעיקרון «לא ליצור threads בעצמכם».

המשמעת בשימוש ב-pool גם היא מתועדת רשמית: לא מסיימים thread של ה-pool ב-TerminateThread / ExitThread; מצב ששיניתם בתוך callback (TLS, עדיפות thread וכו’) מחזירים לפני החזרה; wait handles נשארים בחיים עד שה-pool סיים איתם.9 עוד הערה מעשית: ה-pool מגביל רק את מספר ה-worker threads. מספר ה-callbacks שעדיין לא רצו, שהוגשו ב-SubmitThreadpoolWork, יכול להיערם בלי גבול. ב-resident process שבו ההגשות ממשיכות להקדים את העיבוד, שמים הגבלת כניסה כמו semaphore, או תור עם קיבולת, בצד היישום, כך שצד המגיש מחכה או נדחה כשהוא מלא (זה backpressure כדי שעומס יתר לא יהפוך לבעיית זיכרון, ואותו עיקרון כמו תכנון התור בפרק 5).

4. לצמצם shared mutable state — פיצול, קריאה בלבד והעברה

race קורה רק כש«כמה threads» ו«נתונים משתנים משותפים» נמצאים יחד. לפני שבוחרים synchronization primitive (הפרק הבא), בודקים אם אפשר לצמצם את השיתוף מלכתחילה. יש שלוש משפחות.

פיצול. בצבירה במקביל, במקום שכל thread יכתוב ל-counter משותף, בונים סיכום ביניים במשתנה מקומי לכל thread (או buffer שהוקצה לכל thread), וממזגים פעם אחת בסוף עם משהו כמו InterlockedAdd. כתיבות למצב משותף יורדות מ«כל איטרציה» ל«פעם לכל thread», וגם עלות הסנכרון וגם חלון ה-race קטנים בסדרי גודל. משמעת הבעלות מפרק 2.1 — «לאיזה thread שייך ה-buffer הזה» — הופכת לתרשים איך מפצלים.

קריאה בלבד. הגדרות וטבלאות שנבנות בהפעלה ולא משתנות אחר כך בטוחות לקריאה מכל מספר threads אחרי שהאתחול הסתיים. או מסיימים את כל האתחול לפני ש-threads מתחילים, או, אם צריך lazy init, משתמשים ב-one-time initialization של Win32 (InitOnceExecuteOnce), והגבול — «מאיזה רגע זה קריאה בלבד» — נהיה מפורש בקוד.3

העברה. במקום ששני הצדדים ייגעו במשתנה משותף, מנתבים את זרימת הנתונים בין threads דרך תור producer/consumer. ב-C המימוש הוא בדיוק דפוס ה-condition variable מפרק 5 (circular buffer עם קיבולת יחד עם SleepConditionVariableCS), שזו דוגמת המימוש הרשמית; buffer עם גבול קיבולת גם נותן backpressure טבעי — הייצור מחכה ברגע שהוא מקדים את הצריכה.5

5. בחירת synchronization object ומשמעת locks

ל-Win32 יש הרבה סוגי synchronization primitives, ובחירה שגויה פוגעת גם בביצועים וגם בנכונות. ההנחיה הרשמית בתמונה אחת:3

כןexclusionהגבלת מספר גישות במקבילהודעה שאירוע קרהלא (בתוך ה-process)כןלאכןלאצריך סנכרוןבין processes?לשם מה?named Mutexnamed semaphorenamed eventצריך recursive acquireמאותו thread?CRITICAL_SECTIONקוד C++ עםעדיפות ל-portability?std::mutex /std::shared_mutexSRW lock (ברירת המחדל)

איור 3: איך בוחרים synchronization primitive של Win32. הענף הראשון הוא «האם זה חוצה processes» — הנקודה היא לא לבחור kernel object (Mutex) כשזה לא חוצה

primitive היקף מאפיינים איפה משתמשים
SRW lock בתוך process מהיר (בדרך כלל נשאר כולו ב-user mode), בגודל pointer, לא recursive ברירת המחדל לקוד חדש. AcquireSRWLockShared גם מאפשר קריאה משותפת
CRITICAL_SECTION בתוך process מהיר (spin ואז kernel wait), recursive כשאותו thread צריך recursive acquire
Mutex בתוך process / בין processes תמיד kernel object, ולכן איטי יותר exclusion בין processes (named), או יחד עם WaitForMultipleObjects
semaphore בתוך process / בין processes kernel object הגבלת גישה במקביל למאגר משאבים
event בתוך process / בין processes kernel object להודיע ש«משהו קרה» (לא להגנת נתונים)
פונקציות Interlocked בתוך process (וגם בין processes, על shared memory) פעולות atomic בלי lock counters, flags, החלפת pointers4

הערה לטבלה ולתרשים. kernel objects כמו event, semaphore ו-Mutex עובדים גם לסנכרון בתוך process כשיוצרים אותם בלי שם (ה-stop event בפרק 6 הוא בדיוק unnamed event). kernel object לא אומר «רק בין processes». ולהפך, «בלי שם» לא אומר בהכרח «כלוא ב-process אחד» — אם תהליך-בן יורש את ה-handle, או מכפילים אותו ל-process אחר ב-DuplicateHandle, אפשר להשתמש באותו kernel object מכמה processes גם בלי שם. הניסוח המדויק: מתן שם הוא דרך מייצגת לאפשר ל-processes לפתוח מחדש את אותו object. הענף באיור 3 תופס את נקודת הבחירה «לא בוחרים kernel object ל-lock בתוך process» — להודעה בתוך process (event) או להגבלת מקביליות (semaphore), unnamed kernel object עדיין התשובה הנכונה.

משפחת Interlocked מקבילה למחלקת Interlocked במהדורת .NET ול-std::atomic במהדורת C++. InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange מבצעות פעולה על משתנה בודד באופן שאי אפשר לפצל, וכי רוב הפונקציות כוללות full memory barrier, מקבלים גם הבטחת סדר.4 «סימנתי volatile אז זה בסדר» הוא תפיסה שגויה — volatile לא מבטיח atomicity ולא סדר (ראו FAQ). יש עוד תנאי מוקדם: alignment. המשתנה שיעד של פונקציית Interlocked חייב להיות מיושר על גבול טבעי (גבול 4 bytes לערך 32-bit, גבול 8 bytes לערך 64-bit); בלי זה ההתנהגות לא ניתנת לחיזוי.12 אסור לכוון שדה בתוך struct עם #pragma pack, או שדה ב-buffer שממופה ישר לפורמט תקשורת, לפונקציית Interlocked. counters ו-flags מגבילים למשתנים שהוצהרו כרגיל — כאלה שהקומפיילר יישר. יש גם אזהרה ספציפית סביב החלפת pointer במשהו כמו InterlockedExchangePointer: רק ההחלפה עצמה היא atomic, ואף אחד לא שומר על חיי הבלוק הישן אחרי ההחלפה. אם קורא טוען את ה-pointer הישן בדיוק כשכותב מחליף אותו וקורא ל-free, זו גישה לזיכרון ששוחרר. תכנון שמעדכן נתונים משותפים בהחלפת pointer עובד רק כשהוא צמוד לפרוטוקול reclaim —‏ lock, reference count או דומה (אם יש ספק, להגן ב-SRW lock זו ברירת המחדל הבטוחה).

להמתנה למשהו יש condition variable. יוצרים אחד ב-InitializeConditionVariable; ה-consumer ישן על SleepConditionVariableCS (בזוג עם CRITICAL_SECTION), וה-producer מעיר ב-WakeConditionVariable — זו בדיוק צורת דוגמת המימוש הרשמית של תור producer/consumer על bounded buffer.5 המשמעת החשובה: בהתעוררות תמיד בודקים מחדש את התנאי (האם התור אינו ריק) בתוך ה-lock, וחוזרים ל-wait אם הוא שקרי. ל-condition variable יש spurious wakeup בלי הודעה בכלל, ועד שמתעוררים consumer אחר כבר יכול היה לקחת את הפריט — כך ש«העירו אותי» לא אומר «התנאי מתקיים». זה הכלי של C לבנות את אותה צורה כמו ה-channel במהדורת .NET פרק 4.3 ו-BlockingQueue במהדורת C++ פרק 4. כשמצמידים ל-SRW lock משתמשים ב-SleepConditionVariableSRW.

5.1. משמעת locks — שלושה עקרונות שלא תלויים באיזה primitive בחרתם

גם אם בחרתם את ה-primitive הנכון, בלי משמעת בשימוש עדיין לא מונעים race.

  • מחליטים, אחד לאחד, איזה lock מגן על אילו נתונים. מקצים בדיוק lock אחד (SRW lock או CRITICAL_SECTION) לכל קבוצת נתונים משתנים שרוצים להגן עליהם, ולוקחים את אותו lock ב-כל מקום שנוגע בנתונים האלה. ב-C במיוחד, משתלם לכתוב את זה בהערת כותרת — «ה-struct הזה מוגן ב-g_lockFoo».
  • לא עושים שום דבר איטי או חיצוני כשמחזיקים lock. הדבר היחיד שמותר תוך החזקת lock הוא לקרוא או לכתוב את הנתונים שהוא מגן עליהם. file I/O, קריאות רשת או הפעלת callback תוך כדי החזקת ה-lock מאריכים את זמן ההחזקה, והנקרא עלול לנסות לקחת lock אחר וליצור את ההמתנה המעגלית מאיור 2.
  • קובעים סדר רכישה לכמה locks. בכל מקום שלוקחים שני locks או יותר, הופכים לכלל שכל thread לוקח אותם באותו סדר (היררכיית locks). מסמך ה-best practices של DLL קובע במפורש שהיפוך הסדר הזה (lock order inversion) מייצר deadlocks שקשה לנפות, ושצריך להגדיר היררכיה ולעקוב אחריה בעקביות.10

6. תכנון איך עוצרים — בלי TerminateThread

6.1. מה TerminateThread שובר

TerminateThread מוחק את ה-thread היעד בלי לתת לו להריץ שום קוד ב-user mode. התוצאות שהתיעוד הרשמי מונה חמורות. אם ה-thread היעד החזיק critical section, היא לא תשתחרר לעולם; אם הוא היה באמצע פעולת heap, ה-heap lock נשאר תפוס (וכל thread הבא שקורא ל-malloc נתקע); ואם הוא שינה global state של DLL, המצב הזה נהרס. העמדה הרשמית: «פונקציה מסוכנת שמשתמשים בה רק במקרים הקיצוניים ביותר», ו-code analysis מסמן אותה כאזהרת C6258.67

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

6.2. הצורה הנכונה: stop event יחד עם WaitForMultipleObjects

הדפוס המבוסס ל-cooperative cancellation ב-C הוא ליצור stop event אחד ב-manual reset, ולתת לכל worker thread לחכות ל«אות עבודה» ול«אות עצירה» באותו זמן. תיעוד אזהרת C6258 עצמו מצביע על הצורה הזאת (יוצרים event, כל thread עוקב אחריו ב-WaitForSingleObject ומסיים לבד) כדרך הסיום הנכונה.7

HANDLE hStopEvent;   /* CreateEvent(NULL, TRUE, FALSE, NULL): manual reset */
HANDLE hWorkEvent;   /* CreateEvent(NULL, FALSE, FALSE, NULL): auto-reset.
                        חוזר מעצמו ל-nonsignaled כשמתקבל
                        (manual reset היה מעביר המתנות ישר
                        אחרי שסומן פעם אחת, והופך ל-busy loop
                        על תור ריק) */

static unsigned __stdcall WorkerMain(void* arg)
{
    HANDLE waits[2] = { hStopEvent, hWorkEvent };
    for (;;) {
        DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
        if (r == WAIT_FAILED) {          /* handle לא תקף וכו'. בלי טיפול זה סחרור מלא */
            LogLastError();              /* רושמים GetLastError() ויוצאים */
            break;
        }
        if (r == WAIT_OBJECT_0)          /* בקשת עצירה */
            break;
        if (r == WAIT_OBJECT_0 + 1) {    /* יש עבודה */
            /* מעבירים גם את ה-stop event ל-ProcessNextItem: אם הוא מחכה
               הרבה זמן בתוך פריט אחד, ובלי לראות שם עצירה,
               הכיבוי נעשה בן ערובה של הפריט ההוא */
            while (ProcessNextItem(hStopEvent)) {  /* פריט אחד מהתור. ריק = FALSE */
                /* בודקים בקשת עצירה גם בזמן הריקון. בלי זה
                   אי אפשר לעצור כל עוד עבודה ממשיכה להיערם (stop starvation) */
                if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
                    break;
            }
        }
    }
    Cleanup();                           /* הניקוי של עצמכם, בעצמכם */
    return 0;                            /* מסיימים בעצמכם */
}

BOOL StopWorkers(HANDLE* threads, DWORD count)
{
    BOOL ok = TRUE;
    if (!SetEvent(hStopEvent)) {             /* אם בקשת העצירה לא הגיעה, */
        LogLastError();                      /* לא נכנסים ל-join בלי הגבלה */
        return FALSE;
    }
    /* עכשיו לכולם בקשת העצירה מורמת בבת אחת */
    for (DWORD i = 0; i < count; i++) {
        if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
            CloseHandle(threads[i]);         /* סוגרים רק handle שאישרנו ש-join הסתיים */
        } else {
            LogLastError();                  /* WAIT_FAILED: handle לא תקף וכו' */
            ok = FALSE;                      /* לא מדווחים «כולם נעצרו» */
        }
    }
    return ok;   /* FALSE = אסור להמשיך לשחרור משאבים משותפים */
}

יש סיבה שצד העוצר עושה join ל-thread אחד בכל פעם ב-WaitForSingleObject. WaitForMultipleObjects יכול לחכות לכל היותר ל-MAXIMUM_WAIT_OBJECTS (64) handles בבת אחת; מעבירים מערך גדול יותר וההמתנה עצמה נכשלת ב-WAIT_FAILED, וסוגרים handles מתוך אמונה שחיכיתם לכולם בזמן שבפועל לא חיכיתם לאף אחד. אם כל מה שצריך הוא לחכות שכולם יסיימו, לולאה אחד-אחד בלי תקרה עליונה היא הבחירה הבטוחה.

SetEvent(hStopEvent)צד העוצרstop event(manual reset: כולם רואים)worker 1:WaitForMultipleObjects עלעצירה ועבודה יחדworker 2:WaitForMultipleObjects עלעצירה ועבודה יחדמנקה ועושה return בעצמומנקה ועושה return בעצמוצד העוצר מחכה ל-thread handles ועושה joinרק אז אפשר להגיד שנעצר

איור 4: דפוס ה-stop event. stop event ב-manual reset אומר ש-SetEvent אחד מעיר את כל ה-workers שממתינים בבת אחת. איך מסיימים מחליט כל thread בעצמו, והעצירה נחשבת רק אחרי שה-join הסתיים

שלוש נקודות. ה-stop event הוא manual reset (SetEvent אחד נראה לכל worker); שמים את ה-stop event ראשון במערך ההמתנה (כששניהם מסומנים יחד, לעצירה יש עדיפות); וצד העוצר חייב תמיד לחכות ל-join על ה-thread handles לפני שסוגר אותם.

שתי הסתייגויות לגבי היקף. ראשית, הצורה «event + ריקון-הכול» מיועדת ל-worker אחד. כמה שלא תקראו ל-SetEvent על auto-reset event, הוא יכול לבטא רק «יש מצב מסומן אחד» (אותות רצופים מתמזגים), כך שעם כמה workers רק אחד מתעורר ומעבד את כל ה-burst בטור. אם כמה workers חולקים תור, מחליפים את אות העבודה ל-semaphore, ומעלים את הספירה ב-ReleaseSemaphore(hSem, 1, NULL) בכל פעם שפריט נכנס לתור. המתנת semaphore מוצלחת צורכת 1 מהספירה, ומקבלים את ההתאמה הנכונה: מספר ה-workers שממתינים שקמים, אחד בכל פעם, תואם למספר הפריטים שבתור (השימוש הזה נמצא בלב תחום ה-semaphore, כמו «הגבלת גישה במקביל למאגר משאבים» בטבלת איור 3). אבל כשעוברים ל-semaphore, משנים גם את צד ה-consumer כך ש-המתנה מוצלחת אחת שווה לעיבוד פריט אחד בדיוק מהתור. אם משאירים את לולאת ריקון-הכול מהדוגמה למעלה כפי שהיא, המתנה אחת — שצרכה היתר אחד בלבד — תרוקן את כל התור, והחשבון יישבר: workers אחרים יתעוררו לתור ריק על ההיתרים שנשארו, ו-ReleaseSemaphore של ה-producer יתחיל להיכשל כי חרג מהספירה המרבית. שמירת ההתאמה «היתר אחד = עבודה אחת» היא תנאי מוקדם לגישת ה-semaphore. שנית, מעבירים את נתיב העצירה גם דרך עיבוד של פריט בודד. אם ProcessNextItem מחכה בפנים המתנה חוסמת ארוכה, או מעבירים גם לשם את ה-stop event ומחכים לשניהם יחד, או שמים timeout סופי. בדיקה רק בין פריטים משאירה חור שבו «הכיבוי מחכה לנצח כי פריט אחד לא נגמר». זה אומר בדיוק מה שאומרים StopAsync במהדורת .NET ו-jthread יחד עם join במהדורת C++.

thread שמחכה על I/O חוסם (pipe, socket, serial port) לא יכול לחזור לבדוק את ה-event, ולכן צד ה-I/O צריך תכנון משלו — או OVERLAPPED יחד עם event שמחכים להם יחד, או מעירים את ה-I/O ב-CancelIoEx (לדוגמה מוחשית בסיריאל, ראו «המלכודות של יישום תקשורת טורית - עד לתכנון חיבור מחדש ויומן»).

7. DllMain ו-loader lock — שדה מוקשים לכותבי DLL

רכיבים משותפים שנכתבים ב-C הופכים לעיתים קרובות ל-DLL, ול-DLL יש אילוץ משלהם: loader lock. ה-loader של ה-OS קורא ל-DllMain כשהוא מחזיק את ה-loader lock, כך שכל אחד מהבאים בתוכו הופך למקור deadlock או crash.10

  • סנכרון עם threads אחרים (רכישת lock, המתנה ש-thread יסתיים)
  • קריאה ל-LoadLibrary / FreeLibrary, ישירות או בעקיפין
  • יצירת thread (מסוכן אם כרוך בסנכרון) או ExitThread

«לחכות בתוך DllMain ש-worker thread יסתיים כשה-DLL נפרק» נראה סביר לגמרי אבל הוא deadlock קלאסי: ה-thread שמסיים מנסה לקחת את ה-loader lock כדי למסור DLL_THREAD_DETACH, ושני הצדדים מחכים זה לזה. DLL עם threads משלו צריך לחשוף פונקציות אתחול וכיבוי מפורשות — משהו כמו MyLib_Init / MyLib_Shutdown — ולעשות שם את הפעלת ה-threads ואת ה-join. DllMain האידיאלי קרוב ל-stub ריק.10

8. אפשרות C11 threads — איפה זה עומד

אם רוצים לכתוב C נייד בלי תלות ב-Win32, האפשרות היא <threads.h> של C11 (thrd_create / mtx_lock / cnd_wait) ו-<stdatomic.h>. לפי טבלת ה-conformance הרשמית, התמיכה של MSVC עומדת כך: <threads.h> נתמך מ-Visual Studio 2022 17.8 (דורש /std:c11 ו-Windows SDK תואם), בעוד <stdatomic.h> עדיין experimental ודורש את /experimental:c11atomics.11

אם שיתוף קוד עם Linux הוא דרישה, ל-C11 threads (או עטיפת pthread) יש ערך, אבל ל-codebase של Windows בלבד, גישת Win32 שמאמר זה מכסה יש יתרון בנפח החומר, בניסיון ובקלות הניפוי. מה שלא תבחרו, עקרונות התכנון עד כאן — לצמצם שיתוף, ההתאמה בין lock לנתונים, ו-cooperative cancellation — לא משתנים.

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

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

קו ההגנה הראשון הוא התכנון. בסקירה מאשרים בטבלה: אילו נתונים משתנים משותפים, איזה lock מגן על כל חלק (טבלת ההתאמה מפרק 5.1), האם סדר רכישת ה-locks חד-משמעי, והאם ה-stop event מגיע לכל worker. תכנון שאי אפשר למלא בו את הטבלה הזאת עדיין לא גמור, גם אם כרגע הוא רץ.

שנית, הופכים חריגה למשהו שאפשר לראות. במקום לחכות בלי תנאי ב-INFINITE, שמים timeout בנקודות מפתח ורושמים ללוג כשהזמן נגמר, והופכים hang שהיה נמשך לנצח לכשל שאפשר לגלות. כש-hang קורה בשטח, לוכדים dump, בודקים את ה-stack של כל thread, ומחפשים מעגל מי מחכה ל-lock של מי. בדיקה ב-Application Verifier מומלצת רשמית לשגיאות סביב DLL.10 בניית dump ולוג מכוסה ב«תכנון שמירת יומנים ו-dump בקריסת יישום Windows».

שלישית, מנערים בעומס. stress tests — ריצה ארוכה עם יותר threads ממספר הליבות, ערבוב סדר עיבוד, הכנסת השהיות מלאכותיות — הן דרך מעשית להעלות את הסיכוי שתפגעו ב«זכייה» של race במכונת הפיתוח. לא שוכחים גם בדיקת שחזור ב-release build ממוטב תחת עומס כבד.

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

  1. האם כל thread נוצר ב-_beginthreadex (בלי ערבוב CreateThread / _beginthread)?
  2. האם עושים join ל-thread handle (WaitForSingleObject) לפני CloseHandle?
  3. האם לא מייצרים בהמונים threads עצמיים לעבודה קצרת-חיים (אפשר לזרוק ל-thread pool API)?
  4. האם exclusion בתוך ה-process הוא SRW lock / CRITICAL_SECTION (ולא שימוש שגוי ב-Mutex)?
  5. האם counters ו-flags משותפים מתעדכנים ב-Interlocked ולא נשענים על volatile?
  6. האם לא נשאר polling עם Sleep (הוחלף ב-condition variable או בהמתנת event)?
  7. האם אין בכלל TerminateThread (הריגת thread אחר בכוח)? האם workers מסיימים ב-return מפונקציית ה-thread במקום לקרוא ל-ExitThread (כדי שניקוי CRT ירוץ נכון דרך _endthreadex)?
  8. האם לכל worker יש נתיב עצירה דרך stop event יחד עם WaitForMultipleObjects, ואפשר גם להעיר threads שחסומים על I/O?
  9. האם שחרור locks ו-handles מובטח בכל נתיב חזרה (משמעת goto cleanup)?
  10. האם DllMain לא יוצר threads, לא מסנכרן ולא מחכה ש-thread יסתיים?

בתמורה לכך שאין עזרה מהשפה, איכות ה-multithreading ב-C היא בדיוק מה שבחירות ה-API והמשמעת עושות ממנה. הופכים את _beginthreadex, SRW lock, Interlocked וה-stop event לערכת ארבעת החלקים כברירת מחדל, ואפילו ב-C אפשר לתכנן במרחק מ«מדי פעם נתקע».

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

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

KomuraSoft LLC מטפלת בסקירת תכנון multithreading ל-resident process, ליישומי בקרת ציוד ול-DLL שנכתבים ב-C; בחקירת שורש (ניתוח dump) של hangs ו-crashes שנגרמו מ-TerminateThread או מ-lock שדלף; ובייעוץ טכני על הוספת threads לקוד C ישן.

מקורות

  1. Microsoft Learn, CreateThread function. threads בתוך executable שקורא ל-CRT צריכים להיות מנוהלים ב-_beginthreadex / _endthreadex ולא ב-CreateThread / ExitThread, ו-CRT עלול לסיים את ה-process במצב זיכרון נמוך כש-thread שנוצר ב-CreateThread קורא ל-CRT. ↩ ↩2

  2. Microsoft Learn, Multithreading with C and Win32. תוכניות שקוראות לספריית CRT צריכות להתחיל threads ב-_beginthread / _beginthreadex ולא ב-CreateThread / ExitThread של Win32; משפחת _beginthread מאתחלת את משתני CRT לכל thread; ו-SuspendThread יכול לעצור thread בזמן שהוא ניגש למבני נתונים פנימיים של CRT, וזה עלול להוביל ל-deadlock. ↩ ↩2

  3. Microsoft Learn, About Synchronization. הנחיית בחירת synchronization primitives של Win32: SRW lock כברירת מחדל לקוד חדש, בגודל pointer ובדרך כלל נשאר ב-user mode; CRITICAL_SECTION למקרים שצריכים recursive acquire; Mutex תמיד kernel object, משמש לסנכרון named בין processes ובשילוב עם WaitForMultipleObjects; שימוש ב-Mutex לסנכרון בתוך process הוא «טעות נפוצה» שאיטית בהרבה תחת פעולות תכופות; semaphore משמש להגבלת גישה במקביל למאגר משאבים, event להודעה. ↩ ↩2 ↩3

  4. Microsoft Learn, Interlocked Variable Access. פונקציות Interlocked מסנכרנות גישה למשתנה שמשותף בין כמה threads ומבצעות את הפעולה באופן שאי אפשר לפצל; InterlockedIncrement / Decrement אוגדות קריאה, חיבור וכתיבה חזרה לפעולה atomic אחת, כי בלי סנכרון הגדלה בו-זמנית משני threads יכולה לאבד אחת מההגדלות; משפחת InterlockedExchange / InterlockedCompareExchange; שימוש בין threads ב-processes שונים כשהמשתנה ב-shared memory; רוב פונקציות Interlocked מספקות full memory barrier, עם וריאנטי Acquire / Release לבחירת סמנטיקת סדר. ↩ ↩2 ↩3 ↩4

  5. Microsoft Learn, Using Condition Variables. דוגמת מימוש של תור producer/consumer על circular buffer עם קיבולת המוגן ב-CRITICAL_SECTION; המבנה שבו InitializeConditionVariable יוצר condition variable, ה-consumer ממתין ב-SleepConditionVariableCS, וה-producer מעיר ב-WakeConditionVariable; תמיכה ב-condition variable מ-Windows Vista ואילך. ↩ ↩2 ↩3

  6. Microsoft Learn, TerminateThread function. TerminateThread מסיים את ה-thread היעד בלי לתת לו להריץ קוד ב-user mode; critical section של היעד לא משתחררת אם הוא החזיק אחת; heap lock לא משתחרר אם ה-thread הקצה זיכרון מה-heap; מצב kernel32 או global state של DLL עלולים להיהרס; זו «פונקציה מסוכנת שמשתמשים בה רק במקרים הקיצוניים ביותר», ואין לקרוא לה אלא אם יודעים ושולטים לגמרי בכל נתיב קוד שה-thread היעד יכול היה להריץ. ↩ ↩2

  7. Microsoft Learn, Warning C6258. אזהרת code analysis C6258 מגלה שימוש ב-TerminateThread; TerminateThread אינו יכול לבצע ניקוי thread נאות; הליך הסיום הנכון שמוצג הוא ליצור event ב-CreateEvent, לתת לכל thread לעקוב אחרי מצב ה-event ב-WaitForSingleObject, ולתת ל-thread לסיים את הביצוע בעצמו ברגע שה-event נעשה signaled. ↩ ↩2 ↩3

  8. Microsoft Learn, CreateThreadpoolWork function. יוצרים work object ב-CreateThreadpoolWork, ו-worker thread ב-pool מריץ את ה-callback בכל קריאה ל-SubmitThreadpoolWork; אפשר לציין סביבת ביצוע דרך callback environment (TP_CALLBACK_ENVIRON); זמין מ-Windows Vista ואילך. ↩ ↩2

  9. Microsoft Learn, Thread Pools. thread pool מתאים ליישומים שמריצים הרבה עבודות קצרות אסינכרוניות, או שיוצרים לעיתים קרובות threads קצרי-חיים; רכיבי ממשק ה-thread pool החדש שעוצב מחדש ב-Vista; כ-best practice לא מסיימים thread של ה-pool ב-TerminateThread ולא קוראים ל-ExitThread מתוך callback, מנקים מצב שנוצר ב-callback לפני החזרה, ומשאירים wait handles בחיים עד שה-pool סיים איתם. ↩ ↩2

  10. Microsoft Learn, Dynamic-Link Library Best Practices. DllMain נקראת כשה-loader lock מוחזק, מה שמטיל הגבלות חמורות על אילו APIs בטוח לקרוא; סנכרון עם threads אחרים בתוך DllMain מוביל ל-deadlock; קריאה ל-LoadLibrary ברשימת הפעולות האסורות; הדפוס שבו המתנה ש-thread יסתיים בתוך DllMain בפריקת DLL נכנסת ל-deadlock מול ניסיון ה-thread עצמו לרכוש את ה-loader lock כדי למסור DLL_THREAD_DETACH; DllMain האידיאלי קרוב ל-stub ריק, עם אתחול שנדחה ככל האפשר; וצריך להגדיר היררכיית locks עם ה-loader lock בראש. ↩ ↩2 ↩3 ↩4 ↩5

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. טבלת ה-conformance של ספריית התקן של C: C11 threads (threads.h) נתמכים מ-Visual Studio 2022 17.8; stdatomic.h מטופל כ-experimental (מאחורי /experimental:c11atomics); ותמיכת הקומפיילר ב-C11 / C17 דורשת Visual Studio 2019 16.8 ואילך יחד עם Windows SDK תואם. ↩ ↩2

  12. Microsoft Learn, Interlocked Variable Access. קריאה או כתיבה פשוטה של משתנה 32-bit שמיושר נכון היא atomic, אבל סנכרון (סדר) הגישה אינו מובטח; קריאה או כתיבה פשוטה של משתנה 64-bit היא atomic ב-Windows של 64-bit אבל אינה מובטחת ב-Windows של 32-bit; משתנים בגדלים אחרים אינם מובטחים כ-atomic באף פלטפורמה. ↩ ↩2

  13. Microsoft Learn, _beginthread, _beginthreadex. למה _beginthreadex בטוח יותר מ-_beginthread: thread שנוצר ב-_beginthread יכול להשאיר את ה-handle שחזר לא תקף (או מצביע ל-thread אחר) אם הוא מסתיים מוקדם; את ה-handle מ-_beginthreadex הקורא חייב לסגור ב-CloseHandle ותקפותו מובטחת; _beginthreadex מאפשר להעביר את ה-handle ל-API של סנכרון; פונקציית ה-thread מחזירה thread exit code לפי מוסכמת הקריאה __stdcall; ונדרש קישור ל-CRT multithreaded. ↩

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

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

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

שאלות נפוצות

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

להשתמש ב-CreateThread או ב-_beginthreadex?
Thread שקורא לפונקציות של CRT (C runtime library) נוצר ב-_beginthreadex. _beginthreadex מאתחל את הנתונים הפנימיים ש-CRT צריך לכל thread, ורק אז מתחיל אותו. התיעוד הרשמי קובע במפורש שאם thread שנוצר ב-CreateThread קורא לפונקציית CRT, ה-CRT עלול לסיים את ה-process כשהזיכרון נגמר. בפועל, thread ביישום C כמעט תמיד קורא לפונקציית CRT איפשהו (printf, malloc, strtok וכו'), כך שאפשר פשוט לזכור «תמיד _beginthreadex». גם מ-_beginthread (בלי ex) נמנעים: אם ה-thread מסתיים מוקדם, ה-handle שחזר כבר עלול להיות לא תקף, ולכן בוחרים ב-_beginthreadex שאפשר להעביר את ה-handle שלו ל-API של סנכרון.
אסור לעצור thread עם TerminateThread?
אסור. TerminateThread מוחק את ה-thread בלי לתת לו להריץ שום קוד ב-user mode. אם הוא החזיק critical section, היא לא תשתחרר; אם הוא היה באמצע הקצאה מה-heap, ה-heap lock נשאר תפוס; אם הוא שינה global state של DLL, המצב של ה-DLL נהרס. התיעוד הרשמי מגדיר אותה «פונקציה מסוכנת שמשתמשים בה רק במקרים הקיצוניים ביותר», ו-code analysis מסמן אותה כאזהרת C6258. העצירה הנכונה היא cooperative: יוצרים stop event, כל thread עוקב אחריו ב-WaitForSingleObject / WaitForMultipleObjects, מנקה אחרי עצמו ומסיים לבד.
השתמשתי ב-Mutex ל-exclusion בתוך process. מה רע בזה?
זה עובד, אבל הביצועים נפגעים חזק. Mutex של Win32 הוא תמיד kernel object, אז כל acquire ו-release עוברים ל-kernel mode. ל-exclusion בתוך אותו process, SRW lock או CRITICAL_SECTION — שנשארים ב-user mode ונופלים ל-kernel wait רק תחת contention — מהירים בהרבה, והתיעוד הרשמי קורא לשימוש ב-Mutex לסנכרון בתוך process «טעות נפוצה». מקום של Mutex הוא כשצריך exclusion בין processes כ-named object, או כשרוצים לחכות לו יחד עם kernel objects אחרים ב-WaitForMultipleObjects.
אפשר להשתמש ב-threads.h וב-stdatomic.h של C11 ב-Windows?
ב-MSVC, C11 threads (threads.h) נתמכים מ-Visual Studio 2022 17.8 (דורשים /std:c11 ו-Windows SDK תואם). stdatomic.h עדיין experimental ודורש את /experimental:c11atomics (לפי טבלת ה-conformance הרשמית, אוגוסט 2026). זו אפשרות אם portability היא העדיפות, אבל ל-codebase של Windows בלבד, לכתוב מול Win32 API (_beginthreadex, SRW lock, condition variable, Interlocked) זו הבחירה המציאותית — יש יותר ניסיון ויותר חומר.
אם שמים volatile על flag משותף, הוא בטוח?
לא. volatile ב-C רק מונע אופטימיזציות של הקומפיילר (למשל cache ל-register). הוא לא מבטיח atomicity של פעולה, וגם לא memory order בין processors. קריאה או כתיבה פשוטה של משתנה 32-bit שמיושר נכון היא atomic ב-Windows כשלעצמה, אבל «קרא, חבר, כתוב חזרה» מתפצל לשלבים, ואין הבטחה לגבי הסדר מול פעולות זיכרון מסביב. לעדכון counter או flag משותף משתמשים במשפחת Interlocked. רוב פונקציות Interlocked כוללות full memory barrier, אז מקבלים גם הבטחת סדר. כשצריך להגן על כמה משתנים יחד — SRW lock או CRITICAL_SECTION.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג