共用方式為


Dynamic-Link 資料庫最佳做法

**更新:**

  • 2006年5月17日

重要 API

建立 DLL 會為開發人員提供一些挑戰。 DLL 沒有系統強制執行的版本設定。 當系統上存在多個 DLL 版本時,在缺少版本設定架構的情況下,覆寫的簡易性會建立相依性和 API 衝突。 開發環境中的複雜性、載入器實作和 DLL 相依性已建立負載順序和應用程式行為的脆弱性。 最後,許多應用程式都依賴 DLL,而且具有必須接受的複雜相依性集合,應用程式才能正常運作。 本檔提供 DLL 開發人員的指導方針,可協助建置更強固、可攜式和可延伸 DLL。

DllMain 內的不當同步處理可能會導致應用程式死結或存取未初始化 DLL 中的數據或程式代碼。 於 DllMain 呼叫特定函式會造成這類問題。

載入程式庫時會發生什麼事

一般最佳做法

載入器鎖定時,呼叫 DllMain。 因此,會對可在 DllMain內呼叫的函式施加重大限制。 因此,DllMain 的設計目的是使用Microsoft® Windows® API 的一小部分來執行最少的初始化工作。 您無法在 DllMain 中呼叫任何直接或間接嘗試獲取載入器鎖定的函式。 否則,您將導入應用程式死結或當機的可能性。 DllMain 實作中的錯誤可能會危及整個進程及其所有線程。

理想的 DllMain 只是空的存根。 不過,鑒於許多應用程式的複雜性,這通常太嚴格。 在 DllMain 中,一個好的經驗法則是儘量延遲初始化。 延遲初始化會增加應用程式的健全性,因為初始化不是在載入器鎖定時執行的。 此外,延遲初始化可讓您安全地使用更多 Windows API。

某些初始化工作無法延後。 例如,如果檔案格式不正確或包含垃圾,則相依於組態檔的 DLL 應該無法載入。 針對這種類型的初始化,DLL 應該嘗試動作並快速失敗,而不是藉由完成其他工作來浪費資源。

您不應該從 DllMain內執行下列工作:

  • 呼叫 LoadLibraryLoadLibraryEx (直接或間接)。 這可能會導致死結或當機。
  • 請直接或間接呼叫 GetStringTypeAGetStringTypeEx,或 GetStringTypeW。 這可能會導致死結或當機。
  • 與其他線程同步處理。 這可能會導致死結。
  • 取得正在等待獲取加載鎖的程式代碼所擁有的同步處理物件。 這可能會導致死結。
  • 使用 CoInitializeEx初始化 COM 線程。 在某些情況下,此函式可以呼叫 LoadLibraryEx
  • 呼叫登錄函式。
  • 呼叫 CreateProcess 。 建立程序時可以載入另一個 DLL。
  • 呼叫 ExitThread。 在 DLL 卸載期間結束執行緒可能會導致載入器鎖重新取得,造成死鎖或崩潰。
  • 呼叫 CreateThread 。 如果您未與其他執行緒同步,建立執行緒可能會有作用,但是有風險。
  • 呼叫 ShGetFolderPathW。 呼叫 Shell/已知資料夾 API 可能會導致執行緒同步處理,因此可能會導致死結。
  • 建立命名管道或其他具名物件(僅限 Windows 2000)。 在 Windows 2000 中,終端機服務 DLL 會提供具名物件。 如果未初始化此 DLL,對 DLL 的呼叫可能會導致進程當機。
  • 使用動態 C Run-Time (CRT) 中的記憶體管理功能。 如果未初始化CRT DLL,對這些函式的呼叫可能會導致進程當機。
  • 在 User32.dll 或 Gdi32.dll中呼叫函式。 某些函式會載入另一個 DLL,但可能無法初始化。
  • 使用托管代碼。

在 dllMain 內執行下列工作是安全的:

  • 在編譯時期初始化靜態數據結構和成員。
  • 建立和初始化同步處理物件。
  • 配置記憶體並初始化動態數據結構(避免上述函式)。
  • 設定線程本機記憶體 (TLS)。
  • 開啟、讀取和寫入檔案。
  • 呼叫 Kernel32.dll 中的函式(除了上面所列的函式除外)。
  • 將全域指標設定為 NULL,以推遲動態成員的初始化。 在 Microsoft Windows Vista™ 中,您可以使用一次性初始化函式來確保在多線程環境中只執行一次程式碼區塊。

鎖定順序反轉所造成的死結

當您撰寫使用多個同步物件(例如鎖)的程式碼時,務必遵從鎖的順序。 當需要同時取得多個鎖時,您必須定義一個稱為鎖層次或鎖順序的明確優先級。 例如,如果在程式代碼中某處鎖定 B 之前取得鎖定 A,並在程式代碼中其他地方鎖定 C 之前取得鎖定 B,則鎖定順序為 A、B、C,而且應該在整個程式碼中遵循此順序。 鎖定順序反轉會在未遵循鎖定順序時發生。例如,如果先獲得鎖定 B 再獲得鎖定 A,就可能會導致死結,而這種死結常常難以排除。 若要避免這類問題,所有線程都必須以相同順序取得鎖定。

