DllMain และ Loader Lock — เหตุผลจริงที่ถูกบอกให้ "อย่าทำอะไรในการเริ่มต้น DLL"
· Go Komura · Windows, DLL, การพัฒนา Windows, C++, การแก้ปัญหา, มัลติเธรด, Win32 API
“แอปแฮงตอนเริ่มต้น แต่เฉพาะในสภาพแวดล้อมหนึ่ง” “เมื่อเราโหลด DLL ของตนเอง LoadLibrary บางครั้งไม่กลับเลย” “มันเดดล็อกเฉพาะจังหวะที่บริการเริ่ม” — ตามการสอบสวนแบบนี้ให้ไกลพอ และบ่อยครั้งคุณมาถึงที่เดิม โค้ดเริ่มต้นของ DLL — นั่นคือ DllMain
เอกสารของ Microsoft เตือนเรื่อง DllMain ด้วยน้ำเสียงที่แรงผิดปกติ อย่าเรียก LoadLibrary อย่าซิงโครไนซ์กับเธรดอื่น อย่าเรียกฟังก์ชัน User, Shell หรือ COM DllMain ในอุดมคติคือสตับว่าง — ทำไมภาษาจึงแรงขนาดนี้ เหตุผลรวมอยู่ในกลไกภายในชิ้นเดียว คือ loader lock มุ่งไปที่นักพัฒนาที่เขียน DLL ปลั๊กอิน และแรปเปอร์ C++/CLI บน Windows บทความนี้อธิบายจากแหล่งปฐมภูมิว่า loader lock ทำงานอย่างไร โครงสร้างที่ทำให้เดดล็อกตั้งอยู่ และการออกแบบที่ปลอดภัยกับขั้นตอนสอบสวน
1. สรุปก่อนเลย
DllMainถูกเรียกขณะถือ loader lock ซึ่งเป็นล็อกที่ใช้ร่วมกันที่มีพอดีหนึ่งอันต่อโปรเซส ดังนั้นการเรียกจากDllMainงานที่พยายามถือ loader lock (โดยตรงหรือโดยอ้อม) สร้างความเป็นไปได้ของเดดล็อก หรือของการล่มจากการแตะ DLL ที่ยังไม่ถูกเริ่มต้น1- การเรียก
LoadLibrary/FreeLibraryถูกห้าม มันสร้างการพึ่งลำดับโหลดแบบวงกลม และอาจทำให้โค้ดเริ่มต้นรันกับ DLL ที่การเริ่มต้นของตนเองยังไม่รัน2 - การซิงโครไนซ์กับเธรดอื่นก็ถูกห้ามเช่นกัน การแจ้ง DLL เป็นอนุกรม ดังนั้นการรอภายใน
DllMainให้เธรดเริ่มหรือออก ทำให้เธรดนั้นเองหยุดรอ loader lock และคุณเดดล็อก23 - สิ่งที่คุณเรียกได้อย่างปลอดภัยในทางปฏิบัติคือเพียงสับเซ็ตของ Kernel32.dll และเอกสารทางการระบุตรง ๆ ว่า “รายการสมบูรณ์ของฟังก์ชันที่ปลอดภัยไม่มีอยู่” ฟังก์ชัน User, Shell และ COM โหลดคอมโพเนนต์อื่นและก่อ access violation2
- ใน DLL ที่ลิงก์กับ CRT ข้อจำกัดเดียวกันใช้กับคอนสตรักเตอร์และเดสตรักเตอร์ของโกลบอล พวกมันรันเป็นส่วนโดยพฤตินัยของ
DllMain2 - การออกแบบที่ถูกต้องคือ “เลื่อน” ทำสิ่งเริ่มต้นที่ทำได้ตอนคอมไพล์ (แบบสแตติก) เลื่อนสิ่งที่ทำไม่ได้จนกว่าใช้ครั้งแรก นั่นคือแนวปฏิบัติที่ดีที่สุดอย่างเป็นทางการ1
- DLL ผสม C++/CLI อันตรายเป็นพิเศษ เพื่อหลีกเลี่ยงการรัน MSIL ภายใต้ loader lock
DllMainและต้นไม้การเรียกต้องถูกคอมไพล์เนทีฟ4
2. เมื่อใดและอย่างไร DllMain ถูกเรียก
DllMain คือจุดเข้าที่ตัวโหลดของ OS เรียกเมื่อ DLL เข้าหรือออกจากโปรเซสหรือเธรด มีการแจ้งสี่อย่าง
| การแจ้ง | จังหวะ |
|---|---|
| DLL_PROCESS_ATTACH | เมื่อ DLL ถูกโหลดเข้าโปรเซส |
| DLL_THREAD_ATTACH | เมื่อเธรดใหม่ถูกเริ่มในโปรเซส |
| DLL_THREAD_DETACH | เมื่อเธรดออกตามปกติ |
| DLL_PROCESS_DETACH | เมื่อ DLL ถูกยกเลิกโหลด หรือเมื่อโปรเซสออก |
ข้อเท็จจริงสองอย่างพลาดง่าย ประการแรก ทุกครั้งที่เธรดเส้นเดียวถูกสร้าง DllMain ของทุก DLL ที่โหลดแล้วถูกเรียกด้วย DLL_THREAD_ATTACH กล่าวอีกนัย DllMain ไม่ใช่ “สิ่งที่รันครั้งเดียวเมื่อ DLL ของฉันถูกโหลด” แต่เป็นโค้ดที่ถูกเรียกต่อไปเรื่อย ๆ สำหรับกิจกรรมเธรดของโปรเซส หากคุณไม่ต้องการสิ่งนั้น คุณหยุดได้ด้วยการเรียก DisableThreadLibraryCalls ภายใน DLL_PROCESS_ATTACH (อย่าเรียกจาก DLL ที่ลิงก์กับ CRT สแตติก)5
ประการที่สอง ใน DLL ที่ลิงก์กับ CRT (รันไทม์ C/C++) คอนสตรักเตอร์และเดสตรักเตอร์ของออบเจ็กต์โกลบอลและสแตติก C++ รัน ผ่านจุดเข้าของ CRT เป็นส่วนหนึ่งของ DllMain2 แม้คุณคิดว่า “DllMain ของเราว่าง ดังนั้นเราปลอดภัย” ออบเจ็กต์โกลบอลที่มีการเริ่มต้นซับซ้อนก็เท่ากับการรันงานนั้นใน DllMain
flowchart TB
accTitle: สี่จังหวะที่ DllMain ถูกเรียก
accDescr: DLL_PROCESS_ATTACH รันตอนโหลด DLL DLL_THREAD_ATTACH และ DETACH รันบนทุก DLL ที่โหลดแล้วที่การเริ่มและออกของเธรดแต่ละเส้นในโปรเซส DLL_PROCESS_DETACH รันตอนยกเลิกโหลดหรือออกจากโปรเซส และคอนสตรักเตอร์ของออบเจ็กต์สแตติกก็รันภายในนี้ผ่าน CRT
load["โหลด DLL"] --> pa["DLL_PROCESS_ATTACH"]
pa --> ta["DLL_THREAD_ATTACH (ทุกครั้งที่เธรดเริ่ม)"]
ta --> td["DLL_THREAD_DETACH (ทุกครั้งที่เธรดออก)"]
td --> pd["DLL_PROCESS_DETACH (ตอนยกเลิกโหลดหรือออก)"]
pa -.-> crt["การสร้างออบเจ็กต์สแตติกก็รันที่นี่"]
ภาพ 1: DllMain ถูกเรียกไม่เฉพาะตอนโหลด แต่ทุกครั้งที่เธรดเริ่มและออก และการเริ่มต้นออบเจ็กต์สแตติกก็รันเป็นส่วนหนึ่งของสิ่งนั้น
3. Loader Lock — ล็อกเดียวที่ทำให้การแจ้งทุกครั้งเป็นอนุกรม
ทำไมข้อจำกัดบน DllMain อย่างเดียวจึงรุนแรงขนาดนี้ คำตอบอยู่ในโครงสร้างของตัวโหลด
เพื่อให้งานชุดหนึ่ง — โหลด DLL ยกเลิกโหลด และการแจ้งต่าง ๆ — สอดคล้อง ตัวโหลดของ OS ทำให้เป็นอนุกรมด้วย loader lock หนึ่งอันต่อโปรเซส และจุดสำคัญคือ DllMain ถูกเรียกขณะถือ loader lock นี้อยู่1 ตราบที่คุณอยู่ภายใน DllMain การโหลด DLL อื่นทุกครั้งในโปรเซสนั้น และการแจ้งเริ่มเธรดทุกครั้ง รอให้ล็อกนี้ถูกปล่อย
จากโครงสร้างนั้น เหตุผลของข้อห้ามตามมาทีละข้อ
- คุณต้องไม่เรียก
LoadLibraryเพราะมันสร้างการเข้าซ้ำของ loader lock หรือการพึ่งลำดับโหลดแบบวงกลม มันยังอาจส่งผลให้เรียกฟังก์ชันบน DLL ที่การเริ่มต้นยังไม่เสร็จ2 - การซิงโครไนซ์กับเธรดอื่นอันตราย เพราะเธรดที่คุณกำลังรอมีช่วงที่ต้องการ loader lock (การแจ้งตอนเริ่มและออก การเรียก API ตระกูล
GetModuleHandleเป็นต้น) คุณถือ loader lock แล้วรออีกฝั่ง อีกฝั่งรอ loader lock — การกลับลำดับล็อกแบบคลาสสิก6 - ฟังก์ชัน User, Shell และ COM อันตราย เพราะพวกมันโหลดคอมโพเนนต์ระบบอื่นภายใน คุณแตะคอมโพเนนต์ก่อนที่มันถูกเริ่มต้น หรือหลังมันถูกรื้อ และคุณได้ access violation2
sequenceDiagram
accTitle: ทำไมการรอเธรดภายใน DllMain จึงเดดล็อก
accDescr: DllMain ที่ถือ loader lock รอให้เธรดเวิร์กเกอร์ออก แต่เวิร์กเกอร์ที่กำลังพยายามออกรอให้ loader lock ถูกปล่อยเพื่อรับ DLL_THREAD_DETACH ดังนั้นพวกเขารอกันและกันแล้วเดดล็อก
participant L as Loader(ถือล็อก)
participant D as DllMain
participant W as เวิร์กเกอร์
L->>D: DLL_PROCESS_DETACH
D->>W: ขอให้ออกแล้วรอ
W->>W: ทำงานให้เสร็จ แล้วออก
Note over W: การแจ้งออกต้องการล็อก
Note over D,W: DllMain ถือล็อก W รอ
ภาพ 2: “DllMain รอให้เธรดออก” คือเดดล็อกเชิงโครงสร้าง เพราะการออกของเธรดเองต้องการ loader lock
จุดคือสิ่งนี้ไม่ใช่ชนิดที่ “เกิดถ้าโชคร้าย” มันถูกประกันเชิงโครงสร้างว่าจะตั้งอยู่ เอกสารบอกให้คุณถือ loader lock เป็นยอดของลำดับชั้นล็อกที่แอปกำหนด (อันที่ถือก่อน) ภายใน DllMain คุณถือล็อกระดับบนนั้นอยู่แล้ว ดังนั้นการกระทำใด ๆ ที่เดินต่อจากที่นั่นไปรออย่างอื่นจึงอันตราย — นั่นคือวิธีจำที่มีประโยชน์6
flowchart TB
accTitle: การกลับลำดับล็อกระหว่าง loader lock กับล็อกส่วนตัว
accDescr: DllMain ที่ถือ loader lock ไปถือล็อกส่วนตัว ในขณะที่เวิร์กเกอร์ที่ถือล็อกส่วนตัวนั้นไปถือ loader lock สำหรับ GetModuleHandle หรือคล้ายกัน ดังนั้นลำดับการถือกลับกันแล้วเดดล็อก
d["DllMain: ถือ loader lock"] --> dg["ไปถือล็อกส่วนตัว G"]
w["เวิร์กเกอร์: ถือล็อกส่วนตัว G"] --> wl["ไปถือ loader lock"]
dg -.-> dead["เดดล็อกจากลำดับถือที่กลับกัน"]
wl -.-> dead
wl -.-> api["GetModuleHandle และคล้ายกันต้องการภายใน"]
ภาพ 3: แม้ API ที่ดูไม่เป็นอันตรายอย่าง GetModuleHandle ก็ต้องการ loader lock ภายใน ดังนั้นการกลับลำดับกับล็อกส่วนตัวจึงตั้งอยู่ได้
นอกจากนี้ การเรียก CreateThread จากภายใน DllMain เองก็ไม่ถูกแนะนำ เธรดที่ถูกสร้างต้องการ loader lock เพื่อประมวลผลการแจ้ง DLL_THREAD_ATTACH ดังนั้นมันเริ่มรันไม่ได้จนกว่า DllMain ที่กำลังรันจะกลับและปล่อยล็อก ดังนั้นการรอภายใน DllMain ให้เธรดนั้นเริ่มหรือเสร็จคือเดดล็อกทันที ยังมีปัญหาอายุขัยด้วย — หากหลัง DllMain กลับ DLL ถูกยกเลิกโหลดขณะที่เธรดที่ยังไม่เริ่มรันยังถูกทิ้งไว้ ที่อยู่เริ่มต้นของเธรดยังชี้ไปที่โค้ดที่ถูกปล่อยแล้วและคุณล่ม3
4. สองกับระเบิดที่นักพัฒนา C++ เหยียบง่าย
กับระเบิด 1: การเริ่มต้นไดนามิกของออบเจ็กต์โกลบอล ตามที่บทที่ 2 กล่าว คอนสตรักเตอร์ของออบเจ็กต์สแตติกรันภายใต้ข้อจำกัดของ DllMain การอ่านไฟล์ตั้งค่า การตั้งสิ่งอำนวยความสะดวกการบันทึกล็อก การเริ่มต้น COM การเริ่มเธรด — ชั่วขณะที่คุณวางโกลบอลใน DLL ที่คอนสตรักเตอร์ทำงานแบบนั้น คุณกำลังรัน “สิ่งที่ห้ามทำใน DllMain” การเริ่มต้นคงที่ที่ถูกตรึงตอนคอมไพล์ (สิ่งที่ทำให้เป็น constexpr ได้) ปลอดภัย การเริ่มต้นที่เกี่ยวข้องกับการเรียกฟังก์ชันควรถูกเลื่อน
flowchart TB
accTitle: เส้นทางที่การเริ่มต้นออบเจ็กต์โกลบอลกลายเป็นกับระเบิด
accDescr: Loader lock ถูกถือตอนโหลด DLL และคอนสตรักเตอร์ของออบเจ็กต์โกลบอลรันผ่านจุดเข้า CRT ดังนั้น LoadLibrary การซิงโครไนซ์เธรด และการเริ่มต้น COM ภายในคอนสตรักเตอร์เหล่านั้นคือการรันข้อห้ามของ DllMain
load["โหลด DLL (ถือ loader lock)"] --> crt["จุดเข้า CRT"]
crt --> ctor["คอนสตรักเตอร์ของออบเจ็กต์โกลบอล"]
ctor --> ng1["งานเทียบเท่า LoadLibrary"]
ctor --> ng2["เริ่มเธรดแล้วรอให้เสร็จ"]
ctor --> ng3["ใช้ COM หรือ User32"]
ng1 -.-> risk["ทั้งหมดนี้ตกอยู่ภายใต้ข้อห้ามของ DllMain"]
ng2 -.-> risk
ng3 -.-> risk
ภาพ 4: แม้ “DllMain ว่าง ดังนั้นเราปลอดภัย” ก็ฟื้นอันตรายเดิมในชั่วขณะที่มีโกลบอลที่มีการเริ่มต้นซับซ้อน
กับระเบิด 2: C++/CLI (แอสเซมบลีผสม) ในการตั้งค่าที่ห่อ DLL เนทีฟด้วย C++/CLI (รูปที่ครอบในบทความเรื่องแรปเปอร์) มีอันตรายของการรัน MSIL (โค้ดแมเนจด์) ภายใต้ loader lock การรัน MSIL อาจกระตุ้นการเริ่มต้น CLR หรือการโหลดแอสเซมบลีอื่น คอมไพเลอร์ส่งคำเตือน C4747 บนโค้ดที่ DllMain รัน MSIL โดยตรง แต่ตรวจการรันทางอ้อมผ่านฟังก์ชันในโมดูลอื่นไม่ได้ คอมไพล์ DllMain และฟังก์ชันที่ถูกเรียกจากมันเป็นเนทีฟด้วย #pragma unmanaged หรือใช้การตั้งค่าที่ไม่มี DllMain เลย4
flowchart TB
accTitle: ว่าการรัน MSIL ภายใต้ loader lock ตรวจจับได้หรือไม่
accDescr: โค้ดที่ DllMain รัน MSIL โดยตรง คอมไพเลอร์ตรวจได้ด้วยคำเตือน C4747 แต่การรันทางอ้อมผ่านฟังก์ชันในโมดูลอื่นตรวจไม่ได้ ดังนั้นคุณต้องกันด้วยการตรวจทานต้นไม้การเรียกและยืนยันการคอมไพล์เนทีฟ
d2["การเรียกจาก DllMain"] --> dir["รัน MSIL โดยตรง"]
d2 --> ind["รันผ่านโมดูลอื่น"]
dir --> c47["ตรวจได้ด้วยคำเตือน C4747"]
ind --> nc["คอมไพเลอร์ตรวจไม่ได้"]
nc -.-> rv["กันด้วยการตรวจทานและ #pragma unmanaged"]
ภาพ 5: C4747 ปกป้องคุณเฉพาะต่อการรันโดยตรง เส้นทางอ้อมจับได้ด้วยการตรวจทานเท่านั้น
5. การออกแบบที่ถูกต้อง — ทำให้ “เลื่อน” เป็นนโยบายเริ่มต้น
คำแนะนำแนวปฏิบัติที่ดีที่สุดอย่างเป็นทางการชัดเจน1
- ทำให้การเริ่มต้นที่ทำได้เสร็จตอนคอมไพล์ (แบบสแตติก) ถามก่อนว่าการเริ่มต้นไดนามิกแทนที่ด้วยแบบสแตติกได้หรือไม่
- เลื่อนส่วนที่เหลือจนกว่าใช้ครั้งแรก ตราบที่การใช้ครั้งแรกเกิดจาก API ธรรมดาที่ถูกเรียกหลัง DLL โหลดเสร็จ การเริ่มต้นรันนอก loader lock และคุณใช้ Windows API เกือบทั้งหมดได้อย่างปลอดภัย สำหรับการกันตอนเข้าถึงครั้งแรก คุณใช้
INIT_ONCE(การเริ่มต้นครั้งเดียว) หรือ C++ magic statics (สแตติกเฉพาะฟังก์ชัน) ได้ การเลื่อนไม่ใช่ยาครอบจักรวาล — หากการเข้าถึงครั้งแรกนั้นเองถูกทำจากDllMainหรือตัวเริ่มต้นสแตติก ตัวเริ่มต้นยังรันภายใต้ loader lock และคุณกลับอยู่ภายใต้ข้อจำกัดเดียวกัน - ยกเว้นเฉพาะความล้มเหลวที่ต้องตรวจจับแต่เนิ่น คุณอาจมีข้อกำหนดว่าไฟล์ตั้งค่าที่พังควรทำให้การโหลดเองล้มเหลว แม้เช่นนั้น ให้จำกัดไว้ที่ขั้นต่ำของ “ลองแล้วล้มเหลวทันที”
- พิจารณา
DisableThreadLibraryCallsใน DLL_PROCESS_ATTACH หาก DLL ไม่ใช้การแจ้งเธรด คุณตัดต้นทุนการแจ้งเองได้ (ยกเว้นเมื่อใช้ CRT สแตติกหรือ static TLS)5 - ตรวจด้วย Application Verifier การเรียกอันตรายหลายอย่างภายใน
DllMainคือสิ่งที่ Application Verifier จะตรวจจับตอนรัน1
flowchart TB
accTitle: คำแนะนำการออกแบบสำหรับการเริ่มต้น DLL
accDescr: พิจารณาก่อนว่าการเริ่มต้นเป็นการเริ่มต้นสแตติกตอนคอมไพล์ได้หรือไม่ หากไม่ได้ ค่าเริ่มต้นคือเลื่อนไปใช้ครั้งแรก และทิ้งใน DllMain เฉพาะขั้นต่ำที่ต้องตรวจจับแต่เนิ่นเป็นการล้มเหลวตอนโหลด
q1{"ตัดสินตอนคอมไพล์ได้หรือไม่?"} -->|"ได้"| s["ทำให้เป็นการเริ่มต้นสแตติก"]
q1 -->|"ไม่ได้"| q2{"ต้องตรวจจับความล้มเหลวตอนโหลดหรือไม่?"}
q2 -->|"ไม่"| lazy["เลื่อนไปใช้ครั้งแรก (ค่าเริ่มต้น)"]
q2 -->|"ใช่"| min["ทำเฉพาะขั้นต่ำใน DllMain"]
lazy -.-> once["กันด้วย INIT_ONCE หรือสแตติกเฉพาะฟังก์ชัน"]
ภาพ 6: ลำดับการตัดสินคือ “ทำให้สแตติกได้ → เลื่อนได้” และสิ่งที่คุณทิ้งใน DllMain คือเฉพาะขั้นต่ำที่ต้องตรวจจับแต่เนิ่น
ว่าจะใช้ DisableThreadLibraryCalls หรือไม่ตัดสินได้เชิงกลด้วยกิ่งต่อไปนี้
flowchart TB
accTitle: ว่าจะเรียก DisableThreadLibraryCalls หรือไม่
accDescr: อย่าเรียกจาก DLL ที่ลิงก์กับ CRT สแตติก หาก static TLS มีผล การเรียกเองล้มเหลวจึงไม่เรียก หากไม่มีทั้งคู่และ DLL ไม่ใช้การแจ้งเธรด ให้เรียกใน DLL_PROCESS_ATTACH โดยตรวจค่าที่คืนมา เพื่อตัดต้นทุนการแจ้ง
q1{"ลิงก์กับ CRT สแตติก?"} -->|"ใช่"| no2["ห้ามเรียก"]
q1 -->|"ไม่"| q2{"ใช้ static TLS?"}
q2 -->|"ใช่"| eff["การเรียกล้มเหลวอยู่แล้ว (FALSE)"]
q2 -->|"ไม่"| q3{"ต้องการการแจ้งเธรดหรือไม่?"}
q3 -->|"ไม่"| yes["เรียกใน ATTACH (ตรวจค่าที่คืนมา)"]
q3 -->|"ใช่"| keep["อย่าเรียก จัดการการแจ้ง"]
ภาพ 7: สามเงื่อนไขของ CRT สแตติก static TLS และว่าต้องการการแจ้งหรือไม่ ตัดสินได้อย่างเป็นหนึ่งเดียวว่าคุณควรเรียกหรือไม่
สำหรับการหยุดเธรดตอนยกเลิกโหลด เอกสารทางการให้โปรโตคอลที่เป็นรูปธรรม แทนที่จะ “รอ” ให้เธรดเวิร์กเกอร์ออกใน DLL_PROCESS_DETACH (บนการยกเลิกโหลดผ่าน FreeLibrary) รูปคือ (1) ส่งสัญญาณให้ออกด้วยอีเวนต์ (2) ฝั่งเธรดพับงานลงสู่สถานะที่สอดคล้อง ส่งสัญญาณกลับ และเข้าสู่การรอไม่สิ้นสุด (3) ฝั่ง DllMain ยืนยันสถานะที่สอดคล้องแล้วพับเธรดด้วย TerminateThread3 มันดูหยาบ แต่ถูกบันทึกเป็นคำตอบที่เป็นจริงภายในข้อจำกัด “คุณต้องไม่รอการออกตามธรรมชาติของเธรดภายใน DllMain”
sequenceDiagram
accTitle: โปรโตคอลสำหรับหยุดเธรดตอนยกเลิกโหลด
accDescr: DllMain ส่งสัญญาณให้เธรดเวิร์กเกอร์ออกด้วยอีเวนต์ เวิร์กเกอร์พับงานลงสู่สถานะที่สอดคล้อง ส่งสัญญาณกลับ และเข้าสู่การรอไม่สิ้นสุด DllMain ยืนยันสถานะที่สอดคล้องแล้วยุติเธรด
participant D as DllMain (การจัดการ DETACH)
participant W as เธรดเวิร์กเกอร์
D->>W: ส่งสัญญาณให้ออกด้วยอีเวนต์
W->>W: พับงานลงสู่สถานะที่สอดคล้อง
W->>D: ส่งสัญญาณความสอดคล้องเสร็จแล้วรอตลอดไป
D->>W: ยุติด้วย TerminateThread
Note over D,W: ไม่รอการออกตามธรรมชาติ จึงไม่มีเดดล็อก
ภาพ 8: แทนที่จะ “รอการออกตามธรรมชาติ” “รอสัญญาณความสอดคล้องแล้วตัด” หลีกเลี่ยงการชนกับ loader lock
ในฐานะหลักการแรก การออกแบบที่ปลอดภัยที่สุดคือหลีกเลี่ยงการเป็นเจ้าของเธรดใน DLL ที่ยกเลิกโหลดได้ และคงความเป็นเจ้าของเธรดไว้ฝั่ง EXE
DLL_PROCESS_DETACH ตอนออกจากโปรเซสตรงกันข้าม: อย่าทำอะไรแล้วกลับ คืออุดมคติ ถึงจุดนี้เธรดอื่นทุกเส้นถูกยุติบังคับไปแล้ว และคุณพึ่งสถานะของ DLL ที่พึ่งพาหรือรันไทม์ก็ไม่ได้ งานซับซ้อนที่นี่ก่อแต่เดดล็อกและการล่ม ข้อมูลที่ต้องคงอยู่ควรถูกเขียนบนเส้นทางปิดระบบของแอปเอง อย่าพึ่งการแจ้งนี้3
6. วิธีสอบสวนเมื่อคุณชนเข้า
แฮงจาก loader lock มีลายนิ้วมือที่จำได้
ดูสแตกในดัมป์แฮง ถ่ายดัมป์ของชั่วขณะที่แข็งแล้วตรวจสแตกของแต่ละเธรด หากคุณพบคู่ของเธรดที่รออยู่บนล็อกภายในฟังก์ชันตัวโหลดของ ntdll.dll (ตระกูลที่ชื่อขึ้นต้นด้วย Ldr) กับเธรดที่รออย่างอื่นภายใน DllMain หรือตัวเริ่มต้นสแตติก (dynamic initializer) คุณเกือบแน่นอน เธรดที่หยุดกลางการเรียก LoadLibrary คือตัวละครทั่วไปอีกอย่าง
flowchart TB
accTitle: ลายนิ้วมือของแฮงจาก loader lock
accDescr: ในดัมป์แฮง หากคุณพบทั้งเธรดที่รออยู่บนล็อกภายในฟังก์ชันตัวโหลด ntdll และเธรดที่รออย่างอื่นภายใน DllMain หรือตัวเริ่มต้นสแตติก คุณถือเป็นเดดล็อกของ loader lock ได้เกือบแน่นอน
dump["ดัมป์แฮง"] --> t1["เธรดที่รออยู่บนล็อกในฟังก์ชันตระกูล Ldr"]
dump --> t2["เธรดที่รอภายใน DllMain หรือตัวเริ่มต้นสแตติก"]
t1 --> pair{"มีทั้งคู่?"}
t2 --> pair
pair -->|"ใช่"| conf["เกือบแน่นอนว่าเป็นเดดล็อกของ loader lock"]
pair -->|"ไม่"| other["สอบสวนเป็นแฮงชนิดอื่น"]
ภาพ 9: แฮงจาก loader lock มีลายนิ้วมือที่จำได้คือ “รอใน Ldr + รอภายใน DllMain”
สงสัยลักษณะ “ขึ้นกับจังหวะ” เดดล็อกของ loader lock ตั้งอยู่เฉพาะชั่วขณะที่การโหลด DLL พ้องกับการเริ่มหรือออกของเธรด เงื่อนไขการทำซ้ำ เช่น “เป็นครั้งคราวตอนเริ่มต้น” “เฉพาะบนเครื่องหนึ่ง” และ “เฉพาะเมื่อรันเป็นบริการ” คือสัญญาณของปัญหาชนิดนี้
รันการตรวจเชิงป้องกัน เปิด Application Verifier แล้วรันการทดสอบของคุณ และคุณตรวจการเรียกอันตรายภายใน DllMain ตอนรันได้1 สำหรับ C++/CLI อย่าเพิกเฉยคำเตือน C4747 ในการตรวจทานฟังก์ชันที่เข้าถึงได้จาก DllMain ให้เพิ่มมุมของ “ฟังก์ชันที่เรียก LoadLibrary โดยอ้อม” (การเริ่มต้น COM ฟีเจอร์ CRT บางอย่าง delay-loaded imports เป็นต้น) เข้าสู่รายการตรวจทาน แล้วคุณจะจับอุบัติเหตุก่อนส่งของ การเรียกแรกของ delay-loaded import ที่กลายเป็น LoadLibrary ภายในเป็นจุดที่พลาดง่าย
7. สรุป
DllMainถูกเรียกขณะถือ loader lock (หนึ่งอันต่อโปรเซส ล็อกที่ทำให้การแจ้ง DLL ทุกครั้งเป็นอนุกรม) ข้อจำกัดทุกข้อตามมาจากสิ่งนั้น- แกนของข้อห้ามคือ “อย่าเรียก
LoadLibrary/FreeLibrary” “อย่าซิงโครไนซ์กับเธรดอื่น” และ “อย่าเรียกฟังก์ชันที่พึ่ง DLL อื่นนอกจาก Kernel32” คอนสตรักเตอร์และเดสตรักเตอร์ของออบเจ็กต์สแตติกที่รันผ่าน CRT ตกอยู่ภายใต้ข้อจำกัดเดียวกัน - นโยบายออกแบบพื้นฐานคือการเลื่อน ทำให้สแตติกสิ่งเริ่มต้นที่ทำให้สแตติกได้ เลื่อนส่วนที่เหลือไปใช้ครั้งแรก ใช้
DisableThreadLibraryCallsและ Application Verifier - การหยุดเธรดตอนยกเลิกโหลดตามโปรโตคอลทางการ (ส่งสัญญาณ → ยืนยันความสอดคล้อง → ยุติ) DLL_PROCESS_DETACH ตอนออกจากโปรเซสในอุดมคติว่าง
- ใน C++/CLI การรัน MSIL ภายใต้ loader lock คือกับระเบิดของตนเอง ยืนยันการคอมไพล์เนทีฟของต้นไม้การเรียก
DllMain
ข้อจำกัดของ DllMain ดูตอนแรกเหมือนรายการห้ามที่ไม่สมเหตุสมผล แต่เมื่อคุณถือจุดเดียวที่ “มันถูกเรียกขณะถือ loader lock ซึ่งเป็นล็อกระดับบน” ข้อห้ามทุกข้อคือการกล่าวซ้ำหลักการเดียวกัน จำมันเป็นหลักการ และเมื่อคุณพบกรณีขอบที่ไม่อยู่ในเอกสาร คุณยังควรถามคำถามที่ถูกได้: “นี่คืองานที่ฉันได้รับอนุญาตให้ทำขณะถือล็อกหรือไม่”
บทความที่เกี่ยวข้อง
- การค้นหาชื่อ DLL บน Windows ทำงานอย่างไร - ลำดับการค้นหาและ SxS
- เรียก Native DLL จาก C#: แรปเปอร์ C++/CLI กับ P/Invoke
- แนวปฏิบัติมัลติเธรดเชิงปฏิบัติ: ฉบับ C++ — ตัดอุบัติเหตุด้วยโครงสร้างด้วย RAII และ jthread
- Spurious Wakeup — ทำไม condition variable จึงตื่น “โดยไม่ถูกแจ้ง” และวิธีรออย่างถูกต้องบน Windows
- อ่าน Crash Dump ด้วย WinDbg + SOS — คู่มือวิเคราะห์เชิงปฏิบัติหลังการเก็บ
- พื้นฐาน COM STA/MTA - โมเดลเธรดและวิธีหลีกเลี่ยงแฮง
ด้านให้คำปรึกษาที่เกี่ยวข้อง
KomuraSoft LLC รับการสอบสวนหาสาเหตุของแฮงและเดดล็อกตอนเริ่มต้นหรือตอนโหลด DLL (การวิเคราะห์ดัมป์) การตรวจทานการออกแบบรอบ DllMain และการเริ่มต้นสแตติก และการแก้ไขแรปเปอร์ C++/CLI และ DLL ปลั๊กอินไปสู่การออกแบบเริ่มต้นที่ปลอดภัย คุณปรึกษาเราได้แม้ในขั้นที่ทำซ้ำยากของ “มันแฮงตอนเริ่มต้นเฉพาะในสภาพแวดล้อมหนึ่ง”
- การตรวจสอบบั๊กและวิเคราะห์หาสาเหตุที่แท้จริง
- ที่ปรึกษาด้านเทคนิคและการตรวจทานการออกแบบ
- การพัฒนาแอปพลิเคชัน Windows
- ติดต่อเรา
ลิงก์อ้างอิง
-
Microsoft Learn, Dynamic-Link Library Best Practices. ว่าด้วย DllMain ถูกเรียกขณะถือ loader lock จึงฟังก์ชันที่เรียกได้ถูกจำกัดอย่างรุนแรง DllMain ในอุดมคติคือสตับว่างและการเริ่มต้นถูกเลื่อนให้ไกลที่สุด คำแนะนำของการเริ่มต้นสแตติกตอนคอมไพล์ ทำเฉพาะขั้นต่ำสำหรับความล้มเหลวที่ต้องตรวจจับแต่เนิ่น และตรวจความผิดพลาดทั่วไปของ DllMain ด้วย Application Verifier ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, DllMain entry point. ว่าด้วยการทำการเริ่มต้นและยุติอย่างง่ายเท่านั้นที่จุดเข้า ทำไมคุณต้องไม่เรียก LoadLibrary / FreeLibrary (ลำดับโหลดแบบวงกลมและการใช้ DLL ก่อนเริ่มต้นหรือหลังยุติ) Kernel32.dll ถูกประกันว่าโหลดแล้ว จึงเรียกได้ในช่วงที่ไม่โหลด DLL อื่น ไม่มีรายการครบของฟังก์ชันที่ปลอดภัย ฟังก์ชัน User, Shell และ COM ก่อ access violation การแจ้ง DLL เป็นอนุกรม จึงการสื่อสารกับเธรดหรือโปรเซสอื่นก่อเดดล็อก และข้อจำกัดเดียวกันใช้กับคอนสตรักเตอร์และเดสตรักเตอร์ของออบเจ็กต์สแตติกเมื่อ CRT ถูกลิงก์ ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. ว่าด้วยโครงสร้างที่เดดล็อกหากคุณรอให้เธรดออกภายใน DllMain (การแจ้ง DLL_THREAD_DETACH ของการออกเธรดต้องการ loader lock) โปรโตคอลสำหรับหยุดเธรดตอนยกเลิกโหลด (ส่งสัญญาณด้วยอีเวนต์ ยืนยันสถานะที่สอดคล้อง แล้วยุติ) DLL_PROCESS_DETACH ตอนออกจากโปรเซสมีเธรดอื่นถูกยุติบังคับไปแล้วและไม่มีประกันความสอดคล้องของปริภูมิที่อยู่ จึงตัวจัดการในอุดมคติว่าง และการสร้างเธรดใน DllMain ทิ้งการแจ้งในคิวโดยการเริ่มต้นยังไม่สมบูรณ์แล้วก่อปัญหา ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Initialization of Mixed Assemblies. ว่าด้วยอย่ารัน MSIL ภายใต้ loader lock อย่าคอมไพล์ DllMain และต้นไม้การเรียกเป็น MSIL และจัดการด้วย #pragma unmanaged คำเตือน C4747 ถูกส่งเมื่อ DllMain พยายามรัน MSIL โดยตรง แต่การรันทางอ้อมผ่านโมดูลอื่นตรวจไม่ได้ และตัวเริ่มต้นไดนามิกของออบเจ็กต์สแตติกสามารถก่อปัญหาเดียวกัน ↩ ↩2
-
Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). ว่าด้วยการปิดการแจ้ง DLL_THREAD_ATTACH / DLL_THREAD_DETACH เพื่อลดโอเวอร์เฮดตอนสร้างและทำลายเธรด ไม่เรียกจาก DLL ที่ลิงก์กับ CRT สแตติก และการเพิ่มประสิทธิภาพไม่ถูกทำเมื่อ static TLS (thread_local หรือ __declspec(thread)) มีผล ↩ ↩2
-
Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. ว่าด้วยการกำหนดลำดับชั้นล็อกและถือในลำดับเดียวกันเสมอ ตัวโหลดถือ loader lock ก่อนเรียก DllMain จึง loader lock ควรอยู่ที่ยอดของลำดับชั้นล็อก การสังเกตลำดับการถือระหว่าง API ที่ถือ loader lock โดยอ้อม เช่น GetModuleFileName กับล็อกส่วนตัว และตัวอย่างที่เป็นรูปธรรมของเดดล็อกจากการกลับลำดับล็อก ↩ ↩2
บทความที่เกี่ยวข้อง
บทความล่าสุดที่มีแท็กเดียวกัน เพื่อเจาะลึกหัวข้อใกล้เคียง
Win32 Thread Pool API — คอนเคอร์เรนซีโดยไม่สร้างเธรดเอง ด้วย CreateThreadpoolWork
โค้ดเนทีฟของคุณกระจายการเรียก CreateThread ไปทั่วหรือไม่ บทความนี้อธิบาย Win32 thread pool API ที่ออกแบบใหม่ใน Vista — ออบเจ็กต์สี่ชนิด w...
"ไม่ตอบสนอง" คืออะไรจริง ๆ — Windows ตัดสินว่าแอปแฮงอย่างไร และออกแบบอย่างไรให้ไม่แฮง
"ไม่ตอบสนอง" ของ Windows คือกลไกที่ OS ตัดสินว่าหน้าต่างไม่ได้ดึงข้อความเป็นเวลา 5 วินาทีแล้วแทนที่ด้วยหน้าต่างโกสต์ บทความนี้ครอบคลุมภาย...
Spurious Wakeup — ทำไม condition variable จึงตื่น "โดยไม่ถูกแจ้ง" และวิธีรออย่างถูกต้องบน Windows
การรอของ condition variable สามารถกลับได้แม้ไม่มีการแจ้งมาถึง (spurious wakeup) บทความนี้อธิบายจากการอิมพลีเมนต์ของ Windows ว่าทำไมข้อกำห...
Named Pipe ในทางปฏิบัติ — IPC มาตรฐานของ Windows ตั้งแต่การออกแบบถึงความปลอดภัย
คู่มือเชิงปฏิบัติเรื่อง named pipe ซึ่งเป็นการสื่อสารระหว่างโปรเซสมาตรฐานของ Windows บทความนี้จัดระเบียบจากแหล่งปฐมภูมิ ทั้งการเลือกระหว่...
แอปที่พังเมื่อรีซูมจากสลีป — อีเวนต์พลังงานของ Windows ทำงานอย่างไร และสร้างแอปธุรกิจที่รอดได้อย่างไร
เปิดแล็ปท็อปแล้วการเชื่อมต่อของแอปธุรกิจตาย — สาเหตุคือการออกแบบที่ไม่เคยคิดถึงสลีป บทความนี้ครอบคลุมลำดับการแจ้ง WM_POWERBROADCAST พฤติก...
หัวข้อที่เกี่ยวข้อง
หน้าเหล่านี้วางหัวข้อไว้ในบริบทที่กว้างขึ้นของบริการและการตัดสินใจ
หัวข้อเทคนิคของ Windows
ประตูสู่การพัฒนา Windows การตรวจสอบบั๊ก และการใช้ประโยชน์จากสินทรัพย์เดิม
บริการที่เกี่ยวข้องกับหัวข้อนี้
บทความนี้เกี่ยวข้องโดยตรงกับบริการต่อไปนี้
พัฒนาแอป Windows
แอปธุรกิจ การเชื่อมต่ออุปกรณ์ และเครื่องมือสื่อสาร ตั้งแต่ความต้องการจนถึงการพัฒนา
คำถามที่พบบ่อย
คำถามที่มักพบในการปรึกษาเกี่ยวกับหัวข้อของบทความนี้
- ใน DllMain จริง ๆ แล้วทำอะไรไม่ได้เลยหรือ
- "อย่าทำอะไร" ไม่ใช่การพูดเกินจริง แต่เป็นท่าทางการออกแบบอย่างเป็นทางการ และ Microsoft เองก็บอกว่า DllMain ในอุดมคติคือสตับที่เกือบว่าง สิ่งที่ปลอดภัยคือสับเซ็ตของฟังก์ชัน Kernel32.dll — Kernel32 ถูกประกันว่าโหลดแล้วเมื่อ DllMain รัน — ในช่วงที่ไม่โหลด DLL อื่น การสร้าง critical section หรือ mutex และการใช้ TLS เป็นตัวอย่างของสิ่งที่ทำได้ ในทางกลับกัน LoadLibrary/FreeLibrary การซิงโครไนซ์กับเธรดอื่น และการเรียกฟังก์ชันใน User32, Shell, COM และคล้ายกัน ถูกห้ามเพราะก่อเดดล็อกและ access violation การเริ่มต้นที่คุณไม่แน่ใจไม่ควรทำใน DllMain ให้เลื่อนจนกว่าจะถูกใช้ครั้งแรก
- คอนสตรักเตอร์ของโกลบอล C++ (ออบเจ็กต์สแตติก) ก็ตกอยู่ภายใต้ข้อจำกัดของ DllMain ด้วยหรือไม่
- ตกอยู่ เมื่อ DLL ถูกลิงก์กับ CRT (รันไทม์ C++) คอนสตรักเตอร์และเดสตรักเตอร์ของออบเจ็กต์โกลบอลและสแตติกรัน ผ่านจุดเข้าที่ CRT ให้ เป็นส่วนโดยพฤตินัยของ DllMain นั่นหมายความว่าการเรียก LoadLibrary จากคอนสตรักเตอร์ การเริ่มเธรดอื่นแล้วรอให้เสร็จ การเริ่มต้น COM และอื่น ๆ ล้วนถืออันตรายเดียวกับการทำสิ่งเหล่านั้นใน DllMain สำหรับออบเจ็กต์โกลบอลที่มีการเริ่มต้นที่ไม่เล็กน้อย ให้ถือพอยน์เตอร์แล้วสร้างตอนเข้าถึงครั้งแรก หรือใช้สแตติกเฉพาะฟังก์ชัน เพื่อให้งานรันนอก DllMain
- ควรเรียก DisableThreadLibraryCalls หรือไม่
- มีเงื่อนไข ใช่ หาก DLL ไม่ต้องการการแจ้ง DLL_THREAD_ATTACH/DETACH การเรียก DisableThreadLibraryCalls ใน DLL_PROCESS_ATTACH หยุดการแจ้งต่อการสร้างเธรดและต่อการออกจากเธรด และลดโอเวอร์เฮดในโปรเซสที่สร้างเธรดบ่อย มีข้อยกเว้นสองอย่าง อย่าเรียกจาก DLL ที่ลิงก์กับ CRT แบบสแตติก (CRT สแตติกต้องการการแจ้งเธรด) และหาก static TLS ผ่าน thread_local หรือ __declspec(thread) มีผล การเรียกเองล้มเหลวและคืน FALSE ดังนั้นจงมีนิสัยตรวจค่าที่คืนมา ใช้มันบน DLL ทั่วไปที่ใช้ CRT ที่ลิงก์แบบไดนามิก หลังจากยืนยันว่าไม่มีอะไรพึ่งการแจ้งเธรด
- ทำไม DLL C++/CLI (ผสมแมเนจด์) จึงแฮงตอนเริ่มต้น
- สาเหตุทั่วไปคือการพยายามรัน MSIL (โค้ดแมเนจด์) ขณะถือ loader lock อยู่ในมือ ในแอสเซมบลีผสม C++/CLI หาก DllMain ฟังก์ชันที่ถูกเรียกจากมัน หรือตัวเริ่มต้นไดนามิกของโกลบอลถูกคอมไพล์เป็น MSIL การเริ่มต้น CLR หรือการโหลดแอสเซมบลีอื่นอาจถูกต้องการภายใต้ loader lock และนั่นอาจเดดล็อก คอมไพเลอร์ส่งคำเตือน C4747 เมื่อ DllMain เองพยายามรัน MSIL โดยตรง แต่ตรวจการรันทางอ้อมผ่านโมดูลอื่นไม่ได้ มาตรการคือคอมไพล์ DllMain และต้นไม้การเรียกเป็นเนทีฟด้วย #pragma unmanaged — หรือไม่ให้มี DllMain เลย
- ฉันทำความสะอาดทรัพยากรใน DLL_PROCESS_DETACH ได้หรือไม่
- คำตอบเปลี่ยนระหว่าง "การออกจากโปรเซส" กับ "การยกเลิกโหลดผ่าน FreeLibrary" บน DLL_PROCESS_DETACH ตอนออกจากโปรเซส เธรดอื่นถูกยุติไปแล้ว และไม่มีประกันว่าปริภูมิที่อยู่ยังสอดคล้อง ดังนั้นการทำความสะอาด เช่น ปล่อยหน่วยความจำ กลับเป็นอันตราย คำแนะนำทางการคือ "ตัวจัดการในอุดมคติว่าง" เขียนข้อมูลที่ต้องคงอยู่บนเส้นทางปิดระบบของแอปเอง และที่นี่โดยพื้นฐานอย่าทำอะไรแล้วกลับ บนการยกเลิกโหลดผ่าน FreeLibrary โปรเซสยังดำเนินต่อ จึงต้องทำความสะอาดให้ครบ — หยุดเธรด ปิดแฮนเดิล เป็นต้น แต่การรอให้เธรดออกภายใน DllMain เดดล็อก ดังนั้นคุณต้องตามโปรโตคอลทางการ: ส่งสัญญาณ รอจนถึงสถานะที่สอดคล้อง และทำงานให้เสร็จนอก DllMain