COM高手總結的八個經驗和教訓

在日常工作中,我看到過許多由不同開發人員編寫的 COM 代碼。我爲許多富於創造性的使用 COM 的工作方式感到驚訝,有一些使 COM 工作的巧妙代碼可能連Microsoft 都沒有想到。同樣,看到一些錯誤一次又一次地重犯,使我免不了心灰意懶。這些錯誤很多都與線程和安全有關,完全不成比例,而這也正是 COM 文檔資料中最缺少的兩個領域。如果不仔細計劃,它們也是最可能遇到的並可能會絆住您的兩個領域。

  在下面的篇幅中,您將讀到八位程序員的記述,這些教訓都來自他們的痛苦經歷。每個故事都是真實的,但爲了保護無辜者,名字都已隱去。我的目的是,通過這些真實的 COM 故事,使您不再重蹈其他 COM 程序員的覆轍。它們還可能會幫助您在編寫的代碼中找出存在潛在問題的地方。無論情況如何,我想您都會獲得愉快的閱讀體驗。

  總是調用 CoInitialize(Ex)

  幾個月前,我收到了一封朋友的電子郵件,他就職於一家著名的硬件公司。他的公司編寫了一個非常複雜的基於 COM 的應用程序,其中使用了許多進程內和本地(進程外)的 COM 組件。在開始時,應用程序創建了 COM 對象以服務於運行在多線程單元 (MTA) 中的各種客戶端線程。該對象還可以託管給 MTA,這意味着接口指針可以在客戶端線程之間自由交換。在測試中,我的朋友發現在應用程序準備關閉之前,一切都進行得不錯。然後,不知是什麼原因,對 Release 的調用(必須執行此調用,以便正確釋放客戶端佔用的接口指針)被鎖定了。他的問題是:“到底是哪裏出了問題?”

  其實答案非常簡單。應用程序的開發人員其他都做得很對,只有一點例外,而這點又非常重要:他們沒有在所有的客戶端線程中調用 CoInitialize 或 CoInitializeEx。現代 COM 的基本原則之一,就是每個使用 COM 的線程都應該先調用 CoInitialize 或 CoInitializeEx 來初始化 COM。這條原則是無法免除的。除了其他事情以外,CoInitialize(Ex) 應將線程放入單元中,並初始化重要的每線程狀態信息(這對於 COM 的正確操作是必需的)。調用 CoInitialize(Ex) 失敗通常會在應用程序生命期早期以失敗的 COM API 函數的形式表現出來,最常見的是激活請求。但有時問題很隱蔽,直到一切都太晚了(例如對 Release 的調用一去不復返了)才表現出來。當開發小組將 CoInitialize(Ex) 調用添加到所有接觸 COM 的線程之後,他們的問題就迎刃而解了。

  具有諷刺意義的是,Microsoft 竟是 COM 程序員有時不調用 CoInitialize(Ex) 的原因之一。Microsoft 知識庫中包含的一些文檔中說,調用 CoInitialize(Ex) 對基於 MTA 的線程來說不是必需的(有關示例,請參閱文章 Q150777)。是的,在很多情況下,我們可以跳過 CoInitialize(Ex) 而不會出現問題。但是,這樣是不應該的,除非您知道自己在幹什麼,並且可以絕對肯定自己不會受到負面影響。調用 CoInitialize(Ex) 是沒有害處的,因此我建議 COM 程序員始終從某個與 COM 相關的線程中調用它。

  不要在線程之間傳遞原始接口指針

  我諮詢的首批 COM 項目之一就涉及到一個包含 100,000 行代碼的分佈式應用程序,該程序是由美國西海岸的一個大型軟件公司編寫的。該應用程序在多個機器上創建了數十個 COM 對象,並從客戶端進程啓動的背景線程中調用這些對象。開發小組遇到問題了,調用要麼消失得無影無蹤,要麼在沒有明顯原因的情況下失敗。他們給我演示的最驚人的症狀是:當一個調用無法返回時,在同一臺機器上啓動其他支持 COM 的應用程序(包括 Microsoft Paint 等)會頻繁導致這些應用程序被鎖定。

  檢查他們的代碼後發現,他們違反了 COM 併發的一個基本規則,就是說,如果一個線程要與另一個線程共享一個接口指針,它應首先封送該接口指針。如果有必要,封送接口指針可使 COM 創建一個新的代理(以及一個新的信道對象,將代理和存根結對),以允許從另一個單元向外調用。不通過封送而將原始接口指針(內存中的一個 32 位地址)傳遞給另一個線程,會繞過 COM 的併發機制,並且如果發送和接收的線程位於不同的單元中,將出現各種不良行爲。(在 Windows 2000 中,由於兩個對象可以共享一個單元,但又位於不同的上下文中,因此如果線程位於同一個單元中,可能會使您陷入困境。)典型的症狀包括調用失敗和返回 RPC_E_WRONG_THREAD_ERROR。

   Windows NT 4.0 和更高版本可以使用一對名爲 CoMarshalInterThreadInterfaceInStream 和 CoGetInterfaceAndReleaseStream 的 API 函數,在線程之間輕鬆地封送接口指針。假定您應用程序中的一個線程(線程 A)創建了一個 COM 對象,繼而接收了一個 IFoo 接口指針,並且同一進程中的另一個線程(線程 B)想調用這個對象。在準備將接口指針傳遞給線程 B 時,線程 A 應該封送該接口指針,如下所示:

