Win32 Thread Pool API — מקביליות בלי ליצור תהליכונים, דרך CreateThreadpoolWork

· · Windows, ריבוי תהליכונים, C++, פיתוח Windows, Win32 API, שיפור ביצועים

“‏CreateThread אחד לכל לקוח.” “אחד לטיימר.” “אחד להמתנה לאירוע.” — בקוד נייטיב של Windows, תהליכונים נוטים להתרבות כך. כל תהליכון צורך מחסנית ואובייקט ליבה, וגם ליצירה ולהשמדה יש עלות. העבודה עדינה, והתהליכון כבד — מאגר התהליכונים הוא מה שמערכת ההפעלה מספקת כדי לספוג את אי-ההתאמה הזו.

הנוחות של ThreadPool ו-Task.Run ב-.NET ידועה היטב, אבל למעשה גם לנייטיב של Win32 יש API מעוצב היטב, סטנדרטי של מערכת ההפעלה, למאגר תהליכונים. ה-API הזה, שעוצב מחדש באופן מקיף ב-Windows Vista, הוא היסוד של מקביליות נייטיב: הוא יכול לטפל בעבודה, בטיימרים, בהמתנות וב-I/O אסינכרוני דרך מנגנון קריאה חוזרת מאוחד. מיועד למפתחות ולמפתחים שכותבים יישומי Windows, שירותים ו-DLL ב-C/C++, המאמר מסביר את המבנה והשימוש ב-API הזה, ואת המלכודות שקל ליפול אליהן, על בסיס מקורות ראשוניים.

1. השורה התחתונה קודם

  • להנפקת מספר גדול של עבודות קצרות-חיים, ולהחלפת תהליכונים שרק ממתינים, מאגר תהליכונים עדיף על CreateThread עצמאי. משאירים את ניהול התהליכונים למערכת ההפעלה ואפשר להקטין את מספר התהליכונים ואת החלפות ההקשר.1
  • מה שצריך להשתמש בו הוא ה-API החדש (משפחת CreateThreadpoolWork). עיצוב מחדש ב-Vista הפך אותו לפשוט יותר, אמין יותר ובעל ביצועים גבוהים יותר מה-API הישן (משפחת QueueUserWorkItem), ואפשר גם ליצור כמה מאגרים עצמאיים בתהליך אחד.12
  • יש ארבעה סוגי אובייקט. work, שאליו מגישים עבודות; timer, שנורה בזמן או במחזור; wait, שנורה כשאובייקט ליבה מסומן; ו-io, שנורה כש-I/O אסינכרוני מסתיים. כולם רוכבים על אותו מנגנון קריאה חוזרת.3
  • כיבוי הוא “המתן, ואז סגור”. משמעת של לא להשאיר קריאה חוזרת רצה מאחור — המתנה להשלמה עם משפחת WaitForThreadpoolWorkCallbacks, או טיפול מרוכז דרך קבוצת ניקוי — נדרשת.4
  • בתוך קריאה חוזרת: אל תחסמו זמן ארוך (אם כן, CallbackMayRunLong); אל תמתינו סינכרונית להשלמה באותו מאגר; אל תלכלכו את מצב התהליכון. אלה שלושה כללי ברזל.56
  • שימוש מתוך DLL: שימו לב למירוץ פריקה. המתינו להשלמה בפונקציית כיבוי מפורשת, והכירו את ה-API הייעודיים כמו FreeLibraryWhenCallbackReturns.3

2. למה מאגר, ומתי מאגר

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

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

  • יישומים שמנפיקים מספר גדול של פריטי עבודה קטנים במקביל (חיפוש, I/O רשת וכדומה)
  • יישומים שיוצרים ומפרקים לעיתים קרובות תהליכונים קצרי-חיים
  • יישומים שמעבדים עבודה עצמאית במקביל ברקע
  • יישומים שמחזיקים תהליכונים המוקדשים להמתנה לאובייקטי ליבה או לאירועים

