理解COM套間(第二部分)

原文出處:http://www.codeguru.com/cpp/com-tech/activex/apts/article.php/c5533/Understanding-COM-Apartments-Part-II.htm

本文的前一部分闡述了爲什麼和怎樣使用COM套間。讀過之後,你會知道,調用CoInitialize或者CoInitializeEx的時候,線程被放入到套間中。你還會知道,對象創建的時候也被放入到套間中,COM使用註冊表中的ThreadingModel值決定將進程內對象放到什麼類型的套間中。

你還會知道,有三種類型的套間:單線程套間STA;多線程套間MTA;線程中立套間NTAWindows 2000支持所有這三種套間類型,而Windows NT 4.0只支持兩種(STAMTA)。各種類型的COM套間具有下列特徵:

l 每個STA中只能有一個線程,但是COM不限制進程中STA的個數。進入STA的調用被傳遞到STA中唯一的一個線程。這樣,STA中的對象一次只能接收和處理一個方法調用,並且對象收到的每個方法調用都來自同一個線程。COM通過向爲STA服務的隱藏窗口投遞私有消息來將入調用傳遞給STA線程。

l 每個進程只能有一個MTA,但是可以在其中運行的線程個數是沒有限制的。對MTA中對象的方法調用在進入套間的時候被隨機地傳遞給RPC線程。COM不會對目的地是MTA的方法調用進行串行化,所以基於MTA的對象可能收到來自併發線程的併發調用。因爲入調用被傳遞給RPC線程,所以對基於MTA的對象的每個調用都可能來自於不同的線程,哪怕每個調用都來自於同一個調用者。

l Windows 2000引入了NTA用於性能優化。進行跨套間方法調用時,進入STAMTA的方法調用引起的線程切換會佔用大量開銷。而進入NTA的調用不會引起線程切換。如果STA或者MTA線程調用同一個進程中基於NTA的對象,線程會暫時離開其套間,直接執行NTA中的代碼。

上個月的文章《深入理解IUnknownInto the IUnknown)》展示的某些信息在那時候看起來似乎是令人絕望地抽象難懂,但是如果你想繞開折磨着很多COM程序員的陷阱,本月你將看到爲什麼理解套間的奧祕很重要。有兩類規則需要理解並遵守:一類是關於COM客戶端的,另一類是關於COM服務器的。只要遵守這些規則,COM生活將是充滿歡樂的,就像《音樂之聲(The Sound of Music)》中Julie Andrews唱着電影的主題曲,在高山草原上像陀螺那樣旋轉時候一樣。如果違背這些規則,則可能遇到隱祕而很難重現的錯誤,而且很難診斷。我常常收到關於這些錯誤的電子郵件,是該爲此做點什麼了。

1 編寫可以工作的COM客戶端

要編寫可以工作的COM客戶端,需要遵循三條規則。牢記這些規則,你就可以在編寫COM客戶端時避免嚴重的錯誤。

規則1:客戶線程必須調用CoInitialize[Ex]

線程做任何與COM相關的操作之前,必須調用CoInitialize或者CoInitializeEx初始化COM。如果客戶程序有20個線程,其中10個使用COM,則這10個線程都應該調用CoInitialize或者CoInitializeEx。調用線程將在這兩個API中被分配給一個套間。對於沒有分配給套間的線程,COM是無法施行併發規則的。此外還要記住,成功調用了CoInitialize或者CoInitializeEx的線程應該在終止前調用CoUninitialize。否則,由CoInitialize[Ex]分配的資源將直到進程終止才釋放。

這條規則看起來很簡單,只是一個函數調用而已。但是你會驚奇地發現,這條規則經常被違背。違背這條規則的錯誤一般在調用CoCreateInstance或者其他COM API時展現。但是有時候問題直到很晚纔出現,而且客戶端的錯誤似乎與沒有初始化COM沒有明顯的關係。

具有諷刺意味的是,有時候開發者不調用CoInitialize[Ex]的原因是,微軟告訴他們不需要調用。MSDN中有篇文章說COM客戶端有時候可以避免調用這個函數。但文章隨後說這可能會導致拒絕訪問。我近期收到一個開發者的電話,說客戶線程調用Release的時候會死鎖或者發生拒絕訪問異常。原因是?有些線程沒有調用CoInitialize[Ex]就發起方法調用了,結果調用Release的時候發生問題了。幸運地是,解決問題只需要簡單地加幾個CoInitialize[Ex]調用。

