Win32 Thread Pool API — מקביליות בלי CreateThread, דרך CreateThreadpoolWork
· עודכן בתאריך: · Go Komura · Windows, multithreading, C++, פיתוח Windows, Win32 API, ביצועים
היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 22 Aug 2026)
- פרסום ראשון
לצטט את המאמר הזה(DOI (ארכיון רשום): 10.5281/zenodo.22176805)
מזהי ה־DOI שלהלן מפנים לגרסאות שנשמרו בעבר בארכיון, ועשויים שלא להתאים לטקסט הנוכחי. להפניה לטקסט הנוכחי, השתמשו בכתובת של דף זה.
Go Komura (2026). Win32 Thread Pool API — מקביליות בלי CreateThread, דרך CreateThreadpoolWork. KomuraSoft LLC. https://comcomponent.com/he/blog/win32-thread-pool-api/
- DOI (ארכיון רשום)
- 10.5281/zenodo.22176805
- DOI (הגרסה האחרונה שנרשמה)
- 10.5281/zenodo.22176806
“CreateThread אחד לכל client.” “אחד ל-timer.” “אחד להמתנה ל-event.” בקוד native של Windows קל להרבות threads בשביל עבודה קטנה. כל thread צורך stack ו-kernel object, וגם ליצירה ולהשמדה יש עלות.
מה שסופג את אי-ההתאמה הזו הוא Win32 Thread Pool API הסטנדרטי של ה-OS. האפליקציה מעבירה את העיבוד שהיא רוצה להריץ כ-callback, ומשאירה את ניהול ה-worker threads ל-OS. “בלי ליצור threads” אינו אומר ש-threads מיותרים; הכוונה היא שהאפליקציה לא יוצרת ומשמידה בעצמה לכל עבודה.1
המאמר הזה מיועד למפתחים שכותבים אפליקציות Windows, services ו-DLLs ב-C/C++. הוא מסביר בסדר בחירת כלי → בחירת אובייקט → מימוש work → נקודות זהירות ב-callback ובסיום. היעד הוא משפחת ה-API של CreateThreadpoolWork שעוצבה מחדש ב-Windows Vista. הקוד הוא שלד שימוש, בלי חלקים ייחודיים לאפליקציה כמו תורים.
1. קודם המסקנה
אם מעבדים כמות גדולה של עבודות קצרות-חיים, מנהלים עבודות ולא threads. עם זאת, מתי לעצור עבודה ומתי אפשר לשחרר אותה, מתכננים בצד האפליקציה.
שלושת צירי ההחלטה הם אלה.
- לבחור כלי. אם כלי ספריית התקן של C++ או של .NET מספיקים, משתמשים בהם. התור של ה-API הזה הוא כשרוצים ב-Win32 לאחד timers, המתנות והשלמות I/O, או לפצל pools.
- להעביר ביחידות עבודה. משתמשים ב-work, timer, wait ו-io של ה-API החדש, ומשאירים על dedicated thread עבודה שצריכה מצב ייחודי ל-thread, כמו thread priority או COM STA.
- לבנות עד הסיום כסט אחד. הבסיס הוא “לעצור הגשה → להמתין להשלמה → לסגור”. ב-callback לא חוסמים לאורך זמן, לא ממתינים סינכרונית לעבודה באותו pool, ומחזירים את מצב ה-thread למקור.
אם רוצים קודם להריץ, מתחילים מדוגמת ה-work בפרק 4 ומאשרים את משמעת ה-callback בפרק 5. סיום מרוכז של כמה אובייקטים מכוסה בפרק 6, ו-unload של DLL בפרק 7.
2. בחירת כלי — pool, dedicated thread וספריית תקן
2.1 pool מתאים לעבודה קצרת-חיים, בכמות גדולה, וממוקדת המתנה
Thread pool הוא אוסף worker threads שה-OS מנהל. ה-workers מריצים callbacks בתור, וה-OS מתאים את המספר לפי העומס. כשמפקידים בידיו עיבוד שיוצר ושובר threads בעצמו, מקטינים קוד ניהול ועלות יצירה והשמדה.1
התיעוד הרשמי מונה כמועמדים אפליקציות שמנפיקות פריטי עבודה קטנים רבים במקביל, אפליקציות שיוצרות ומשמידות threads קצרי-חיים לעיתים קרובות, אפליקציות שמעבדות עבודות עצמאיות ברקע במקביל, ואפליקציות שיש להן threads ייעודיים שממתינים ל-kernel objects או ל-events. חיפוש, network I/O וסידור threads ייעודיים להמתנה הם הטיפוסיים.1
לעומת זאת, עבודה שצריכה שינוי thread priority, COM STA, או לרוץ לאורך כל חיי ה-process, מחזיקים על dedicated thread. הקריטריון הוא אם מספיק שהעיבוד ירוץ, או שה-thread עצמו צריך “אישיות” .
flowchart TB
accTitle: בחירה בין dedicated thread ל-pool
accDescr: קודם בודקים אם נדרשת אישיות של thread כמו priority או STA, ואם העבודה רצה לאורך זמן; רק עבודה קצרת-חיים, בכמות גדולה וממוקדת המתנה שאינה אף אחד מאלה עולה ל-thread pool
q1{"צריך אישיות כמו priority או STA?"} -->|"כן"| ded["להחזיק על dedicated thread"]
q1 -->|"לא"| q2{"רץ לאורך זמן?"}
q2 -->|"כן"| ded
q2 -->|"לא"| pool["להעלות ל-thread pool"]
pool -.-> ex["עבודה קצרת-חיים, המתנה, timer, השלמת I/O"]
איור 1: ל-pool עולה רק עבודה ש”לא צריכה אישיות ומסתיימת מהר”. כל השאר נשאר על dedicated thread כמו קודם.
2.2 קודם לשאול אם ספריית התקן של C++ או .NET מספיקה
אם הרזולוציה שמספיקה היא std::async או std::thread של C++, ספריית התקן היא המועמד הראשון. יש portability, ואת ההתנהגות של std::async ו-future אפשר לטפל לפי התקן.2
הסיבה להשתמש ישירות ב-Win32 thread pool היא דרישה לאחד מנגנון callback שכולל timer, wait ו-io, לפצל pools ומספר threads לפי סוג עבודה, או לא להחזיק threads עצמאיים ב-DLL או ברכיב COM. מפרידים בין “רק רוצים מקביליות” לבין “צריך שליטה ייחודית ל-Windows”.
flowchart TB
accTitle: החלטה באיזו שכבה של כלים לכתוב
accDescr: אם כלי async או thread של C++ התקני מספיקים משתמשים בהם, וכשצריך איחוד של timer, המתנה והשלמת I/O, פיצול pool ושליטה במספר threads, או הימנעות מ-threads עצמאיים ב-DLL או ברכיב COM, משתמשים ישירות ב-Win32 thread pool
q1{"כלי C++ התקני מספיק?"} -->|"כן"| std["std::async / std::thread"]
q1 -->|"לא"| q2{"מה נדרש?"}
q2 -->|"איחוד timer, wait, io"| tp["Win32 thread pool"]
q2 -->|"פיצול pool ושליטה במספר"| tp
q2 -->|"הימנעות מ-thread עצמאי ב-DLL"| tp
איור 2: בספק, קודם ספריית התקן; כשעולה דרישה שאי אפשר לבטא שם, זה התור של ה-API הזה.
ב-.NET, ThreadPool ו-Task ממלאים את אותו תפקיד, והשלמת I/O קשורה ל-IOCP. הסבר מפורט על הקשר ראו ב-מאמר על IOCP ו-thread pool של .NET. במקומות שכלי השכבה העליונה מספיקים, אין צורך לרדת ל-Win32 API.
2.3 בקוד חדש משתמשים ב-API החדש מ-Vista ואילך
ל-Win32 thread pool API יש שני דורות. ה-API הישן שנמשך מ-Windows 2000, כמו QueueUserWorkItem ו-RegisterWaitForSingleObject, וה-API החדש ממשפחת CreateThreadpoolWork שעוצב מחדש לגמרי ב-Windows Vista.
ב-API החדש אוחדו סוגי worker threads, ויש תור timers יחיד, threads ייעודיים מתמשכים, כמה pools עצמאיים בתוך process, ו-cleanup groups. גם הרשמי מציין כיתרון את הפשטות, האמינות, הביצועים והגמישות של ה-API החדש.13
ל-API הישן יש גם אילוץ מבני: אין דרך לבטל עבודה שנכנסה לתור. בקוד חדש משתמשים ב-API החדש, ובבחינה מחדש של קוד קיים יוצאים מההתאמה הבאה.3
flowchart TB
accTitle: התאמה בין API thread pool ישן ל-API חדש
accDescr: QueueUserWorkItem של ה-API הישן מוחלף באובייקט work של ה-API החדש, תור timers ב-timer, המתנה רשומה ב-wait, ו-BindIoCompletionCallback ב-io
o1["QueueUserWorkItem"] --> n1["work"]
o2["תור timers"] --> n2["timer"]
o5["המתנה רשומה"] --> n3["wait"]
o4["BindIoCompletionCallback"] --> n4["io"]
איור 3: יעד המעבר מה-API הישן קבוע אחד-לאחד. ספירת מלאי של קוד קיים יכולה להתחיל מטבלת ההתאמה הזו.
3. המנגנון — ארבעה אובייקטים עם תנאי ירי שונים
3.1 בוחרים לפי מה אמור להפעיל
מרכז ה-API החדש הוא ארבעת הסוגים הבאים. בוחרים לא רק לפי “מה מעבדים” אלא לפי מה רוצים שיהיה הטריגר להרצת ה-callback.4
| אובייקט | פונקציית יצירה | תנאי הירי של ה-callback |
|---|---|---|
| work | CreateThreadpoolWork |
כשמגישים ב-SubmitThreadpoolWork |
| timer | CreateThreadpoolTimer |
כשמגיע הזמן או המחזור שצוין |
| wait | CreateThreadpoolWait |
כש-kernel object עובר ל-signaled |
| io | CreateThreadpoolIo |
כש-I/O אסינכרוני על ה-handle המשויך מסתיים |
גם אם תנאי הירי שונה, מי שמריץ הוא אותה קבוצת workers של ה-pool. במקום לכתוב threads ייעודיים לעיבוד מחזורי, לתגובה ל-events ולהשלמת I/O, אפשר ליישר הכול למנגנון callback משותף.
flowchart TB
accTitle: מבנה שמפריד תנאי ירי מהרצת callback
accDescr: callbacks שהפכו לניתנים להרצה מאובייקט עם תנאי ירי רצים על קבוצת ה-workers של ה-pool, ו-worker שסיים משמש גם ל-callback הבא, כך שהאפליקציה לא יוצרת thread לכל עבודה
app["האפליקציה מציינת עבודה ותנאי ירי"] --> obj["האובייקט ממתין לתנאי"]
obj --> ready["ה-callback הופך לניתן להרצה"]
ready --> workers["קבוצת ה-workers של ה-pool מריצה"]
workers --> done["מסיימים ומחזירים את ה-worker"]
done --> next["משתמשים שוב גם ב-callback הבא"]
איור 4: בהפרדת המנגנון שממתין לירי מה-workers שמריצים, מקטינים ניהול threads לפי עבודה.
3.2 אפשר להחליף גם “threads שרק ישנים וממתינים”
אפקט ה-pool אינו מוגבל לעבודה שצורכת CPU. timers מרוכזים לתור timers יחיד, והמתנה לכמה handles מרוכזת למספר קטן של wait threads.1
למשל, אם יש חמישה threads שישנים רק כדי “לרוץ כש-event עובר ל-signaled”, אפשר לחשוב על החלפה בחמישה אובייקטי wait. במקום להחזיק threads שישנים בנפרד, מריצים את העיבוד בזמן ה-signal כ-callback.
flowchart TB
accTitle: החלפת threads ייעודיים להמתנה באובייקטי wait
accDescr: threads ייעודיים להמתנה שישנו אחד לכל event מוחלפים באובייקטי wait ומרוכזים ל-wait threads של ה-pool, כך שה-callback רץ רק בזמן signal
old2["5 threads ייעודיים להמתנה ישנים בנפרד"] -.-> waste["צורכים stack ו-thread פי 5"]
new2["5 אובייקטי wait"] --> agg["ריכוז ל-wait threads של ה-pool"]
agg --> cb2["callback רץ רק בזמן signal"]
איור 5: “thread שרק ישן וממתין” אפשר לבטל בהפיכה לאובייקט wait. זה צעד ראשון ברור למעבר ל-pool.
4. יסודות המימוש — ליצור work, להגיש, ולסיים בבטחה
4.1 קודם סיבוב אחד מיצירה עד סיום
מסתובבים על אובייקט work הבסיסי ביותר. CreateThreadpoolWork קושר callback ו-context, ו-SubmitThreadpoolWork מבקש הרצה. אם הארגומנט השלישי הוא NULL, משתמשים ב-pool ברירת המחדל של ה-process. לרוב השימושים ה-pool הזה מספיק.56
להלן קטע שמציג את השלבים, לא תוכנית מלאה שמתקמפלת כמו שהיא. WORK_QUEUE, ITEM, Enqueue, Dequeue, ProcessItem מייצגים עיבוד בצד האפליקציה. בקרת exclusion של התור, עצירת צד ההגשה וטיפול בכישלון מממשים בנפרד, ואם יצירת ה-work נכשלה לא ממשיכים להגשה, המתנה ושחרור שאחריה.
VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
PVOID context, PTP_WORK work)
{
// context קבוע ביצירה. נתונים לפי פריט מועברים בתור מסונכרן
WORK_QUEUE* queue = (WORK_QUEUE*)context;
ITEM* item = Dequeue(queue); // מוציאים פריט אחד תחת exclusion
ProcessItem(item);
}
// 1) יצירה (קושרים את ה-callback ל-context המשותף = התור)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* טיפול בכישלון עם GetLastError */ }
// 2) מגישים פעם אחת לכל פריט שנכנס לתור (משאירים את מספר הפריטים ומספר ההגשות מסונכרנים)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);
// 3) עוצרים את צד ההגשה, ואז ממתינים להשלמה (TRUE גם מנסה לבטל עבודה שעוד לא התחילה)
WaitForThreadpoolWorkCallbacks(work, FALSE);
// 4) סוגרים
CloseThreadpoolWork(work);
flowchart TB
accTitle: מחזור החיים של אובייקט work
accDescr: יוצרים ב-CreateThreadpoolWork, מגישים ב-SubmitThreadpoolWork וה-callback רץ במקביל. בסיום קודם עוצרים הגשות חדשות, ממתינים להשלמת כל ה-callbacks ב-WaitForThreadpoolWorkCallbacks ואז סוגרים ב-CloseThreadpoolWork
c["יצירה ב-CreateThreadpoolWork"] --> s["הגשה ב-SubmitThreadpoolWork (אפשר כמה פעמים)"]
s --> run["ה-callback רץ במקביל"]
run --> stop3["לעצור הגשות חדשות"]
stop3 --> w["המתנה להשלמה ב-WaitForThreadpoolWorkCallbacks"]
w --> cl["סגירה ב-CloseThreadpoolWork"]
איור 6: סדר הסיום הוא “לעצור הגשה → להמתין להשלמה → לסגור”. דילוג על אחד מהם מוביל לגישה אחרי שחרור או למרוץ.
4.2 אפשר לעשות reuse ל-work, אבל context לא משתנה בכל הגשה
אותו אובייקט work אפשר להגיש כמה פעמים גם לפני ש-callback קודם הסתיים. בכל הגשה רץ callback, וכמה הגשות רצות במקביל. מספר ה-threads שבאמת בשימוש ה-pool עשוי להתאים מטעמי יעילות.7
מה שרוצים להבדיל כאן הוא work מול נתוני כל פריט עבודה. ה-context שמועבר ל-callback קבוע ביצירה. אם מעבדים N פריטי נתונים שונים עם work אחד, כמו בדוגמה למעלה, שמים בתור מסונכרן כ-context. על כל פריט שמוסיפים מגישים פעם אחת, וה-callback מוציא פריט אחד מהתור. גם תכנון שיוצר אובייקט work לכל פריט בסדר.5
flowchart TB
accTitle: קבלת נתונים לפי פריט מ-context קבוע
accDescr: ביצירת work מקבעים את ה-context לתור משותף, צד ההגשה מגיש פעם אחת לכל פריט שמוסיפים, וכל callback שרץ במקביל מוציא פריט אחד מתור עם בקרת exclusion
create["קיבוע context ביצירת work"] -.-> queue["תור משותף עם בקרת exclusion"]
item["הוספת פריט אחד"] --> queue
item --> submit["Submit אחד לכל פריט"]
submit --> callbacks["כל callback רץ במקביל"]
callbacks --> dequeue["מוציאים פריט אחד בכל פעם מהתור"]
queue --> dequeue
dequeue --> process["מעבדים את הפריט שהוצא"]
איור 7: מה שקבוע הוא context שמצביע לתור; נתונים לפי פריט מגיעים מתור מסונכרן.
4.3 בסיום, עוצרים הגשה לפני שממתינים
סדר סיום בטוח הוא “לעצור הגשות חדשות → להמתין להשלמה ב-WaitForThreadpoolWorkCallbacks → לסגור ב-CloseThreadpoolWork“. גם זיכרון שה-callback מפנה אליו אסור לשחרר לפני שאישרתם השלמה. אם משחררים את היעד בזמן שנשאר עיבוד רץ או ממתין, זה מוביל לגישה אחרי שחרור.6
לא מספיק לחשוב “קראנו להמתנת השלמה אז בטוח”. אם thread אחר עדיין יכול לקרוא ל-SubmitThreadpoolWork, הגשה אחרי שההמתנה נגמרה מתחרה ב-Close. כדי שההמתנה תהיה קו גבול של סיום, ההנחה היא שקודם עוצרים את צד ההגשה.
הארגומנט השני של WaitForThreadpoolWorkCallbacks הוא FALSE להמתין להשלמה, ו-TRUE גם לציין ביטול של callbacks שעוד לא התחילו. זה לא אומר שמותר להשאיר עיבוד רץ מאחור. איך מסיימים כמה אובייקטים יחד מכוסה בפרק 6.
5. משמעת callback — לא לתפוס ולא לזהם thread שאול
5.1 אם זה ייקח זמן, מכריזים ובודקים גם את ערך ההחזרה
ה-pool מתאים את מספר ה-threads בהנחה ש-callbacks חוזרים במהירות. אם ממשיכים עיבוד ארוך או המתנה ארוכה בלי להודיע, הרצת callbacks אחרים מתעכבת. אם זה עשוי לקחת זמן, מודיעים על האפשרות ב-CallbackMayRunLong או מפרידים ל-dedicated thread.8
עם זאת, קריאה ל-CallbackMayRunLong לבדה אינה מספיקה. אם אי אפשר להכין worker ל-callbacks אחרים, מוחזר FALSE. במקום להתעלם מערך ההחזרה ולהמשיך לחסום, במקרה הזה מפצלים את העיבוד או מעבירים ל-dedicated thread, לצד שלא סותם את ה-pool.8
flowchart TB
accTitle: החלטה איך לטפל ב-callback ארוך
accDescr: עבודה שצריכה עיבוד או המתנה ארוכים מפרידים ל-dedicated thread או מודיעים ל-pool ב-CallbackMayRunLong, ואם אי אפשר להכין worker אחר ומוחזר FALSE נמנעים מחסימה בפיצול או בהעברה ל-dedicated thread
long["צריך עיבוד או המתנה ארוכים"] --> choice{"איפה לעבד"}
choice -->|"לייעד"| dedicated["להפריד ל-dedicated thread"]
choice -->|"לעשות ב-pool"| notify["להודיע ב-CallbackMayRunLong"]
notify --> result{"האם הוכן worker אחר"}
result -->|"TRUE"| run["לבצע את העיבוד הארוך"]
result -->|"FALSE"| split["לפצל או להעביר לייעודי"]
איור 8: הודעה על עיבוד ארוך הופכת לסט אחד רק כשבודקים את ערך ההחזרה ומחליטים על הצעד הבא.
5.2 לא להמתין סינכרונית מתוך worker לעבודה באותו pool
לתכנון שמגיש מתוך callback A עבודה B לאותו pool וממתין להשלמתה ב-WaitForThreadpoolWorkCallbacks וכדומה צריך זהירות. אם כל ה-workers מגיעים ל”ממתינים לעבודה של worker אחר”, אין worker פנוי להריץ את B ומתקבל deadlock מרעב-pool.
הטיפול אינו להבטיח worker להמתנה, אלא לשנות לצורה של המשך שמגיש את העבודה הבאה מ-callback ההשלמה של B. לא כותבים תלות כהמתנה שסותמת worker.
flowchart TB
accTitle: מבנה deadlock מרעב-pool
accDescr: אם כל ה-worker threads ממתינים סינכרונית להשלמת עבודה אחרת שהוגשה לאותו pool, אין worker פנוי שיכול להריץ את העבודה הזו, וכולם ממתינים לנצח ב-deadlock
w1["worker 1: ממתין להשלמת עבודה X"] --> q["עבודות X ו-Y ממתינות להרצה"]
w2["worker 2: ממתין להשלמת עבודה Y"] --> q
q -.-> none["אין worker פנוי שיכול להריץ"]
none -.-> dead["כולם ממתינים לנצח (deadlock רעב)"]
איור 9: אם מתוך worker ממתינים סינכרונית ל-worker, לא נשאר מי שיריץ את העבודה שממתינים לה.
5.3 להחזיר את מצב ה-thread למקור לפני שחוזרים
Worker threads משמשים גם ל-callback הבא, הלא קשור. השארת priority שהשתנה, מצב אתחול COM, ערך ב-TLS, או lock שלא שוחרר, מעבירים השפעה לעבודה הבאה. צריך לא להניח, כולל הפונקציה שמגישים ל-pool והעיבוד שהיא קוראת, שהם רצים על dedicated thread.9
יש גם APIs שקושרים cleanup לסיום ה-callback. למשל LeaveCriticalSectionWhenCallbackReturns מבקש מה-pool לשחרר critical section אחרי שה-callback חוזר.4
flowchart TB
accTitle: לא להעביר מצב thread ל-callback הבא
accDescr: כי callback אחר עושה reuse לאותו worker, חזרה עם priority, COM, TLS או lock שנשארו מזהמת את העבודה הבאה, ו-cleanup שמחזיר את המצב למקור מונע את ההעברה
first["callback A שואל worker"] --> cleanup{"האם נוקה לפני החזרה"}
cleanup -->|"לא"| dirty["reuse עם מצב שנשאר"]
dirty --> impact["משפיע על B הלא קשור"]
cleanup -->|"כן"| clean["מחזירים את ה-worker במצב המקור"]
clean --> next["callback B הבא משתמש"]
איור 10: ה-thread שאול, ולכן האחריות היא לא רק לתוצאת העיבוד אלא גם למצב ה-thread ברגע ההחזרה.
5.4 לא להוציא exception לא מטופל אל מחוץ ל-worker
Exception לא מטופל על worker thread יכול לערב את כל ה-process. כמו בפונקציית thread של dedicated thread, מחילים מדיניות לתפוס exceptions בכניסה ל-callback ולהשאיר לוג. זה שהופקד בידי ה-pool אינו מבטל את הצורך לטפל בכשל שקרה בתוך העבודה.
6. לפצל תצורה — custom pool ו-cleanup group
6.1 custom pool משמש לבידוד סוגי עבודה
כש-pool ברירת המחדל אינו מספיק, אפשר ליצור pool עצמאי ב-CreateThreadpool. מגדירים תקרה ורצפה למספר worker threads ב-SetThreadpoolThreadMaximum ו-SetThreadpoolThreadMinimum.10
המטרה הטיפוסית היא בידוד. כדי ש”עיבוד batch שאפשר שיהיה איטי” לא יגמור את ה-workers של “עבודה שצריכה תגובה מיידית”, מפצלים pools ונותנים לכל אחד תקציב threads. זה לא סיפור של פשוט להוסיף threads, אלא של להפריד איזו עבודה משתמשת באילו workers.
6.2 בסביבת callback קושרים יעד הרצה ו-cleanup
מי שמציין באיזה pool להריץ הוא TP_CALLBACK_ENVIRON. מאתחלים את סביבת ה-callback, מציינים pool ב-SetThreadpoolCallbackPool, ומעבירים ל-CreateThreadpoolWork וכדומה. הארגומנט השלישי שהיה NULL בדוגמת פרק 4 הוא כאן המקום להעביר את הסביבה.5
דרך אותה סביבה אפשר גם לקשור cleanup group. custom pool הוא יעד ההרצה, cleanup group הוא יחידת ה-cleanup. כשמפרידים את שני התפקידים האלה, קל יותר לעקוב אחרי התצורה.
flowchart TB
accTitle: קישור תצורה דרך סביבת callback
accDescr: סביבת callback מצביעה ל-custom pool ול-cleanup group, ו-work או timer שנוצרו עם הסביבה הזו רצים ב-pool ההוא, ואפשר לרכז המתנת השלמה ושחרור בטיפול המרוכז של ה-cleanup group
env["סביבת callback (TP_CALLBACK_ENVIRON)"] --> cp["custom pool (שליטה במספר threads)"]
env --> cg["cleanup group"]
env --> obj["מועבר ליצירת work / timer / wait / io"]
cg -.-> close["המתנת השלמה ושחרור במרוכז"]
איור 11: סביבת callback היא מנגנון שמזריק ביצירת האובייקט “באיזה pool זה רץ ומי מנקה”.
6.3 לרכז המתנת השלמה ושחרור של כמה אובייקטים
כשמתרבים work ו-timer בתוך מודול, עיבוד הסיום הופך לרשימה של “להמתין לכל אחד ולסגור כל אחד”. אם יוצרים קבוצה ב-CreateThreadpoolCleanupGroup ומשייכים אובייקטים שנוצרים דרך סביבת ה-callback, קריאה אחת ל-CloseThreadpoolCleanupGroupMembers מרכזת המתנת השלמה ושחרור לכל האובייקטים השייכים.46
גם כאן המטרה היא לא להשאיר callbacks רצים מאחור. בוחרים בין סיום נפרד של work כמו בפרק 4 לבין סיום ביחידת קבוצה, לפי מספר האובייקטים שמנהלים.
7. כשמשתמשים מ-DLL — לא לעשות unload לפני ה-callback
7.1 המתנת השלמה נעשית בפונקציית סיום מפורשת, לא ב-DllMain
הסכנה הגדולה ביותר ב-DLL היא שה-DLL נפרק בזמן שקוד ה-callback עדיין יכול לרוץ. אם רץ קוד שנפרק, מתקבל access violation.
הבסיס הוא בפונקציית סיום מפורשת של ה-DLL לעצור הגשה, להמתין להשלמת callbacks, לסגור אובייקטים ורק אז לעשות unload. בין אם בפונקציות המתנה נפרדות ובין אם ב-cleanup group מפרק 6, אסור לדלג על אישור ההשלמה הזה.6
אל תעשו את ההמתנה הזו בתוך DllMain. היא עלולה לגרום ל-deadlock אחר דרך הקשר עם loader lock. פירוט ב-מאמר על DllMain ו-loader lock.
7.2 FreeLibraryWhenCallbackReturns בא כזוג עם הבטחת reference לפני הגשה
למצב של “זה ה-callback האחרון ולכן רוצים לשחרר את ה-reference ל-DLL אחרי שהוא חוזר” קיים FreeLibraryWhenCallbackReturns.4
עם זאת, ה-API הזה לבדו אינו מונע unload לפני שה-callback התחיל. מה שהוא עושה הוא לשחרר reference אחד למודול כשה-callback הרץ חוזר. משתמשים בזוג: לפני ההגשה מבטיחים module reference לעיבוד הזה ב-GetModuleHandleEx, ומה-callback משחררים ב-API הזה.
flowchart TB
accTitle: סגירת DLL בפונקציית סיום מול החזרת reference מ-callback
accDescr: הבסיס הוא לעצור הגשה, להמתין להשלמה ולשחרר בפונקציית סיום שאינה DllMain ורק אז לעשות unload ל-DLL; בתכנון שמחזיר reference מה-callback האחרון מבטיחים reference ב-GetModuleHandleEx לפני הגשה ומזווגים לשחרור אחרי סיום ב-FreeLibraryWhenCallbackReturns
shutdown["פונקציית סיום מפורשת"] --> stop["עצירת הגשה, המתנת השלמה, שחרור"]
stop --> unload["אחר כך unload ל-DLL"]
note["לא ממתינים ב-DllMain"] -.-> shutdown
acquire["הבטחת module reference לפני הגשה"] --> submit["הגשת callback"]
submit --> callback["שמירת שחרור מתוך ה-callback"]
api["FreeLibraryWhenCallbackReturns"] -.-> callback
callback --> returned["אחרי החזרה משחררים reference אחד"]
איור 12: לא מערבבים המתנת השלמה בפונקציית סיום עם שחרור reference שהובטח ל-callback; בשניהם מתכננים קודם את ה-lifetime של ה-DLL.
8. סיכום — קודם work, ולהחליף יחד עם עיבוד הסיום
Win32 thread pool הוא הבסיס שמעביר מקביליות בקוד native מ”ליצור thread” ל”להעביר עבודה כ-callback”. אפשר לסדר עבודות קצרות-חיים ו-threads ייעודיים להמתנה, ולהפקיד ניהול threads בידי ה-OS.
ההכנסה יכולה להיות הדרגתית. קודם עושים סט אחד של יצירה, הגשה, עצירת הגשה, המתנת השלמה ושחרור עם work. אחר כך מחליפים threads ייעודיים להמתנה ב-wait, ו-threads ייעודיים ל-timer ב-timer. כשצריך, שוקלים איחוד io ופיצול pool.
flowchart TB
accTitle: העברה הדרגתית של קוד קיים ל-thread pool
accDescr: בוחנים מחדש threads עצמאיים קיימים, משאירים עבודה שמתאימה לספריית תקן או ל-dedicated thread, מעבירים עבודה שמתאימה ל-pool יחד עם עצירת הגשה והמתנת השלמה של work, ומחליפים בהדרגה המתנה ב-wait ו-timer ב-timer
inventory["לבחון מחדש threads עצמאיים קיימים"] --> choose{"עבודה שמתאימה ל-pool?"}
choose -->|"לא"| keep["לבחור ספריית תקן או ייעודי"]
choose -->|"כן"| work["להעביר work ועיבוד סיום כסט"]
work --> more["המתנה ל-wait, timer ל-timer"]
more --> optional["במידת הצורך איחוד I/O ופיצול pool"]
איור 13: הבסיס למעבר הדרגתי הוא לא רק להקטין threads אלא להשלים עיבוד סיום בכל שלב.
מה ששומרים עד הסוף הוא שלוש המשמעות לא לחסום לאורך זמן, לא להמתין סינכרונית לאותו pool, לא לזהם מצב thread. ב-DLL מונעים בנוסף מרוץ מול unload. במקומות שכלי C++ התקני או .NET מספיקים משאירים להם, ובמקומות שצריך איחוד או שליטה ייחודיים ל-Windows משתמשים ב-API הזה.
מאמרים קשורים
- Windows I/O לעומק (חלק 3) — I/O completion port (IOCP) ו-thread pool של .NET: המרתף של async/await
- שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת C++
- שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת C — כתיבה בטוחה לפי Win32 API
- Spurious wakeup — למה condition variable מתעורר בלי notify, ואיך לחכות נכון ב-Windows
- DllMain ו-loader lock — הסיבה האמיתית שאומרים לא לעשות כלום ב-initialization של DLL
- למה Sleep(1) ב-Windows לא מדויק, ולמה עדיף event wait
תחומי ייעוץ קשורים
KomuraSoft LLC מטפלת בתכנון מעבר של קוד native שבו threads התרבו אל thread pool, בסקירות תכנון של מקביליות באפליקציות ו-DLLs ב-C++, ובחקירת שורש של hangs ו-crashes מרעב-pool או מ-callbacks. אפשר להתייעץ גם משלב ספירת מלאי של קוד קיים.
קישורים
-
Microsoft Learn, Thread Pools. על כך ש-thread pool הוא אוסף worker threads שמריצים callbacks אסינכרוניים ביעילות במקום האפליקציה, על סוגי אפליקציות מתאימות (הנפקה מקבילית גדולה של פריטי עבודה קטנים, יצירה והשמדה תכופות של threads קצרי-חיים, עיבוד מקבילי של עבודות עצמאיות, המתנה בלעדית ל-kernel objects וכדומה), ועל העיצוב מחדש המקיף ב-Vista (איחוד סוגי worker threads, תור timers יחיד, threads ייעודיים מתמשכים, cleanup groups, כמה pools בתוך process, API חדש). ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, <future>. על כך שביצוע אסינכרוני ברמת משימה דרך std::async ו-future מסופק כספריית תקן, כך שאפשר לכתוב מקביליות בלי לנהל threads ישירות. ↩
-
Microsoft Learn, Thread Pooling. על מבנה API ה-thread pool הישן (QueueUserWorkItem, תור timers, המתנה רשומה, BindIoCompletionCallback), על כך שאין דרך לבטל עבודה שנכנסה לתור, ועל כך שמצוין במפורש ש-API ה-thread pool החדש שהוכנס ב-Vista פשוט יותר ועדיף באמינות, בביצועים ובגמישות. ↩ ↩2
-
Microsoft Learn, threadpoolapiset.h header. על ארבע פונקציות יצירת האובייקטים CreateThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWait ו-CreateThreadpoolIo, על cleanup groups (CreateThreadpoolCleanupGroup), ועל רשימת הפונקציות שכוללת cleanup שקשור להשלמת callback (LeaveCriticalSectionWhenCallbackReturns, FreeLibraryWhenCallbackReturns וכדומה). ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). על יצירת אובייקט work מפונקציית callback וממצביע context, ועל כך שהארגומנט השלישי, TP_CALLBACK_ENVIRON, יכול לציין את סביבת הריצה של ה-callback (ה-pool שאליו הוא שייך וכן הלאה), ו-NULL פירושו הרצה בסביבת ברירת המחדל. ↩ ↩2 ↩3
-
Microsoft Learn, Using the Thread Pool Functions. על השלבים הבסיסיים של יצירה ב-CreateThreadpoolWork, הגשה ב-SubmitThreadpoolWork, המתנה להשלמה ב-WaitForThreadpoolWorkCallbacks וסגירה ב-CloseThreadpoolWork, ועל דוגמת תצורה שמשלבת custom pool עם סביבת callback ו-cleanup group. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). על כך שאפשר להגיש את אותו אובייקט work כמה פעמים בלי לחכות לסיום ה-callback הקודם, כך שה-callbacks רצים במקביל, ועל כך שה-pool יכול לכוונן (לחנוק) את מספר ה-threads ליעילות. ↩
-
Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). על הודעה ל-pool שה-callback הנוכחי עשוי לרוץ זמן רב, חומר שה-pool משתמש בו כדי להחליט אם להבטיח threads ל-callbacks אחרים, ועל כך של-callback ארוך-ריצה כדאי לשקול thread ייעודי כשאפשר. ↩ ↩2
-
Microsoft Learn, Thread Pooling. על כך שפריט עבודה שמגישים ל-thread pool והפונקציות שנקראות ממנו חייבים להיות thread-pool-safe, על כך שאסור להניח ש-thread ההרצה הוא dedicated או מתמשך, ועל כך שיש להימנע משימוש ב-TLS ומקריאות אסינכרוניות שדורשות thread מתמשך. ↩
-
Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). על כך של-pool שנוצר ב-CreateThreadpool אפשר להגדיר תקרה למספר worker threads (הרצפה היא SetThreadpoolThreadMinimum). ↩
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
DllMain ו-loader lock — הסיבה האמיתית שאומרים לא לעשות כלום ב-initialization של DLL
למה אסור לקרוא ל-LoadLibrary או להסתנכרן עם threads מתוך DllMain. המאמר מסביר ממקורות ראשוניים איך loader lock מסדר כל DLL notification, ...
למה arguments נשברים — כללי command-line arguments ב-Windows
Windows מעביר ל-CreateProcess מחרוזת אחת שהמקבל מפצל. מכסה את כללי CommandLineToArgvW, CRT ו-.NET, ArgumentList, ובניה ב-C++.
מה נשאר אחרי שה-parent מת — מחזיקים child processes ב-Job Object
למה SDK helpers שורדים UI שנהרג ומחזיקים את המצלמה או את ה-COM port? מתכננים משך חיים של child process עם Job Objects, KillOnJobClose ו-c...
Named Pipes בפועל — ה-IPC הסטנדרטי של Windows, מתכנון עד אבטחה
מדריך מעשי ל-Named Pipes, ה-IPC הסטנדרטי ב-Windows. המאמר מסדר לפי מקורות ראשוניים את הבחירה בין byte mode ל-message mode, תכנון server ל...
מה Not Responding באמת אומר — איך Windows מחליט שיש hang, ואיך לתכנן אפליקציות שלא נתקעות
Windows מסמן window כ-Not Responding אחרי 5 שניות בלי שליפת message ומציג ghost window: השיפוט, סיבות ה-hang, תכנון ה-UI thread, והחקירה.
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
יישומים עסקיים, חיבור התקנים וכלי תקשורת, מהגדרת הדרישות ועד הפיתוח.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- מה עדיף ב-thread pool לעומת יצירת threads עצמאיים עם CreateThread?
- יעילות כשיש מספר גדול של עבודות קצרות-חיים לביצוע, והפחתה בקוד ניהול ה-threads. ליצירה ולהשמדה של thread יש עלות שאי אפשר להתעלם ממנה, כך שאפליקציה שחוזרת על CreateThread לכל עבודה והשמדה בסיום, או שמחזיקה threads רבים שקיימים רק כדי לישון בהמתנה ל-event, יכולה להקטין את מספר ה-threads ואת ה-context switches במעבר ל-pool. גם התיעוד הרשמי מונה כמועמדים ל-pool אפליקציות שמנפיקות מספר גדול של פריטי עבודה קטנים במקביל, אפליקציות שיוצרות threads קצרי-חיים רבים, ואפליקציות שיש להן threads המוקדשים אך ורק להמתנה ל-kernel objects. ולהפך, עבודה שצריכה מאפיינים ייחודיים על ה-thread עצמו, שינוי priority, COM STA, עיבוד ייעודי ארוך, עדיין צריך להחזיק על dedicated thread כמו קודם.
- במה זה שונה מפונקציות thread pool הישנות כמו QueueUserWorkItem?
- ה-thread pool עוצב מחדש באופן מקיף ב-Windows Vista. ה-API הנוכחי ממשפחת threadpoolapiset (CreateThreadpoolWork וכדומה) הוא ה-API החדש; QueueUserWorkItem, RegisterWaitForSingleObject וכדומה הם ה-API הישן (legacy). ה-API החדש מאחד את סוגי worker threads, מאפשר ליצור כמה pools עצמאיים ב-process אחד, ומספק מנגנונים כמו שחרור מרוכז דרך cleanup group ושחרור lock או unload של DLL הקשורים להשלמת callback. גם התיעוד הרשמי אומר שה-API החדש פשוט יותר ועדיף באמינות, בביצועים ובגמישות. ל-API הישן יש גם אילוצים מבניים, כמו אין דרך לבטל עבודה ברגע שהיא בתור, ולכן בקוד חדש משתמשים ב-API החדש.
- יש דברים שאסור לעשות בתוך callback?
- יש שלושה גדולים. ראשית, חסימה ממושכת או עבודה ארוכה בברירות המחדל. ה-pool מתאים את מספר ה-threads מתוך הנחה שה-callbacks מסתיימים במהירות, ולכן לעבודה שתיקח זמן ארוך מכריזים עליה עם CallbackMayRunLong או משתמשים ב-dedicated thread. שנית, המתנה סינכרונית להשלמת עבודה אחרת שהגשתם לאותו pool. אם כל worker מגיע למצב ממתין ל-worker אחר, מתקבל deadlock מרעב-pool. שלישית, תלות במאפיינים ייחודיים של ה-thread. worker threads משותפים בין callbacks, כך שחזרה עם thread priority או מצב אתחול COM שהשתנו, או השארת state ב-TLS, מזהמת את ה-callback הבא. לניקוי בסוף (שחרור lock או unload של DLL) מסופקים מנגנונים ייעודיים כמו LeaveCriticalSectionWhenCallbackReturns ו-FreeLibraryWhenCallbackReturns.
- יש נקודות לתשומת לב בשימוש ב-thread pool מתוך DLL?
- הסכנה הגדולה ביותר היא שה-DLL נפרק בזמן ש-callback עדיין רץ. אם ה-callback רץ אחרי ה-unload, מתקבל access violation. צד ה-DLL חייב, בעיבוד הכיבוי שלו, להמתין באופן אמין להשלמת ה-callbacks שהנפיק, עם פונקציית המתנה כמו WaitForThreadpoolWorkCallbacks, או CloseThreadpoolCleanupGroupMembers על cleanup group, ורק אז לסגור את האובייקטים. המתנה לזה בתוך DllMain, לעומת זאת, עלולה לגרום ל-deadlock דרך אינטראקציה עם loader lock, ולכן הכלל הוא לעשות זאת בפונקציית כיבוי מפורשת, לא ב-DllMain. למצב שבו ה-callback עצמו רוצה לשחרר את ה-DLL כי זו העבודה האחרונה, מסופק API ייעודי, FreeLibraryWhenCallbackReturns.
- עכשיו שיש C++ std::async ו-ThreadPool של .NET, עדיין יש מקרים להשתמש ב-API הזה ישירות?
- יש. הקריטריון הוא האם הכלי בשכבה הזו מספיק. אם רמת המקביליות שצריך ב-C++ מכוסה ב-std::async או ב-std::thread, ספריית התקן היא המועמד הראשון גם מבחינת portability. מצד שני, רצון לאחד timers, המתנות ל-kernel objects והשלמות I/O אסינכרוני במנגנון callback אחד; רצון לפצל pools ולשלוט במספר threads לפי סוג עבודה; אי-רצון להחזיק threads עצמאיים בתוך DLL או רכיב COM, אלה הדרישות ש-Win32 thread pool מכסה. הקשר עם ThreadPool ו-IOCP של .NET מכוסה במאמר קשור, וכל עוד כותבים native, הכרת המנגנון שיושב בשכבה מתחת אינה מבוזבזת.