הפריט האחרון קל לפספס. אם יש לכם חמישה תהליכונים שקיימים רק כדי לישון כדי שיוכלו “לרוץ כשהאירוע מסומן”, אפשר להחליף אותם בחמישה אובייקטי wait על המאגר, וההמתנה מתרכזת על תהליכוני ההמתנה של המאגר.

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

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

איור 1: העבודה היחידה שמותר לשים על מאגר היא עבודה ש”לא צריכה אישיות ומסתיימת בקצרה”. כל השאר נשאר על תהליכון ייעודי כמו קודם.

יש גם פיסת היסטוריה אחת לקבע. ל-API של מאגר התהליכונים יש שני דורות. ה-API הישן שנמשך מאז Windows 2000 ‏(QueueUserWorkItem, RegisterWaitForSingleObject וכדומה), וה-API החדש שעוצב מחדש באופן מקיף ב-Vista (משפחת CreateThreadpoolWork). ה-API החדש מאחד את סוגי תהליכון העבודה, מספק תהליכונים מתמידים ייעודיים, כמה מאגרים בתהליך אחד, קבוצות ניקוי ועוד, והתיעוד הרשמי קובע במפורש שהוא “פשוט יותר, אמין יותר, בעל ביצועים טובים יותר וגמיש יותר”.1 ל-API הישן יש גם אילוצים מבניים כמו “אי אפשר לבטל עבודה ברגע שהיא בתור”.2 מכאן ואילך המאמר מטפל רק ב-API החדש.

ההתאמה בין ה-API הישן של מאגר התהליכונים ל-API החדשQueueUserWorkItem של ה-API הישן ממופה לאובייקט work של ה-API החדש, תורי טיימר ל-timer, המתנות רשומות ל-wait, ו-BindIoCompletionCallback ל-ioQueueUserWorkItemworkתורי טיימרtimerהמתנות רשומותwaitBindIoCompletionCallbackio

איור 2: יעד ההעברה מה-API הישן הוא אחד-לאחד. מלאי של קוד קיים יכול להתחיל מההתאמה הזו.

3. ארבעת האובייקטים — work, timer, wait ו-io

במרכז ה-API החדש עומדים ארבעה סוגי אובייקט שתנאי הירי של הקריאה החוזרת שלהם שונים.3

אובייקט פונקציית יצירה מתי הקריאה החוזרת נורה
work CreateThreadpoolWork כשמגישים אותו עם SubmitThreadpoolWork
timer CreateThreadpoolTimer כשהזמן או המחזור שצוינו מגיעים
wait CreateThreadpoolWait כשאובייקט ליבה הופך למסומן
io CreateThreadpoolIo כש-I/O אסינכרוני על ה-handle המשויך מסתיים
ארבעת האובייקטים של מאגר התהליכונים ומנגנון הקריאה החוזרתwork נורה בהגשה מפורשת, timer בזמן, wait בסימון אובייקט ליבה, ו-io בהשלמת I/O אסינכרוני; כולם רצים כקריאות חוזרות על אותה קבוצת תהליכוני עבודהאיזה אובייקט?work(בהגשה)טיימר, המתנה או io?timer(זמן / מחזור)המתנה או io?wait(בסימון)io(השלמת I/O)העובדים מריצים קריאה חוזרת

איור 3: תנאי הירי שונים, אבל כל הארבעה מאוחדים במנגנון שבו “עובדים באותו מאגר מריצים את הקריאה החוזרת”.

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

החלפת תהליכונים שרק ממתינים באובייקטי waitתהליכונים שרק ממתינים ושנהגו לישון אחד לכל אירוע הופכים לאובייקטי wait ומרוכזים על תהליכוני ההמתנה של המאגר, כך שהקריאה החוזרת רצה רק כשיש סימון5 תהליכוני המתנה ייעודיים ישנים בנפרדצורך 5 מחסניות ו-5 תהליכונים5 אובייקטי waitריכוז על תהליכוני ההמתנה של המאגרהקריאה החוזרת רצה רק בסימון