請務必注意,載入器在已取得載入器鎖定的情況下呼叫 DllMain,因此在鎖定階層中,載入器鎖定應具有最高優先順序。 另請注意,程序代碼只需要取得它所需的鎖定才能進行適當的同步處理;它不需要取得階層中定義的每個鎖定。 例如,如果程式代碼區段只需要鎖定 A 和 C 才能進行適當的同步處理,則程式代碼應該在取得鎖定 C 之前取得鎖定 A;程序代碼不需要也取得鎖定 B。此外,DLL 程式代碼無法明確取得載入器鎖定。 如果程式碼必須呼叫像是 GetModuleFileName 這類可以間接取得載入器鎖定的 API,並且程式碼也必須獲取私人鎖定,那麼程式碼應該在取得鎖定 P 之前先呼叫 GetModuleFileName,以確保遵循載入順序。

圖 2 是說明鎖定順序反轉的範例。 請考慮主線程包含 DllMain的 DLL。 函式庫載入器會取得載入器鎖 L,然後呼叫 DllMain。 主線程會建立同步處理物件 A、B 和 G,以串行化其數據結構的存取權,然後嘗試取得鎖定 G。已成功取得鎖定 G 的背景工作線程接著會呼叫 GetModuleHandle 等函式,嘗試取得載入器鎖定 L。因此,背景工作線程在 L 上遭到封鎖,且主要線程在 G 上遭到封鎖,導致死結。

鎖定順序反轉所造成的死結

若要防止鎖定順序反轉所造成的死結,所有線程都應該嘗試在定義的載入順序中隨時取得同步處理物件。

同步處理的最佳做法

請考慮一個在初始化時建立工作線程的 DLL。 在 DLL 清除時,必須與所有背景工作線程同步處理,以確保數據結構處於一致狀態,然後終止背景工作線程。 目前,在多線程環境中完全解決完全同步處理和關閉 DLL 的問題並沒有任何直接的方法。 本節說明 DLL 關機期間線程同步處理目前的最佳做法。

DllMain 中進程結束期間的線程同步

  • 在程序結束時 DllMain 被呼叫時,所有程序的線程都已被強制清除,而且地址空間可能會不一致。 在此情況下,不需要同步處理。 換句話說,理想的DLL_PROCESS_DETACH處理程式是空的。
  • Windows Vista 可確保核心數據結構(環境變數、目前目錄、進程堆積等)處於一致的狀態。 不過,其他數據結構可能會損毀,因此清除記憶體不安全。
  • 需要儲存的持續性狀態必須寫入永久儲存裝置。

卸載 DLL 期間 DllMain 中的 DLL_THREAD_DETACH 執行緒同步

  • 卸除 DLL 時,不會釋放地址空間。 因此,DLL 預期會執行正常關閉。 這包括線程同步處理、開啟句柄、永續性狀態,以及配置的資源。
  • 線程同步處理很棘手,因為在 DllMain 等待線程完成可能會導致死結。 例如,DLL A 會保留載入器鎖定。 它會發出線程 T 結束的訊號,並等候線程結束。 執行緒 T 結束後,載入器會嘗試取得載入器鎖定,以在 DLL_THREAD_DETACH 的上下文中調用 DLL A 的 DllMain。 這會導致死結。 若要將死結的風險降到最低:
    • DLL A 在其 DllMain 中收到 DLL_THREAD_DETACH 訊息,並為線程 T 設定事件,發出信號使其退出。
    • 線程 T 會完成其目前的工作、使自己處於一致狀態、發出 DLL A 信號,並無限等候。 請注意,一致性檢查例程應該遵循與 DllMain 相同的限制,以避免死結。
    • DLL A 終止 T,確知其處於一致狀態。

如果 DLL 在建立所有執行緒之後被卸除,但在開始執行之前,執行緒可能會崩潰。 如果 DLL 在其 DllMain 建立線程作為初始化的一部分,某些線程可能尚未完成初始化,而且其DLL_THREAD_ATTACH訊息仍在等候傳遞至 DLL。 在此情況下,如果 DLL 已卸除,則會開始終止線程。 不過,某些執行緒可能會因載入器鎖定而被封鎖。 當 DLL 被解除映射後,其 DLL_THREAD_ATTACH 訊息才會被處理,導致程序當機。

建議

以下是建議的指導方針:

  • 使用應用程式驗證器來攔截 DllMain中最常見的錯誤。
  • 如果在 DllMain內使用私人鎖定,請定義鎖定階層並一致地使用它。 載入器鎖定必須位於這個階層的底部。
  • 確認沒有任何呼叫相依於可能尚未完全載入的另一個 DLL。
  • 在編譯階段以靜態方式執行簡單初始化,而不是在 DllMain中執行。
  • DllMain 中任何可以稍後再進行的呼叫延遲。
  • 延遲那些稍後可以再做的初始化工作。 必須儘早偵測某些錯誤狀況,讓應用程式可以正常處理錯誤。 不過,早期偵測和因此可能造成的穩健性降低之間存在取捨。 延遲初始化通常是最好的。