DllMain ו-loader lock — הסיבה האמיתית שאומרים לא לעשות כלום ב-initialization של DLL

· עודכן בתאריך: · · 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 שלכם.

למה עבודה ב-DllMain משפיעה על DLL notifications בכל ה-processה-loader לוקח את loader lock המשותף ל-process לפני שהוא קורא ל-DllMain, וטעינות DLL ו-thread notifications ב-threads אחרים ממתינים לשחרורו, כך שעבודה ב-DllMain משפיעה גם על DLLs ו-threads אחריםה-loader לוקח את ה-lock המשותףDllMain רץDllMain חוזרloader lock משוחררטעינת DLL או notification ב-thread אחרממתין לאותו lock שישוחררהעבודה הממתינה יכולה להמשיך

איור 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

למה המתנה ל-thread ב-DllMain עושה deadlockDllMain, שמחזיק את loader lock, ממתין ליציאת worker thread, אבל ה-worker שיוצא ממתין לשחרור loader lock בשביל notification DLL_THREAD_DETACH, כך ששניהם ממתינים זה לזה ועושים deadlockWorker threadDllMainLoader (מחזיק את ה-lock)Worker threadDllMainLoader (מחזיק את ה-lock)notification היציאה צריכה את loader lockDllMain ממתין כשהוא מחזיק את ה-lock, W ממתין ל-lockמודיע DLL_PROCESS_DETACHמבקש יציאה וממתין לסיוםמסיים עבודה ופונה ליציאת thread

איור 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.
היפוך סדר בין loader lock ל-lock פרטיDllMain הולך ל-lock פרטי בזמן שהוא מחזיק את loader lock, ו-worker thread הולך ל-loader lock, בשביל GetModuleHandle וכדומה, בזמן שהוא מחזיק את ה-lock הפרטי, כך שסדר הלקיחה מתהפך והם עושים deadlockDllMain: מחזיק את loader lockהולך ל-lock פרטי GWorker: מחזיק lock פרטי Gהולך ל-loader lockdeadlock מהיפוך סדר לקיחהנדרש מבפנים ב-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

שתי בעיות שנשארות כשיוצרים thread ב-DllMainthread שנוצר ב-DllMain ממתין ל-loader lock בשביל notification ההתחלה, כך שאם DllMain ממתין שיתחיל או שיסתיים הם עושים deadlock, וגם אם DllMain חוזר בלי להמתין, ה-lifetime של הקוד נגמר ויש crash אם ה-DLL נפרק לפני שה-thread מתחיל לרוץכןלאthread נוצר בתוך DllMainה-thread החדש ממתין ל-notification ההתחלהלהמתין להתחלה או לסיום בתוך DllMain?המתנה הדדית בזמן שה-lock מוחזקחזרה מ-DllMainה-DLL נפרק לפני שה-thread מתחיל לרוץהקוד בכתובת ההתחלה נעלם

איור 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.

איך initialization של אובייקט גלובלי הופך למוקשטעינת ה-DLL לוקחת את loader lock וה-constructors של אובייקטים גלובליים רצים דרך ה-CRT, כך ש-LoadLibrary, סנכרון threads או אתחול COM בתוכם הם הרצה של מה ש-DllMain אוסרטעינת DLL (loader lock נלקח)נקודת הכניסה של ה-CRTconstructor של אובייקט גלובליעבודה שקולה ל-LoadLibraryהפעלת thread והמתנה לסיומושימוש ב-COM או ב-User32כל אלה הם איסורי DllMain

איור 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

האם אפשר לזהות הרצת MSIL תחת loader lockקוד שבו DllMain מריץ MSIL ישירות המהדר יכול לזהות באזהרת C4747, אבל הרצה עקיפה דרך פונקציה במודול אחר לא, ולכן צריך למנוע אותה בסקירת עץ הקריאות ובתרגום native לאורך כל הדרךקריאה מ-DllMainמריץ MSIL ישירותמריץ דרך מודול אחרמזוהה באזהרת C4747המהדר לא יכול לזהותלמנוע בסקירה וב-#pragma unmanaged

איור 6: מעבר לנתיב הישיר ש-C4747 מזהה, לסקור גם קריאות שעוברות דרך מודול אחר.

הטיפול הוא לתרגם את DllMain וכל פונקציה שאפשר להגיע אליה ממנו כ-native עם #pragma unmanaged, או לא לקיים DllMain בכלל. גם באפשרות השנייה, אל תפספסו נתיבים עקיפים כמו static initializers.3

5. תכנון initialization — לעשות סטטי, לדחות, להשאיר רק את המינימום

5.1 לפני שמשאירים משהו ב-DllMain, לשאול אם אפשר לשנות את התזמון

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