איור 4: תהליכונים ש”רק ישנים וממתינים” אפשר להסיר בהפיכתם לאובייקטי wait. זה מהלך ראשון ברור להעברה למאגר.

4. התבנית הבסיסית — הלוך ושוב אחד עם אובייקט work

עוברים פעם אחת על הנימוסים עם אובייקט work, שהוא הנפוץ ביותר.4

VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
                           PVOID context, PTP_WORK work)
{
    // ההקשר קבוע בזמן היצירה. נתונים לכל פריט עוברים דרך תור מסונכרן
    WORK_QUEUE* queue = (WORK_QUEUE*)context;
    ITEM* item = Dequeue(queue);        // לקיחת פריט אחד תחת שליטה בלעדית
    ProcessItem(item);
}

// 1) יצירה (קישור הקריאה החוזרת להקשר המשותף = התור)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* טיפול בכישלון עם GetLastError */ }

// 2) הגשה אחת לכל פריט שמוכנס לתור (לשמור על מספר הפריטים ומספר ההגשות צמודים)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);

// 3) עצירת המגיש, ואז המתנה להשלמה (TRUE מנסה גם לבטל עבודה שעדיין לא התחילה)
WaitForThreadpoolWorkCallbacks(work, FALSE);

// 4) סגירה
CloseThreadpoolWork(work);

יש שתי נקודות להחזיק. ראשית, מותר לעשות SubmitThreadpoolWork לאותו אובייקט work יותר מפעם אחת. כל הגשה מריצה את הקריאה החוזרת (במקביל).7 עם זאת, ההקשר שמועבר לקריאה החוזרת קבוע בזמן היצירה, כך שכשכותבים “N פריטים מאותו סוג עבודה” עם אובייקט work אחד, שמים תור מסונכרן בהקשר כמו בקוד למעלה ולוקחים פריט אחד לכל הגשה (גם תכנון שיוצר אובייקט work לכל פריט תקין). שנית, תמיד ממתינים להשלמה לפני שסוגרים. סגירת האובייקט בזמן שנשארת קריאה חוזרת רצה או בתור, או שחרור זיכרון שהקריאה החוזרת מתייחסת אליו, הוא use-after-free כמות שהוא. כדי שההמתנה הזו תהיה סגירה בטוחה, עצירת המגיש קודם היא תנאי מוקדם — במבנה שבו תהליכון אחר עדיין יכול לעשות Submit במקביל להמתנה, הגשה אחרי ההמתנה מתחרה עם Close. העברת TRUE כארגומנט השני של WaitForThreadpoolWorkCallbacks מנסה גם לבטל הגשות שעדיין לא התחילו.

מחזור החיים של אובייקט workיצירה עם CreateThreadpoolWork; הגשה עם SubmitThreadpoolWork והקריאות החוזרות רצות במקביל. בכיבוי, קודם עוצרים הגשות חדשות, ממתינים להשלמת כל הקריאות החוזרות עם WaitForThreadpoolWorkCallbacks, ואז סוגרים עם CloseThreadpoolWorkיצירה עם CreateThreadpoolWorkהגשה עם SubmitThreadpoolWork (אפשר לחזור)קריאות חוזרות רצות במקבילעצירת הגשות חדשותהמתנה להשלמה עם WaitForThreadpoolWorkCallbacksסגירה עם CloseThreadpoolWork

איור 5: סדר הכיבוי הוא “עצירת הגשות → המתנה להשלמה → סגירה”. מדלגים על אחד מהם ומקבלים use-after-free או מירוץ.

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

5. מאגרים מותאמים וקבוצות ניקוי

פצלו את המאגר. אפשר ליצור מאגר עצמאי עם CreateThreadpool ולהגדיר את התקרה והרצפה של מספר התהליכונים עם SetThreadpoolThreadMaximum / SetThreadpoolThreadMinimum.8 השימוש הטיפוסי הוא בידוד. כדי ש”עבודה דמוית-אצווה שעלולה להיות איטית” לא תאכל את העובדים של “עבודה שצריכה להגיב מיד”, מפצלים את המאגרים ונותנים לכל אחד תקציב תהליכונים משלו.

