DllMain và Loader Lock — Lý do thật sự bạn bị bảo "đừng làm gì trong khởi tạo DLL"

· · 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 / FreeLibrary bị 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 DllMain cho 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, DllMain và 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.

Bốn thời điểm DllMain được gọiDLL_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 CRTNạp DLLDLL_PROCESS_ATTACHDLL_THREAD_ATTACH(mỗi lần khởi động luồng)DLL_THREAD_DETACH(mỗi lần thoát luồng)DLL_PROCESS_DETACH(khi unload hoặc thoát)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 LoadLibrary vì 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
Vì sao chờ một luồng bên trong DllMain deadlockDllMain, đ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à deadlockWorkerDllMainLoader(giữ khóa)WorkerDllMainLoader(giữ khóa)Thông báo thoát cần khóaDllMain giữ khóa, W chờDLL_PROCESS_DETACHYêu cầu thoát rồi chờXong việc, rồi thoát

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

Đảo thứ tự khóa giữa loader lock và khóa riêngDllMain, đ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 deadlockDllMain: đang giữ loader lockĐi lấy khóa riêng GWorker: đang giữ khóa riêng GĐi lấy loader lockDeadlock do thứ tự lấy đảoGetModuleHandle 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.

Đường mà khởi tạo đối tượng toàn cục trở thành mìnLoader 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 DllMainNạp DLL(lấy loader lock)Điểm vào CRTConstructor của đối tượng toàn cụcViệc tương đương LoadLibraryKhởi động luồng rồi chờ nó xongDùng COM hoặc User32Tất cả chúng nằm dưới lệnh cấm DllMain

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

Liệu thực thi MSIL dưới loader lock có thể được phát hiện khôngMã 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 nativeLời gọi từ DllMainThực thi MSIL trực tiếpThực thi qua mô-đun khácPhát hiện được bằng cảnh báo C4747Trình biên dịch không phát hiện đượcNgă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

  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.
  2. 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ừ DllMain hoặc initializer tĩnh, initializer vẫn chạy dưới loader lock và bạn trở lại cùng hạn chế.
  3. 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”.
  4. Cân nhắc DisableThreadLibraryCalls trong 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
  5. Kiểm tra bằng Application Verifier. Nhiều lời gọi nguy hiểm bên trong DllMain là những thứ Application Verifier sẽ phát hiện lúc chạy.1
Hướng dẫn thiết kế cho khởi tạo DLLTrướ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ạpkhôngkhôngCó thể quyết định lúc biên dịch?Làm thành khởi tạo tĩnhThất bại phải được phát hiện lúc nạp?Hoãn tới lần dùng đầu(mặc định)Chỉ làm mức tối thiểu trong DllMainLoạ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.

Có nên gọi DisableThreadLibraryCallsĐừ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áokhôngkhôngkhôngLiên kết với CRT tĩnh?Không được gọiĐang dùng TLS tĩnh?Lời gọi vẫn thất bại(FALSE)Thông báo luồng có cần không?Gọi trong ATTACH(kiểm tra giá trị trả về)Đừ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”.

Giao thức dừng luồng lúc unloadDllMain 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ồngLuồng workerDllMain(xử lý DETACH)Luồng workerDllMain(xử lý DETACH)Không chờ thoát tự nhiên, nên không deadlockTín hiệu thoát bằng sự kiệnGấp việc xuống trạng thái nhất quánTín hiệu nhất quán xong rồi chờ mãiChấm dứt bằng TerminateThread

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.

Dấu vân tay của treo loader lockTrong 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 đốikhôngDump treoLuồng chờ khóa trong hàm họ LdrLuồng chờ bên trong DllMain hoặc initializer tĩnhCả hai có mặt?Gần như chắc deadlock loader lockĐ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 DisableThreadLibraryCalls và 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

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ể”.

Liên kết tham khảo

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

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

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

  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

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

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

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.

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.

Bài viết liên quan trực tiếp đến các dịch vụ sau.

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.

Hồ sơ tác giả

Trang giới thiệu tác giả bài viết.

Go Komura

Đại diện của KomuraSoft LLC

Chuyên về phát triển phần mềm Windows, tư vấn kỹ thuật và điều tra lỗi, đặc biệt trong các dự án có hệ thống hiện hữu và lỗi khó tái hiện.

Liên kết công khai

Quay lại blog