編寫拙作《關於COM組件線程模型的實驗》的過程中,發現自己無法合理解釋特定情況下程序的運行情況。爲更深入理解COM的線程模型,合理解釋程序運行情況,找了一些資料看。發現一篇英文文章不錯,特地翻譯出來。關於對STA中對象的回調處理、其他套間中的線程對MTA中的對象的調用是通過RPC線程池裏的線程進行的,以及不應該在自由線程和雙線程模型的組件中使用線程局部存儲這三點,是我在這篇文章中首次看到的,也是這篇文章比其他資料深入的地方,很值得學習和思考。
COM引入了一種併發機制,可以截獲並串行化對於設計爲只能一次處理一個方法調用的對象的併發調用。這種機制以稱爲“套間(apartments)”的抽象邊界概念爲中心。在解決不能正確工作的COM系統的問題時,我發現大約40%問題的原因是缺乏對於套間的理解。這種知識的缺乏並不意外,因爲套間是COM中最複雜的領域,而且也沒有很好地文檔化。微軟的目的是好的,但是在Windows
本文是一個兩部分系列文章的第一部分。系列文章將解釋什麼是套間、套間存在於什麼地方,以及如何避免套間引入的問題。文章的第一部分將介紹COM基於套間的併發機制;第二部分將介紹一些規則,以避免隱藏而又令人討厭的Bug。
1
套間是一個併發邊界,一個在對象和客戶線程之間的假想的盒子,用以隔離具有不兼容線程特性的COM客戶和COM對象。套間存在的主要目的是讓COM可以串行化對於非線程安全對象的方法調用。如果沒有告訴COM對象是線程安全的,則COM不會允許多個調用同時到達對象。相反地,如果告訴COM對象是線程安全的,則COM會讓對象處理多個線程中的併發調用。
每個使用COM的線程,以及這些線程創建的每個對象,都被分配到某個套間中。套間不能跨越進程邊界,所以如果對象和其客戶位於不同的進程中,則它們也在不同的套間中。客戶創建進程內對象的時候,COM必須決定將其放在創建者套間中,還是放在客戶進程中的另一個套間裏。如果COM將對象和創建對象的線程放在同一個套間中,則客戶將直接訪問對象。如果COM將對象放在另一個不同的套間中,則創建對象的線程對對象的調用將被列集(marshaled)。
圖1展示了線程和對象共享套間,以及線程和對象位於不同套間時二者的關係。線程1中的調用將直接訪問線程創建的對象;線程2中的調用將通過代理(proxy)和樁基(stub)進行。COM在將接口指針列集到線程2的套間時創建代理/樁基對。跨越套間邊界傳遞接口指針時必須對指針進行列集,這是一條規則。這意味着,如果涉及到定製接口,即使是進程內對象,如果需要與位於其他套間中的客戶通信,也還是需要提供與跨進程和跨機器方法調用時一樣的,用於列集支持的代理/樁基DLL(或者選擇類型庫列集時的類型庫)。
圖1:對其他套間中對象的調用被列集,即使對象和調用者屬於同一個進程
Windows
l
l
l
每個線程只能有一個單線程套間,但是可以容納的對象個數是無限的。而且,COM不限制進程中STA的個數。進程中第一個創建的STA稱爲進程的主STA。對STA中對象的調用在投遞前都先傳遞到STA線程中。因爲所有對對象的調用都在同一個線程中執行,所以基於STA的對象不能同時執行多個調用。COM使用STA來串行化對於非線程安全對象的調用。如果不明確告訴COM對象是線程安全的,則COM將把對象放到STA中,從而讓對象不會被併發訪問。
關於STA操作的一個有趣方面是:COM如何將對基於STA的對象的調用傳遞到STA線程中。COM創建STA的時候,會同時創建一個隱藏窗口,這個窗口的窗口過程知道如何處理代表方法調用的私有消息。目標是STA的方法調用離開COM的RPC通道時,COM將向STA窗口投遞一個代表這個調用的消息。STA中的線程收到消息後,將消息分發到隱藏窗口,隱藏窗口的窗口過程將調用投遞給樁基,樁基將最終執行對對象的調用。因爲線程一次只能接收、分發和處理一個消息,STA自然而有效地成爲調用串行機制。如圖2所示,如果同時發起n個對基於STA的對象的調用,則調用將被排隊,然後依次投遞給對象。
圖2:進入STA的調用被轉化爲消息,投遞到消息隊列。消息隊列中的消息被STA中運行的線程依次轉化回方法調用。
調用離開STA時的情形也同樣重要。COM不能讓線程阻塞在RPC通道中,因爲回調可能導致死鎖:想象一下當STA線程調用其他套間中的對象,而這個對象反過來調用STA中的另一個對象時會發生什麼。如果STA線程阻塞在RPC通道中,則調用永遠不會返回,因爲唯一一個可以處理回調的線程正在RPC通道中等待最初的調用返回。因此,調用離開STA時,COM會阻塞STA線程,但是讓STA線程仍然可以處理回調。爲了讓回調可以發生,COM會跟蹤每個方法調用的因果關係,以便能夠識別何時應該釋放正在RPC通道中等待某方法調用返回的STA線程,讓其處理另一個進入的調用。默認情況下,STA入口有調用到達時,如果STA線程正在等待出調用返回,而且到達的入調用與正在等待返回的出調用不屬於同一個因果鏈,則到達的入調用將阻塞。編寫消息過濾器可以改變這個默認行爲,下一部分對此進行討論。
多線程套間完全不同。COM限制每個進程只能有一個MTA,但是沒有限制MTA中線程和對象的個數。MTA沒有隱藏窗口和消息隊列。對MTA中對象的調用被隨機地傳遞給RPC線程池裏的線程,不會串行化(見圖3)。這意味着MTA中的對象最好是線程安全的,因爲沒有外部機制保證基於MTA的對象一次只接收一個調用,對象可能被不同的RPC線程併發地調用。
圖3:進入MTA的調用被傳遞到RPC線程而不會被串行化
對於離開MTA的調用,COM不會進行特別處理。調用線程可以阻塞在RPC通道中,如果發生回調,不會產生死鎖,因爲回調會被傳遞給另一個RPC線程。
Windows
2如何爲線程分配套間
以任何方式使用COM的線程必須首先調用CoInitialize或者CoInitializeEx初始化COM。調用這兩個函數時,線程將被放入到套間中。放入到什麼類型的套間決定於線程調用哪個函數以及如何調用。
l
l
l
作爲一個例子,假設啓動了一個新進程,進程中的線程1調用CoInitialize:
隨後,線程1啓動線程2,3,4和5,這些線程使用下列語句初始化COM:
圖4展示了最終的套間配置。線程1,2和5被分配到單獨的STA中,因爲每個STA中只能有一個線程。另一方面,線程3和4被分配到進程的MTA中。記住:COM不會爲進程創建多個MTA,而是在唯一的一個MTA中放置任何數量的線程。
圖4:進程有五個線程,分佈於三個STA和一個MTA中。
如果你喜歡刨根問底(if
3
現在該介紹如何爲對象分配套間了。COM用於決定在哪個套間中創建對象的算法對於進程內對象和進程外對象是不同的。進程內對象更有趣,因爲只有進程內對象是可以創建於創建者套間中的。我們首先討論進程內對象,然後討論進程外對象。
COM通過從註冊表中讀取對象的ThreadingModel值來決定在哪個套間中創建進程內對象。ThreadingModel是分配給用以標識對象DLL的InprocServer32子鍵的命名值。下面以REGEDIT格式顯示的註冊表條目標識了CLSID爲99999999-0000-0000-0000-111111111111、DLL爲MyServer.dll,ThreadingModel爲Apartment的對象:
Apartment是Windows
Apartment
COM儘量放置進程內對象到創建者線程所屬的套間中。比如說,如果STA線程創建標識爲ThreadingModel=Apartment的對象,COM將在創建者線程的STA中創建對象。如果MTA線程創建ThreadingModel=Free的對象,COM將會把對象放置在MTA中。然而,有時候COM不能將對象放置在創建者的套間中。比如說,如果STA線程創建標識爲ThreadingModel=Free的對象,則對象將在進程的MTA中創建,創建者線程將通過代理和樁基訪問對象。類似地,如果MTA線程創建ThreadingModel=None或者ThreadingModel=Apartment的對象,則來自創建者線程的調用將從MTA列集到對象的STA中。下表顯示了STA和MTA線程創建具有任何有效的ThreadingModel值(或者沒有ThreadingModel值)的對象時的情況:
爲什麼ThreadingModel=None限制對象在進程的主STA中?因爲只有這樣COM才能在不知道對象是否是線程安全時讓多個對象安全地執行。假設從同一個DLL創建兩個ThreadingModel=None的對象。如果這兩個對象訪問DLL中的任何全局變量,則COM必須在相同線程中執行所有對這兩個對象的調用,否則兩個對象可能試圖同時讀或者寫同一個全局變量。限制對象在主STA中就是COM讓對象在相同線程中執行的方式。
線程模型對於編碼有重要的指導意義。比如說,標記爲ThreadingModel=Free或者ThreadingModel=Both的對象必須是線程安全的,因爲對基於MTA的對象的調用時不會被串行化。即使是ThreadingModel=Apartment的對象,也應該是部分線程安全的,因爲ThreadingModel=Apartment不能阻止從同一個DLL創建多個對象,從而在共享數據上發生衝突。本文的下一部分將討論這個問題。
4
進程外對象沒有ThreadingModel值,因爲COM使用完全不同的算法爲進程外對象分配套間。簡而言之,COM將進程外對象放到與創建對象的服務器進程相同的套間中。大多數進程外(EXE)COM服務器以調用CoInitialize或者CoInitializeEx將主線程放入到STA中開始。然後服務器爲其可以創建的對象類型創建類對象並使用CoRegisterClassObject進行註冊。激活請求到達以這種方式初始化的服務器時,請求在進程的STA中被處理。結果,服務器進程創建的對象也在進程的STA中。
可以將進程外對象移動到MTA中,只要將註冊類對象的線程放置在MTA中就可以了。這樣進入的激活請求會到達在服務器進程的MTA中執行的RPC線程。爲響應請求創建的對象也將位於MTA中。
要點是,在EXE類型的COM服務器裏,調用CoRegisterClassObject的線程所在的套間,也是服務器創建的對象所在的套間。當然也存在例外:使用ATL的CComAutoThreadModule和CComClassFactoryAutoThre
繼續下一部分
這就完了?本文展示的很多細節看起來很神祕,沒什麼實際價值。然而,要避免大多數常見的、折磨着COM程序員的危險陷阱,理解COM套間絕對是必要的。在下一部分你會明白我的意思的。