קשרו אותם עם סביבת קריאה חוזרת. על איזה מאגר העבודה רצה מצוין באתחול TP_CALLBACK_ENVIRON (סביבת קריאה חוזרת), הפניה אליו למאגר עם SetThreadpoolCallbackPool, והעברתו כארגומנט השלישי של CreateThreadpoolWork וכדומה.9

קפלו אותם עם קבוצת ניקוי. במודול שיוצר אובייקטים רבים, עיבוד הכיבוי נוטה להפוך לדקלום של “המתן לכולם, סגור את כולם”. אם יוצרים קבוצה עם CreateThreadpoolCleanupGroup ומחברים כל אובייקט אליה דרך סביבת הקריאה החוזרת, CloseThreadpoolCleanupGroupMembers יחיד מבצע המתנה להשלמה ושחרור לכל אובייקטי החברים יחד.34

קישור התצורה דרך סביבת קריאה חוזרתסביבת הקריאה החוזרת מצביעה על מאגר מותאם ועל קבוצת ניקוי; work ו-timer שנוצרו עם הסביבה הזו רצים על המאגר הזה, ופעולה מרוכזת על קבוצת הניקוי אוספת המתנה להשלמה ושחרורסביבת קריאה חוזרת (TP_CALLBACK_ENVIRON)מאגר מותאם (שליטה במספר התהליכונים)קבוצת ניקוימועבר ביצירת work / timer / wait / ioהמתנה להשלמה ושחרור במכה אחת

איור 6: סביבת קריאה חוזרת היא המנגנון שמזריק “על איזה מאגר זה רץ, ומי מנקה” בזמן יצירת האובייקט.

6. מלכודות — משמעת בתוך קריאה חוזרת

כמעט כל באג במאגר תהליכונים מגיע מ”לעשות כרצונכם על תהליכון שאול”.

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

המתנה סינכרונית להשלמה באותו מאגר. צורה שבה, בתוך קריאה חוזרת A, ממתינים עם WaitForThreadpoolWorkCallbacks או דומה להשלמת עבודה B שהוגשה לאותו מאגר הופכת לקיפאון מרעב-מאגר ברגע שכל עובד “ממתין לעובד אחר”. כתבו מחדש תלות בין עבודות לא כהמתנה אלא כהמשך ש”מגיש את הבא מקריאת ההשלמה של B”.

המבנה של קיפאון מרעב-מאגראם כל תהליכון עבודה ממתין סינכרונית להשלמת עבודה אחרת שהוגשה לאותו מאגר, לא נשאר עובד פנוי להריץ את העבודה הזו, והכול ממתין לנצחעובד 1: ממתין להשלמת עבודה Xעבודות X ו-Y ממתינות לריצהעובד 2: ממתין להשלמת עבודה Yאין עובד פנוי להריץ אותןהכול ממתין לנצח (קיפאון רעב)

איור 7: אם ממתינים סינכרונית לעובד מתוך עובד, לא נשאר מי שיריץ את העבודה שממתינים לה.

לכלוך מצב התהליכון. תהליכון עבודה ממוחזר לקריאה החוזרת הבאה. שינוי עדיפות תהליכון, מצב אתחול COM, ערך שנשאר ב-TLS, נעילה ששכחתם לעזוב — כל אחד מאלה הופך לזיהום של הקריאה החוזרת הבאה (שאינה קשורה). “פונקציה שזורקים למאגר אסור שתהיה תלויה באישיות התהליכון” היא אזהרה רשמית עוד מימי ה-API הישן.6 יש מנגנונים ייעודיים לניקוי; למשל, LeaveCriticalSectionWhenCallbackReturns יכול לבקש מהמאגר “שחרר את הנעילה הזו כשהקריאה החוזרת הזו חוזרת”.3

