DllMain ו-loader lock — הסיבה האמיתית שאומרים לא לעשות כלום ב-initialization של DLL
· עודכן בתאריך: · Go Komura · Windows, DLL, פיתוח Windows, C++, troubleshooting, multithreading, Win32 API
היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 22 Aug 2026)
- פרסום ראשון
לצטט את המאמר הזה(DOI (ארכיון רשום): 10.5281/zenodo.22176703)
מזהי ה־DOI שלהלן מפנים לגרסאות שנשמרו בעבר בארכיון, ועשויים שלא להתאים לטקסט הנוכחי. להפניה לטקסט הנוכחי, השתמשו בכתובת של דף זה.
Go Komura (2026). DllMain ו-loader lock — הסיבה האמיתית שאומרים לא לעשות כלום ב-initialization של DLL. KomuraSoft LLC. https://comcomponent.com/he/blog/dllmain-loader-lock/
- DOI (ארכיון רשום)
- 10.5281/zenodo.22176703
- DOI (הגרסה האחרונה שנרשמה)
- 10.5281/zenodo.22176704
“כשטוענים DLL משלנו, LoadLibrary לא חוזר.” “זה נתקע רק במחשבים מסוימים, או רק כשה-service עולה.” בכשלים כאלה, אחד המקומות הראשונים לבדוק הוא קוד ה-initialization של ה-DLL: DllMain וכל מה שנקרא ממנו.
DllMain אינו דומה לפונקציית initialization רגילה של אפליקציה. זו פונקציה שה-OS קורא לה בזמן שהוא מחזיק את loader lock, ולכן מה שמותר לעשות בה מוגבל בחומרה. העמדה של Microsoft ש”DllMain האידיאלי הוא stub ריק” נובעת מכך שהמגבלה הזו משפיעה לא רק על ה-DLL שלכם אלא גם על DLLs ו-threads אחרים ב-process.1
המאמר הזה מיועד למפתחים שכותבים DLLs, plug-ins ועטיפות C++/CLI ב-Windows. הוא מסדר את הנושא בסדר הזה: למה זה נעצר, אחר כך לאן להעביר את ה-initialization, אחר כך איך לסיים, אחר כך איך לחקור hang.
1. קודם המסקנה — להקטין את DllMain ולשנות מתי העבודה רצה
במקום לחפש איך לכתוב משהו בבטחה בתוך DllMain, הגישה הבסיסית היא להקטין את העבודה שרצה שם. מה שנשאר מחליטים בסדר הבא.1
| החלטה | מדיניות בסיסית | איפה לקרוא עוד |
|---|---|---|
| מתי לאתחל | לעשות סטטי כל מה שאפשר להחליט בזמן קומפילציה; לדחות את השאר לשימוש ראשון אחרי שהטעינה הסתיימה | פרק 5 |
| מה לקרוא מ-DllMain | להימנע מ-LoadLibrary / FreeLibrary, מסנכרון עם threads אחרים, ומשימוש ב-User, Shell, COM וכדומה. לבדוק גם קריאות עקיפות |
פרק 3 |
| מה לעשות ב-shutdown | להפריד את המקרה שבו רק ה-DLL נפרק מהמקרה שבו כל ה-process יוצא | פרק 6 |
קל לפספס שאותן מגבלות חלות גם על constructors ו-destructors של אובייקטי C++ גלובליים וסטטיים. ב-C++/CLI מבטלים גם כל נתיב שמריץ MSIL תחת loader lock. זה שגוף DllMain עצמו ריק אינו מספיק כדי לשפוט.23
אם אתם חוקרים hang עכשיו, התחילו בפרק 7; אם אתם סוקרים תכנון, קראו את פרקים 2 עד 6 לפי הסדר, ותוכלו להתאים כל איסור לטיפול שלו.
2. המנגנון — DllMain נקרא בתוך loader lock
2.1 הוא נקרא לא רק בטעינה אלא גם כש-threads מתחילים ויוצאים
DllMain הוא נקודת הכניסה ש-loader של ה-OS קורא לה כש-DLL נכנס ל-process או ל-thread או יוצא מהם. יש ארבע notifications.2
| Notification | מתי |
|---|---|
| DLL_PROCESS_ATTACH | כשה-DLL נטען ל-process |
| DLL_THREAD_ATTACH | כש-thread חדש מתחיל ב-process |
| DLL_THREAD_DETACH | כש-thread יוצא באופן רגיל |
| DLL_PROCESS_DETACH | כשה-DLL נפרק, או כשה-process יוצא |
notification של התחלת thread מגיעה לא רק ל-DLL שיצר את ה-thread אלא לכל DLL שטעון ב-process. DllMain אינו “קוד שרץ פעם אחת כשה-DLL שלי נטען”. ב-process שיוצר threads לעיתים קרובות, הוא רץ בכל notification.2
אם אינכם צריכים את ה-notifications, אפשרות אחת היא לקרוא ל-DisableThreadLibraryCalls בתוך DLL_PROCESS_ATTACH. אי אפשר להשתמש בזה ב-DLL שמקושר עם CRT סטטי, ויש גם תנאי ל-static TLS, ולכן פרק 5.3 מטפל בהחלטה הזו בנפרד.4
2.2 זה שנקראים כשה-lock מוחזק הוא נקודת המוצא של כל מגבלה
כדי לשמור על עקביות של טעינת DLLs, unload ו-notifications, ה-loader של ה-OS מסדר אותם עם loader lock אחד לכל process. הנקודה החשובה היא שהוא לוקח את ה-lock הזה לפני הקריאה ל-DllMain וממשיך להחזיק אותו בזמן ש-DllMain רץ.1
באותו זמן, כל thread אחר באותו process שמנסה לטעון DLL או להתקדם ב-notification של התחלת thread או יציאה ממתין לשחרור ה-lock. זה לא מקום שאפשר להכניס בו המתנה ארוכה רק כי זה נוח ל-DLL שלכם.
flowchart TB
accTitle: למה עבודה ב-DllMain משפיעה על DLL notifications בכל ה-process
accDescr: ה-loader לוקח את loader lock המשותף ל-process לפני שהוא קורא ל-DllMain, וטעינות DLL ו-thread notifications ב-threads אחרים ממתינים לשחרורו, כך שעבודה ב-DllMain משפיעה גם על DLLs ו-threads אחרים
loader["ה-loader לוקח את ה-lock המשותף"] --> dll["DllMain רץ"]
dll --> ret["DllMain חוזר"]
ret --> unlock["loader lock משוחרר"]
other["טעינת DLL או notification ב-thread אחר"] --> wait["ממתין לאותו lock שישוחרר"]
unlock --> resume["העבודה הממתינה יכולה להמשיך"]
wait --> resume
איור 1: ה-lock המשותף מוחזק בזמן ש-DllMain רץ, ולכן המתנה שם עוצרת את ההתקדמות של DLLs ו-threads אחרים.
האיסורים בהמשך מובנים ברגע ששואלים “האם העבודה הזו צריכה, ישירות או בעקיפין, את loader lock או את ה-initialization של DLL אחר?”
3. למה זה נעצר — להבין את האיסורים דרך ארבעה נתיבים
3.1 קריאה למשהו שטוען DLL אחר
הימנעו מקריאה ל-LoadLibrary / FreeLibrary מתוך DllMain. LoadLibrary יוצר תלות מעגלית בסדר הטעינה ומוביל לשימוש ב-DLL לפני שקוד ה-initialization שלו רץ. בצד ה-shutdown יש גם סיכון להשתמש ב-DLL שכבר טופל או שוחרר.2
זה שלא כתבתם LoadLibrary בעצמכם אינו הופך אתכם לבטוחים. חלק מפונקציות User, Shell ו-COM טוענות רכיבי מערכת אחרים מבפנים, והן יכולות לגעת ברכיב שעוד לא אותחל או שכבר שוחרר ולגרום ל-access violation.2
קו הבסיס של מה שאפשר לקרוא בבטחה הוא הפונקציות ב-Kernel32.dll שלא טוענות DLLs אחרים, כי Kernel32.dll מובטח טעון עד ש-DllMain רץ. לדוגמה אפשר ליצור אובייקטי סנכרון כמו critical section ו-mutex, ואפשר להשתמש ב-TLS. עם זאת התיעוד הרשמי קובע במפורש שאין רשימה ממצה של פונקציות בטוחות. אל תחשבו “זה ב-Kernel32, אז הכול מותר” או “אפשר ליצור אובייקט סנכרון, אז מותר לחכות ל-threads אחרים”.2
3.2 המתנה בתוך DllMain ליציאת thread אחר
ה-deadlock הקלאסי קורה כשה-DLL נפרק: DllMain מבקש מ-worker thread לעצור ואז ממתין ליציאתו.
גם אחרי שה-worker סיים את העבודה שלו, הוא צריך לעבור את notification ה-DLL_THREAD_DETACH ביציאת thread. ה-notification הזו צריכה את loader lock, ש-DllMain הממתין מחזיק. התוצאה היא ש-DllMain ממתין ל-worker, וה-worker ממתין ש-DllMain יחזור.5
sequenceDiagram
accTitle: למה המתנה ל-thread ב-DllMain עושה deadlock
accDescr: DllMain, שמחזיק את loader lock, ממתין ליציאת worker thread, אבל ה-worker שיוצא ממתין לשחרור loader lock בשביל notification DLL_THREAD_DETACH, כך ששניהם ממתינים זה לזה ועושים deadlock
participant L as Loader (מחזיק את ה-lock)
participant D as DllMain
participant W as Worker thread
L->>D: מודיע DLL_PROCESS_DETACH
D->>W: מבקש יציאה וממתין לסיום
W->>W: מסיים עבודה ופונה ליציאת thread
Note over W: notification היציאה צריכה את loader lock
Note over D,W: DllMain ממתין כשהוא מחזיק את ה-lock, W ממתין ל-lock
איור 2: גם אחרי שה-worker סיים את העבודה, הוא לא יכול לעבור את notification יציאת ה-thread, ולכן המתנת היציאה בתוך DllMain לא נגמרת.
זה לא מקרה של “זה נעצר אם אין מזל”; ההמתנה ההדדית מחזיקה מבנית. ה-cleanup שנדרש ב-unload מטופל בפרק 6, בנפרד מהעבודה ביציאת process.
3.3 ה-lock שלכם ו-loader lock נלקחים בסדר הפוך
זה לא רק התחלת thread ויציאה; גם APIs כמו GetModuleHandle צריכים את loader lock מבפנים. כששני הנתיבים הבאים חופפים, סדר לקיחת ה-locks מתהפך.6
- בצד DllMain, קוד שכבר מחזיק את loader lock מנסה לקחת lock פרטי G.
- בצד ה-worker, קוד שכבר מחזיק lock פרטי G קורא ל-API ומנסה לקחת את loader lock.
flowchart TB
accTitle: היפוך סדר בין loader lock ל-lock פרטי
accDescr: DllMain הולך ל-lock פרטי בזמן שהוא מחזיק את loader lock, ו-worker thread הולך ל-loader lock, בשביל GetModuleHandle וכדומה, בזמן שהוא מחזיק את ה-lock הפרטי, כך שסדר הלקיחה מתהפך והם עושים deadlock
d["DllMain: מחזיק את loader lock"] --> dg["הולך ל-lock פרטי G"]
w["Worker: מחזיק lock פרטי G"] --> wl["הולך ל-loader lock"]
dg -.-> dead["deadlock מהיפוך סדר לקיחה"]
wl -.-> dead
wl -.-> api["נדרש מבפנים ב-GetModuleHandle ואחרים"]
איור 3: כשצד אחד לוקח את loader lock ואז את G, והשני לוקח את G ואז את loader lock, כל אחד ממתין שהשני ישחרר.
ההנחיה הרשמית מבקשת לטפל ב-loader lock כראש היררכיית ה-locks של האפליקציה, כלומר ה-lock שנלקח ראשון. עד שנכנסתם ל-DllMain, ה-lock הזה כבר מוחזק. בדקו לא רק את שם הפונקציה שאתם קוראים לה אלא גם אילו locks אתם מחזיקים כשאתם קוראים לה.6
3.4 גם יצירת thread בלבד משאירה בעיות של המתנת התחלה ושל lifetime
גם CreateThread בתוך DllMain אינו מומלץ. ה-thread החדש לא יכול להתחיל להריץ את פונקציית ה-thread עד ש-notification ה-DLL_THREAD_ATTACH טופלה. כי DllMain הנוכחי מחזיק את loader lock, המתנה בתוך אותו DllMain שה-thread יתחיל או יסתיים עושה deadlock.5
גם אי-המתנה אינה פותרת הכול. אם ה-DLL נפרק אחרי ש-DllMain חוזר אבל לפני שה-thread שיצרתם התחיל לרוץ, כתובת ההתחלה של ה-thread מצביעה על קוד שכבר שוחרר, ו-crash נשאר אפשרי.5
flowchart TB
accTitle: שתי בעיות שנשארות כשיוצרים thread ב-DllMain
accDescr: thread שנוצר ב-DllMain ממתין ל-loader lock בשביל notification ההתחלה, כך שאם DllMain ממתין שיתחיל או שיסתיים הם עושים deadlock, וגם אם DllMain חוזר בלי להמתין, ה-lifetime של הקוד נגמר ויש crash אם ה-DLL נפרק לפני שה-thread מתחיל לרוץ
create["thread נוצר בתוך DllMain"] --> pending["ה-thread החדש ממתין ל-notification ההתחלה"]
pending --> q{"להמתין להתחלה או לסיום בתוך DllMain?"}
q -->|"כן"| dead["המתנה הדדית בזמן שה-lock מוחזק"]
q -->|"לא"| returns["חזרה מ-DllMain"]
returns --> race["ה-DLL נפרק לפני שה-thread מתחיל לרוץ"]
race --> crash["הקוד בכתובת ההתחלה נעלם"]
איור 4: לא להמתין בתוך DllMain, ושמירה על ה-lifetime של ה-DLL שה-thread שנוצר משתמש בו, הם שני דרישות נפרדות.
4. שני מקומות שמסוכנים גם כש-DllMain ריק
4.1 initialization דינמי של אובייקטים גלובליים וסטטיים
ב-DLL שמקושר עם CRT (C/C++ runtime), constructors ו-destructors של אובייקטי C++ גלובליים וסטטיים רצים דרך נקודת הכניסה של ה-CRT. הם חלק דה-פקטו מ-DllMain ונופלים תחת אותן מגבלות.2
לשים טעינת קובץ הגדרות, הפעלת logger, אתחול COM או הפעלת thread בתוך constructor רק מסתיר איפה הקריאה נעשית; הרגע שהיא רצה עדיין בתוך loader lock. אם העבודה המורכבת הזו כוללת טעינת DLL אחר או סנכרון עם threads, זה מסוכן בדיוק כמו לכתוב את זה בגוף DllMain.
flowchart TB
accTitle: איך initialization של אובייקט גלובלי הופך למוקש
accDescr: טעינת ה-DLL לוקחת את loader lock וה-constructors של אובייקטים גלובליים רצים דרך ה-CRT, כך ש-LoadLibrary, סנכרון threads או אתחול COM בתוכם הם הרצה של מה ש-DllMain אוסר
load["טעינת DLL (loader lock נלקח)"] --> crt["נקודת הכניסה של ה-CRT"]
crt --> ctor["constructor של אובייקט גלובלי"]
ctor --> ng1["עבודה שקולה ל-LoadLibrary"]
ctor --> ng2["הפעלת thread והמתנה לסיומו"]
ctor --> ng3["שימוש ב-COM או ב-User32"]
ng1 -.-> risk["כל אלה הם איסורי DllMain"]
ng2 -.-> risk
ng3 -.-> risk
איור 5: לסקור לא רק את גוף DllMain אלא גם את ה-initialization והסיום של אובייקטים סטטיים שנקראים מה-CRT.
טפלו ב-initialization קבוע בזמן קומפילציה, למשל כל מה שיכול להיות constexpr, בנפרד מ-initialization מורכב בזמן ריצה. דחו initialization דינמי שכולל קריאות לפונקציות, וסדרו שהגישה הראשונה תקרה גם היא מחוץ ל-DllMain.
4.2 MSIL שרץ ב-callee של C++/CLI
עטיפת native DLL ב-C++/CLI מכוסה ב-C# מול native DLL: מתי P/Invoke ומתי C++/CLI wrapper. בתצורה הזו, שימו לב לנתיבים שמריצים MSIL (managed code) תחת loader lock. אם הרצת ה-MSIL דורשת אתחול CLR או טעינת assembly אחר, deadlock אפשרי.3
המהדר פולט אזהרת C4747 לקוד שבו DllMain מנסה ישירות להריץ MSIL. אבל הוא לא יכול לזהות הרצה עקיפה דרך פונקציה במודול אחר. היעדר האזהרה לבדו אינו בסיס לקרוא לקוד בטוח. גם dynamic initializers של אובייקטים סטטיים נמצאים בהיקף.3
flowchart TB
accTitle: האם אפשר לזהות הרצת MSIL תחת loader lock
accDescr: קוד שבו DllMain מריץ MSIL ישירות המהדר יכול לזהות באזהרת C4747, אבל הרצה עקיפה דרך פונקציה במודול אחר לא, ולכן צריך למנוע אותה בסקירת עץ הקריאות ובתרגום native לאורך כל הדרך
d2["קריאה מ-DllMain"] --> dir["מריץ MSIL ישירות"]
d2 --> ind["מריץ דרך מודול אחר"]
dir --> c47["מזוהה באזהרת C4747"]
ind --> nc["המהדר לא יכול לזהות"]
nc -.-> rv["למנוע בסקירה וב-#pragma unmanaged"]
איור 6: מעבר לנתיב הישיר ש-C4747 מזהה, לסקור גם קריאות שעוברות דרך מודול אחר.
הטיפול הוא לתרגם את DllMain וכל פונקציה שאפשר להגיע אליה ממנו כ-native עם #pragma unmanaged, או לא לקיים DllMain בכלל. גם באפשרות השנייה, אל תפספסו נתיבים עקיפים כמו static initializers.3
5. תכנון initialization — לעשות סטטי, לדחות, להשאיר רק את המינימום
5.1 לפני שמשאירים משהו ב-DllMain, לשאול אם אפשר לשנות את התזמון
קו הבסיס הרשמי הוא להשלים בזמן קומפילציה כל initialization שאפשר, ולדחות את השאר כמה שאפשר. רק עבודה שחייבים לזהות מוקדם ככשל טעינה נשארת, כחריג ובמינימום.1
למשל, יכולה להיות דרישה שהטעינה עצמה של ה-DLL תיכשל כי קובץ הגדרות שהוא תלוי בו פגום. גם אז מצמצמים ל”לנסות את העבודה הנדרשת ולהיכשל מיד” במקום להריץ initialization אחר קודם ולהיכשל אחר כך. זה אינו חריג שמתיר initialization מורכב בזמן טעינה.1
flowchart TB
accTitle: הנחיות תכנון ל-initialization של DLL
accDescr: קודם שוקלים אם אפשר לעשות את ה-initialization סטטי בזמן קומפילציה; אם לא, ברירת המחדל היא לדחות לשימוש ראשון, וב-DllMain משאירים רק את המינימום שחייבים לזהות מוקדם ככשל טעינה
q1{"אפשר להחליט בזמן קומפילציה?"} -->|"כן"| s["לעשות static initialization"]
q1 -->|"לא"| q2{"חייבים לזהות את הכשל בטעינה?"}
q2 -->|"לא"| lazy["לדחות לשימוש ראשון (ברירת המחדל)"]
q2 -->|"כן"| min["לעשות רק את המינימום ב-DllMain"]
lazy -.-> once["להגן עם INIT_ONCE או סטטי מקומי-לפונקציה"]
איור 7: לשקול קודם static initialization ודחייה, ולהשאיר ב-DllMain רק את המינימום שצריך זיהוי מוקדם.
5.2 דחיית initialization צריך לתכנן עד “מי קורא ראשון”
ל-mutual exclusion בשימוש ראשון אפשר להשתמש ב-one-time initialization עם INIT_ONCE או בסטטי מקומי-לפונקציה ב-C++ (magic static). הרעיון הוא להעביר אובייקטים גלובליים מורכבים למצביע שנוצר בגישה ראשונה או לסטטי מקומי-לפונקציה.
אבל דחייה לבדה אינה מוציאה אתכם מ-loader lock. המגבלה מורמת רק כשהגישה הראשונה מגיעה מ”API רגיל שנקרא אחרי שטעינת ה-DLL הסתיימה”. במקרה הזה אפשר לתכנן את זה כקוד initialization רגיל שיכול להשתמש כמעט בכל Windows API.1
ולהפך, אם הגישה הראשונה הזו קורה מ-DllMain או מ-static initializer, ה-initialization עדיין רץ תחת loader lock. מעבר לחילוץ פונקציית initialization, אשרו מי קורא לה ראשון, ומתי.
flowchart TB
accTitle: מתי דחיית initialization בורחת מ-loader lock
accDescr: אם הגישה הראשונה של דחיית initialization מגיעה מ-API רגיל אחרי שהטעינה הסתיימה, אפשר לאתחל מחוץ ל-loader lock, אבל אם היא מגיעה מ-DllMain או מ-static initializer, היא רצה תחת אותן מגבלות
first{"מאיפה מגיעה הגישה הראשונה?"}
first -->|"API רגיל אחרי שהטעינה הסתיימה"| outside["לאתחל מחוץ ל-loader lock"]
first -->|"DllMain או static initializer"| inside["לאתחל תחת אותן מגבלות"]
outside --> once["להגן עם INIT_ONCE או סטטי מקומי-לפונקציה"]
inside --> move["להעביר גם את רגע הגישה הראשונה"]
איור 8: INIT_ONCE וסטטי מקומי-לפונקציה מטפלים ב-mutual exclusion של ה-initialization; הם לא מבטיחים שהוא נקרא מחוץ ל-loader lock.
5.3 להחליט על DisableThreadLibraryCalls לפי שלושה תנאים
ב-DLL שאינו תלוי ב-thread notifications, קריאה ל-DisableThreadLibraryCalls ב-DLL_PROCESS_ATTACH עוצרת את notifications ה-DLL_THREAD_ATTACH / DLL_THREAD_DETACH. ב-process שיוצר threads לעיתים קרובות, זה מקטין overhead של notifications.4
לפני שמחילים, בדקו CRT סטטי, static TLS, והאם משהו משתמש ב-notifications. DLL שמקושר עם CRT סטטי אסור לו לקרוא לזה, כי ה-CRT עצמו צריך את thread notifications. כש-static TLS דרך thread_local או __declspec(thread) בתוקף, הקריאה עצמה נכשלת ומחזירה FALSE.4
flowchart TB
accTitle: החלטה אם לקרוא ל-DisableThreadLibraryCalls
accDescr: DLL שמקושר עם CRT סטטי אסור לו לקרוא לזה, ועם static TLS בתוקף הקריאה עצמה נכשלת ולכן לא קוראים, אבל DLL שאינו אף אחד מאלה ואינו משתמש ב-thread notifications יכול לקרוא ב-DLL_PROCESS_ATTACH, עם בדיקת ערך ההחזרה, כדי לקצץ עלות notifications
q1{"מקושר עם CRT סטטי?"} -->|"כן"| no2["אסור לקרוא"]
q1 -->|"לא"| q2{"משתמש ב-static TLS?"}
q2 -->|"כן"| eff["הקריאה נכשלת בכל מקרה (FALSE)"]
q2 -->|"לא"| q3{"צריך thread notifications?"}
q3 -->|"לא"| yes["לקרוא ב-ATTACH (לבדוק את ערך ההחזרה)"]
q3 -->|"כן"| keep["לא לקרוא; לטפל ב-notifications"]
איור 9: להבחין בין CRT סטטי, שבו אסור להשתמש בזה, לבין static TLS, שבו זה נכשל, ולבדוק את ערך ההחזרה גם כש-notifications אינן נחוצות.
זו אופטימיזציה לשקול ב-DLL טיפוסי שמשתמש ב-CRT מקושר דינמית ועומד בתנאים האלה. DisableThreadLibraryCalls קיים כדי להקטין notifications; הוא אינו אמצעי לעשות initialization מורכב ב-DllMain.
6. Shutdown — להפריד unload של DLL מיציאת process
6.1 אותו DLL_PROCESS_DETACH, אבל מה שנשאר אחר כך שונה
DLL_PROCESS_DETACH מגיע גם כשרק ה-DLL נפרק וגם כשכל ה-process יוצא. ל-notification אותו שם, אבל הנחות ה-cleanup שונות.5
ב-unload דרך FreeLibrary, ה-process ממשיך לרוץ אחר כך. לכן צריך לעצור threads, ולנקות כמו שצריך handles פתוחים, משאבים שהוקצו, מצב שצריך לשמור, וכדומה. כפי שפרק 3.2 הראה, עם זאת, המתנה בתוך DllMain ליציאה טבעית של thread לשם כך עושה deadlock.5
עדיף עוד, להימנע מתכנון שבו DLL שאפשר לפרוק בכלל מחזיק threads; העברת בעלות ה-threads לצד ה-EXE היא האפשרות הבטוחה ביותר. לתכנון קיים שבו ה-DLL מחזיק threads, שוקלים את פרוטוקול העצירה הבא יחד עם האילוצים שלו, אף פעם לא אחד בלי השני.
6.2 פרוטוקול העצירה המתועד רשמית כשה-DLL מחזיק worker
ה-best practices של Microsoft מתעדים הליך עצירה ל-unload שממתין לא ל”יציאה טבעית של ה-thread” אלא ל”אות שהגיע למצב עקבי”.5
- צד DllMain מסמן ל-worker לעצור, באמצעות event.
- ה-worker מקפל את העבודה הנוכחית למצב עקבי, מסמן סיום, ונכנס להמתנה אינסופית.
- צד DllMain מאשר את המצב העקבי ומסיים את ה-thread עם
TerminateThread.
זה נראה גס, אבל זה תועד תחת האילוץ שהמתנה ליציאה טבעית גורמת ל-notification היציאה להתנגש עם loader lock. ההנחה היא שגם העבודה של הגעה למצב העקבי מצייתת לאותן מגבלות כמו DllMain. אם העבודה הזו נכנסת לטעינת DLL אחר או להמתנה ל-loader lock, deadlock עם הצד שממתין לאות בלתי נמנע.5
sequenceDiagram
accTitle: המתנה ב-unload להשלמת עקביות במקום ליציאה טבעית
accDescr: DllMain מסמן ל-worker לעצור, ה-worker מגיע למצב עקבי תוך ציות לאותן מגבלות כמו DllMain, מסמן בחזרה, ונכנס להמתנה אינסופית, ו-DllMain מאשר את המצב העקבי לפני שהוא מסיים את ה-thread
participant D as DllMain (ב-unload)
participant W as Worker thread
D->>W: מסמן עצירה ב-event
W->>W: מגיע למצב עקבי תחת אותן מגבלות
W->>D: מסמן שהעקביות הושגה
W->>W: נכנס להמתנה אינסופית
D->>W: מאשר עקביות, ואז TerminateThread
Note over D,W: מה שממתינים לו הוא אות העקביות, לא יציאה טבעית
איור 10: גם בפרוטוקול הזה, העבודה שמקימה את המצב העקבי אסור לה שתחכה ל-loader lock.
זה לא אומר שאפשר פשוט לחתוך כל thread רץ. קודם שוקלים אם אפשר להחזיק את ה-thread מחוץ ל-DLL.
6.3 ביציאת process, האידיאל הוא לחזור בלי לעשות כלום
עד ש-DLL_PROCESS_DETACH מגיע ביציאת process, ה-threads האחרים יצאו או הסתיימו בכפייה, ואי אפשר לסמוך על עקביות מרחב הכתובות. גם מצב של DLLs ו-runtimes תלויים אינו אמין, כך ש-cleanup כמו שחרור זיכרון הופך למסוכן במקום מועיל. גם ההנחיה הרשמית אומרת ש-handler האידיאלי במקרה הזה ריק.5
כתבו החוצה נתונים שצריך לשמור בקוד ה-shutdown של האפליקציה עצמה. אל תשתמשו ב-DLL_PROCESS_DETACH כמקום האחרון שבו אפשר לסדר הכול.
flowchart TB
accTitle: הנחות cleanup שונות בין unload של DLL ליציאת process
accDescr: כשרק ה-DLL נפרק ה-process ממשיך לרוץ ולכן צריך לנקות משאבים, אבל נמנעים מהמתנה ליציאה טבעית בתוך DllMain; ביציאת process לא סומכים על עקביות של threads ומשאבים אחרים, בעצם חוזרים בלי לעשות כלום, וכותבים מצב שצריך לשמור בקוד ה-shutdown של האפליקציה מראש
reason{"הסיבה ל-DLL_PROCESS_DETACH?"}
reason -->|"unload של DLL בלבד"| alive["ה-process ממשיך לרוץ אחר כך"]
alive --> cleanup["לנקות משאבים שנשארים כמו שצריך"]
cleanup -.-> nojoin["לא להמתין ליציאה טבעית בתוך DllMain"]
reason -->|"יציאת process"| processEnd["מצב של threads ומשאבים אחרים אינו ודאי"]
processEnd --> empty["בעצם לחזור בלי לעשות כלום"]
empty -.-> save["לשמור מראש בקוד ה-shutdown של האפליקציה"]
איור 11: מחליטים לא רק “האם צריך cleanup” אלא אם ה-process ממשיך לרוץ ואם אפשר לסמוך על המשאבים שמשמשים ל-cleanup.
7. חקירת hang — למצוא את הצד שממתין ל-loader lock ואת הצד שמחזיק אותו
7.1 לאשר ב-dump את הזוג של threads שממתינים זה לזה
לוכדים dump ברגע הקיפאון ובודקים את ה-stack של כל thread. טביעת האצבע הטיפוסית היא זוג: thread שממתין ל-lock בתוך פונקציית loader ב-ntdll.dll ששמה מתחיל ב-Ldr, ו-thread שממתין למשהו אחר בתוך DllMain או static initializer (dynamic initializer).
thread שנעצר באמצע קריאה ל-LoadLibrary הוא משתתף טיפוסי נוסף. ברגע שהקשר בין הצד שממתין ל-lock לבין הצד שמחזיק אותו בזמן שהוא ממתין למשהו אחר מתחבר, אפשר כמעט בוודאות להסיק שזה deadlock של loader lock.
flowchart TB
accTitle: אישור המתנה הדדית של loader lock מ-dump של hang
accDescr: ב-stack של כל thread מחפשים המתנת lock בפונקציות Ldr והמתנה בתוך DllMain או static initializer, מאשרים ששניהם ממתינים זה לזה, ואם הזוג הטיפוסי לא מופיע חוקרים גם סוגי hang אחרים
dump["ללכוד dump בזמן ה-hang"] --> stacks["לבדוק את ה-stack של כל thread"]
stacks --> ldr["המתנת lock בפונקציות Ldr"]
stacks --> init["המתנה בתוך DllMain או static initializer"]
ldr --> pair{"האם קשר ההמתנה ההדדית מתחבר?"}
init --> pair
pair -->|"כן"| found["deadlock של loader lock"]
pair -->|"לא נראה"| more["לחקור גם סוגי hang אחרים"]
איור 12: לא מחליטים מ-stack אחד; מחפשים את הזוג של הצד שממתין ל-loader lock והצד שמחזיק אותו בזמן שהוא ממתין.
תנאים כמו “לפעמים ב-startup”, “רק במכונה מסוימת”, או “רק כשמריצים כ-service” הם גם רמזים. ההמתנה ההדדית נוצרת כשטעינת DLL חופפת להתחלת thread או ליציאה, כך שהבדלי סביבה ותזמון הרצה משנים איך זה עולה על פני השטח.
7.2 למנוע עם Application Verifier וסקירת callees
הרצת בדיקות עם Application Verifier מופעל מזהה את טעויות DllMain הטיפוסיות בזמן ריצה.1 ב-C++/CLI, אל תתעלמו מאזהרת C4747, ובדקו את הנתיבים העקיפים שהאזהרה לא יכולה לזהות מנקודת המבט של פרק 4.2.
בסקירה, חפשו פונקציות שקוראות ל-LoadLibrary בעקיפין בין העבודה שאפשר להגיע אליה מ-DllMain. אתחול COM, חלק מיכולות CRT, ו-delay-load imports הם דברים לבדוק. בפרט, קל לפספס שהקריאה הראשונה לפונקציית import ב-delay-load הופכת מבפנים ל-LoadLibrary. גם אם שם הפונקציה חי מחוץ ל-DllMain, כל עוד הקורא הוא DllMain, המגבלה נשארת.
8. סיכום — להסתכל לא על שמות פונקציות אלא על “מתי, ותחת איזה lock, זה רץ”
מגבלות DllMain אפשר להבין כולן מנקודה אחת: הוא נקרא בזמן שמחזיקים את loader lock המשותף ל-process. לא רק DllMain שלכם אלא גם static initialization וסיום של ה-CRT וקריאות עקיפות של C++/CLI נופלים לאותו היקף.
מתחילים סקירה בשאלה אם אפשר לעשות את ה-initialization סטטי ואם אפשר לדחות את השאר עד אחרי שהטעינה מסתיימת. בודקים עד הגישה הראשונה של העבודה שנדחתה, ואם notifications אינן נחוצות, שוקלים DisableThreadLibraryCalls אחרי בדיקת תנאי CRT סטטי ו-static TLS.
בצד ה-shutdown, מפרידים unload של DLL בלבד מיציאת process שלם. אם ה-DLL מחזיק worker, עוקבים אחרי פרוטוקול האות, בדיקת העקביות והסיום, ובמידת האפשר מעבירים בעלות threads לצד ה-EXE. מקרבים את DllMain ביציאת process לריק, ושמים שמירת נתונים בקוד ה-shutdown של האפליקציה עצמה.
flowchart TB
accTitle: הסדר לסקירת DllMain ומה שהוא קורא
accDescr: בודקים את ההיקף כולל לא רק DllMain אלא static initialization וקריאות עקיפות, מעבירים initialization לסטטי או אחרי טעינה, מתכננים עצירה ו-cleanup לפי סיבת ה-shutdown, ואז מאשרים עם Application Verifier ו-dumps
scope["DllMain, static initialization ו-callees"] --> timing["להעביר initialization לסטטי או אחרי טעינה"]
timing --> first["לבדוק גם את הגישה הראשונה של עבודה שנדחתה"]
first --> shutdown["לתכנן cleanup לפי סיבת ה-shutdown"]
shutdown --> verify["לאשר עם Verifier, אזהרות ו-dumps"]
איור 13: מעקב לא רק אחרי מה ש-initialization עושה אלא מתי הוא רץ ואחרי ה-lifetime שלו ב-shutdown מאפשר לטפל באיסורים כמדיניות תכנון אחת.
השאלה לשאול במקרה גבול היא “האם זו עבודה שמותר להריץ בזמן שמחזיקים את loader lock?” לא לדחוס initialization מפוקפק ל-DllMain, ולשנות במקום זאת מתי הוא רץ, הוא נקודת ההתחלה של תכנון DLL בטוח.
מאמרים קשורים
- איך עובדת רזולוציית שמות DLL ב-Windows - סדר חיפוש ו-SxS
- C# מול native DLL: מתי P/Invoke ומתי C++/CLI wrapper
- שיטות עבודה מומלצות ל-multithreading בפועל: מהדורת C++
- Spurious wakeup — למה condition variable מתעורר בלי notify, ואיך לחכות נכון ב-Windows
- קריאת crash dumps עם WinDbg + SOS — מדריך מעשי לניתוח אחרי איסוף
- STA ו-MTA ב-COM — threading model, ואיך נמנעים מ-hang
תחומי ייעוץ קשורים
KomuraSoft LLC מטפלת בחקירת שורש (ניתוח dump) של hangs ו-deadlocks ב-startup או בטעינת DLL, בסקירות תכנון סביב DllMain ו-static initialization, ובתיקון עטיפות C++/CLI ו-DLLs של plug-ins לכיוון תכנון initialization בטוח. אפשר להתייעץ גם בשלב הקשה לשחזור של “זה נתקע ב-startup רק בסביבה מסוימת”.
קישורים
-
Microsoft Learn, Dynamic-Link Library Best Practices. על כך ש-DllMain נקרא בזמן ש-loader lock מוחזק, כך שהפונקציות שאפשר לקרוא להן מוגבלות בחומרה; ש-DllMain האידיאלי הוא stub ריק ו-initialization נדחה ככל האפשר; המלצה על static initialization בזמן קומפילציה; עשיית המינימום בלבד לכשלים שחייבים לזהות מוקדם; וזיהוי טעויות DllMain טיפוסיות עם Application Verifier. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, DllMain entry point. על ביצוע initialization וסיום פשוטים בלבד בנקודת הכניסה; למה אסור לקרוא ל-LoadLibrary / FreeLibrary (סדר טעינה מעגלי ושימוש ב-DLL לפני initialization או אחרי סיום); היות Kernel32.dll מובטח כבר טעון, כך שאפשר לקרוא לו בטווח שלא טוען DLLs אחרים; היעדר רשימה ממצה של פונקציות בטוחות; פונקציות User, Shell ו-COM שגורמות ל-access violation; DLL notifications מסודרות בסדרה, כך שתקשורת עם threads או processes אחרים גורמת ל-deadlock; ואותן מגבלות חלות על constructors ו-destructors של אובייקטים סטטיים כשמקושר CRT. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Initialization of Mixed Assemblies. על אי-הרצת MSIL תחת loader lock; אי-תרגום DllMain ועץ הקריאות שלו ל-MSIL והטיפול בזה דרך #pragma unmanaged; אזהרת C4747 נפלטת כש-DllMain מנסה להריץ MSIL ישירות, אבל הרצה עקיפה דרך מודול אחר אינה ניתנת לזיהוי; ומאתחלים דינמיים של אובייקטים סטטיים יכולים לגרום לאותה בעיה. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). על השבתת notifications של DLL_THREAD_ATTACH / DLL_THREAD_DETACH כדי להקטין overhead ביצירת thread ובהריסתו; אי-קריאה מ-DLL שמקושר עם CRT סטטי; והאופטימיזציה לא מבוצעת כש-static TLS (thread_local או __declspec(thread)) בתוקף. ↩ ↩2 ↩3
-
Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. על המבנה שעושה deadlock אם ממתינים ליציאת thread בתוך DllMain (notification DLL_THREAD_DETACH של יציאת thread צריכה את loader lock); הפרוטוקול לעצירת thread ב-unload (אות ב-event, אישור מצב עקבי, ואז סיום); DLL_PROCESS_DETACH ביציאת process שבו ה-threads האחרים כבר הסתיימו בכפייה ואין ערובה לעקביות מרחב הכתובות, כך שה-handler האידיאלי ריק; ויצירת thread ב-DllMain שמשאירה notifications ממתינות עם initialization לא שלם וגורמת לבעיות. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. על הגדרת היררכיית locks ולקיחה תמיד באותו סדר; ה-loader לוקח את loader lock לפני קריאה ל-DllMain, כך ש-loader lock צריך לשבת בראש היררכיית ה-locks; שמירה על סדר לקיחה בין APIs שלוקחים את loader lock בעקיפין, כמו GetModuleFileName, ל-locks פרטיים; ודוגמה קונקרטית ל-deadlock מהיפוך סדר locks. ↩ ↩2
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
Win32 Thread Pool API — מקביליות בלי CreateThread, דרך CreateThreadpoolWork
מפזרים קריאות CreateThread בכל הקוד ה-native? המאמר מסביר את ה-Win32 Thread Pool API שעוצב מחדש ב-Vista: ארבעת האובייקטים work, timer, wa...
למה 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
יישומים עסקיים, חיבור התקנים וכלי תקשורת, מהגדרת הדרישות ועד הפיתוח.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- האם באמת אי אפשר לעשות כלום ב-DllMain?
- "לא לעשות כלום" אינו הגזמה; זו מדיניות התכנון הרשמית, ו-Microsoft עצמה אומרת ש-DllMain האידיאלי הוא כמעט stub ריק. מה שבטוח הוא פונקציות ב-Kernel32.dll — שמובטח טעון עד ש-DllMain רץ — בטווח שלא טוען DLLs אחרים. לדוגמה אפשר ליצור critical section או mutex ולהשתמש ב-TLS. לעומת זאת LoadLibrary/FreeLibrary, סנכרון עם threads אחרים, וקריאה לפונקציות ב-User32, Shell, COM וכדומה אסורים כי הם גורמים ל-deadlock ול-access violation. Initialization שאתם לא בטוחים לגביו לא צריך להיעשות ב-DllMain; לדחות אותו עד השימוש הראשון.
- האם constructors של משתנים גלובליים ב-C++ (אובייקטים סטטיים) גם נופלים תחת מגבלות DllMain?
- כן. כשה-DLL מקושר עם CRT (C++ runtime), constructors ו-destructors של אובייקטים גלובליים וסטטיים רצים, דרך נקודת הכניסה שה-CRT מספק, כחלק דה-פקטו מ-DllMain. כלומר קריאה ל-LoadLibrary מתוך constructor, הפעלת thread אחר והמתנה לסיומו, אתחול COM וכדומה נושאים את אותה סכנה כמו לעשות אותם ב-DllMain. לאובייקט גלובלי עם initialization מורכב, שמרו מצביע ובנו אותו בגישה ראשונה, או השתמשו בסטטי מקומי-לפונקציה, כדי שהעבודה תרוץ מחוץ ל-DllMain.
- כדאי לקרוא ל-DisableThreadLibraryCalls?
- בתנאי, כן. אם ה-DLL לא צריך notifications של DLL_THREAD_ATTACH/DETACH, קריאה ל-DisableThreadLibraryCalls ב-DLL_PROCESS_ATTACH עוצרת את ה-notifications בכל יצירת thread וביציאה ממנו ומקטינה overhead ב-process שיוצר threads לעיתים קרובות. יש שני חריגים. אל תקראו לזה מ-DLL שמקושר עם CRT סטטי (ה-CRT הסטטי צריך את thread notifications). ואם static TLS דרך thread_local או __declspec(thread) בתוקף, הקריאה עצמה נכשלת ומחזירה FALSE, לכן הרגילו לבדוק את ערך ההחזרה. השתמשו בזה ב-DLL טיפוסי שמשתמש ב-CRT מקושר דינמית, אחרי שאישרתם ששום דבר לא תלוי ב-thread notifications.
- למה DLL מעורב של C++/CLI נתקע ב-startup?
- הסיבה הטיפוסית היא ניסיון להריץ MSIL (managed code) בזמן ש-loader lock מוחזק. ב-assembly מעורב של C++/CLI, אם DllMain, פונקציות שנקראות ממנו, או dynamic initializers של globals מתורגמים ל-MSIL, אתחול CLR או טעינת assembly אחר יכולים להידרש תחת loader lock, וזה יכול לעשות deadlock. המהדר פולט אזהרת C4747 כש-DllMain עצמו מנסה להריץ MSIL ישירות, אבל הוא לא יכול לזהות הרצה עקיפה דרך מודול אחר. הטיפול הוא לתרגם את DllMain ואת עץ הקריאות שלו כ-native עם #pragma unmanaged, או לא לקיים DllMain בכלל.
- מותר לנקות משאבים ב-DLL_PROCESS_DETACH?
- התשובה משתנה בין "יציאת process" לבין "unload דרך FreeLibrary". ב-DLL_PROCESS_DETACH ביציאת process, ה-threads האחרים כבר הסתיימו בכפייה, ואין ערובה שמרחב הכתובות עדיין עקבי, כך ש-cleanup כמו שחרור זיכרון למעשה מסוכן; ההנחיה הרשמית היא ש"ה-handler האידיאלי ריק". כתבו החוצה כל נתון שחייב להישמר בנתיב ה-shutdown של האפליקציה עצמה, וכאן בעצם אל תעשו כלום וחזרו. ב-unload דרך FreeLibrary ה-process ממשיך, ולכן כן צריך cleanup מלא — עצירת threads, סגירת handles וכדומה. המתנה ליציאת thread בתוך DllMain מקפיאה, ולכן חייבים לעקוב אחרי הפרוטוקול הרשמי: signal, המתנה עד מצב עקבי, וסיום העבודה מחוץ ל-DllMain.