COM單線程套間詳解
一 簡介
進階COM工程項目經常需要跨線程傳遞對象,以在不同線程中調這些對象方法,激發它們的事件。下面這篇文章針對具有基本的com知識(比如理解IUnkown和IDispatch介面)初級com開發人員。想要瞭解com套間的讀者請進入!com套間是一個值得花時間學習和理解的專題,但為了讓讀者更好的com套間,本文只針對單線程套間分兩部分進行講解。第一部分專註於STAs(單線程套間)基本架構的理論和理解,第二部分通過眾多複雜的例子加深對單線程套間的理解。本文中我將逐一樣本跨線程調用對象方法和激發對象事件的情形。另外之所以選擇單線程套間,是因為它使用廣泛,而且線上程安全的應用中不需要複雜的同步機制。本文概架構如下:
COM套間
本文對com套間的概念、應用目的以及為什麼需要它們進行了簡單講解。也討論了套間、線程和com對象三者之間的關係,說明了套間內線程和對象彼此是如何依賴互存的。
單線程套間(STA)
這一部分是本文重點講解的內容,為了加深讀者對單線程套間的學習。在這一部分,我們會看到單線程套間規則下的線程,瞭解com如何充分使用古老的訊息迴圈。在真正討論實現單線程套間com對象和線程之前,本文列舉了單線程套間的優缺點。
EXE COM伺服器和套間
本文最後一部分主要講述了EXE COM伺服器及與套間的關係。眾所周知,DLL伺服器和EXE伺服器有一些重要的差別。也希望這一部分能使讀者意識到類工廠的重要性。為了說明重要的概念,我手寫了例子原始碼。
來吧,一起享受這篇文章吧!
二 詳解
COM套間
為了理解com是如何處理線程的,讀者需要掌握套間的概念!套間在應用中是一個邏輯容器,以使com對象可以分享遵循相同的線程訪問規則(例在所屬線程中,規則限定了對象的方法和屬性在有套間和無套間情形中是如何被調用的)。套間本質上只是一個概念,不像對象有自己的屬性和方法。沒有控制代碼類型可以引用它,更沒有可調用的API操縱它。對於新手來說,這或許是套間難理解的一個重要原因,它是如此的抽象。
如果存在CoCreateApartment()和一些諸如CoEnterApartment()的API函數,如果微軟提供帶有IApartment介面和操縱線程、對象方法的COM類,套間也許會很容易理解。從編程來說,似乎沒有切實的方式去洞察套間。為了協助新手克服剛開始學習套間遇到的困難,提供以下幾點參考:
1、套間由應用建立,沒有直接建立或者檢查它們存在的函數。
2、線程和對象也通過應用進入套間並且參與套間相關的活動,沒有現成的函數完成這一過程。
3、套間模型十分像協議,或者需遵循的規則集合。
在多線程訪問眾多com對象的作業系統中,我們如何能保證一個線程對com對象屬性、方法的調用結果不受另一線程對該對象訪問的影響?為瞭解決上述問題,com套間應運而生,它的出現就是為了保證所謂的現安全執行緒。通過套間,我們就可以保證本線程訪問對象的內部狀態免受其它線程訪問的影響。
com中包含三種套間模型:單線程套間、多線程套間和中性套間(Neutral Apartment),每一類型套間代表了一種跨線程同步對象內部狀態的機制。對於線程和對象,套間遵循以下的基本原則:
1、一個com對象僅且只能存在於一個套間。運行時對象一經建立就確定所屬套間,並且直到銷毀它一直存在於這個套間。
2、一個com線程(內部建立了 com對象或者調用了com方法的線程)也屬於一個套間。與com對象一樣,線程從建立到結束都存在於同一套間。
3、屬於相同套間的線程和對象遵循相同的線程訪問規則。套間內部方法的調用是直接完成的,不需要com額外的輔助。
4、不同套間的線程和對象遵循了不同的線程訪問規則。跨套間方法調用通過列集實現,這就需要採用proxies和stubs。
除了保證安全執行緒,套間的另一好處是透明性,對象和用戶端都不需要關心他們採用的套間模型。底層的套間實現細節(特別是列集機制)由com子系統管理,開發人員無需關心。
(二)COM對象套間模型設定
從本段開始一直到“EXE COM伺服器和套間”,期間涉及的com對象都是在DLL伺服器中實現。正如上面提到的,com對象僅屬於一個啟動並執行套間,在對象建立時就已經確定。然而首先需要明白的是一個com對象是如何關聯它的套間模型的?對於DLL伺服器中的com類,當com線程執行個體化它時,該類會參考註冊表“InProcServer32”欄位項“ThreadingModel”的字串值。這個設定有開發人員控制。比如,當你使用ATL開發com對象時,你可以指定對象在運行時採用的執行緒模式。下面列舉了執行緒模式字串值和代表的套間模型:
序號 註冊表值 套間模型
1 “Apartment” 單線程套間
2 “Single”或者空 Legacy單線程套間
3 “Free” 多線程套間
4 “Neutral” Neutral套間
5 “Both” 建立線程的套間模型
“Both”字串值說明com對象適用於單線程套間和多線程套間。
(三)COM線程套間模型設定
每一個com線程必須通過CoInitializeEx()函數並且設定參數為COINIT_APARTMENTTHREADED或者COINIT_MULTITHREADED進行自身初始化。訪問了CoInitializeEx()函數的線程即為com線程,也即線程已經進入套間。直到線程調用CoUninitialize()函數或者自身終止,才會離開套間。
單線程套間
單線程套間只能包含一個線程(因此才稱為單線程套間),但是可以包含多個對象。包含在單線程套間中的線程有一特別之處——如果線程中的對象需要匯出給其它線程,那麼它必須有自己的訊息迴圈。線程通過調用CoInitializeEx()並指定函數參數為COINIT_APARTMENTTHREADED或者簡介的調用CoInitizlize()函數(CoInitialize()函數實際上會調用參數設定為COINIT_APARTMENTTHREADED的CoInitializeEx())進入單線程套間。進入單線程套間的線程也可認為已經建立了那個套間(畢竟,在套間內沒有其它線程第一次建立它)。通過指定“Apartment”到註冊表合適位置和在單線程套間內執行個體化對象,我們可以說com對象進入單線程套間。
(一)STA線程訪問規則
STA的線程訪問規則如下:
1、所有的SAT對象如STA線程一樣屬於相同的單線程套間。
2、STA內的所有對象只接受該STA線程的方法調用。
第一點理解起來十分自然和簡單。然而,請注意在相同DLL伺服器、不同STA線程中建立的同一com類的兩個對象屬於不同的套間。訪問這兩個對象輸入跨套間,必須通過com的列集完成。至於第二點,有兩種方式可以訪問STA對象:1、STA線程內部訪問。這種情形方法的調用被序列化。2、跨線程訪問(也即跨套間)。這種情形下STA線程必須包含訊息迴圈,COM才能保證對象僅接受自身STA線程的方法訪問。
(二)STA中的訊息迴圈
擁有訊息迴圈的線程是UI線程,它關聯著一個或多個視窗,即線程擁有這些視窗。視窗對應的視窗過程由所屬線程建立。任何線程發送或者發布訊息給所有的視窗,但是只有目標視窗過程才會響應這些訊息。發往目標視窗的訊息被同步,即視窗會保證按照訊息發送或發布的順序接受處理訊息。對於Windows程式開發人員來說,視窗處理過程不必是安全執行緒的。每一個視窗訊息會轉化成原子操作,只有當前訊息處理完才能處理下一訊息。在Windows系統中,這給com帶來與生俱來的優點:使得com對象輕鬆的實現安全執行緒。COM通過發布私人訊息給對象關聯的隱藏視窗,實現外部套間對STA對象方法的調用。隱藏視窗的視窗過程安排處理對象的訪問並且把結果返回調用者。
需要注意的是當涉及到外部套間時,COM總是引入porxies和stubs,如訊息迴圈一樣兩者形成了單線程套間協議的一部分。有兩個重要的知識點需要關註:
1、系統只有當訪問來自外部套間時,採用訊息迴圈激發STA對象的方法才是可使用的。如果是來自套間內的訪問,完全不需要com的參與,STA線程自身會保證調用以串列方式執行。
2、如果STA線程從訊息佇列中擷取或分發訊息失敗,線程套間內的com對象將無法接受套間之間的訪問。
考慮到上述第2點,諸如Sleep()、WaitForSingleObject()、WaitForMultipleObjects()等影響線程訊息處理順序的函數是非常重要的。如果STA線程需要等待同步對象,為了保證訊息迴圈不被打亂,必須採取特殊處理。
實際應用中某些情形STA線程不需要包含訊息迴圈。
(三)STA優缺點
使用STA最大優點就是簡潔。對於com物件服務器來說,除了一些基本的代碼,參與的com對象和線程幾乎不需要同步代碼。com中所有方法的調用會自動串列。因STA對象總是由相同線程訪問,即具有線程相似性。正是線程相似性,使得STA對象開發人員能夠採用線程局部儲存儲存對象內部資料的狀態。VB和MFC採用這種開發技術,所以它們開發的對象是單線程套間對象。在需要支援legacy com組件的情形中採用STA是不可避免的。
任何事物都有兩面性,當然使用STA也不例外點。多個線程訪問同一com對象時STA架構嚴重降低了效能。由於每一線程訪問對象都會被序列化,所以線程必須等待其他佔用線程的返回。等待時間降低了應用響應或者效能。如果線程包含過多的對象時,STA架構也會降低效能。切記單線程套間包含一個線程和一個訊息佇列,因而STA內各個對象的訪問由訊息佇列序列化。
鑒於STA的優缺點,使用時必鬚根據實際應用情境來選擇。
(四)STA COM對象及其伺服器實現
對於開發人員,STA com對象的實現一般不必關心內部成員資料的序列化訪問。然而,單線程套間本身無法確保com DLL伺服器的全域資料和匯出的全域函數(如DllGetClassObject()和DllCanUnloadNow())安全執行緒。切記com伺服器對象可以在任何線程中建立,相同Dll伺服器中的兩個對象可以在各自獨立的STA線程中建立,這保證了無需com的序列化機制伺服器的全域資料和函數可以由兩個不同線程正確訪問。
另外,這種情形下線程的訊息迴圈也無法提供任何協助,畢竟這不是對象的內部狀態(如果是程式就要付出代價啦),而是伺服器內部狀態。由於不同線程的對象可能訪問伺服器的全域變數和全域函數,因而它們需要適當序列化。這一規則也適用於類的靜態成員函數和靜態成員變數。
一個眾所周知的com伺服器變數是全域對象計數,其由常用的全域函數DllGetClassObject()和DllCanUnloadNow()調用。API函數InterlockedIncrement()和InterlockedDecrement()可以同步不同線程對全域對象計數的訪問。
總結STA伺服器DLL實現的一般原則:
1、伺服器dll必須有安全執行緒的標準入口函數,比如函數DllGetClassObject()和DllCanUnloadNow()。
2、伺服器dll的私人全域函數必須是安全執行緒的。
3、私人全域變數(特別是全域對象計數)必須是安全執行緒的。
DllGetClassObject()函數功能是提供類對象的訪問。類對象根據CLSID返回,並且通過它的介面(通常是IClassFactory)指標引用自身。DllGetClassObject()函數在API函數CoGetClassObject()內部調用,com開發人員無需直接調用。通過類對象的CLSID建立對象執行個體(由IClassFactory::CreateInstance()),我們可以把DllGetClassObject()函數視為com對象建立的開關,請記住該函數最重要的一點:影響全域對象計數。我們調用函數DllGetClassObject(),可以獲得標誌com服務DLL包含對象是否仍舊存在和發揮功效。DllGetClassObject()函數根據全域對象計數決定傳回值,如果com伺服器dll中沒有對象存在,調用該函數可以從記憶體中卸載com伺服器DLL。
函數DLLGetClassObject()和DllCanUnLoadNow()必須是安全執行緒的以同步全域對象計數。一般來說,當一個對象建立或者銷毀的時候,全域對象計數相應增加或者減少。本文沒有對私人全域函數和全域變數的安全執行緒進行詳細介紹,只能留給專家和經驗豐富者去探討。
對於com伺服器來說,保證安全執行緒不是複雜的過程,許多情形下只需要簡單的思考。上面的這些講解對於ATL COM伺服器開發人員已經足夠(除了私人全域資料和全域函數的安全執行緒),所以他們只需要關注com對象的商務邏輯開發。
(五)STA線程實現
STA線程通過調用CoInitialize()或者CoInitializeEx(COINIT_APARTMENTTHREADED)進行初始化自身。其次,如果線程建立的對象需要匯出到其它線程(亦即其它套間),該線程需要提供訊息迴圈處理com對象隱藏視窗的訊息。切記com中隱藏視窗的視窗過程接受和處理這些私人訊息,STA線程自身不會處理。如下的程式碼範例了STA線程的架構:
DWORD WINAPI ThreadProc(LPVOID lpvParamater) { /* Initialize COM and declare this thread to be an STA thread. */ ::CoInitialize(NULL); ... ... ... /* The message loop of the thread. */ MSG msg; while (GetMessage(&msg, NULL, NULL, NULL)) { TranslateMessage(&msg);
DispatchMessage(&msg); } ::CoUninitialize(); return 0; }
STA線程十分像WinMain()函數,實際上Windows應用的WinMain()函數也運行線上程中。你可以實現像WinMain()函數一樣的STA線程,即在訊息迴圈之前建立視窗並保證視窗有合適的視窗過程。當然,在視窗過程中你可以建立和管理com對象,也可以跨套間訪問外部STA對象。如果你不想線上程中建立視窗,你仍然可以建立操縱對象並且跨套間訪問外部線程的方法。
(六)不需要訊息迴圈的STA線程
STA線程有時是不需要訊息迴圈的,比如線程僅僅建立和使用對象而不供其它套間訪問的情況。樣本如下:
int main() { ::CoInitialize(NULL); if (1) { ISimpleCOMObject1Ptr spISimpleCOMObject1; spISimpleCOMObject1.CreateInstance(__uuidof(SimpleCOMObject1)); spISimpleCOMObject1 -> Initialize(); spISimpleCOMObject1 -> Uninitialize(); } ::CoUninitialize(); return 0;
}
上面的控制台程式樣本了主線程中建立STA而不需要訊息迴圈。注意我們可以成功的調用Initialize()函數和Uninitialize()函數,因它們在STA內部調用不要列集和訊息迴圈的參與。但是,如果我們調用::CoInitializeEx(NULL, COINIT_MULTITHREADED)函數,main()函數變成了多線程套間,同時程式發生了以下變化:
1、Initialize()函數和Uninitialize()函數的調用需要com列集的協助。
2、com對象spISimpleCOMObject1屬於com子系統建立的預設STA套間而不是MTA套間。
3、main()線程本身仍然不要訊息迴圈,但是預設STA需要。
4、調用Initialize()函數和Uninitialize()函數需要訊息迴圈參與。
切記如果STA線程需要訊息迴圈,一定要保證該訊息迴圈穩定而不受中斷的執行。
(七)樣本聚焦STA
下面我們重點講解單線程套間。採用的方法是觀察com對象方法被調用時執行線程的ID,對於標準STA對象,此ID就是STA對象的ID。如果一個STA對象不屬於建立它的線程(即這個線程不是STA線程),那麼該線程ID與執行對象方法的線程ID不匹配。
標準STA
以一個簡單的例子講解標準STA。如下所示,例子包括一個簡單的STA com對象,對應的com類CSimpleCOMObject2實現了TestMethod1()方法。TestMethod1()顯示正在執行線程的ID訊息框,代碼如下:
STDMETHODIMP CSimpleCOMObject2::TestMethod1() { TCHAR szMessage[256]; sprintf (szMessage, "Thread ID : 0x%X", GetCurrentThreadId()); ::MessageBox(NULL, szMessage, "TestMethod1()", MB_OK); return S_OK; }
我們通過一個簡單的測試程式執行個體化CSimpleCOMObject2並調用方法TestMethod1,代碼如下:
int main() { HANDLE hThread = NULL; DWORD dwThreadId = 0; ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); DisplayCurrentThreadId(); if (1) { ISimpleCOMObject2Ptr spISimpleCOMObject2; spISimpleCOMObject2.CreateInstance(__uuidof(SimpleCOMObject2)); spISimpleCOMObject2
-> TestMethod1(); hThread = CreateThread ( (LPSECURITY_ATTRIBUTES)NULL, // SD (SIZE_T)0, // initial stack size (LPTHREAD_START_ROUTINE)ThreadFunc, // thread function (LPVOID)NULL, // thread argument (DWORD)0, // creation
option (LPDWORD)&dwThreadId // thread identifier ); WaitForSingleObject(hThread, INFINITE); spISimpleCOMObject2 -> TestMethod1(); } ::CoUninitialize(); return 0; }
線程函數ThreadFunc()函數如下:
DWORD WINAPI ThreadFunc(LPVOID lpvParameter) { ::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); DisplayCurrentThreadId(); if (1) { ISimpleCOMObject2Ptr spISimpleCOMObject2A; ISimpleCOMObject2Ptr spISimpleCOMObject2B; spISimpleCOMObject2A.CreateInstance(__uuidof(SimpleCOMObject2));
spISimpleCOMObject2B.CreateInstance(__uuidof(SimpleCOMObject2)); spISimpleCOMObject2A -> TestMethod1(); spISimpleCOMObject2B -> TestMethod1(); } ::CoUninitialize(); return 0; }
線程函數中包含了展示當前線程ID訊息框的函數DisplayCurrentThreadId(),其代碼如下:
/* Simple function that displays the current thread ID. */ void DisplayCurrentThreadId() { TCHAR szMessage[256]; sprintf (szMessage, "Thread ID : 0x%X", GetCurrentThreadId()); ::MessageBox(NULL, szMessage, "TestMethod1()", MB_OK); }
上面的例子建立了單線程套間,通過線程ID來解釋這一過程。從main()函數開始,詳細分析如下:
1、main()函數通過調用CoInitializeEx和參數COINIT_APARTMENTTHREADED進入單線程套間。由此在main()中建立的STA對象屬於main()線程的一部分。
2、main()函數調用DisplayCurrentThreadId()函數,此時會顯示main()線程的ID,設為thread_id_1。
3、隨後程式執行個體化了com類SimpleCOMObject2,該對象和main()線程屬於相同的STA。
4、spISimpleCOMObject2的方法TestMethod1()顯示的線程ID肯定也是thread_id_1。隨後啟動了線程和線程函數ThreadFunc(),主程式調用WaitForSingleObject()等待線程函數ThreadFunc()的結束。
5、線程函數ThreadFunc()再次調用CoInitializeEx和參數COINIT_APARTMENTTHREADED,同時進入單線程套間。注意這裡的STA套間是新建立的,不同於main()函數的STA。
6、線程函數中調用了DisplayCurrentThreadId()函數,此時顯示的是ThreadFunc()線程的ID,設為thread_id_2。
7、隨後我們又建立了兩個com對象(spISimpleCOMObject2A和spISimpleCOMObject2B)。
8、線程函數中調用了兩個對象的方法TestMethod1()。
9、當對象spISimpleCOMObject2A和spISimpleCOMObject2B調用各自的方法時運行線程ID分別顯示。
10、它們顯示和函數ThreadFunc()相同的線程ID,即thread_id_2。
11、線程結束後放回主程式。
12、主程式中又一次調用方法TestMethod1(),當然顯示的線程ID依然是thread_id_1。
通過樣本需要記住的是:同一com類的不同執行個體可以屬於不同獨立的STA。對於一個標準的STA模型,重要的是哪個套間執行個體化了該STA對象。這個例子不需要提供任何的訊息徐迴圈,因為各對象都在他們自己的套間內,並沒有跨線程調用。即使我們在main()中調用了WaitForSingleObject()也不會遇到任何麻煩。
預設STA
假如STA對象在一個非STA線程中建立情形會如何?下面的程式碼範例了此種情況,其中com類 SimpleCOMObject2和函數DisplayCurrentThreadId()同第一個例子。
int main() { ::CoInitializeEx(NULL, COINIT_MULTITHREADED); DisplayCurrentThreadId(); if (1) { ISimpleCOMObject2Ptr spISimpleCOMObject2; /* If a default STA is to be created and used, it will be created */ /* right after spISimpleCOMObject2 (an STA object) is
created. */ spISimpleCOMObject2.CreateInstance(__uuidof(SimpleCOMObject2)); spISimpleCOMObject2 -> TestMethod1(); } ::CoUninitialize(); return 0; }
程式的詳細執行過程如下:
1、main()函數調用CoInitializeEx(NULL, COINIT_MULTITHREADED),主線程初始化自身為多線程套間。
2、隨即程式調用DisplayCurrentThreadId()函數,顯示了main()線程的ID。
3、接著STA對象spISimpleCOMObject2線上程中執行個體化。
4、注意對象spISimpleCOMObject2在非STA線程中初始化,它不屬於main()線程的MTA,屬於預設的STA。
5、調用spISimpleCOMObject2對象的方法TestMethod1(),此時顯示的線程ID不是main()線程ID。
當所有的STA對象在一個非STA線程中建立時,它們自動歸屬於一個預設的STA,該STA是在建立對象時建立的。這時在建立對象的線程中獲得是對象的代理而非指標。注意,預設的STA必須包含訊息迴圈,由com提供。
做為com套間世界的新人,必須注意:即使訪問createInstance或者CoCreateInstance函數,產生的對象有可能是在另一線程執行個體化的。com的這種行為對於使用者是完全透明的,請注意這些小的細節,特別是在調試時。
Legacy STA
Legacy STA屬於預設的單線程套間,其對象屬於legacy com對象。Legacy意味著那些組件沒有任何線程的知識,這些組件必須有設定成“Single”的ThreadingModel註冊表或者其它註冊表。Legacy STA對象比較重要的一點是這些對象的執行個體必須在相同的STA中建立,它們一直存在和運行在這個legacy STA中,即使它們建立在以::CoInitializeEx(NULL, COINIT_APARTMENTTHREADED)初始化的線程中。
Legacy STA通常是進程中的第一個單線程套間,如果一個legacy STA對象建立在任何STA模型之前,一個Legacy STA由com子系統自動建立。開發legacy STA對象好處在於這些對象執行個體的訪問將被序列化,任何兩個legacy STA對象間的調用不要列集的參與。然而,非legacy STA的對象必須通過inter-apartment列集訪問legacy STA對象,反之也是如此。我認為這不是一個有吸引力的優點。
另外,所有的Legacy STA對象必須僅能在相同的STA線程建立。
EXE COM伺服器和套間
討論完Dll com伺服器,為了文章的完整性接著介紹EXE COM伺服器。首先介紹DLL伺服器和EXE伺服器的兩個重要差別。
差別1:對象建立的方式
當COM建立定義在DLL中的com對象時,必須加裝該dll,調用匯出函數DllGetClassObject()獲得com對象工廠類的IClassFactory介面指標。EXE伺服器實際上也遵循這個過程:獲得com對象工廠類的IClassFactory介面指標並通過它建立對象。那麼DLL伺服器和EXE伺服器差別在那裡?
DLL伺服器需要匯出函數DllGetClassObject()以便com能夠提取類工廠,而EXE伺服器無需匯出任何函數,但要在啟動時向com子系統註冊類工廠,退出時銷毀。這一註冊過程通過調用API函數CoRegisterClassObject()實現。
差別2:對象套間模型聲明的方式
本文前面提到DLL伺服器中的對象通過設定“InProcServer2”註冊表中的“ThreadingModel”字串項聲明自己的套間。EXE伺服器中的對象不用設定註冊表,註冊對象類工廠的線程的套間模型決定了對象套間模型。
除了上述兩點差別,讀者還應該注意的是DLL伺服器中的STA對象有可能只服務於線程內的調用,而來用戶端對EXE伺服器對象的所有方法調用都以跨線程的方式實現,這一過程需要使用列集和stubs,以及對象所屬套間線程的訊息迴圈。
參考書籍:
The Essence of COM, A Programmer’s Workbook (3rd Edition) by David S. Platt. Published by Prentice Hall PTR.
Inside COM by Dale Rogerson. Published by Microsoft Press.
Essential COM by Don Box. Published by Addison-Wesley.