מירוץ עם פריקת DLL. אם ה-DLL שמכיל את הקוד נפרק בזמן שקריאה חוזרת רצה, מתקבלת הפרת גישה. הצורה הבסיסית היא להמתין ביסודיות להשלמה בפונקציית הכיבוי של ה-DLL; FreeLibraryWhenCallbackReturns מסופק למצב “הקריאה החוזרת הזו היא העבודה האחרונה, וכשהיא מסתיימת אני רוצה שה-DLL ישוחרר כולל אני עצמי”. ה-API הזה, עם זאת, רק “מרפה מאזכור אחד כשהקריאה החוזרת הרצה חוזרת”; הוא לא מונע פריקה לפני שהקריאה החוזרת התחילה. משתמשים בו כזוג: לוקחים אזכור מודול משלכם עם GetModuleHandleEx לפני ההגשה, ונותנים לקריאה החוזרת להרפות מהאזכור הזה עם ה-API הזה.3 ואל תעשו את ההמתנה להשלמה הזו בתוך DllMain — כפי שנאמר ב”DllMain ונעילת הטוען”, המתנה לתהליכון אחר בתוך DllMain היא תבנית קיפאון.

הכנה למירוץ בין פריקת DLL לקריאה חוזרתפריקת ה-DLL בזמן שקריאה חוזרת רצה הופכת להפרת גישה, ולכן הצורה הבסיסית היא להמתין להשלמה בפונקציית כיבוי מפורשת ואז לסגור; כשהקריאה החוזרת האחרונה עצמה משחררת את ה-DLL, משתמשים ב-FreeLibraryWhenCallbackReturnsפריקה במהלך קריאה חוזרתהפרת גישההמתנה, ואז סגירהפריקה בטוחהבפונקציית כיבויFreeLibraryWhenCallbackReturnsהקריאה האחרונה משחררת DLLלא בתוך DllMain

איור 8: הצורה הבסיסית היא לעשות “המתן, ואז סגור” בפונקציית כיבוי מפורשת. המתנה בתוך DllMain מזמינה קיפאון אחר.

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

7. איך זה קשור לספריית התקן ול-.NET — באיזו שכבה כותבים

לבסוף, מסדרים איך זה יושב מול הכלים האחרים.

  • אם std::async / std::thread של C++ מספיקים, הם המועמד הראשון. הם ניידים, הקוד קצר, ואפילו הסמנטיקה של future מיושבת בתקן.10
  • סיבות להשתמש במאגר התהליכונים של Win32 ישירות הן כש(1) רוצים מנגנון קריאה חוזרת מאוחד שכולל timer, wait ו-io, (2) רוצים פיצול מאגרים או שליטה במספר תהליכונים, או (3) לא רוצים להחזיק תהליכונים עצמאיים בתוך DLL או רכיב COM.
  • בצד .NET, ThreadPool ו-Task ממלאים אותו תפקיד, והשלמת I/O קשורה ל-IOCP. מבנה המרתף הזה מוסבר ב”IOCP ומאגר התהליכונים של .NET”.
החלטה באילו כלי שכבה לכתובאם async או thread של C++ התקני מספיקים, משתמשים בהם; משתמשים במאגר התהליכונים של Win32 ישירות כשצריך שילוב של טיימרים, המתנות והשלמות I/O, פיצול מאגרים או שליטה במספר תהליכונים, או כשלא רוצים תהליכונים עצמאיים בתוך DLL או רכיב COMכןלאשילוב של timer, wait ו-ioפיצול מאגר / שליטה במספרהימנעות מתהליכונים עצמאיים ב-DLLהאם כלי C++ התקניים מספיקים?std::async / std::threadמה צריך?מאגר תהליכונים של Win32

איור 9: בספק, מתחילים בספריית התקן; תורו של ה-API הזה מגיע כשמופיעה דרישה שהוא לא יכול לבטא.

במילים אחרות, ה-API הזה הוא יסוד המקביליות בנקודה שבה החלטתם “לכתוב נייטיב”. ניקוי ריאליסטי הוא מדורג: כיעד העברה מהתרבות CreateThread עצמאי, מתחילים בהכנסת אובייקט work, ואז מחליפים תהליכונים שרק ממתינים ב-wait ותהליכוני טיימר ב-timer.