למשל, יכולה להיות דרישה שהטעינה עצמה של ה-DLL תיכשל כי קובץ הגדרות שהוא תלוי בו פגום. גם אז מצמצמים ל”לנסות את העבודה הנדרשת ולהיכשל מיד” במקום להריץ initialization אחר קודם ולהיכשל אחר כך. זה אינו חריג שמתיר initialization מורכב בזמן טעינה.1

הנחיות תכנון ל-initialization של DLLקודם שוקלים אם אפשר לעשות את ה-initialization סטטי בזמן קומפילציה; אם לא, ברירת המחדל היא לדחות לשימוש ראשון, וב-DllMain משאירים רק את המינימום שחייבים לזהות מוקדם ככשל טעינהכןלאלאכןאפשר להחליט בזמן קומפילציה?לעשות static initializationחייבים לזהות את הכשל בטעינה?לדחות לשימוש ראשון (ברירת המחדל)לעשות רק את המינימום ב-DllMainלהגן עם 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, אשרו מי קורא לה ראשון, ומתי.

מתי דחיית initialization בורחת מ-loader lockאם הגישה הראשונה של דחיית initialization מגיעה מ-API רגיל אחרי שהטעינה הסתיימה, אפשר לאתחל מחוץ ל-loader lock, אבל אם היא מגיעה מ-DllMain או מ-static initializer, היא רצה תחת אותן מגבלותAPI רגיל אחרי שהטעינה הסתיימהDllMain או static initializerמאיפה מגיעה הגישה הראשונה?לאתחל מחוץ ל-loader lockלאתחל תחת אותן מגבלותלהגן עם INIT_ONCE או סטטי מקומי-לפונקציהלהעביר גם את רגע הגישה הראשונה

איור 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

החלטה אם לקרוא ל-DisableThreadLibraryCallsDLL שמקושר עם CRT סטטי אסור לו לקרוא לזה, ועם static TLS בתוקף הקריאה עצמה נכשלת ולכן לא קוראים, אבל DLL שאינו אף אחד מאלה ואינו משתמש ב-thread notifications יכול לקרוא ב-DLL_PROCESS_ATTACH, עם בדיקת ערך ההחזרה, כדי לקצץ עלות notificationsכןלאכןלאלאכןמקושר עם CRT סטטי?אסור לקרואמשתמש ב-static TLS?הקריאה נכשלת בכל מקרה (FALSE)צריך thread notifications?לקרוא ב-ATTACH (לבדוק את ערך ההחזרה)לא לקרוא; לטפל ב-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

  1. צד DllMain מסמן ל-worker לעצור, באמצעות event.
  2. ה-worker מקפל את העבודה הנוכחית למצב עקבי, מסמן סיום, ונכנס להמתנה אינסופית.
  3. צד DllMain מאשר את המצב העקבי ומסיים את ה-thread עם TerminateThread.

זה נראה גס, אבל זה תועד תחת האילוץ שהמתנה ליציאה טבעית גורמת ל-notification היציאה להתנגש עם loader lock. ההנחה היא שגם העבודה של הגעה למצב העקבי מצייתת לאותן מגבלות כמו DllMain. אם העבודה הזו נכנסת לטעינת DLL אחר או להמתנה ל-loader lock, deadlock עם הצד שממתין לאות בלתי נמנע.5

המתנה ב-unload להשלמת עקביות במקום ליציאה טבעיתDllMain מסמן ל-worker לעצור, ה-worker מגיע למצב עקבי תוך ציות לאותן מגבלות כמו DllMain, מסמן בחזרה, ונכנס להמתנה אינסופית, ו-DllMain מאשר את המצב העקבי לפני שהוא מסיים את ה-threadWorker threadDllMain (ב-unload)Worker threadDllMain (ב-unload)מה שממתינים לו הוא אות העקביות, לא יציאה טבעיתמסמן עצירה ב-eventמגיע למצב עקבי תחת אותן מגבלותמסמן שהעקביות הושגהנכנס להמתנה אינסופיתמאשר עקביות, ואז TerminateThread

איור 10: גם בפרוטוקול הזה, העבודה שמקימה את המצב העקבי אסור לה שתחכה ל-loader lock.

זה לא אומר שאפשר פשוט לחתוך כל thread רץ. קודם שוקלים אם אפשר להחזיק את ה-thread מחוץ ל-DLL.

6.3 ביציאת process, האידיאל הוא לחזור בלי לעשות כלום

עד ש-DLL_PROCESS_DETACH מגיע ביציאת process, ה-threads האחרים יצאו או הסתיימו בכפייה, ואי אפשר לסמוך על עקביות מרחב הכתובות. גם מצב של DLLs ו-runtimes תלויים אינו אמין, כך ש-cleanup כמו שחרור זיכרון הופך למסוכן במקום מועיל. גם ההנחיה הרשמית אומרת ש-handler האידיאלי במקרה הזה ריק.5