記住:調用CoInitialize[Ex]總是沒有壞處的。對於調用COM API或者以任何方式使用COM對象的線程,調用CoInitialize[Ex]應該說是必須的。

規則2STA線程需要消息循環

如果不理解單線程套間機制,這條規則看起來不那麼明顯。客戶調用基於STA的對象時,調用將被傳遞到STA中運行的線程。COM通過向STA的隱藏窗口投遞消息來完成這種傳遞。那麼,如果STA中的線程不接收和分發消息將發生什麼?調用將在RPC通道中消失,永遠也不返回。它將永遠凋謝在STA的消息隊列中(It will languish in the STA's message queue forever)。

開發者問我爲什麼方法調用不返回的時候,我首先問他們“你調用的對象是在STA中嗎?如果是,驅動STA的線程是否有消息循環?”。多半的回答是“我不知道”。如果你不知道,你就是在玩火。調用CoInitialize,或者使用參數COINIT_APARTMENTTHREADED調用CoInitializeEx,或者調用MFCAfxOleInit的時候,線程被分配到一個STA中。如果隨後在這個STA中創建對象,而STA線程又沒有消息泵,那麼對象不能接收來自其他套間的客戶的方法調用。消息泵可以這樣簡單:

翻譯:理解COM套間(第二部分)

如果缺少這些簡單的語句,把線程放入STA時要當心。一個常見的情況是MFC應用程序啓動工作線程(MFC工作線程的定義是,缺少消息泵的線程),而線程調用AfxOleInit將自身放入到STA中。如果STA不容納任何對象,或者雖然容納對象但是卻沒有來自其他套間的客戶,你不會遇到問題。但是如果STA容納導出接口指針到其他套間的對象,則對這些接口指針的調用將永遠不會返回。

規則3:不要在套間之間傳遞原始未列集的接口指針

設想編寫一個有兩個線程的COM客戶端。兩個線程都調用CoInitialize進入一個STA,然後其中一個線程——線程A,使用CoCreateInstance創建一個COM對象。線程A想要與線程B共享從CoCreateInstance返回的接口指針。所以線程A將接口指針賦值給一個全局變量,然後通知線程B指針已經準備好了。線程B從全局變量讀取接口指針並且對對象發起調用。這個過程有什麼錯誤嗎?

這個過程會引發事故。問題是線程A向其他套間中的線程傳遞了原始未列集的接口指針。線程B應該只通過列集到線程B所屬套間的接口指針與對象通信。

這裏“列集(Marshaling)”的意思是給COM在線程B所屬套間中創建新代理的機會,讓線程B可以安全地進行調用。在套間之間傳遞原始接口指針的後果可以從與時間極其相關(也很難重現)的數據損壞到完全死鎖。

如果線程A列集接口指針,則可以安全地與線程B共享接口指針。COM客戶端有兩種基本的方法將接口指針列集到其他套間:

l 使用COM API函數CoMarshalInterThreadInterfaceInStreamCoGetInterfaceAndReleaseStream

線程A調用CoMarshalInterThreadInterfaceInStream列集接口指針,線程B調用CoGetInterfaceAndReleaseStream進行散集。通過函數CoGetInterfaceAndReleaseStreamCOM在調用者套間中創建新的代理。如果接口指針不需要進行列集(比如說,兩個線程共享同一個套間時),CoGetInterfaceAndReleaseStream會智能地不創建代理。

l 使用在Windows NT 4.0 Service Pack 3中首次引入的全局接口表(Global Interface TableGIT)。

GIT是每個進程一個的表格,讓各個線程可以安全地共享接口指針。如果線程A想要與同一個進程中的其他線程共享接口指針,可以使用IGlobalInterfaceTable::RegisterInterfaceInGlobal來將接口指針放到GIT中。然後想要使用接口的線程可以調用IGlobalInterfaceTable::GetInterfaceFromGlobal來獲取接口指針。神奇之處在於線程從GIT獲取接口指針的時候,COM會將接口指針列集到獲取線程所屬的套間中。

有沒有不列集需要與其他線程共享的接口指針也OK的情況?有。如果兩個線程屬於同一個套間,則可以共享原始未列集的接口指針,而這隻可能在兩個線程都屬於MTA時發生。如果不確定是否需要,請進行列集。調用CoMarshalInterThreadInterfaceInStreamCoGetInterfaceAndReleaseStream或者使用GIT總是無害的,因爲COM只在必要的時候才進行列集。

2 編寫可以工作的COM服務器

編寫COM服務器時也應該遵守一些規則。

規則1:保護ThreadingModel=Apartment的對象的共享數據

標記對象的ThreadingModel=Apartment就可以不考慮線程安全問題?這是關於COM編程的一個最常見的錯誤想法。註冊進程內對象的ThreadingModel=Apartment暗示COM,對象(以及從DLL創建的其他對象)會以線程安全的方式訪問共享數據。這意味着已經使用臨界區或者其他線程同步原語來保證在任何時刻只有一個線程可以接觸到共享數據。對象之間數據共享通常有三種方式:

l DLL中聲明全局變量

l C++類中的靜態成員變量

l 靜態局部變量

爲什麼線程同步對於ThreadingModel=Apartment的對象是很重要的?考慮從同一個DLL創建兩個對象AB的情況。假定兩個對象都讀寫在DLL中聲明的一個全局變量。因爲標記爲ThreadingModel=Apartment,對象可能分別在不同的STA中創建和運行,因此,也是在不同的線程中運行。但是兩個對象訪問的全局變量是共享的,只在進程內實例化一次。如果來自AB的調用幾乎同時發生,而且A寫入那個變量,B讀取那個變量(或者相反),那麼變量可能被破壞,除非串行化線程的操作。如果不提供同步機制,那麼多數時候會遇到問題。最終兩個線程可能在共享數據上發生衝突,後果無法預知。

存在不需要同步機制就可以安全地訪問共享數據的情況嗎?存在。下列條件下可以不需要同步機制:

l 沒有爲對象註冊ThreadingModel值(也稱作ThreadingModel=None或者ThreadingModel=Single)時,所有對象在相同STA(主STA)和相同線程中運行,因此不會在共享數據上發生衝突。

l 雖然標記爲ThreadingModel=Apartment,但是確信對象將在相同的STA中運行(比如說,所有對象都由同一個STA線程創建)。

l 確信對象不會被併發地調用時。

對於除此之外的情況,要確保ThreadingModel=Apartment的對象以線程安全的方式訪問共享數據,只有這樣纔是正確完成了任務。

規則2:標記爲ThreadingModel=Free或者ThreadingModel=Both的對象應該是線程安全的。

標記對象是ThreadingModel=Free或者ThreadingModel=Both時,對象將被或者可能被放入到MTA中。記住:COM不會串行化對基於MTA的對象的調用。因此,毫無疑問地(beyond the shadow of a doubt),除非確信對象的客戶不會進行併發調用,對象應該是完全線程安全的。這意味着除了要同步由多個實例共享的數據之外,還必須同步對非靜態成員變量的訪問。編寫線程安全的代碼不容易,但是如果準備使用MTA,就必須這麼做。

規則3:避免在標記爲ThreadingModel=Free或者ThreadingModel=Both的對象裏使用線程局部存儲(TLS

一些Windows程序員使用線程局部存儲臨時保存數據。設想在實現一個COM方法時,需要緩存一些關於當前調用的信息,以備下次調用時使用。這時你可能很想使用TLS。在STA中,這樣做沒問題。但是如果對象在MTA中,就應該像躲避瘟疫那樣避免使用TLS

爲什麼?因爲進入MTA的調用被傳遞給RPC線程。每次調用可能被傳遞給不同的RPC線程,即使調用都是來自於同一個線程中的同一個調用者。一個線程不能訪問另一個線程的線程局部存儲。所以如果調用1到達線程A,對象將數據保存在TLS中;然後調用2到達線程B,對象試圖取出在調用1中存入TLS的數據時,會找不到數據。這個道理很簡單。

對於基於MTA的對象,在方法調用之間使用TLS緩存數據時要注意,這種方法只在所有的方法調用來自於對象所在的MTA中的同一個線程時纔可以正確工作。

你在開玩笑?

我應該嚴肅對待這些規則嗎?一點沒錯。我在COM應用程序中發現的bug大約有一半是因爲違背本文描述的規則而導致的。即使你不理解這些規則,也請遵守它們,這樣你的世界纔會是美好的。


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