8. סיכום

  • להנפקת מספר גדול של עבודות קצרות-חיים, ולניקוי תהליכונים שרק ממתינים ורק מטיימרים, מאגר התהליכונים הסטנדרטי של מערכת ההפעלה ולא תהליכונים עצמאיים. מה שמשתמשים בו הוא ה-API החדש מ-Vista ואילך.
  • במרכז עומדים ארבעת האובייקטים work, timer, wait ו-io. תנאי הירי שונים; הם מאוחדים על אותה קבוצת עובדים ואותו סגנון קריאה חוזרת.
  • הנימוסים הם “צור → הגש → המתן להשלמה → סגור”. כמה הגשות רצות במקביל. קבוצת ניקוי יכולה לרכז את עיבוד הכיבוי.
  • שלושת כללי הברזל של קריאה חוזרת: אל תחסמו זמן ארוך (אם כן, CallbackMayRunLong); אל תמתינו סינכרונית באותו מאגר; אל תלכלכו את מצב התהליכון.
  • שימוש מתוך DLL: שימו לב למירוץ פריקה. המתינו להשלמה בפונקציית כיבוי מפורשת; אל תעשו זאת ב-DllMain.
  • היכן ש-C++ תקני או .NET מספיקים, השתמשו בהם. תורו של ה-API הזה הוא כשצריך שילוב של timer/wait/io או שליטה במאגר.

ה-API של מאגר התהליכונים נמצא, בין ה-API של Win32, בצד החדש והמעוצב יותר. ברגע שסיימתם את המעבר מהרעיון של “ליצור תהליכון” לרעיון של “לזרוק קריאה חוזרת”, מקביליות בקוד נייטיב הופכת לברורה בהרבה לכתיבה.

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

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

KomuraSoft LLC מטפלת בתכנון העברה מקוד נייטיב שתהליכוניו התרבו אל מאגר התהליכונים, בסקירות תכנון של עיבוד מקבילי ביישומי C++ וב-DLL, ובחקירת שורש של תקיעות וקריסות שנגרמו מרעב-מאגר או מקריאות חוזרות. מוזמנים להתייעץ איתנו החל ממלאי של הקוד הקיים.

