在日常工作中,我閱讀過無數位開發人員撰寫的 COM 程式碼。既驚嘆於許多富有創意的 COM 使用方式,也對錯誤一次次重演感到無力。這些錯誤多集中在執行緒與安全性兩大領域——恰好也是 COM 文件最缺乏、卻最容易絆倒開發者的地方。
本文不提供可直接複製的程式碼,而是透過八位程式設計師的真實經歷,揭示實作基於 COM 應用程式的正確與錯誤做法,助您避開前人踩過的坑。
一位知名硬體公司工程師遭遇詭異問題:複雜的 COM 應用程式在測試中運作正常,但在關閉時對 Release 的呼叫竟然死結。根因竟是未在所有客戶端執行緒中呼叫 CoInitialize 或 CoInitializeEx。
現代 COM 鐵律:每個使用 COM 的執行緒必須先初始化 COM。CoInitialize(Ex) 會將執行緒置入單元(Apartment)並初始化必要的執行緒狀態。缺少此呼叫有時不會立即顯現,直到對 Release 的呼叫一去不回才暴露。雖然微軟知識庫曾稱 MTA 執行緒可略過此呼叫,但除非你百分之百確定不會受影響,否則永遠呼叫它無害且必要。
一個 10 萬行的分散式應用程式,從背景執行緒呼叫多台機器上的 COM 物件,調用要么消失、要么無故失敗,甚至導致同機器上其他支援 COM 的應用程式(如畫圖)凍結。
違反了 COM 並發基本規則:若一執行緒要與另一執行緒共享介面指標,必須先封送(Marshal)該指標。未封送直接傳遞 32 位元位址會繞過 COM 並發機制,若發送與接收執行緒位於不同單元,會出現調用失敗、返回 RPC_E_WRONG_THREAD_ERROR 等詭異行為。
Windows NT 4.0+ 提供 CoMarshalInterThreadInterfaceInStream 與 CoGetInterfaceAndReleaseStream 輕鬆完成跨執行緒封送:
// 執行緒 A 封送
CoMarshalInterThreadInterfaceInStream(IID_IFoo, pFoo, &pStream);
// 執行緒 B 取消封送
IFoo* pFoo;
CoGetInterfaceAndReleaseStream(pStream, IID_IFoo, (void**)&pFoo);
也可使用全域介面表(GIT,Windows NT 4.0 SP4+),從 GIT 擷取的指標會自動封送。
上述專案另一致命缺陷:MFC 輔助執行緒呼叫 CoInitialize 或 AfxOleInit 進入 STA,創建 COM 物件並封送指標給其他執行緒。從創建執行緒呼叫物件順利,但從其他執行緒呼叫永不返回。
原因:STA 執行緒若無訊息迴圈,針對該 STA 物件的跨單元呼叫將永遠滯留在訊息佇列中。COM 在 STA 建立隱藏視窗,跨單元呼叫轉為訊息投遞至該視窗佇列,執行緒必須泵送訊息才能處理。MFC 輔助執行緒預設無訊息迴圈,故不適合寄宿需接收跨單元呼叫的物件。
解決之道:
while (GetMessage(&msg,0,0,0)) DispatchMessage(&msg);標記 ThreadingModel=Apartment 的進程內物件,實例會建立在呼叫者的 STA 中。若用戶端有五個 STA 執行緒各自 CoCreateInstance,將產生五個並行執行的實例。若這些實例共享資料(全域變數、靜態成員、函數靜態變數),除非所有存取皆為讀取,否則必須同步化存取,否則會產生難以重現的時序錯誤。
許多開發者誤以為 Apartment 模型自動保證線程安全,實則不然——它只是承諾你會以線程安全方式存取共享資料。即使 static int nCallCount++; 這樣看似無害的語句,在 STA 中也會出錯,正確寫法:
static int nCallCount = 0;
InterlockedIncrement(&nCallCount); // 或使用臨界區、互斥體
分散式應用中,客戶端連接遠端 Singleton 物件。測試時兩種場景結果截然不同:一種正常連接同一實例,另一種卻產生多個伺服器進程導致無法共享實例。差別在於測試人員登入身分不同。
DCOM SCM 在遠端啟動進程時,預設使用啟動使用者的身分。若 Bob 與 Alice 分別從不同機器啟動,SCM 會建立兩個不同身分的伺服器進程,破壞 Singleton 語義。
解決:配置 COM 伺服器使用特定使用者帳號而非啟動使用者身分。在伺服器機器執行 DCOMCNFG,將「Launching User」改為「This User」;或在登錄 HKEY_CLASSES_ROOT\AppID 加入 RunAs 值,並用 LsaStorePrivateData 儲存密碼、LsaAddAccountRights 授予「Logon as batch job」權限。

DCOM 能跨 Internet 運作(配置 TCP/UDP、授予匿名權限),但現有內網 DCOM 應用移至 Internet 多半慘烈失敗——防火牆是主因。
較安全方案:限制 DCOM 使用埠範圍,每個伺服器進程分配一個埠,配置使用 TCP(尤其需回呼時)。Windows 2000/NT 4.0 SP4+ 可用 DCOMCNFG 設定:

SP4+ 也可用 COM Internet Services (CIS) 透過埠 80 路由 DCOM 流量。
遠端機器離線時,CoCreateInstanceEx 或方法調用在 Windows NT 4.0 上可能等待一分鐘才失敗。無單一設定能限制 DCOM 超時,但有兩招緩解:
ICancelMethodCalls::Cancel 取消。WaitForSingleObject 並指定可接受超時。背景執行緒完成時觸發事件,主執行緒超時即可繼續執行,不被封鎖。// 背景執行緒調用範例 (NT 4.0)
HANDLE g_hEvent; IStream* g_pStream;
// 執行緒 A
g_hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);
CoMarshalInterThreadInterfaceInStream(IID_IFoo, pFoo, &g_pStream);
CreateThread(NULL, 0, ThreadFunc, NULL, 0, &dwThreadID);
DWORD dw = WaitForSingleObject(g_hEvent, 5000); // 5 秒超時
if (dw == WAIT_TIMEOUT) { /* 超時處理 */ }
// 執行緒 B
IFoo* pFoo;
CoGetInterfaceAndReleaseStream(g_pStream, IID_IFoo, (void**)&pFoo);
pFoo->Bar(); SetEvent(g_hEvent); CloseHandle(g_hEvent);
如何讓多個客戶端連接同一物件實例?COM 提供建立物件的多種方式,但缺乏通用命名服務,無內建方式建立物件後標記為共享目標。五種實現共享的途徑(環境決定適用性):
CoCreateInstance 皆返回同一指標。ATL 可用 DECLARE_CLASSFACTORY_SINGLETON。IPersistFile 並在 ROT 中以檔案名註冊,客戶端可用檔案名連接現有實例,甚至跨機器。IClassFactory 的自訂啟動介面,提供檢索既有實例的方法。MkParseDisplayName 取得連接的名字物件(NT 4.0 不支援跨機器)。共享前請自問:共享真的是必須的嗎?若方法調用從外部資料源(資料庫、硬體、全域變數)取得資料,為何不給每個客戶端一個實例,各自存取資料源?同步化資料源存取即可,這正是 MTS 應用程式的運作模式,已證明是極具吸引力的程式設計模型。