CoMarshalInterThreadInterfaceInStream (IID_IFoo, pFoo, &pStream);


  在 CoMarshalInterThreadInterfaceInStream 返回後,線程 B 就可以安全地取消封送該接口指針:

IFoo* pFoo;
CoGetInterfaceAndReleaseStream (pStream, IID_IFoo, (void**) &pFoo);


  在這些示例中,pFoo 是一個 IFoo 接口指針,pStream 是一個 IStream 接口指針。COM 在調用 CoMarshalInterThreadInterfaceInStream 時初始化 IStream 接口指針,然後在 CoGetInterfaceAndReleaseStream 內部使用和釋放該接口指針。實際上,您通常要使用一個事件或其他同步化基元來協調這兩個線程的行爲 — 例如,讓線程 B 知道接口指針已準備好,可以取消封送。

  請注意,以這種方式封送接口指針不會出現任何問題,因爲 COM 有足夠的智能,在不需要進行封送時不會去封送(或重新封送)指針。如果在線程之間傳遞接口指針時這樣做,使用 COM 就輕鬆多了。

  如果調用 CoMarshalInterThreadInterfaceInStream 和 CoGetInterfaceAndReleaseStream 看起來太麻煩,您還可以通過將接口指針放在全局接口表 (GIT) 中,並讓其他線程去那裏檢索它們,從而實現在線程之間傳遞接口指針。從 GIT 中檢索到的接口指針在被檢索時會自動封送。更多信息,請參閱 IGlobalInterfaceTable 中的文檔。請注意,GIT 只存在於 Windows NT 4.0 Service Pack 4 及更高版本中。

STA 線程需要消息循環

  上一部分中描述的應用程序還有另一個致命缺陷。看看您是否能指出來。

  這個特殊的應用程序恰好是用 MFC 編寫的。在一開始,它使用了 MFC 的 AfxBeginThread 函數啓動一系列輔助線程。每個輔助線程要麼調用 CoInitialize 要麼調用 AfxOleInit(MFC 中類似 CoInitialize 的函數)來初始化 COM。某些輔助線程則調用 CoCreateInstance 來創建 COM 對象,並將所返回的接口指針封送到其他輔助線程。從創建這些對象的線程中調用對象將非常順利,但從其他線程的調用卻從不返回。您知道這是爲什麼嗎?

  如果您認爲問題與消息循環(或缺少消息循環)相關,那麼答案完全正確。事實確實如此。當一個線程調用 CoInitialize 或 AfxOleInit 時,它是放在單線程單元 (STA) 中。當 COM 創建一個 STA 時,它會創建一個隨附的隱藏窗口。以 STA 中的對象爲目標的方法調用將轉換爲消息,並放入與該 STA 關聯的窗口的消息隊列中。當運行在該 STA 中的線程檢索到代表方法調用的消息時,隱藏窗口的窗口過程就會將消息轉換回方法調用。COM 使用 STA 執行調用序列化。STA 中的對象一次不可能接收一個以上的調用,因爲每個調用都要傳遞給一個而且是惟一一個運行在對象單元中的線程。

  如果基於 STA 的線程無法處理消息會怎麼樣呢?如果它沒有消息循環又會怎麼樣呢?針對該 STA 中對象的單元間方法調用將不再返回;它們將在消息隊列中被永遠擱置。MFC 輔助線程中沒有消息循環,因此如果寄宿在這些 STA 中的對象要從其他單元的客戶端接收方法調用,那麼 MFC 輔助線程和 STA 是配合不好的。

  這個故事的寓意何在呢?STA 線程需要消息循環,除非您肯定它們不會包含要從其他線程調用的對象。消息循環可以像這樣簡單:

MSG msg;
while (GetMessage (&msg, 0, 0, 0))
DispatchMessage (&msg);

  另一種方案是將 COM 線程移到 MTA 中(或者在 Windows 2000 中,移到中立線程單元,即 NTA 中),這裏沒有消息隊列依賴項。

  單元模型對象必須保護共享數據

  另一個困擾 COM 開發人員的通病是標記爲 ThreadingModel=Apartment 的進程內對象。這項指定告訴 COM,對象的實例必須只能在 STA 中創建。它還可讓 COM 自由地將這些對象實例放在任何主機進程的 STA 中。

  假設客戶端應用程序有五個 STA 線程,每個線程都使用 CoCreateInstance 來創建同一個對象的一個實例。如果線程是基於 STA 的,且對象標記爲 ThreadingModel=Apartment,則這五個對象實例將在對象創建者的 STA 中創建。因爲每個對象實例都在佔用其 STA 的線程上運行,因此所有五個對象實例都可以並行運行。

  到目前爲止,一切良好。現在考慮一下,如果這些對象實例共享數據會發生什麼情況。因爲對象都在併發線程上執行,兩個或更多的對象可能會同時嘗試訪問同一個數據。除非所有這些訪問都是讀取訪問,否則就會釀成災難。問題可能不會很快顯現出來;它們會以和時間緊密相關的錯誤形式出現,因此很難診斷和重現。這就解釋了以下事實的原因:ThreadingModel=Apartment 對象應該包括可同步對共享數據的訪問的代碼,除非您能夠確定對象的客戶端不會對執行訪問的方法進行重疊調用。

  問題在於,太多的 COM 開發人員相信 ThreadingModel=Apartment 能夠使他們免於編寫線程安全的代碼。事實並非如此 — 至少不完全如此。ThreadingModel=Apartment 並不意味着對象必須是完全線程安全的,它代表的是一個對 COM 的承諾,即訪問兩個或更多對象實例共享的數據(或此對象和其他對象的實例共享的數據)時是以線程安全的方式進行的。而提供該線程安全性的任務應該由您,即對象實現者來負責。共享數據的類型和大小多種多樣,但大多是以全局變量、C++ 類中的靜態成員變量和函數中聲明的靜態變量的形式出現。即使是以下這樣無害的語句也會在 STA 中出問題:

static int nCallCount = 0;
nCallCount++;

  因爲這個對象的所有實例都將共享一個 nCallCount 實例,編寫這些語句的正確方式如下:

