DllMain và Loader Lock — Lý do thật sự bạn bị bảo "đừng làm gì trong khởi tạo DLL"
· Go Komura · Windows, DLL, Phát triển Windows, C++, Xử lý sự cố, Đa luồng, Win32 API
“Ứng dụng treo lúc khởi động, nhưng chỉ trong một môi trường cụ thể.” “Khi chúng tôi nạp DLL của mình, LoadLibrary đôi khi không bao giờ trở về.” “Nó chỉ deadlock đúng lúc dịch vụ khởi động.” — Theo các cuộc điều tra như thế đủ xa, thường hơn không, bạn tới cùng một chỗ. Mã khởi tạo của DLL — tức là DllMain.
Tài liệu của Microsoft cảnh báo về DllMain bằng giọng khác thường mạnh. Đừng gọi LoadLibrary. Đừng đồng bộ với luồng khác. Đừng gọi hàm User, Shell, hoặc COM. DllMain lý tưởng là một stub rỗng — vì sao ngôn ngữ mạnh đến vậy? Lý do tập trung vào một cơ chế nội bộ duy nhất, loader lock. Hướng tới các nhà phát triển viết DLL, plug-in, và wrapper C++/CLI trên Windows, bài viết này giải thích, từ nguồn gốc, cách loader lock hoạt động, cấu trúc làm deadlock đứng vững, và thiết kế an toàn cùng thủ tục điều tra.
1. Kết luận trước
DllMainđược gọi trong khi giữ loader lock, một khóa dùng chung mà mỗi tiến trình có đúng một. Nên gọi, từDllMain, việc cố lấy loader lock (trực tiếp hoặc gián tiếp) tạo khả năng deadlock, hoặc sự cố vì chạm DLL chưa được khởi tạo.1- Gọi
LoadLibrary/FreeLibrarybị cấm. Nó tạo phụ thuộc thứ tự nạp vòng và có thể khiến mã khởi tạo chạy trên DLL mà bản thân khởi tạo của nó chưa chạy.2 - Đồng bộ với luồng khác cũng bị cấm. Thông báo DLL được tuần tự hóa, nên chờ bên trong
DllMaincho một luồng khởi động hoặc thoát để luồng đó tự dừng chờ loader lock, và bạn deadlock.23 - Những gì bạn có thể gọi an toàn, trên thực tế, chỉ là một tập con của Kernel32.dll. Và tài liệu chính thức nói thẳng rằng “danh sách đầy đủ các hàm an toàn không tồn tại”. Hàm User, Shell và COM nạp thành phần khác và gây vi phạm truy cập.2
- Trong DLL liên kết với CRT, cùng hạn chế áp dụng cho constructor và destructor của biến toàn cục. Chúng chạy như một phần thực tế của
DllMain.2 - Thiết kế đúng là “hoãn”. Làm khởi tạo bạn có thể lúc biên dịch (tĩnh); hoãn những gì bạn không thể tới lần dùng đầu. Đó là thực hành tốt nhất chính thức.1
- DLL hỗn hợp C++/CLI đặc biệt nguy hiểm. Để tránh chạy MSIL dưới loader lock,
DllMainvà cây gọi của nó phải được biên dịch native.4
2. Khi nào và cách DllMain được gọi
DllMain là điểm vào loader của hệ điều hành gọi khi một DLL vào hoặc rời một tiến trình hoặc một luồng. Có bốn thông báo.
| Thông báo | Thời điểm |
|---|---|
| DLL_PROCESS_ATTACH | Khi DLL được nạp vào tiến trình |
| DLL_THREAD_ATTACH | Khi một luồng mới được khởi động trong tiến trình |
| DLL_THREAD_DETACH | Khi một luồng thoát bình thường |
| DLL_PROCESS_DETACH | Khi DLL bị unload, hoặc khi tiến trình thoát |
Hai thực tế dễ bỏ lỡ. Thứ nhất, mỗi lần một luồng đơn được tạo, DllMain của mọi DLL đã nạp được gọi với DLL_THREAD_ATTACH. Nói cách khác DllMain không phải “thứ chạy một lần khi DLL của tôi được nạp”; nó là mã tiếp tục được gọi cho hoạt động luồng của tiến trình. Nếu bạn không cần điều đó, bạn có thể dừng nó bằng cách gọi DisableThreadLibraryCalls bên trong DLL_PROCESS_ATTACH (đừng gọi từ DLL liên kết với CRT tĩnh).5
Thứ hai, trong DLL liên kết với CRT (runtime C/C++), constructor và destructor của đối tượng C++ toàn cục và tĩnh chạy, qua điểm vào của CRT, như một phần của DllMain.2 Ngay cả nếu bạn nghĩ “DllMain của chúng ta rỗng, nên chúng ta an toàn”, một đối tượng toàn cục với khởi tạo cầu kỳ cũng giống như chạy việc đó trong DllMain.
flowchart TB
accTitle: Bốn thời điểm DllMain được gọi
accDescr: DLL_PROCESS_ATTACH chạy khi nạp DLL; DLL_THREAD_ATTACH và DETACH chạy trên mọi DLL đã nạp ở mỗi lần khởi động và thoát luồng trong tiến trình; DLL_PROCESS_DETACH chạy khi unload hoặc thoát tiến trình; và constructor của đối tượng tĩnh cũng chạy bên trong đây qua CRT
load["Nạp DLL"] --> pa["DLL_PROCESS_ATTACH"]
pa --> ta["DLL_THREAD_ATTACH(mỗi lần khởi động luồng)"]
ta --> td["DLL_THREAD_DETACH(mỗi lần thoát luồng)"]
td --> pd["DLL_PROCESS_DETACH(khi unload hoặc thoát)"]
pa -.-> crt["Xây đối tượng tĩnh cũng chạy ở đây"]
Hình 1: DllMain được gọi không chỉ lúc nạp mà ở mỗi lần khởi động và thoát luồng, và khởi tạo đối tượng tĩnh cũng chạy như một phần của điều đó.
3. Loader Lock — Một khóa tuần tự hóa mọi thông báo
Vì sao hạn chế trên riêng DllMain lại nghiêm đến vậy? Câu trả lời nằm ở cấu trúc của loader.
Để giữ một chuỗi thao tác — nạp DLL, unload, và các thông báo khác nhau — nhất quán, loader của hệ điều hành tuần tự hóa việc bằng một loader lock duy nhất mỗi tiến trình. Và điểm quan trọng là DllMain được gọi trong khi loader lock này đang được giữ.1 Trong suốt thời gian bạn ở bên trong DllMain, mọi lần nạp DLL khác trong tiến trình đó, và mọi thông báo bắt đầu-luồng, chờ khóa này được nhả.
Từ cấu trúc đó, lý do của các lệnh cấm theo nhau ra.
- Bạn không được gọi
LoadLibraryvì nó tạo tái nhập loader lock, hoặc phụ thuộc thứ tự nạp vòng. Nó cũng có thể dẫn tới gọi hàm trên DLL mà khởi tạo chưa xong.2 - Đồng bộ với luồng khác nguy hiểm vì luồng bạn đang chờ có những khoảnh khắc nó cần loader lock (thông báo lúc khởi động và thoát, lời gọi API họ
GetModuleHandle, và tương tự). Bạn giữ loader lock và chờ phía bên kia; phía bên kia chờ loader lock — đảo thứ tự khóa kinh điển.6 - Hàm User, Shell và COM nguy hiểm vì chúng nạp thành phần hệ thống khác bên trong. Bạn chạm một thành phần trước khi nó được khởi tạo, hoặc sau khi nó đã bị tháo, và bạn gặp vi phạm truy cập.2
sequenceDiagram
accTitle: Vì sao chờ một luồng bên trong DllMain deadlock
accDescr: DllMain, đang giữ loader lock, chờ một luồng worker thoát, nhưng worker đang cố thoát chờ loader lock được nhả để nhận DLL_THREAD_DETACH, nên chúng chờ nhau và deadlock
participant L as Loader(giữ khóa)
participant D as DllMain
participant W as Worker
L->>D: DLL_PROCESS_DETACH
D->>W: Yêu cầu thoát rồi chờ
W->>W: Xong việc, rồi thoát
Note over W: Thông báo thoát cần khóa
Note over D,W: DllMain giữ khóa, W chờ
Hình 2: “DllMain chờ một luồng thoát” là deadlock cấu trúc, vì bản thân thoát luồng cần loader lock.
Điểm then chốt là đây không phải thứ “xảy ra nếu bạn xui”; nó được bảo đảm đứng vững về cấu trúc. Tài liệu bảo bạn coi loader lock là đỉnh của hệ cấp khóa mà ứng dụng định nghĩa (cái được lấy trước). Bên trong DllMain bạn đã giữ khóa cấp cao nhất đó, nên mọi hành động từ đó đi tiếp để chờ thứ khác đều nguy hiểm — đó là cách nhớ hữu ích.6
flowchart TB
accTitle: Đảo thứ tự khóa giữa loader lock và khóa riêng
accDescr: DllMain, đang giữ loader lock, đi lấy khóa riêng, trong khi một worker, đang giữ khóa riêng đó, đi lấy loader lock cho GetModuleHandle hoặc tương tự, nên thứ tự lấy đảo và chúng deadlock
d["DllMain: đang giữ loader lock"] --> dg["Đi lấy khóa riêng G"]
w["Worker: đang giữ khóa riêng G"] --> wl["Đi lấy loader lock"]
dg -.-> dead["Deadlock do thứ tự lấy đảo"]
wl -.-> dead
wl -.-> api["GetModuleHandle và tương tự đòi nó bên trong"]
Hình 3: Ngay cả một API vô hại như GetModuleHandle cũng đòi loader lock bên trong, nên đảo thứ tự với khóa riêng có thể đứng vững.
Ngoài ra, gọi CreateThread từ bên trong DllMain bản thân không được khuyến nghị. Luồng được tạo cần loader lock để xử lý thông báo DLL_THREAD_ATTACH, nên nó không thể bắt đầu chạy cho tới khi DllMain đang thực thi trở về và nhả khóa. Vì vậy chờ bên trong DllMain cho luồng đó khởi động hoặc xong là deadlock ngay. Còn một vấn đề vòng đời — nếu, sau khi DllMain trở về, DLL bị unload trong khi một luồng chưa bắt đầu chạy vẫn còn lại, địa chỉ bắt đầu của luồng vẫn trỏ vào mã đã được giải phóng và bạn sự cố.3
4. Hai mìn mà nhà phát triển C++ dễ giẫm phải
Mìn 1: Khởi tạo động của đối tượng toàn cục. Như Chương 2 đã nói, constructor của đối tượng tĩnh chạy dưới hạn chế DllMain. Đọc tệp cấu hình, dựng cơ sở nhật ký, khởi tạo COM, khởi động luồng — khoảnh khắc bạn đặt một biến toàn cục trong DLL mà constructor làm loại việc đó, bạn đang thực thi “những việc không được làm trong DllMain”. Khởi tạo hằng được chốt lúc biên dịch (mọi thứ bạn có thể làm constexpr) thì an toàn; khởi tạo liên quan lời gọi hàm nên được hoãn.
flowchart TB
accTitle: Đường mà khởi tạo đối tượng toàn cục trở thành mìn
accDescr: Loader lock được lấy khi nạp DLL, và constructor của đối tượng toàn cục chạy qua điểm vào CRT, nên LoadLibrary, đồng bộ luồng, và khởi tạo COM bên trong các constructor đó là thực thi các lệnh cấm của DllMain
load["Nạp DLL(lấy loader lock)"] --> crt["Điểm vào CRT"]
crt --> ctor["Constructor của đối tượng toàn cục"]
ctor --> ng1["Việc tương đương LoadLibrary"]
ctor --> ng2["Khởi động luồng rồi chờ nó xong"]
ctor --> ng3["Dùng COM hoặc User32"]
ng1 -.-> risk["Tất cả chúng nằm dưới lệnh cấm DllMain"]
ng2 -.-> risk
ng3 -.-> risk
Hình 4: Ngay cả “DllMain rỗng, nên chúng ta an toàn” cũng hồi sinh cùng nguy hiểm ngay khi bạn có một biến toàn cục với khởi tạo cầu kỳ.
Mìn 2: C++/CLI (assembly hỗn hợp). Trong cấu hình bọc DLL native bằng C++/CLI (hình dạng được trình bày trong bài viết về wrapper), có nguy hiểm chạy MSIL (mã managed) dưới loader lock. Chạy MSIL có thể kích hoạt khởi tạo CLR hoặc việc nạp assembly khác. Trình biên dịch phát cảnh báo C4747 trên mã nơi DllMain thực thi MSIL trực tiếp, nhưng nó không phát hiện được thực thi gián tiếp qua hàm trong mô-đun khác. Biên dịch DllMain và các hàm được gọi từ nó thành native bằng #pragma unmanaged, hoặc dùng cấu hình không có DllMain nào cả.4
flowchart TB
accTitle: Liệu thực thi MSIL dưới loader lock có thể được phát hiện không
accDescr: Mã nơi DllMain thực thi MSIL trực tiếp có thể được trình biên dịch phát hiện bằng cảnh báo C4747, nhưng thực thi gián tiếp qua hàm trong mô-đun khác thì không, nên bạn phải ngăn nó bằng rà soát cây gọi và khăng khăng biên dịch native
d2["Lời gọi từ DllMain"] --> dir["Thực thi MSIL trực tiếp"]
d2 --> ind["Thực thi qua mô-đun khác"]
dir --> c47["Phát hiện được bằng cảnh báo C4747"]
ind --> nc["Trình biên dịch không phát hiện được"]
nc -.-> rv["Ngăn bằng rà soát và #pragma unmanaged"]
Hình 5: C4747 chỉ bảo vệ bạn chống thực thi trực tiếp. Đường gián tiếp chỉ bắt được bằng rà soát.
5. Thiết kế đúng — Làm “hoãn” thành chính sách mặc định
Khuyến nghị thực hành tốt nhất chính thức rất rõ.1
- Hoàn tất khởi tạo bạn có thể lúc biên dịch (tĩnh). Trước hết hỏi liệu khởi tạo động có thể được thay bằng khởi tạo tĩnh không.
- Hoãn phần còn lại tới lần dùng đầu. Miễn là lần dùng đầu xảy ra từ một API thông thường được gọi sau khi DLL đã nạp xong, khởi tạo chạy ngoài loader lock và bạn có thể dùng an toàn gần như toàn bộ Windows API. Để loại trừ ở lần truy cập đầu bạn có thể dùng
INIT_ONCE(khởi tạo một lần) hoặc magic statics của C++ (static cục bộ hàm). Hoãn không phải thuốc chữa bách bệnh, tuy nhiên — nếu bản thân lần truy cập đầu được làm từDllMainhoặc initializer tĩnh, initializer vẫn chạy dưới loader lock và bạn trở lại cùng hạn chế. - Chỉ ngoại lệ cho thất bại bạn phải phát hiện sớm. Bạn có thể có yêu cầu rằng tệp cấu hình hỏng phải làm bản thân lần nạp thất bại. Ngay cả vậy, hãy giữ nó ở mức tối thiểu “thử và thất bại ngay”.
- Cân nhắc
DisableThreadLibraryCallstrong DLL_PROCESS_ATTACH. Nếu DLL không dùng thông báo luồng, bạn có thể gỡ chính chi phí thông báo (trừ khi dùng CRT tĩnh hoặc TLS tĩnh).5 - Kiểm tra bằng Application Verifier. Nhiều lời gọi nguy hiểm bên trong
DllMainlà những thứ Application Verifier sẽ phát hiện lúc chạy.1
flowchart TB
accTitle: Hướng dẫn thiết kế cho khởi tạo DLL
accDescr: Trước hết cân nhắc liệu khởi tạo có thể là khởi tạo tĩnh lúc biên dịch; nếu không, mặc định là hoãn tới lần dùng đầu, và để lại trong DllMain chỉ mức tối thiểu phải được phát hiện sớm như thất bại nạp
q1{"Có thể quyết định lúc biên dịch?"} -->|"có"| s["Làm thành khởi tạo tĩnh"]
q1 -->|"không"| q2{"Thất bại phải được phát hiện lúc nạp?"}
q2 -->|"không"| lazy["Hoãn tới lần dùng đầu(mặc định)"]
q2 -->|"có"| min["Chỉ làm mức tối thiểu trong DllMain"]
lazy -.-> once["Loại trừ bằng INIT_ONCE hoặc static cục bộ hàm"]
Hình 6: Thứ tự quyết định là “có thể tĩnh → có thể hoãn”, và những gì bạn để lại trong DllMain chỉ là mức tối thiểu phải được phát hiện sớm.
Có nên áp dụng DisableThreadLibraryCalls có thể được quyết định máy móc bằng nhánh sau.
flowchart TB
accTitle: Có nên gọi DisableThreadLibraryCalls
accDescr: Đừng gọi từ DLL liên kết với CRT tĩnh; nếu TLS tĩnh đang có hiệu lực thì bản thân lời gọi thất bại nên bạn không gọi; nếu không cái nào khớp và DLL không dùng thông báo luồng, gọi nó trong DLL_PROCESS_ATTACH, kiểm tra giá trị trả về, để cắt chi phí thông báo
q1{"Liên kết với CRT tĩnh?"} -->|"có"| no2["Không được gọi"]
q1 -->|"không"| q2{"Đang dùng TLS tĩnh?"}
q2 -->|"có"| eff["Lời gọi vẫn thất bại(FALSE)"]
q2 -->|"không"| q3{"Thông báo luồng có cần không?"}
q3 -->|"không"| yes["Gọi trong ATTACH(kiểm tra giá trị trả về)"]
q3 -->|"có"| keep["Đừng gọi; xử lý các thông báo"]
Hình 7: Ba điều kiện CRT tĩnh, TLS tĩnh, và liệu thông báo có cần quyết định duy nhất bạn có nên gọi hay không.
Với dừng luồng lúc unload, tài liệu chính thức đưa một giao thức cụ thể. Thay vì “chờ” các luồng worker thoát trong DLL_PROCESS_DETACH (trên lần unload qua FreeLibrary), hình dạng là (1) tín hiệu thoát bằng sự kiện, (2) phía luồng gấp việc xuống trạng thái nhất quán, tín hiệu lại, và vào chờ vô hạn, (3) phía DllMain xác nhận trạng thái nhất quán rồi gấp luồng bằng TerminateThread.3 Nó trông thô, nhưng được ghi như câu trả lời thực tế bên trong ràng buộc “bạn không được chờ thoát tự nhiên của một luồng bên trong DllMain”.
sequenceDiagram
accTitle: Giao thức dừng luồng lúc unload
accDescr: DllMain tín hiệu luồng worker thoát bằng sự kiện; worker gấp việc xuống trạng thái nhất quán, tín hiệu lại, và vào chờ vô hạn; DllMain xác nhận trạng thái nhất quán rồi chấm dứt luồng
participant D as DllMain(xử lý DETACH)
participant W as Luồng worker
D->>W: Tín hiệu thoát bằng sự kiện
W->>W: Gấp việc xuống trạng thái nhất quán
W->>D: Tín hiệu nhất quán xong rồi chờ mãi
D->>W: Chấm dứt bằng TerminateThread
Note over D,W: Không chờ thoát tự nhiên, nên không deadlock
Hình 8: Thay vì “chờ thoát tự nhiên”, “chờ tín hiệu nhất quán rồi cắt” tránh va chạm với loader lock.
Như nguyên tắc đầu tiên, thiết kế an toàn nhất là tránh sở hữu luồng trong DLL có thể bị unload, và giữ quyền sở hữu luồng ở phía EXE.
DLL_PROCESS_DETACH lúc thoát tiến trình thì ngược lại: không làm gì rồi trở về là lý tưởng. Tới điểm này mọi luồng khác đã bị chấm dứt cưỡng bức, và bạn cũng không thể dựa vào trạng thái của DLL phụ thuộc hay runtime. Việc cầu kỳ ở đây chỉ gây deadlock và sự cố. Dữ liệu phải được bền vững nên được ghi ra trên đường tắt riêng của ứng dụng; đừng phụ thuộc vào thông báo này.3
6. Cách điều tra khi bạn gặp phải
Treo loader lock có dấu vân tay nhận ra được.
Nhìn các stack trong dump treo. Lấy dump của khoảnh khắc đóng băng và kiểm tra stack của từng luồng. Nếu bạn tìm thấy một cặp gồm một luồng đang chờ khóa bên trong các hàm loader của ntdll.dll (họ có tên bắt đầu bằng Ldr) và một luồng đang chờ thứ khác bên trong DllMain hoặc initializer tĩnh (dynamic initializer), bạn gần như chắc chắn. Một luồng dừng giữa lời gọi LoadLibrary là một nhân vật điển hình khác.
flowchart TB
accTitle: Dấu vân tay của treo loader lock
accDescr: Trong dump treo, nếu bạn tìm thấy cả một luồng chờ khóa bên trong hàm loader ntdll và một luồng chờ thứ khác bên trong DllMain hoặc initializer tĩnh, bạn có thể coi đó là deadlock loader lock với độ chắc gần như tuyệt đối
dump["Dump treo"] --> t1["Luồng chờ khóa trong hàm họ Ldr"]
dump --> t2["Luồng chờ bên trong DllMain hoặc initializer tĩnh"]
t1 --> pair{"Cả hai có mặt?"}
t2 --> pair
pair -->|"có"| conf["Gần như chắc deadlock loader lock"]
pair -->|"không"| other["Điều tra như treo loại khác"]
Hình 9: Treo loader lock có dấu vân tay nhận ra được “chờ trong Ldr + chờ bên trong DllMain”.
Nghi nhân vật “phụ thuộc thời điểm”. Deadlock loader lock chỉ đứng vững đúng lúc một lần nạp DLL trùng với khởi động hoặc thoát luồng. Điều kiện tái hiện như “thỉnh thoảng lúc khởi động”, “chỉ trên một máy cụ thể”, và “chỉ khi chạy như dịch vụ” là dấu hiệu của loại vấn đề này.
Chạy kiểm tra phòng ngừa. Bật Application Verifier và chạy các bài kiểm của bạn, và bạn có thể phát hiện lời gọi nguy hiểm bên trong DllMain lúc chạy.1 Với C++/CLI, đừng bỏ qua cảnh báo C4747; trong rà soát các hàm tới được từ DllMain, thêm góc “các hàm gọi LoadLibrary gián tiếp” (khởi tạo COM, một số tính năng CRT, import nạp trễ, và tương tự) vào danh sách kiểm rà soát, và bạn sẽ bắt tai nạn trước khi giao hàng. Lời gọi đầu của một import nạp trễ trở thành LoadLibrary bên trong là điểm dễ bỏ lỡ.
7. Tóm tắt
DllMainđược gọi trong khi giữ loader lock (một mỗi tiến trình, khóa tuần tự hóa mọi thông báo DLL). Mọi hạn chế đều theo từ đó.- Cốt lõi của các lệnh cấm là “đừng gọi
LoadLibrary/FreeLibrary”, “đừng đồng bộ với luồng khác”, và “đừng gọi hàm phụ thuộc DLL khác ngoài Kernel32”. Constructor và destructor của đối tượng tĩnh chạy qua CRT nằm dưới cùng hạn chế. - Chính sách thiết kế cơ bản là hoãn. Làm tĩnh khởi tạo bạn có thể làm tĩnh; hoãn phần còn lại tới lần dùng đầu. Dùng
DisableThreadLibraryCallsvà Application Verifier. - Dừng luồng lúc unload theo giao thức chính thức (tín hiệu → xác nhận nhất quán → chấm dứt). DLL_PROCESS_DETACH lúc thoát tiến trình lý tưởng là rỗng.
- Trong C++/CLI, chạy MSIL dưới loader lock là một mìn riêng. Khăng khăng biên dịch native cây gọi
DllMain.
Các hạn chế DllMain trông, lúc đầu, như một danh sách lệnh cấm vô lý. Nhưng một khi bạn nắm điểm duy nhất rằng “nó được gọi trong khi giữ loader lock, khóa cấp cao nhất”, mọi lệnh cấm là cách nói lại cùng một nguyên tắc. Nhớ nó như một nguyên tắc và, khi bạn gặp trường hợp biên không có trong tài liệu, bạn vẫn nên có thể hỏi đúng câu: “Đây có phải việc tôi được phép làm trong khi giữ khóa không?”
Bài viết liên quan
- Cách Windows phân giải tên DLL — Thứ tự tìm kiếm và SxS
- Gọi DLL native từ C#: Wrapper C++/CLI so với P/Invoke
- Thực hành tốt nhất về đa luồng: Bản C++ — Loại bỏ tai nạn bằng cấu trúc với RAII và jthread
- Thức tỉnh giả — Vì sao biến điều kiện thức “mà không được thông báo” và cách chờ đúng trên Windows
- Đọc crash dump bằng WinDbg + SOS — Hướng dẫn thực tiễn phân tích sau khi thu thập
- Nền tảng COM STA/MTA — Mô hình luồng và cách tránh treo
Lĩnh vực tư vấn liên quan
KomuraSoft LLC đảm nhận điều tra nguyên nhân gốc rễ của treo và deadlock lúc khởi động hoặc lúc nạp DLL (phân tích dump), rà soát thiết kế quanh DllMain và khởi tạo tĩnh, và khắc phục wrapper C++/CLI cùng DLL plug-in hướng tới thiết kế khởi tạo an toàn. Bạn có thể tư vấn chúng tôi ngay cả ở giai đoạn khó tái hiện “nó chỉ treo lúc khởi động trong một môi trường cụ thể”.
- Điều tra lỗi và phân tích nguyên nhân gốc rễ
- Tư vấn kỹ thuật và rà soát thiết kế
- Phát triển ứng dụng Windows
- Liên hệ với chúng tôi
Liên kết tham khảo
-
Microsoft Learn, Dynamic-Link Library Best Practices. Về DllMain được gọi trong khi loader lock đang được giữ, nên các hàm bạn có thể gọi bị hạn chế nghiêm; DllMain lý tưởng là stub rỗng và khởi tạo được hoãn càng xa càng tốt; khuyến nghị khởi tạo tĩnh lúc biên dịch; chỉ làm mức tối thiểu cho thất bại phải được phát hiện sớm; và phát hiện các lỗi DllMain điển hình bằng Application Verifier. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, DllMain entry point. Về việc chỉ thực hiện khởi tạo và kết thúc đơn giản trong điểm vào; vì sao bạn không được gọi LoadLibrary / FreeLibrary (thứ tự nạp vòng và dùng DLL trước khởi tạo hoặc sau kết thúc); Kernel32.dll được bảo đảm đã nạp, nên bạn có thể gọi nó trong phạm vi không nạp DLL khác; không có danh sách đầy đủ các hàm an toàn; hàm User, Shell và COM gây vi phạm truy cập; thông báo DLL được tuần tự hóa, nên giao tiếp với luồng hoặc tiến trình khác gây deadlock; và cùng hạn chế áp dụng cho constructor và destructor của đối tượng tĩnh khi CRT được liên kết. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. Về cấu trúc deadlock nếu bạn chờ một luồng thoát bên trong DllMain (thông báo DLL_THREAD_DETACH của thoát luồng cần loader lock); giao thức dừng luồng lúc unload (tín hiệu bằng sự kiện, xác nhận trạng thái nhất quán, rồi chấm dứt); DLL_PROCESS_DETACH lúc thoát tiến trình có các luồng khác đã bị chấm dứt cưỡng bức và không bảo đảm nhất quán không gian địa chỉ, nên handler lý tưởng là rỗng; và tạo luồng trong DllMain để lại thông báo xếp hàng với khởi tạo chưa xong và gây vấn đề. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Initialization of Mixed Assemblies. Về việc không thực thi MSIL dưới loader lock; không biên dịch DllMain và cây gọi của nó thành MSIL và xử lý điều đó qua #pragma unmanaged; cảnh báo C4747 được phát khi DllMain cố thực thi MSIL trực tiếp, nhưng thực thi gián tiếp qua mô-đun khác không phát hiện được; và initializer động của đối tượng tĩnh có thể gây cùng vấn đề. ↩ ↩2
-
Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). Về việc tắt thông báo DLL_THREAD_ATTACH / DLL_THREAD_DETACH để giảm chi phí lúc tạo và hủy luồng; không gọi từ DLL liên kết với CRT tĩnh; và tối ưu hóa không được thực hiện khi TLS tĩnh (thread_local hoặc __declspec(thread)) đang có hiệu lực. ↩ ↩2
-
Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. Về việc định nghĩa hệ cấp khóa và luôn lấy theo cùng thứ tự; loader lấy loader lock trước khi gọi DllMain, nên loader lock nên ngồi ở đỉnh hệ cấp khóa; quan sát thứ tự lấy giữa các API lấy loader lock gián tiếp, như GetModuleFileName, và khóa riêng; và ví dụ cụ thể về deadlock từ đảo thứ tự khóa. ↩ ↩2
Bài viết liên quan
Các bài viết gần đây có cùng thẻ để tìm hiểu sâu hơn những chủ đề lân cận.
API thread pool Win32 — Đồng thời mà không tạo luồng, với CreateThreadpoolWork
Bạn đang rải các lời gọi CreateThread khắp mã native? Bài viết này giải thích API thread pool Win32 được thiết kế lại từ Vista — bốn đối ...
"Không phản hồi" thực sự là gì — Cách Windows quyết định ứng dụng đã treo, và cách thiết kế ứng dụng không treo
"Không phản hồi" của Windows là cơ chế trong đó hệ điều hành phán rằng một cửa sổ chưa lấy thông điệp trong 5 giây và thay nó bằng cửa sổ...
Thức tỉnh giả — Vì sao biến điều kiện thức "mà không được thông báo" và cách chờ đúng trên Windows
Lần chờ của biến điều kiện có thể trở về ngay cả khi không có thông báo nào tới (thức tỉnh giả). Bài viết này giải thích, từ triển khai W...
Named pipe trong thực tiễn — IPC chuẩn của Windows, từ thiết kế đến bảo mật
Hướng dẫn thực tiễn về named pipe, cơ chế giao tiếp giữa các tiến trình chuẩn của Windows. Bài viết sắp xếp, từ nguồn gốc, việc chọn giữa...
Ứng dụng hỏng khi thức dậy từ ngủ — Sự kiện nguồn Windows và cách viết ứng dụng nghiệp vụ sống sót
Bạn mở laptop và kết nối của ứng dụng nghiệp vụ đã chết — nguyên nhân là thiết kế chưa tính đến ngủ. Bài viết này trình bày luồng thông b...
Chủ đề liên quan
Các trang này đặt chủ đề trong bối cảnh rộng hơn của dịch vụ và quyết định.
Chủ đề kỹ thuật Windows
Cổng vào phát triển Windows, điều tra lỗi và khai thác tài sản hiện có.
Dịch vụ liên quan đến chủ đề này
Bài viết liên quan trực tiếp đến các dịch vụ sau.
Phát triển ứng dụng Windows
Ứng dụng nghiệp vụ, tích hợp thiết bị và công cụ liên lạc, từ yêu cầu đến phát triển.
Câu hỏi thường gặp
Các câu hỏi thường gặp khi tư vấn về chủ đề của bài viết.
- Thật sự không được làm gì cả trong DllMain sao?
- "Đừng làm gì" không phải khoa trương; đó là lập trường thiết kế chính thức, và chính Microsoft nói rằng DllMain lý tưởng là một stub gần như rỗng. Thứ an toàn là một tập con các hàm Kernel32.dll — Kernel32 được bảo đảm đã nạp vào lúc DllMain chạy — trong phạm vi không nạp DLL khác. Tạo critical section hoặc mutex, và dùng TLS, là ví dụ những gì bạn có thể làm. Ngược lại, LoadLibrary/FreeLibrary, đồng bộ với luồng khác, và gọi hàm trong User32, Shell, COM, và tương tự bị cấm vì chúng gây deadlock và vi phạm truy cập. Khởi tạo bạn không chắc không nên làm trong DllMain; hãy hoãn tới lần dùng đầu tiên.
- Constructor của biến toàn cục C++ (đối tượng tĩnh) cũng nằm dưới hạn chế DllMain chứ?
- Có. Khi DLL được liên kết với CRT (runtime C++), constructor và destructor của đối tượng toàn cục và tĩnh chạy, qua điểm vào CRT cung cấp, như một phần thực tế của DllMain. Nghĩa là gọi LoadLibrary từ constructor, khởi động luồng khác rồi chờ nó xong, khởi tạo COM, và tương tự đều mang cùng nguy hiểm như làm những việc đó trong DllMain. Với đối tượng toàn cục có khởi tạo không tầm thường, hãy giữ một con trỏ và xây nó ở lần truy cập đầu, hoặc dùng static cục bộ hàm, để việc chạy ngoài DllMain.
- Tôi có nên gọi DisableThreadLibraryCalls không?
- Có điều kiện, có. Nếu DLL không cần thông báo DLL_THREAD_ATTACH/DETACH, gọi DisableThreadLibraryCalls trong DLL_PROCESS_ATTACH dừng các thông báo theo mỗi lần tạo luồng và mỗi lần thoát luồng, và giảm chi phí trong tiến trình tạo luồng thường xuyên. Có hai ngoại lệ. Đừng gọi nó từ DLL liên kết với CRT tĩnh (CRT tĩnh cần các thông báo luồng). Và nếu TLS tĩnh qua thread_local hoặc __declspec(thread) đang có hiệu lực, bản thân lời gọi thất bại và trả về FALSE, nên hãy có thói quen kiểm tra giá trị trả về. Dùng nó trên DLL điển hình dùng CRT liên kết động, sau khi bạn đã xác nhận không có gì phụ thuộc vào thông báo luồng.
- Vì sao DLL C++/CLI (hỗn hợp managed) treo lúc khởi động?
- Nguyên nhân điển hình là cố chạy MSIL (mã managed) trong khi loader lock đang được giữ. Trong assembly hỗn hợp C++/CLI, nếu DllMain, các hàm được gọi từ nó, hoặc initializer động của biến toàn cục được biên dịch thành MSIL, khởi tạo CLR hoặc việc nạp assembly khác có thể bị đòi hỏi dưới loader lock, và điều đó có thể deadlock. Trình biên dịch phát cảnh báo C4747 khi bản thân DllMain cố thực thi MSIL trực tiếp, nhưng nó không phát hiện được thực thi gián tiếp qua mô-đun khác. Cách giảm thiểu là biên dịch DllMain và cây gọi của nó thành native bằng #pragma unmanaged — hoặc không có DllMain nào cả.
- Tôi có được dọn tài nguyên trong DLL_PROCESS_DETACH không?
- Câu trả lời đổi giữa "thoát tiến trình" và "unload qua FreeLibrary". Trên DLL_PROCESS_DETACH lúc thoát tiến trình, các luồng khác đã bị chấm dứt, và không có bảo đảm không gian địa chỉ vẫn nhất quán, nên dọn như giải phóng bộ nhớ thực ra nguy hiểm; hướng dẫn chính thức là "handler lý tưởng là rỗng". Ghi ra mọi dữ liệu phải được bền vững trên đường tắt riêng của ứng dụng, và ở đây về cơ bản đừng làm gì rồi trở về. Trên lần unload qua FreeLibrary, tiến trình tiếp tục, nên bạn cần dọn hoàn chỉnh — dừng luồng, đóng handle, và tương tự. Chờ một luồng thoát bên trong DllMain thì deadlock, tuy nhiên, nên bạn phải theo giao thức chính thức: tín hiệu, chờ tới trạng thái nhất quán, và hoàn tất việc ngoài DllMain.