קישורים

  1. Microsoft Learn, Thread Pools. על כך שמאגר תהליכונים הוא אוסף של תהליכוני עבודה שמבצעים ביעילות קריאות חוזרות אסינכרוניות בשם יישום; על סוגי היישום שהוא מתאים להם (הנפקת מספר גדול של פריטי עבודה קטנים במקביל, יצירה ופירוק תכופים של תהליכונים קצרי-חיים, עיבוד עבודה עצמאית במקביל, המתנות בלעדיות לאובייקטי ליבה ועוד); ועל עיצוב מחדש מקיף ב-Vista (איחוד סוגי תהליכון עבודה, תור טיימר יחיד, תהליכונים מתמידים ייעודיים, קבוצות ניקוי, כמה מאגרים בתהליך אחד, וה-API החדש).  2 3 4 5

  2. Microsoft Learn, Thread Pooling. על מבנה ה-API הישנים יותר של מאגר התהליכונים (QueueUserWorkItem, תורי טיימר, המתנות רשומות, BindIoCompletionCallback); על כך שאין דרך לבטל עבודה ברגע שהיא בתור; ועל כך שה-API החדש של מאגר התהליכונים שהוצג ב-Vista נקבע כפשוט יותר ועדיף באמינות, בביצועים ובגמישות.  2

  3. Microsoft Learn, threadpoolapiset.h header. על רשימת הפונקציות שכוללת את ארבע פונקציות יצירת האובייקטים CreateThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWait ו-CreateThreadpoolIo; קבוצות ניקוי (CreateThreadpoolCleanupGroup); וניקוי הקשור להשלמת קריאה חוזרת (LeaveCriticalSectionWhenCallbackReturns, FreeLibraryWhenCallbackReturns ועוד).  2 3 4 5 6

  4. Microsoft Learn, Using the Thread Pool Functions. על ההליך הבסיסי של יצירה עם CreateThreadpoolWork, הגשה עם SubmitThreadpoolWork, המתנה להשלמה עם WaitForThreadpoolWorkCallbacks וסגירה עם CloseThreadpoolWork; ועל דוגמת תצורה שמשלבת מאגר מותאם עם סביבת קריאה חוזרת וקבוצת ניקוי.  2 3

  5. Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). על הודעה למאגר שהקריאה החוזרת הנוכחית עלולה לרוץ זמן ארוך, כדי שהמאגר יוכל להשתמש בזה כחומר להחלטה אם להבטיח תהליכון לקריאות חוזרות אחרות; ועל שקילת תהליכון ייעודי לקריאה חוזרת ארוכת-ריצה היכן שאפשר.  2

  6. Microsoft Learn, Thread Pooling. על כך שפריטי עבודה שמוגשים למאגר תהליכונים, והפונקציות שהם קוראים להן, חייבים להיות בטוחים למאגר; על אי-הנחה שתהליכון הביצוע הוא תהליכון ייעודי ומתמיד; ועל הימנעות משימוש ב-TLS ובקריאות אסינכרוניות שדורשות תהליכון מתמיד.  2

  7. Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). על היכולת להגיש את אותו אובייקט work יותר מפעם אחת בלי להמתין להשלמת קריאה חוזרת קודמת, כך שהקריאות החוזרות רצות במקביל; ועל כך שהמאגר יכול להתאים (לחנוק) את מספר התהליכונים ליעילות. 

  8. Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). על היכולת לקבוע תקרה למספר תהליכוני העבודה למאגר שנוצר עם CreateThreadpool (הרצפה היא SetThreadpoolThreadMinimum). 

  9. Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). על יצירת אובייקט work מפונקציית קריאה חוזרת וממצביע הקשר; ועל כך שהארגומנט השלישי, TP_CALLBACK_ENVIRON, יכול לציין את סביבת הביצוע של הקריאה החוזרת (המאגר שאליו היא שייכת ועוד), ו-NULL פירושו שהיא רצה בסביבת ברירת המחדל. 

  10. Microsoft Learn, <future>. על ביצוע אסינכרוני לפי משימה דרך std::async ו-future שמסופקים כספריית התקן, כך שאפשר לכתוב מקביליות בלי לנהל תהליכונים ישירות. 

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

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

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

שאלות נפוצות

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