static int nCallCount = 0;
InterlockIncrement (&nCallCount);

  注意:您可以使用臨界區、互鎖函數或您希望的任何方式,但不要忘了訪問基於 STA 的對象共享的數據時要進行同步化!

  謹慎啓動用戶

  這裏還有一個問題讓許多 COM 開發人員都吃過苦頭。去年春天,有一家公司向我緊急呼救,他們的開發人員使用 COM 構建了一個分佈式應用程序,其中客戶端進程運行在與遠程服務器的 Singleton 對象相連接的網絡工作站上。在測試過程中,他們遇到了一些非常奇怪的行爲。在一種測試場景中,客戶端對 CoCreateInstanceEx 的調用可使它們與 Singleton 對象正常連接。而在另一個場景中,對 CoCreateInstanceEx 的相同調用產生了多個對象實例和多個服務器進程,使客戶端無法與同一個對象   實例連接,從而實際影響了應用程序。在這兩個場景中,硬件和軟件是完全相同的。

  此問題似乎與安全有關。當處理遠程激活請求的 COM 服務控制管理器 (SCM) 在另一臺機器上啓動一個進程時,它會爲該進程分配一個標識。除非另外指定,它選擇的標識就是啓動用戶的標識。換句話說,分配給服務器進程的標識與啓動它的客戶端進程的標識相同。在這種情況下,如果 Bob 登錄機器 A,並使用 CoCreateInstanceEx 連接機器 B 上的 Singleton 對象,而 Alice 也在機器 C 上如法炮製,就會啓動兩個不同的服務器進程(至少在兩臺不同的 WinStation 上),實際上使客戶端無法再用 Singleton 語義與共享的對象實例連接。

  兩個測試場景之所以會產生大相徑庭的結果,其原因就是在一個場景(那個可以工作的場景)中,所有測試人員都使用只爲測試而設置的一個特殊帳戶以同一個人的身份登錄。而在另一個場景中,測試人員都使用他們的普通用戶帳戶登錄。當兩個或更多的客戶端進程具有相同標識時,它們可以成功連接到配置爲假定啓動用戶標識的服務器進程。但是,如果客戶端有不同的標識,SCM 會使用多個服務器進程(每個唯一客戶端標識一個)分隔分配給不同對象實例的標識。


圖 1 DCOMCNFG 中的用戶帳戶

  找到問題以後,解決起來就很簡單了:配置 COM 服務器,讓其使用特定的用戶帳戶而不是假定啓動用戶的標識。完成這一任務的一種方式是在服務器機器上運行 DCOMCNFG(Microsoft 的 DCOM 配置工具),並將“launching user ”更改爲“This user”(請參見圖 1)。如果您喜歡通過編程方式進行更改(可能從安裝程序着手),請在主機註冊表的 HKEY_CLASSES_ROOT/AppID 部分的 COM 服務器項中添加 RunAs 值(請參見圖 2)。


圖 2 添加 RunAs 值到註冊表中

  您還需要使用 LsaStorePrivateData 將 RunAs 帳戶的密碼存儲爲 LSA 密鑰,並使用 LsaAddAccountRights 確保帳戶擁有“Logon as batch job”的權限。(有關具體操作的示例,請參見 Platform SDK 中的 DCOMPERM 示例。請特別注意名爲 SetRunAsPassword 和 SetAccountRights 的函數。)

DCOM 不適於防火牆

  關於 DCOM 特性和功能的一個常見問題是:“它能跨 Internet 工作嗎?”DCOM 能夠很好地跨 Internet 工作,只要將它配置爲使用 TCP 或者 UDP,並且通過授予任何人啓動和訪問權限,可將服務器配置爲允許匿名方法調用。畢竟,Internet 是一個巨大的 IP 網絡。但矛盾的是,如果您將一個現有的 DCOM 應用程序(在公司的內部網絡或 intranet 中工作得很好)改爲跨 Internet 工作,它很有可能失敗得很慘。可能是什麼原因呢?防火牆。

  DCOM 生來與防火牆的關係就如油與水的關係。原因之一是 COM 的 SCM 使用端口 135 與其他機器上的 SCM 通信。防火牆限制了它可以使用的端口和協議,可能會拒絕通過端口 135 傳入的通信量。但更大的問題在於,爲了避免與使用套接字、管道和其他 IPC 機制的應用程序衝突,DCOM 沒有固定使用特定範圍的端口,相反,它在運行時才選擇所使用的端口。默認情況下,它可以使用從 1,024 到 65,535 範圍內的任何端口。

  允許 DCOM 應用程序通過防火牆的一種方式是,爲 DCOM 要使用的協議打開端口 135 和端口 1,024-65,535。(默認情況下,Windows NT 4.0 是 UDP 協議,Windows 2000 是 TCP 協議。)但是,這比移除所有防火牆好不了多少。對此,您公司的 IT 人員可能要發表意見了。

  另一種更安全和更現實的解決方案是,限制 DCOM 使用的端口範圍,並只爲 DCOM 通信量打開一組小範圍端口。根據實踐原則,您應該爲每個服務器進程分配一個端口,將連接導出到遠程 COM 客戶端(不是每個接口指針一個端口或每個對象一個端口,而是每個服務器進程一個)。將 DCOM 配置爲使用 TCP 而不是 UDP 是一個好方法,特別是在服務器對其客戶端執行回調時。

  DCOM 用於遠程連接的端口範圍和所用的協議可通過註冊表進行配置。在 Windows 2000 和 Windows NT 4.0 Service Pack 4 或更高版本上,您可以用 DCOMCNFG 應用這些配置更改。以下是將 DCOM 配置爲通過防火牆工作的辦法。