כתבו החוצה נתונים שצריך לשמור בקוד ה-shutdown של האפליקציה עצמה. אל תשתמשו ב-DLL_PROCESS_DETACH כמקום האחרון שבו אפשר לסדר הכול.

הנחות cleanup שונות בין unload של DLL ליציאת processכשרק ה-DLL נפרק ה-process ממשיך לרוץ ולכן צריך לנקות משאבים, אבל נמנעים מהמתנה ליציאה טבעית בתוך DllMain; ביציאת process לא סומכים על עקביות של threads ומשאבים אחרים, בעצם חוזרים בלי לעשות כלום, וכותבים מצב שצריך לשמור בקוד ה-shutdown של האפליקציה מראשunload של DLL בלבדיציאת processהסיבה ל-DLL_PROCESS_DETACH?ה-process ממשיך לרוץ אחר כךלנקות משאבים שנשארים כמו שצריךלא להמתין ליציאה טבעית בתוך DllMainמצב של threads ומשאבים אחרים אינו ודאיבעצם לחזור בלי לעשות כלוםלשמור מראש בקוד ה-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.

אישור המתנה הדדית של loader lock מ-dump של hangב-stack של כל thread מחפשים המתנת lock בפונקציות Ldr והמתנה בתוך DllMain או static initializer, מאשרים ששניהם ממתינים זה לזה, ואם הזוג הטיפוסי לא מופיע חוקרים גם סוגי hang אחריםכןלא נראהללכוד dump בזמן ה-hangלבדוק את ה-stack של כל threadהמתנת lock בפונקציות Ldrהמתנה בתוך DllMain או static initializerהאם קשר ההמתנה ההדדית מתחבר?deadlock של loader lockלחקור גם סוגי 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 של האפליקציה עצמה.

הסדר לסקירת DllMain ומה שהוא קוראבודקים את ההיקף כולל לא רק DllMain אלא static initialization וקריאות עקיפות, מעבירים initialization לסטטי או אחרי טעינה, מתכננים עצירה ו-cleanup לפי סיבת ה-shutdown, ואז מאשרים עם Application Verifier ו-dumpsDllMain, static initialization ו-calleesלהעביר initialization לסטטי או אחרי טעינהלבדוק גם את הגישה הראשונה של עבודה שנדחתהלתכנן cleanup לפי סיבת ה-shutdownלאשר עם Verifier, אזהרות ו-dumps

איור 13: מעקב לא רק אחרי מה ש-initialization עושה אלא מתי הוא רץ ואחרי ה-lifetime שלו ב-shutdown מאפשר לטפל באיסורים כמדיניות תכנון אחת.

השאלה לשאול במקרה גבול היא “האם זו עבודה שמותר להריץ בזמן שמחזיקים את loader lock?” לא לדחוס initialization מפוקפק ל-DllMain, ולשנות במקום זאת מתי הוא רץ, הוא נקודת ההתחלה של תכנון DLL בטוח.

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

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

KomuraSoft LLC מטפלת בחקירת שורש (ניתוח dump) של hangs ו-deadlocks ב-startup או בטעינת DLL, בסקירות תכנון סביב DllMain ו-static initialization, ובתיקון עטיפות C++/CLI ו-DLLs של plug-ins לכיוון תכנון initialization בטוח. אפשר להתייעץ גם בשלב הקשה לשחזור של “זה נתקע ב-startup רק בסביבה מסוימת”.

קישורים

  1. Microsoft Learn, Dynamic-Link Library Best Practices. על כך ש-DllMain נקרא בזמן ש-loader lock מוחזק, כך שהפונקציות שאפשר לקרוא להן מוגבלות בחומרה; ש-DllMain האידיאלי הוא stub ריק ו-initialization נדחה ככל האפשר; המלצה על static initialization בזמן קומפילציה; עשיית המינימום בלבד לכשלים שחייבים לזהות מוקדם; וזיהוי טעויות DllMain טיפוסיות עם Application Verifier. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  2. 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

  3. Microsoft Learn, Initialization of Mixed Assemblies. על אי-הרצת MSIL תחת loader lock; אי-תרגום DllMain ועץ הקריאות שלו ל-MSIL והטיפול בזה דרך #pragma unmanaged; אזהרת C4747 נפלטת כש-DllMain מנסה להריץ MSIL ישירות, אבל הרצה עקיפה דרך מודול אחר אינה ניתנת לזיהוי; ומאתחלים דינמיים של אובייקטים סטטיים יכולים לגרום לאותה בעיה. ↩ ↩2 ↩3 ↩4

  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

  5. 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

  6. 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

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

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

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

שאלות נפוצות

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

האם באמת אי אפשר לעשות כלום ב-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.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג