本文的前一部分闡述了爲什麼和怎樣使用COM套間。讀過之後,你會知道,調用CoInitialize或者CoInitializeEx的時候,線程被放入到套間中。你還會知道,對象創建的時候也被放入到套間中,COM使用註冊表中的ThreadingModel值決定將進程內對象放到什麼類型的套間中。
你還會知道,有三種類型的套間:單線程套間STA;多線程套間MTA;線程中立套間NTA。Windows
l
l
l
上個月的文章《深入理解IUnknown(Into
1
要編寫可以工作的COM客戶端,需要遵循三條規則。牢記這些規則,你就可以在編寫COM客戶端時避免嚴重的錯誤。
規則1:客戶線程必須調用CoInitialize[Ex]
線程做任何與COM相關的操作之前,必須調用CoInitialize或者CoInitializeEx初始化COM。如果客戶程序有20個線程,其中10個使用COM,則這10個線程都應該調用CoInitialize或者CoInitializeEx。調用線程將在這兩個API中被分配給一個套間。對於沒有分配給套間的線程,COM是無法施行併發規則的。此外還要記住,成功調用了CoInitialize或者CoInitializeEx的線程應該在終止前調用CoUninitialize。否則,由CoInitialize[Ex]分配的資源將直到進程終止才釋放。
這條規則看起來很簡單,只是一個函數調用而已。但是你會驚奇地發現,這條規則經常被違背。違背這條規則的錯誤一般在調用CoCreateInstance或者其他COM
具有諷刺意味的是,有時候開發者不調用CoInitialize[Ex]的原因是,微軟告訴他們不需要調用。MSDN中有篇文章說COM客戶端有時候可以避免調用這個函數。但文章隨後說這可能會導致拒絕訪問。我近期收到一個開發者的電話,說客戶線程調用Release的時候會死鎖或者發生拒絕訪問異常。原因是?有些線程沒有調用CoInitialize[Ex]就發起方法調用了,結果調用Release的時候發生問題了。幸運地是,解決問題只需要簡單地加幾個CoInitialize[Ex]調用。
記住:調用CoInitialize[Ex]總是沒有壞處的。對於調用COM
規則2:STA線程需要消息循環
如果不理解單線程套間機制,這條規則看起來不那麼明顯。客戶調用基於STA的對象時,調用將被傳遞到STA中運行的線程。COM通過向STA的隱藏窗口投遞消息來完成這種傳遞。那麼,如果STA中的線程不接收和分發消息將發生什麼?調用將在RPC通道中消失,永遠也不返回。它將永遠凋謝在STA的消息隊列中(It
開發者問我爲什麼方法調用不返回的時候,我首先問他們“你調用的對象是在STA中嗎?如果是,驅動STA的線程是否有消息循環?”。多半的回答是“我不知道”。如果你不知道,你就是在玩火。調用CoInitialize,或者使用參數COINIT_APARTMENTTHREADED調用CoInitializeEx,或者調用MFC的AfxOleInit的時候,線程被分配到一個STA中。如果隨後在這個STA中創建對象,而STA線程又沒有消息泵,那麼對象不能接收來自其他套間的客戶的方法調用。消息泵可以這樣簡單:
如果缺少這些簡單的語句,把線程放入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
線程A調用CoMarshalInterThreadInte
l
GIT是每個進程一個的表格,讓各個線程可以安全地共享接口指針。如果線程A想要與同一個進程中的其他線程共享接口指針,可以使用IGlobalInterfaceTable::RegisterInterfaceInGloba
有沒有不列集需要與其他線程共享的接口指針也OK的情況?有。如果兩個線程屬於同一個套間,則可以共享原始未列集的接口指針,而這隻可能在兩個線程都屬於MTA時發生。如果不確定是否需要,請進行列集。調用CoMarshalInterThreadInte
2
編寫COM服務器時也應該遵守一些規則。
規則1:保護ThreadingModel=Apartment的對象的共享數據
標記對象的ThreadingModel=Apartment就可以不考慮線程安全問題?這是關於COM編程的一個最常見的錯誤想法。註冊進程內對象的ThreadingModel=Apartment暗示COM,對象(以及從DLL創建的其他對象)會以線程安全的方式訪問共享數據。這意味着已經使用臨界區或者其他線程同步原語來保證在任何時刻只有一個線程可以接觸到共享數據。對象之間數據共享通常有三種方式:
l
l
l
爲什麼線程同步對於ThreadingModel=Apartment的對象是很重要的?考慮從同一個DLL創建兩個對象A和B的情況。假定兩個對象都讀寫在DLL中聲明的一個全局變量。因爲標記爲ThreadingModel=Apartment,對象可能分別在不同的STA中創建和運行,因此,也是在不同的線程中運行。但是兩個對象訪問的全局變量是共享的,只在進程內實例化一次。如果來自A和B的調用幾乎同時發生,而且A寫入那個變量,B讀取那個變量(或者相反),那麼變量可能被破壞,除非串行化線程的操作。如果不提供同步機制,那麼多數時候會遇到問題。最終兩個線程可能在共享數據上發生衝突,後果無法預知。
存在不需要同步機制就可以安全地訪問共享數據的情況嗎?存在。下列條件下可以不需要同步機制:
l
l
l
對於除此之外的情況,要確保ThreadingModel=Apartment的對象以線程安全的方式訪問共享數據,只有這樣纔是正確完成了任務。
規則2:標記爲ThreadingModel=Free或者ThreadingModel=Both的對象應該是線程安全的。
標記對象是ThreadingModel=Free或者ThreadingModel=Both時,對象將被或者可能被放入到MTA中。記住:COM不會串行化對基於MTA的對象的調用。因此,毫無疑問地(beyond
規則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大約有一半是因爲違背本文描述的規則而導致的。即使你不理解這些規則,也請遵守它們,這樣你的世界纔會是美好的。