本文講解COM提出的各個類型的線程模型,再說明COM運行時期庫是如何實現它們的
線程模型是一種數學模型,專門針對多線程編程而提供的算法,但也僅是算法,不是實現。本文講解COM提出的各個類型的線程模型,再說明COM運行時期庫是如何實現它們的,就像說明Windows是如何實現線程這個數學模型的一樣,最後指明一下跨套間調用和各種類型套間編寫的要求以幫助理解。希望讀者對於Windows操作系統的線程這個概念相當熟悉,對何謂“線程安全的”亦非常瞭解。
COM線程模型
COM提供的線程模型共有三種:Single-Threaded Apartment(STA 單線程套間)、Multithreaded Apartment(MTA 多線程套間)和Neutral Apartment/Thread Neutral Apartment/Neutral Threaded Apartment(NA/TNA/NTA 中立線程套間,由COM+提供)。雖然它們的名字都含有套間這個詞,這只是COM運行時期庫(注意,不是COM規範,以下簡稱COM)使用套間技術來實現前面的三種線程模型,應注意套間和線程模型不是同一個概念。COM提供的套間共有三種,分別一一對應。而線程模型的存在就是線程規則的不同導致的,而所謂的線程規則就只有兩個:代碼是線程安全的或不安全的,即代碼訪問公共數據時會或不會發生訪問衝突。由於線程模型只是個模型,概念上的,因此可以違背它,不過就不能獲得COM提供的自動同步調用及兼容等好處了。
STA 一個對象只能由一個線程訪問(通過對象的接口指針調用其方法),其他線程不得訪問這個對象,因此對於這個對象的所有調用都是同步了的,對象的狀態(也就是對象的成員變量的值)肯定是正確變化的,不會出現線程訪問衝突而導致對象狀態錯誤。其他線程要訪問這個對象,必須等待,直到那個唯一的線程空閒時才能調用對象。注意:這只是要求、希望、協議,實際是否做到是由COM決定的。如上所說,這個模型很像Windows提供的窗口消息運行機制,因此這個線程模型非常適合於擁有界面的組件,像ActiveX控件、OLE文檔服務器等,都應該使用STA的套間。
MTA 一個對象可以被多個線程訪問,即這個對象的代碼在自己的方法中實現了線程保護,保證可以正確改變自己的狀態。這對於作爲業務邏輯組件或幹後臺服務的組件非常適合。因爲作爲一個分佈式的服務器,同一時間可能有幾千條服務請求到達,如果排隊進行調用,那麼將是不能想像的。注意:這也只是一個要求、希望、協議而已。
NA 一個對象可以被任何線程訪問,與MTA不同的是任何線程,而且當跨套間訪問時(後面說明),它的調用費用(耗費的CPU時間及資源)要少得多。這準確的說都已經不能算是線程模型了,它是結合套間的具體實現而提出的要求,它和MTA不同的是COM的實現方式而已。
COM套間
Apartment被翻譯成套間或是單元,是線程模型的一個實現者,就像在操作系統課程中講到的線程只是一個數學模型,而Windows的線程、進程是它(數學模型的線程、進程)的實現者。套間只是邏輯上的一個概念,實現時只是一個結構(由COM管理)而已,記錄着相關信息,如它的種類(只能是上面那三個,至少現在是),並由COM根據那個結構進行相應的處理。下面說明這三種套間的實現方式:
STA套間 一個套間如果是STA,那麼那個套間有且只有一個線程和其關聯,有多個對象或沒有對象和其關聯,就像有多個線程和一個進程關聯一樣,也就是說套間那個結構和某個線程及多個對象之間有關係,關係具體是什麼由COM說得算,幸運的是COM正是按照上面的線程模型來定義互相之間關係的。根據上面的算法,很容易就知道只有這個線程可以訪問這個套間裏的對象。
COM是通過在STA套間裏的線程中創建一個隱藏窗口,然後外界(這個套間外的線程)對這個對象的調用都轉變成對那個隱藏窗口發送消息,然後由這個隱藏窗口的消息處理函數來實際調用組件對象的方法來實現STA的規則的。之所以使用一個隱藏窗口是爲了方便組件代碼的編寫——只需調用DispatchMessage即可將方法調用的消息和普通的消息區分開來(通過隱藏窗口的消息處理函數)。外界對這個對象的調用都將轉變成對這個隱藏窗口的消息發送來實現同步。至於COM如何截獲外界對對象的調用,則是利於代理對象,後面再說明。
值得注意的是,如果使用標準彙集法生成代理對象,則代理對象會根據是進程內還是進程外的跨套間調用,來決定具體操作。如果外界線程和STA線程在同一進程內,則代理對象將直接向STA線程中的隱藏窗口發送消息;如果不在同一進程內(包括遠程進程),代理對象將向RPC管理的一個線程池請求一個線程(RPC線程)來專門向另一進程中的STA線程的隱藏窗口發送消息,而不是代理對象直接發送消息,以防止外界線程由於網絡等不穩定因素而導致掛起。
因爲COM利用消息機制來實現STA,因此STA套間裏的線程必須實現消息循環,否則COM將不能實現STA的要求。
MTA套間 這種類型的套間可以和多個線程及多個或沒有對象相關聯。根據上面的MTA模型,可知只有這個套間裏的線程才能訪問這個套間裏的對象,和STA不同的只是可以多個線程同時訪問對象。
外界(不屬於這個套間的線程)對這個套間裏的對象的調用將會導致調用線程(外界線程,也就是STA線程,因爲NA沒有線程)掛起,然後向RPC管理的一個線程池請求一個線程(RPC線程,並已經進入了這個MTA套間)以調用那個對象的方法。對象返回後,調用線程被喚醒,繼續運行。雖然可以讓STA線程直接調用對象(而不用像前述的掛起等待另一個線程來調用對象),但這是必須的,因爲可能會有回調問題,比如這個MTA線程又反過來回調外界線程中的組件對象(假設客戶本身也是一個組件對象,這正是連接點技術),如果異步回調將可能發生錯誤。
反過來,MTA的線程訪問STA裏的對象時,COM將把調用轉換成對STA線程裏那個隱藏窗口的一個消息發送,返回後再由COM轉成結果返回給MTA的線程(如果使用標準彙集法生成標準代理對象,則發生的具體情況就如上面STA套間所述)。因此STA和MTA都是隻能由它們關聯的線程調用它們關聯的對象。而根據上面所說,當MTA調STA或STA調MTA,都會發生線程切換,也就是說一個線程掛起而換成執行另一個線程。這是相當大的消耗(需要從內核模式向用戶模式轉換,再倒轉好幾回),而NA就是針對這個設計的。
NA套間 這種套間只和對象相關聯,沒有關聯的線程,因此任何線程都可以直接訪問裏面的對象,不存在STA的還是MTA的。
外界(其實就是任何線程)對這個套間裏面的調用都不需要掛起等待,而是進入NA套間,直接調用對象的方法。NA套間是由COM+提供的,COM+中的每個對象都有一個環境和其相綁定,環境記錄了必要的信息,並監聽對對象的每一次調用,以保證當將對象的接口指針成員變量進行傳遞或回調時其操作的正確性(保證執行線程在正確的套間內,MTA線程就是通過將自己掛起以等待STA線程的消息處理完畢來保證的),從而避免了調用線程的掛起,因此這個代理(其實也就是環境的一部分)被稱作輕量級代理(相對於STA套間和MTA套間的重量級代理——需要掛起調用線程,發生線程切換)。
這個輕量級代理並不是永遠都不發生線程切換。當NA對象裏有個對指向一個STA對象的指針的調用而調用線程不是那個STA對象關聯的線程時,調用將會轉成向被調用的STA對象的關聯線程發送消息,此時照樣會發生線程切換。同理,如果那個對象是MTA的,而調用線程是STA線程時,依舊發生線程切換。不過除此以外的大多數情況(即不在NA對象的方法中調用另一個套間對象的方法)都不會發生線程切換,即使出現上面的情況也只有必要(MTA調NA再調MTA就不用切換)才切換線程。
根據上面所說,STA其實和MTA邏輯上是完全一樣的,只是一個是關聯一個線程,一個是關聯多個線程而已。但把它們分開是必要的,因爲線程安全就是針對是一個線程還是多個線程。而NA之所以不關聯線程是因爲它的目的是消除上面跨套間調用時產生的線程切換損耗,關聯線程沒有任何意義。
COM強行規定(不遵守也沒轍,因爲全是COM實現套間的,根本沒有插手的餘地)一個進程可以擁有多個STA的套間,但只能擁有一個MTA套間和一個NA套間,我想這應該已經很容易理解了(要兩個MTA套間或NA套間幹甚?)。
套間生成規則
線程在進行大多數COM操作之前,需要先調用CoInitialize或CoInitializeEx。調用CoInitialize告訴COM生成一個STA套間,並將當前的調用線程和這個套間相關聯。而調用CoInitializeEx( NULL, COINIT_MULTITHREADED );告訴COM檢查是否已經有了一個MTA套間,沒有則生成一個MTA套間,然後將那個套間和調用線程相關聯。接着在調用CoCreateInstance或CoGetClassObject等創建對象的函數時,創建的對象將以一個特定規則決定和哪個套間相關聯(後敘)。這樣完成後,就完成了線程、對象和套間的關聯(或綁定)。
前面提到的決定對象去向的規則如下。
當是進程內組件時,根據註冊表項<CLSID>/InprocServer32/ThreadingModel和線程的不同,列於下表:
創建線程關聯的套間種類 | ThreadingModel鍵值 | 組件對象最後所在套間 |
STA | Apartment | 創建線程的套間 |
STA | Free | 進程內的MTA套間 |
STA | Both | 創建線程的套間 |
STA | ""或Single | 進程內的主STA套間 |
STA | Neutral | 進程內的NA套間 |
MTA | Apartment | 新建的一個STA套間 |
MTA | Free | 進程內的MTA套間 |
MTA | Both | 進程內的MTA套間 |
MTA | ""或Single | 進程內的主STA套間 |
MTA | Neutral | 進程內的NA套間 |
進程內的主STA套間是進程中第一個調用CoInitialize的線程所關聯的套間(即進程中的第一個STA套間)。後面說明爲什麼還來個進程內的主STA套間。
當是進程外組件時,由主函數調用CoInitializeEx或CoInitialize指定組件所在套間,與上面的相同,CoInitialize代表STA,CoInitializeEx( NULL, COINIT_MULTITHREADED );代表MTA,沒有NA。因爲NA是COM+提供的,而COM+服務只能提供給進程內服務器,因此只使用上面的註冊表項的規則決定DLL組件是否放進NA套間,而沒有提供類似CoInitializeEx( NULL, COINIT_NEUTRAL );來處理EXE組件。而且如果可以使用CoInitializeEx( NULL, COINIT_NEUTRAL );將導致調用線程和NA套間相關聯了,違背了NA的線程模型,這也是爲什麼ThreadingModel鍵在<CLSID>/InprocServer32鍵下。
跨套間調用
STA線程1創建了一個STA對象,得到接口指針IABCD*,接着它發起STA線程2,並且將IABCD*作爲線程參數傳入。在線程2中,調用IABCD::Abc()方法,成功或者失敗天註定。由於線程2所在的STA套間不同於線程1所在的STA套間,這樣線程2就跨套間調用另一個套間的對象了。按照前述的STA規則,IABCD::Abc()應該被轉成消息來發送,而如果如上面做法,可以,編譯通過,不過運行就不保證了。
COM之所以能夠實現前面所說的那些規則(STA、MTA、NA),是因爲跨套間調用時,被調用的對象指針是指向一個代理對象,不是組件對象本身。而那個代理對象實現前述的那三個實現算法(轉成消息發送,線程切換等),而一般所說的代理/佔位對象(Proxy/Stub)等其實都只是指進行彙集工作的代碼(後述)。而按照上面直接通過線程參數傳入的指針是直接指向對象的,所以將不能實現STA規則,爲此COM提供瞭如下兩個函數(還有其他方式,如通過全局接口表GIT)來方便產生代理:CoMarshalInterface和CoUnmarshalInterface(如果在同一進程內的線程間傳遞接口指針,則可以通過這兩個函數來進一步簡化代碼的編寫:CoMarshalInterThreadInterfaceInStream和CoGetInterfaceAndReleaseStream)。
現在重寫上面代碼,線程1得到IABCD*後,調用CoMarshalInterface得到一個IStream*,然後將IStream*傳入線程2,在線程2中,調用CoUnmarshalInterface得到IABCD*,現在這個IABCD*就是指向代理對象的,而不是組件對象了。
因此,前面所說過的所有線程模型的算法都是通過代理對象實現的。要跨套間時,使用CoMarshalInterface將代理對象的CLSID和其與組件對象建立聯繫的一些必要信息(如組件對象的接口指針)列集(Marshaling)到一個IStream*中,再通過任何線程間通信手段(如全局變量等)將IStream*傳到要使用的線程中,再用CoUnmarshalInterface散集(Unmarshaling)出接口以獲得指向代理對象的接口指針。因此之所以要獲得代理對象的指針是因爲想使用COM提供的線程模型(但在COM+中,這不是唯一的理由),如果不想使用大可不必這麼麻煩(不過後果自負),並沒有強制要求必須那麼做。
當線程1和線程2都是MTA時,則可以像最開始說的那樣,直接傳遞IABCD*到線程2中,因爲MTA線程模型同意多個線程同時直接調用對象,線程1和線程2在同一個MTA套間中,而那個對象通過某種形式(如ThreadingModel = Free)向COM聲明瞭自己支持MTA線程模型。
而當a.exe的線程1和b.exe的線程2都是MTA時,則依舊需要像上面那樣進行接口指針的彙集(列集→傳輸→散集這個過程)以得到指向代理而非對象的指針,即使線程1和線程2都是在MTA套間中,卻是在兩個不同的MTA套間中,因此是跨套間調用,需要彙集操作。
彙集代碼
前面已經說明了套間的規則都是通過對代理對象而非組件對象發起調用以截取對組件對象的調用由代理對象來實現的。代理對象要和組件對象交互,將方法參數傳遞給組件對象,需要使用到彙集技術,也就是列集→傳輸→散集這個過程。
列集(Marshaling)指將信息以某種格式存爲流(IStream*)形式的操作;散集(Unmarshaling)則是列集的反操作,將信息從流形式中反還出來;傳輸則只是流形式的傳遞操作。
這裏經常發生誤會。前面的CoMarshalInterface所做的列集,是將代理對象的CLSID及一些持久信息(用於初始化代理對象)格式化爲一種格式(網絡數據描述——Network Data Representation)後放到一個流對象中,可以通過網絡(或其他方式)將這個流對象傳遞到客戶機,由客戶通過CoUnmarshalInterface從傳來的流對象中反還出代理對象的CLSID和初始化用的一些持久信息,生成代理對象並使用持久信息初始化它以用於彙集操作。這就是發生誤會的地方——這裏的彙集操作不同於上面的彙集操作,其彙集的是接口方法的參數而不是什麼CLSID和一些初始化信息。
因此CoMarshalInterface和CoUnmarshalInterface是用於彙集接口指針的,再準確點應該是用於生成代理對象的。代理對象應由讀者自己實現,用於彙集接口方法的參數。一般有兩種代理對象的實現方式:自定義彙集和標準彙集。
對於自定義彙集,組件需實現IMarshal接口和一個代理組件(即完全實現真正組件所有接口的一個副本,實現了彙集方法參數及線程模型的規則,也必須實現IMarshal接口),並將這個代理組件在客戶機上註冊,以保證代理對象的正確生成。注意:如果參數中有接口指針,必須用CoMarshalInterface和CoUnmarshalInterface進行彙集,否則無法實現正確的線程模型,且代理組件是線程模型的實現者,這點組件必須自己保證(如發送消息等)。
對於標準彙集,組件無需實現IMarshal接口及代理組件,代替的,組件則需要爲自己生成一個代理/佔位組件(Proxy/Stub),其由於可通過MIDL由IDL文件自動生成,效率高,代碼的正確性有保證,因而被鼓勵使用。COM提供了一個標準代理對象的實現,其通過聚合組件的代理/佔位組件以表現出其好像是組件的代理對象。與自定義彙集一樣,需要將這個代理/佔位組件在客戶機上註冊以保證代理對象的正確生成。
至於這兩種彙集的具體工作機理,由於與本文無關,在此不表,這裏僅僅只爲消除代理對象和代理/佔位組件之間的混淆。
注意:對於將運行於NA套間的組件,由於COM+的強制要求,其必須使用標準彙集進行代理對象的生成而不是自定義彙集(COM+運行時期庫重寫了標準代理對象來截獲對組件對象的調用和其自身的某些特殊處理——如保證NA套間正確工作)。
套間實現規則
如前面所說,COM的套間機制要成功,必須服務器(組件)、客戶和COM運行時期庫三方面合力實現,其中有任何一方不按着規矩來,將不能實現套間機制的功能,不過這並不代表什麼錯誤,套間機制不能運作並不代表程序會崩潰,只是不能和其他COM應用兼容而已。
比如:對象中的屬性1在設計的算法中肯定不會被兩個以上的線程寫入,只是會被多個線程同時讀出而已,因此不用同步,可以用MTA,但對象的屬性2卻可能被多個線程寫入,因此決定使用STA。從而在客戶端,通過前面說的CoMarshalInterface和CoUnmarshalInterface將對象指針傳到那個只會寫入對象的屬性1的線程,其實這時就可以直接將對象指針傳到這個線程,而不用想上面那樣麻煩(而且增加了效率),但是就破壞了COM的套間規矩了——兩個線程可以訪問對象,但對象在STA套間中。所以?!!什麼事都不會發生,因爲已經準確知道這個算法不會捅婁子(線程訪問衝突),即使破壞COM的規矩又怎樣?!而且組件仍可以和其他客戶兼容,因爲不按規矩來的是客戶,與組件無關。不過如果組件破壞規矩,那麼它將不能和每一個客戶兼容,但並不代表它和任何客戶都不兼容。這裏其實就是客戶和組件聯合起來欺騙了COM運行時期庫。
上面的例子只是想幫助讀者加深對套間的理解,實際中應該儘量保持和COM規範的兼容性(但不兼容並不代表是錯誤的)。客戶要做的工作前面已經說過了(那兩個函數或全局接口表或其他只要正確的方式),下面說明組件應該做的工作。組件可以存在於四個套間中(多了一個主STA套間),所需工作分別如下:
STA 當一個組件是STA時,它必須同步保護全局變量和靜態變量,即對全局變量和靜態變量的訪問應該用臨界段或其他同步手段保護,因爲操作全局和靜態變量的代碼可以被多個STA線程同時執行,所以那些代碼的地方要進行保護。比如對象計數(注意,不是引用計數),代表當前組件生成的對象個數,當減爲零時,組件被卸載。此變量一般被類廠對象使用,還好ATL和MFC已經幫我們實現了缺省類廠,這裏一般不用擔心,但自定義的全局或靜態變量得自己處理。
主STA 與STA唯一的不同是這是傻瓜型的,連靜態和全局變量都可以不用線程保護,因爲所有不是安全訪問靜態和全局變量的對象都通過主線程(第一個調用CoInitialize的線程)的消息派送機制運行,因此不安全的訪問都被集中到了一個線程的調用中,因而調用被序列化了,也就實現了對靜態和全局變量的線程保護。至於爲什麼是主線程,因爲進程要使用STA,則一定會創建主線程,所以一定可以創建主STA。因此主STA並不是什麼第四種套間,只是一個STA套間,不過關聯的是主線程而已,由於它可以被用作保護靜態和全局變量而被單獨提出來說明。因此一個進程內也只有一個主STA套間。
MTA 必須對組件中的每個成員和全局及靜態變量的訪問使用同步手段進行保護,還應考慮線程問題,即不是簡單地保護訪問即可,還應注意線程導致的錯誤的操作,最經典的就是IUnknown::Release()。
DWORD IUnknown::Release() { DWORD temp = InterlockedDecreament( &m_RefCount ); if( !temp ) // 不能用m_RefCount,原因請自己思考 delete this; // 因此不是隻要用原子訪問函數保護了m_RefCount的訪問就行了 return temp; // 前面對全局變量的保護也和此類似,要考慮線程問題 } |
如果讀者對自己多線程編程的技術沒有信心,建議最好不要編寫可以存在於MTA套間的組件,不過就不能獲得MTA的高性能了。
在編寫MTA時還應該注意到線程親緣性(thread affinity)。沒有線程親緣性是指沒有任何線程範圍的成員變量,比如線程局部存儲(TLS)、窗口句柄等。也就是說在MTA中不能保存任何記錄着TLS內存的指針或窗口句柄,如果保存將沒有意義(比如A線程記錄的內存空間對B線程來說是無效的,因爲TLS構造了一個線程相關的內存空間,就像每個進程都有自己的私有空間)。而不幸地MFC在它的底層運作機制的實現中大量使用了TLS,如模塊線程狀態、線程狀態等。正是由於這個原因,MFC不能編寫在MTA中運行的組件。
NA 由於可能會多個線程同時訪問NA套間的對象,因此和MTA一樣,其不能有線程親緣性並需要保護每個成員和全局及靜態變量。而關於NA的輕量級代理,是由COM+運行時期庫生成的,讀者完全不用操心(只需將那個組件的ThreadingModel鍵值賦值爲“Neutral”即可)。
前面提到過有一種進程內組件的ThreadingModel鍵值可以被賦爲“Both”,這種組件很像NA,哪個套間都可能直接訪問它,但只是可能,而NA組件是可以,這點可以從前面的那個進程內組件所屬套間的規則表中看出。這種組件可以支持一種稱作自由線程彙集器(FTM——Free Threaded Marshaler)的技術,由於其與本文題目無關,在此不表。當Both的組件使用了自由線程彙集器時,除了滿足MTA的要求以外(上面所說的線程安全保護和沒有線程相關性),還要記錄傳進來的接口指針的中立形式(比如IStream*,通過CoMarshallInterface得到),以防止對客戶的回調問題。
最後只是提醒一下,有3個STA套間,STA1、STA2和STA3。STA1用CoMarshallInterface得到的IStream*傳到STA2中通過CoUnmarshalInterface得到的代理和在STA3中同樣通過CoUnmarshalInterface得到的代理不同,不能混用。因爲當STA2和STA3調用在STA1的對象時,STA1如果回調(連接點技術就是一種回調)調用者,則STA2和STA3的代理能分別正確的指出需要讓哪個線程執行回調操作,即向哪個線程發送消息,因此不能混用。