圖 3 選擇協議

  在服務器(在防火牆後寄存遠程對象的機器)上,將 DCOM 配置爲使用 TCP 作爲其所選協議,如圖 3 中所示。

  在服務器上,限制 DCOM 將使用的端口範圍。記住爲每個服務器進程至少分配一個端口。圖 4 中的示例將 DCOM 限制爲端口 8,192 到 8,195。

  打開您在步驟 2 中選擇的端口,使 TCP 通信量能夠通過防火牆。同時打開端口 135。


圖 4 選擇端口

  執行這些步驟,DCOM 就可以很好地跨防火牆工作了。如果您願意,SP4 和更高版本還可讓您爲單獨的 COM 服務器指定終結點。更多信息,請閱讀 Michael Nelson 關於 DCOM 和防火牆的優秀論文,該論文可在 MSDN Online 站點上找到(請參見 http://msdn.microsoft.com/library/enus/dndcom/html/msdn_dcomfirewall.asp)。

  還應注意的是,通過在服務器上安裝 Internet 信息服務 (IIS),並使用 COM Internet 服務 (CIS) 通過端口 80 路由 DCOM 通信量,SP4 和更高版本的用戶還可以使用 CIS 來提供與防火牆兼容的 DCOM。有關該主題的更多信息,請參閱 http://msdn.microsoft.com/library/en-us/dndcom/html/cis.asp。

使用線程或異步調用來避免 DCOM 超時設定太長

  總是有人問我當 DCOM 無法完成遠程實例化請求或方法調用時出現的超時設定太長的問題。典型的場景如下:客戶端調用 CoCreateInstanceEx 來實例化遠程機器上的一個對象,但是這臺機器臨時離線了。在 Windows NT 4.0 上,激活請求不會立即失敗,DCOM 可能會花上一分鐘或更長時間來返回失敗的 HRESULT。DCOM 還可能花費很長時間,使指向已不再存在或其主機已離線的遠程對象的方法調用失敗。如果可能,開發人員應該如何避免這些較長的超時設定呢?

  要回答這個問題,幾句話是講不清楚的。DCOM 高度依賴於基礎網絡協議和 RPC 子系統。並沒有什麼神奇的設置可讓您限制 DCOM 超時設定的持續時間。但是,我經常使用兩種技巧來避免較長超時設定的負作用。

  在 Windows 2000 中,當調用在 COM 信道中掛起時,您可以使用異步方法調用來釋放調用線程。(有關異步方法調用的介紹,請參 MSDN Magazine 2000 年 4 月刊的“Windows 2000: Asynchronous Method Calls Eliminate the Wait for COM Clients and Servers Alike”。如果異步調用在合理時間內沒有返回,您可以通過調用用於初始化調用的調用對象上的 ICancelMethodCalls::Cancel 來取消它。

   Windows NT 4.0 不支持異步方法調用,甚至在 Windows 2000 中也不支持異步激活請求。怎麼解決呢?從背景線程調用遠程對象(或是實例化該對象的請求)。使主線程在事件對象上阻塞,並指定超時設定值以反映您願意等待的時間長度。當調用返回時,讓背景線程來設置事件。假設主線程使用 WaitForSingleObject 阻塞,當 WaitForSingleObject 返回時,返回值可以告訴您,返回是因爲方法調用或激活請求返回,還是因爲您在 WaitForSingleObject 調用中指定的超時設定到期。您不能在 Windows NT 4.0 中取消掛起調用,但是至少主線程可以自由地執行自己的任務。

  下面的代碼演示了基於 Windows NT 4.0 的客戶端如何才能從背景線程調用對象。

//////////////////////////////////////////////////////
// Placing a Method Call from a Background Thread
///////////////////////////////////////////////////// 
HANDLE g_hEvent;
IStream* g_pStream;

// Thread A
g_hEvent = CreateEvent (NULL, FALSE, FALSE, NULL);
CoMarshalInterThreadInterfaceInStream (IID_IFoo, pFoo, &g_pStream);
DWORD dwThreadID;
CreateThread (NULL, 0, ThreadFunc, NULL, 0, &dwThreadID);
DWORD dw = WaitForSingleObject (g_hEvent, 5000);
if (dw == WAIT_TIMEOUT) {
 // Call timed out
}
else {
 // Call completed
}
...

// Thread B
IFoo* pFoo;
CoGetInterfaceAndReleaseStream (g_pStream, IID_IFoo, (void**) &pFoo);
pFoo->Bar (); // Make the call!
SetEvent (g_hEvent);
CloseHandle (g_hEvent);

  在此示例中,線程 A 封送了一個 IFoo 接口指針,並啓動線程 B。線程 B 取消封送了該接口指針,並調用 IFoo::Bar。無論調用返回所花費的時間有多長,線程 A 都不會阻塞超過 5 秒鐘,因爲它在 WaitForSingleObject 的第二個參數中傳遞的是 5,000 (單位爲微秒)。這並不是太好的辦法,但是如果“無論在線路的另一端發生什麼情況,線程 A 都不會掛起”這一點很重要的話,忍受這種麻煩也算值得。

共享對象並不容易

  從我收到的郵件和在會議上被問到的問題判斷,困擾許多 COM 程序員的一個問題是如何將兩個或更多的客戶端與一個對象實例連接。要回答這個問題,寫出長篇大論(或是一本小冊子)都很容易,但其實只要說明與現有對象的連接既不容易也不自動化,就足夠了。COM 提供了大量創建對象的方式,包括很受歡迎的 CoCreateInstance(Ex) 函數。但是 COM 缺乏一種通用的命名服務,允許使用名稱或 GUID 來標識對象實例。而且它沒有提供內置的方式來創建對象,然後將它標識爲調用的目標以檢索接口指針。

  這是不是意味着將多個客戶端與單一對象實例連接就不可能了呢?當然不是。實現這一點有五種方式。在這些資源鏈接中,您可以找到更多信息甚至是示例代碼,來指導您的操作。請注意,這些技術從一般意義上講不能互換;通常,環境因素會決定哪種方式(如果有)適用於手邊的任務: Singleton 對象 Singleton 對象就是隻實例化一次的對象。可能會有 10 個客戶端調用 CoCreateInstance 來“創建”Singleton 對象,但實際上,它們都是接收指向同一對象的接口指針。ATL COM 類可通過在其類的聲明中添加 DECLARE_CLASSFACTORY_SINGLETON 語句,來轉換爲 Singleton。

  文件名字對象 如果一個對象實現了 IpersistFile,並在運行中對象表 (ROT) 中使用文件名字對象(它封裝了傳遞給對象的 IPersistFile::Load 方法的文件名稱)註冊了自己,那麼客戶端就可以使用文件名字對象連接對象的現有實例了。實際上,文件名字對象允許使用文件名稱來命名對象實例,對象可在這些文件名稱中存儲它們的持久性數據。它們甚至能夠跨機器工作。

  CoMarshalInterface 和 CoUnmarshalInterface 保存接口指針的 COM 客戶端可以與其他客戶端共享這些接口指針,只要它們願意封送指針。COM 爲願意將接口指針封送給同一進程中其他線程的線程提供了優化(請參見教訓 2),但是如果客戶端線程屬於其他進程,CoMarshalInterface 和 CoUnmarshalInterface 就是實現接口共享的關鍵途徑了。有關討論和示例代碼,請參閱 MSJ 1999 年 8 月刊中我的超酷代碼專欄。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章