מה עדיף במאגר תהליכונים לעומת יצירת תהליכונים עצמאיים עם CreateThread?
יעילות כשיש מספר גדול של עבודות קצרות-חיים לביצוע, והפחתה בקוד ניהול התהליכונים. יצירה והשמדה של תהליכון יש להן עלות שאי אפשר להתעלם ממנה, כך שיישום שחוזר על "CreateThread לכל עבודה והשמדה בסיום", או שמחזיק תהליכונים רבים שקיימים רק כדי לישון בהמתנה לאירוע, יכול להקטין את מספר התהליכונים ואת החלפות ההקשר במעבר למאגר. גם התיעוד הרשמי מונה כמועמדים למאגר יישומים שמנפיקים מספר גדול של פריטי עבודה קטנים במקביל, יישומים שיוצרים תהליכונים קצרי-חיים רבים, ויישומים שיש להם תהליכונים המוקדשים אך ורק להמתנה לאובייקטי ליבה. ולהפך, עבודה ש"צריכה אישיות משלה על התהליכון" — שינוי עדיפות, COM STA, עיבוד ייעודי ארוך — עדיין צריך להחזיק על תהליכון ייעודי כמו קודם.
במה זה שונה מפונקציות מאגר התהליכונים הישנות כמו QueueUserWorkItem?
מאגר התהליכונים עוצב מחדש באופן מקיף ב-Windows Vista. ה-API הנוכחי ממשפחת threadpoolapiset (CreateThreadpoolWork וכדומה) הוא ה-API החדש; QueueUserWorkItem, RegisterWaitForSingleObject וכדומה הם ה-API הישן (legacy). ה-API החדש מאחד את סוגי תהליכוני העבודה, מאפשר ליצור כמה מאגרים עצמאיים בתהליך אחד, ומספק מנגנונים כמו שחרור מרוכז דרך קבוצת ניקוי ושחרור נעילה או פריקת DLL הקשורים להשלמת קריאה חוזרת. גם התיעוד הרשמי אומר שה-API החדש פשוט יותר ועדיף באמינות, בביצועים ובגמישות. ל-API הישן יש גם אילוצים מבניים, כמו "אין דרך לבטל עבודה ברגע שהיא בתור", ולכן בקוד חדש משתמשים ב-API החדש.
יש דברים שאסור לעשות בתוך קריאה חוזרת?
יש שלושה גדולים. ראשית, חסימה ממושכת או עבודה ארוכה בברירות המחדל. המאגר מתאים את מספר התהליכונים מתוך הנחה שהקריאות החוזרות מסתיימות במהירות, ולכן לעבודה שתיקח זמן ארוך מכריזים עליה עם CallbackMayRunLong או משתמשים בתהליכון ייעודי. שנית, המתנה סינכרונית להשלמת עבודה אחרת שהגשתם לאותו מאגר. אם כל עובד מגיע למצב "ממתין לעובד אחר", מתקבל קיפאון מרעב-מאגר. שלישית, תלות באישיות התהליכון. תהליכוני עבודה משותפים בין קריאות חוזרות, כך שחזרה עם עדיפות תהליכון או מצב אתחול COM שהשתנו, או השארת מצב ב-TLS, מזהמת את הקריאה החוזרת הבאה. לניקוי בסוף (שחרור נעילה או פריקת DLL) מסופקים מנגנונים ייעודיים כמו LeaveCriticalSectionWhenCallbackReturns ו-FreeLibraryWhenCallbackReturns.
יש נקודות לתשומת לב בשימוש במאגר התהליכונים מתוך DLL?
הסכנה הגדולה ביותר היא "ה-DLL נפרק בזמן שקריאה חוזרת עדיין רצה". אם הקריאה החוזרת רצה אחרי הפריקה, מתקבלת הפרת גישה. צד ה-DLL חייב, בעיבוד הכיבוי שלו, להמתין באופן אמין להשלמת הקריאות החוזרות שהנפיק — עם פונקציית המתנה כמו WaitForThreadpoolWorkCallbacks, או CloseThreadpoolCleanupGroupMembers על קבוצת ניקוי — ורק אז לסגור את האובייקטים. המתנה לזה בתוך DllMain, לעומת זאת, עלולה לקפוא דרך אינטראקציה עם נעילת הטוען, ולכן הכלל הוא לעשות זאת בפונקציית כיבוי מפורשת, לא ב-DllMain. למצב שבו הקריאה החוזרת עצמה רוצה לשחרר את ה-DLL כי "זו העבודה האחרונה", מסופק API ייעודי, FreeLibraryWhenCallbackReturns.
עכשיו שיש C++ std::async ו-ThreadPool של .NET, עדיין יש מקרים להשתמש ב-API הזה ישירות?
יש. הקריטריון הוא "האם הכלי בשכבה הזו מספיק". אם רמת המקביליות שצריך ב-C++ מכוסה ב-std::async או ב-std::thread, ספריית התקן היא המועמד הראשון גם מבחינת ניידות. מצד שני, רצון לאחד טיימרים, המתנות לאובייקטי ליבה והשלמות I/O אסינכרוני במנגנון קריאה חוזרת אחד; רצון לפצל מאגרים ולשלוט במספר תהליכונים לפי סוג עבודה; אי-רצון להחזיק תהליכונים עצמאיים בתוך DLL או רכיב COM — אלה הדרישות שמאגר התהליכונים של Win32 מכסה. הקשר עם ThreadPool ו-IOCP של .NET מכוסה במאמר קשור, וכל עוד כותבים נייטיב, הכרת המנגנון שיושב בשכבה מתחת אינה מבוזבזת.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג