COM STA/MTA 基礎知識 - 執行緒模型與避免停止回應的思考方式
· 更新日期: · Go Komura · COM, Windows 開發, STA, MTA, 執行緒
更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(DOI: 10.5281/zenodo.21616233)
本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。
Go Komura(2026)。〈COM STA/MTA 基礎知識 - 執行緒模型與避免停止回應的思考方式〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616233 https://comcomponent.com/zh-TW/blog/2026/01/31/000-sta-mta-com-relationship/
- DOI(最新版本)
- 10.5281/zenodo.21616233
- DOI(此版本)
- 10.5281/zenodo.22297063
COM 的 STA/MTA,是做 Windows 開發或從 .NET 碰 COM 時很難繞開的基礎知識。 搜尋時特別多的疑問是:UI 執行緒為什麼是 STA,跨 Apartment 會發生什麼事,為什麼會停止回應(hang)。
目錄
- 1. 先講結論(一句話)
- 2. Apartment Model 的呼叫模式(圖)
- 3. STA(Single-Threaded Apartment)
- 4. MTA(Multi-Threaded Apartment)
- 5. STA/MTA 在哪裡決定
- 6. STA 用錯時發生停止回應的具體例子
- 7. 大致的取捨
- 8. 總結
- 9. 參考資料
使用 COM 時,「在哪一個執行緒上執行」是躲不掉的問題。位於核心的就是 Apartment Model(STA/MTA)。STA/MTA 不是 Windows 一般的執行緒概念,而是為了決定 COM 物件呼叫規則而存在的執行緒模型。
本文一邊用圖整理 STA、MTA 與 COM 的關係,一邊一路說明到「為什麼有時候會停止回應」。
flowchart TB
accTitle: Apartment Model 的定位
accDescr: 說明 STA/MTA 不是 Windows 一般的執行緒概念,而是決定 COM 物件呼叫規則的執行緒模型 Apartment Model 的兩種形式的圖。
com["COM 物件的呼叫規則"] --> am["Apartment Model"]
am --> sta["STA"]
am --> mta["MTA"]
am -.-> note["不是 Windows 一般的執行緒概念"]
圖 1: STA/MTA 是決定 COM 物件呼叫規則的 Apartment Model 的兩種形式。
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 20 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
1. 先講結論(一句話)
- COM 物件的呼叫規則,由「屬於哪一個 Apartment」決定
- STA 是 一個執行緒一個 Apartment,MTA 是 多個執行緒共用一個 Apartment,這樣想比較好理解
- 跨 Apartment 的呼叫,由 COM 透過 Proxy/Stub 進行封送處理(marshalling)
flowchart TB
accTitle: STA、MTA 與跨 Apartment 的關係
accDescr: 說明 STA 採一個執行緒一個 Apartment,MTA 採多個執行緒共用一個 Apartment,而跨 Apartment 的呼叫由 COM 透過 Proxy/Stub 進行封送處理這個結論的圖。
sta["STA(一個執行緒一個 Apartment)"] -->|"跨 Apartment 的呼叫"| m["透過 Proxy / Stub 進行封送處理"]
mta["MTA(多個執行緒共用一個 Apartment)"] -->|"跨 Apartment 的呼叫"| m
圖 2: 所屬的 Apartment 決定呼叫規則,只有跨 Apartment 時才會插入封送處理。
2. Apartment Model 的呼叫模式(圖)
COM 物件的呼叫大致分成三種模式。
flowchart TB
accTitle: 呼叫的三種模式
accDescr: 說明 COM 物件的呼叫分為同一個 STA 執行緒內的呼叫、同一個 MTA 內的呼叫,以及跨 Apartment 的呼叫這三種模式的圖。
caller["COM 物件的呼叫"] --> p1["模式 1:同一個 STA 執行緒內"]
caller --> p2["模式 2:同一個 MTA 內"]
caller --> p3["模式 3:跨 Apartment"]
圖 3: 呼叫模式分成三種,落在哪一種會改變額外負擔與注意事項。
2.1. 模式 1:同一 STA 執行緒內的呼叫
在同一個 STA 執行緒內可以直接呼叫,沒有額外負擔。
flowchart LR
subgraph STA[STA 執行緒]
Caller[呼叫端程式碼]
Obj[COM 物件]
Caller -->|直接呼叫| Obj
end
圖 4: 同一個 STA 執行緒內的呼叫是直接呼叫,沒有額外負擔。
2.2. 模式 2:同一 MTA 內的呼叫
在 MTA 內的多個執行緒中,不論從哪一個執行緒都可以直接呼叫。 但是物件本身必須設計成執行緒安全。
flowchart LR
subgraph MTA[MTA(一個 Apartment)]
Thread1[工作者執行緒 1]
Thread2[工作者執行緒 2]
Obj[COM 物件]
Thread1 -->|直接呼叫| Obj
Thread2 -->|直接呼叫| Obj
end
圖 5: 在同一個 MTA 內,任何執行緒都能直接呼叫,但物件本身需要執行緒安全的設計。
2.3. 模式 3:跨 Apartment 的呼叫
在不同的 Apartment 之間,COM 會使用 Proxy/Stub 進行轉送。 如果是標準的介面,COM 執行階段會替你處理。
接下來的表格會直接出現 COM 特有的名詞,先一行一行寫清楚。
| 名詞 | 意義 |
|---|---|
| 封送處理 | 跨越 Apartment 或處理程序的邊界時,把呼叫與引數改裝成「可以原樣傳遞的形式」再送過去。到了邊界的另一側會還原成原本的形式 |
| Proxy / Stub | 負責封送處理的一組元件。站在呼叫端這一側的是 Proxy(假裝成本尊接下呼叫),站在被呼叫這一側的是 Stub(把接到的呼叫交給真正的物件) |
IDispatch |
用字串查詢方法名稱,再用編號呼叫的 COM 介面。指令碼語言或 VBA 之所以能使用 COM,就是靠這個機制 |
| Automation | 以 IDispatch 以及在其中可用的有限資料型別(BSTR、VARIANT 等)為前提的 COM 使用方式的總稱。只要待在這個範圍內,封送處理就由作業系統這一側的 oleaut32.dll 接手 |
| 型別程式庫 | 以機器可讀的形式描述介面樣貌(方法、引數的型別)的資料。可能是單獨的 .tlb 檔案,也可能內嵌在 DLL / EXE 裡 |
| 型別程式庫封送處理器 | 讀取型別程式庫,當場進行封送處理的 COM 標準功能。不必自己做專用的 Proxy/Stub,就是靠它 |
| MIDL | 從介面定義(.idl)產生 Proxy/Stub 程式碼等內容的 Microsoft 編譯器 |
注意: Proxy/Stub 並不是什麼情況都會自動備妥,不過在實務上,多數情況都不需要明確產生。
| 模式 | Proxy/Stub 的準備 |
|---|---|
以 IDispatch 為基礎(Automation) |
不需要。由 oleaut32.dll 處理 |
| 已註冊型別程式庫 | 不需要。由型別程式庫封送處理器處理 |
| .NET COM Interop | 通常不需要。透過型別程式庫運作 |
直接從 IUnknown 衍生的自訂介面 |
需要用 MIDL 產生並註冊 Proxy/Stub |
也就是說,需要用 MIDL 產生 Proxy/Stub 的,是不使用 IDispatch 而直接從 IUnknown 衍生介面的情況。
從 .NET 或指令碼語言使用的一般 COM 元件,很少需要做這件事。
flowchart TB
accTitle: 是否要自己準備 Proxy/Stub 的分岔點
accDescr: 說明只要 IDispatch 或型別程式庫封送處理器等 COM 標準的封送處理能應付就不需要產生 Proxy/Stub,只有標準封送處理應付不了的直接從 IUnknown 衍生的自訂介面才需要用 MIDL 產生並註冊 Proxy/Stub 這個分岔點的圖。
q{"標準的封送處理能不能應付"} -->|"能"| auto["不需要產生 Proxy/Stub"]
q -->|"不能"| midl["用 MIDL 產生並註冊 Proxy/Stub"]
auto -.-> how["由 IDispatch 或型別程式庫處理"]
auto -.-> few["實務上多數是這一邊"]
圖 6: 需要明確產生 Proxy/Stub 的,是標準封送處理應付不了、直接從 IUnknown 衍生的自訂介面。
flowchart LR
subgraph STA[STA 執行緒]
StaCaller[呼叫端程式碼]
end
subgraph RT[COM 執行階段(自動)]
Proxy[Proxy]
RPC[RPC/IPC]
Stub[Stub]
Proxy --> RPC --> Stub
end
subgraph MTA[MTA 執行緒]
MtaObj[COM 物件]
end
StaCaller -->|呼叫| Proxy
Stub -->|轉送| MtaObj
圖 7: 跨 Apartment 的呼叫,會經由 COM 執行階段自動備妥的 Proxy、RPC、Stub 轉送。
重點: 跨 Apartment 就會產生封送處理的額外負擔。 高頻率呼叫時會影響效能,設計時必須納入考量。
2.4. 封送處理的額外負擔參考值
以下是一般性的參考值(不是實測值,會隨情況與參數的複雜度大幅變動)。
| 呼叫模式 | 大致的時間 | 相對的感覺 |
|---|---|---|
| 同一個 Apartment 內(直接) | 10〜100 奈秒 | 幾乎與一般的函式呼叫相同 |
| 不同 Apartment(同一個處理程序) | 1〜10 微秒 | 直接呼叫的 100〜1000 倍 |
| 不同處理程序(Out-of-proc) | 100〜1000 微秒 | 直接呼叫的 1 萬〜10 萬倍 |
相對的比較:
- 同一個 Apartment:大約是一次記憶體存取
- 不同 Apartment:大約是一次系統呼叫
- 不同處理程序:大約是對本機主機的一次網路通訊
在迴圈中呼叫一萬次這種場面,這個差距會明顯顯現。
關於這組數值的看待方式
上表是 為了掌握數量級的感覺,並不是有出處的實測值。 也沒有公開的統一效能基準,所以請不要把這些數值本身當成設計判斷的依據。
另一方面,「同一個 Apartment 內是直接呼叫,一旦跨越邊界就一定會插入封送處理」這個結構,是 Microsoft 文件寫明的規格。Single-Threaded Apartments 一節明確寫著:在同一個 Apartment 內可以不經封送處理直接傳遞介面指標;跨 Apartment 時即使在同一個處理程序內,也會使用與跨處理程序相同的封送處理機制;而且呼叫是以送往隱藏視窗(OleMainThreadWndClass)的視窗訊息形式送達。數量級會改變就是因為這個結構,數值本身則隨環境與引數的複雜度浮動。
想針對自己的情況做判斷時,最可靠的是拿同一個介面,分別從同一個 Apartment 與另一個 Apartment 呼叫,實際量測比較。
flowchart TB
accTitle: 數量級改變的結構性理由
accDescr: 說明同一個 Apartment 內是直接呼叫,而跨 Apartment 時即使在同一個處理程序內也一定會插入封送處理,且目的地是 STA 時呼叫會以視窗訊息的形式抵達隱藏視窗,這個造成數量級改變的結構性理由的圖。
same["同一個 Apartment 內的呼叫"] --> direct["直接呼叫"]
cross["跨 Apartment 的呼叫"] --> marshal["一定會插入封送處理"]
marshal -.-> msg["送往 STA 的會抵達隱藏視窗"]
圖 8: 數量級會改變,是因為規格上的結構規定跨越邊界時一定會插入封送處理。
// C#。把同一個呼叫重複 N 次,算出每一次的時間
static void Measure(string label, Action call, int iterations = 100_000)
{
call(); // 把第一次的延遲(JIT、產生 Proxy、建立連線)排除在量測之外
var sw = System.Diagnostics.Stopwatch.StartNew();
for (int i = 0; i < iterations; i++)
{
call();
}
sw.Stop();
double perCallNs = sw.Elapsed.TotalMilliseconds * 1_000_000.0 / iterations;
Console.WriteLine($"{label}: {perCallNs:F1} ns/call");
}
同時量測引數少的方法與傳遞字串或陣列的方法,就能看出「封送處理的量會改變影響的程度」。
3. STA(Single-Threaded Apartment)
STA 是 「一個執行緒 = 一個 Apartment」 的模型。
- 該 Apartment 內的 COM 物件,原則上只在那個執行緒上執行
- 從其他執行緒呼叫時,COM 會經由訊息佇列 / RPC 轉送呼叫
- 常用於 UI 執行緒(WinForms/WPF)(UI 同樣是「單一執行緒親和性+訊息迴圈」,因此很合)
flowchart TB
accTitle: STA 的執行模型
accDescr: 說明在 STA 中,Apartment 內的 COM 物件原則上只在建立它的執行緒上執行,來自其他執行緒的呼叫由 COM 經由訊息佇列或 RPC 轉送到該執行緒的圖。
obj["STA 的 COM 物件"] --> own["只在建立它的執行緒上執行"]
other["來自其他執行緒的呼叫"] --> fwd["經由訊息佇列 / RPC 轉送"]
fwd --> own
圖 9: STA 的物件只在自己所屬的執行緒上運作,來自別處的呼叫要經過轉送才會送達。
3.1. 為什麼 UI 執行緒使用 STA
因為 UI 執行緒與 STA 的設計是一致的。
- UI 控制項不是執行緒安全的 按鈕、文字方塊等只能由建立它的執行緒安全操作
- STA 同樣是「單一執行緒親和性」 COM 物件只在建立它的執行緒上直接執行
- UI 執行緒一定會跑訊息迴圈 處理視窗事件時不可或缺,與 STA 的前提(訊息幫浦)一致
所以 WinForms/WPF 的 UI 執行緒預設就是 STA。
flowchart TB
accTitle: UI 執行緒與 STA 在設計上的一致
accDescr: 說明 UI 控制項只能由建立它的執行緒安全操作,STA 同樣是單一執行緒親和性,而 UI 執行緒一定會跑的訊息迴圈正好符合 STA 的前提訊息幫浦,因此 UI 執行緒預設就是 STA 的圖。
ui["UI 控制項的單一執行緒親和性"] --> match["設計一致"]
sta["STA 的單一執行緒親和性"] --> match
loop["UI 執行緒的訊息迴圈"] --> pre["STA 的前提(訊息幫浦)"]
pre --> match
match --> def["UI 執行緒預設是 STA"]
圖 10: 因為在單一執行緒親和性與訊息迴圈這兩點上設計一致,UI 執行緒才會是 STA。
重點: STA 的執行緒親和性高,代價是呼叫端一多就容易塞車。
4. MTA(Multi-Threaded Apartment)
MTA 是 「多個執行緒共用一個 Apartment」 的模型。
- COM 物件會被多個執行緒同時呼叫
- 物件本身必須設計成執行緒安全
- 適合伺服器端處理或背景處理
重點: MTA 的平行度高,但物件實作要背的責任也重。
flowchart TB
accTitle: MTA 的執行模型
accDescr: 說明 MTA 由多個執行緒共用一個 Apartment,COM 物件會被多個執行緒同時呼叫,因此物件本身必須設計成執行緒安全的圖。
mta["MTA(多個執行緒共用一個 Apartment)"] --> par["會被多個執行緒同時呼叫"]
par --> safe["物件本身必須設計成執行緒安全"]
mta -.-> use["適合伺服器端或背景處理"]
圖 11: MTA 可以平行呼叫,代價是安全性的責任移到物件實作這一側。
5. STA/MTA 在哪裡決定
COM 的 Apartment 由每個執行緒各自初始化來決定。
- 呼叫
CoInitialize/CoInitializeEx的那一刻,該執行緒的 Apartment 就決定了 - STA:
COINIT_APARTMENTTHREADED - MTA:
COINIT_MULTITHREADED
flowchart TB
accTitle: Apartment 決定的那一刻
accDescr: 說明執行緒呼叫 CoInitialize 或 CoInitializeEx 的那一刻就決定了該執行緒的 Apartment,指定 COINIT_APARTMENTTHREADED 就是 STA,指定 COINIT_MULTITHREADED 就是 MTA 的圖。
th["執行緒"] --> init["呼叫 CoInitialize / CoInitializeEx"]
init -->|"COINIT_APARTMENTTHREADED"| sta["決定為 STA"]
init -->|"COINIT_MULTITHREADED"| mta["決定為 MTA"]
圖 12: Apartment 由每個執行緒呼叫初始化那一刻的指定決定。
5.1. .NET 中的 STA/MTA
.NET 也有 [STAThread] / [MTAThread] 屬性與 ApartmentState,但它們是用來設定 COM Apartment Model 的包裝層。
[STAThread]→ 加在 Main 方法(進入點)上。使用 COM 時會以 STA 初始化[MTAThread]→ 同樣用於 Main 方法,會以 MTA 初始化Thread.SetApartmentState(ApartmentState.STA)→ 用於額外建立的執行緒,必須在執行緒啟動前設定
注意事項:
- 即使有
[STAThread],在實際呼叫 COM 之前都不會初始化(不使用 COM 就沒有作用) [STAThread]對額外建立的執行緒無效,要改用Thread.SetApartmentState
換句話說,.NET 的 STA/MTA 就是 COM 的 STA/MTA 本身,是為了 COM 互通而準備的機制。
重要: 之後無法變更 Apartment。第一次的初始化就決定了一切。
flowchart TB
accTitle: 在 .NET 中設定 Apartment 的位置
accDescr: 說明 Main 方法要加上 STAThread 或 MTAThread 屬性,額外建立的執行緒要在啟動前用 Thread.SetApartmentState 設定,兩者都在第一次初始化時確定 Apartment 且之後無法變更的圖。
main["Main 方法"] --> attr["〔STAThread〕/〔MTAThread〕屬性"]
newth["額外建立的執行緒"] --> setap["啟動前呼叫 Thread.SetApartmentState"]
attr --> fixed["第一次初始化就確定 Apartment"]
setap --> fixed
fixed -.-> nochange["之後無法變更"]
圖 13: 進入點用屬性,額外的執行緒用 SetApartmentState,兩者都在第一次就確定。
6. STA 用錯時發生停止回應的具體例子
下面這種組合,實際上很容易引發停止回應。
6.1. 常見的情況
- 在背景建立 STA 執行緒,並在上面建立 COM 物件
- 那個執行緒沒有跑訊息迴圈
- 從其他執行緒(不論 STA 或 MTA)呼叫那個 COM 物件
flowchart TB
accTitle: 容易造成停止回應的組合
accDescr: 說明在背景建立的 STA 執行緒建立了 COM 物件卻沒有跑訊息迴圈,又從其他執行緒呼叫該 COM 物件,這種容易造成停止回應的組合的圖。
bg["背景的 STA 執行緒"] --> gen["建立 COM 物件"]
bg --> noloop["沒有跑訊息迴圈"]
other["其他執行緒(不論 STA / MTA)"] -->|"呼叫"| gen
圖 14: 「從其他執行緒去呼叫一個不跑迴圈的 STA 所持有的物件」這個組合很危險。
6.2. 到底發生了什麼
停止回應的原因可以收攏成 STA 的兩個前提。本節就是原因的說明,後面的章節不會再重複。
- COM 物件在建立它的 STA 執行緒上處理
無論呼叫端是 STA 還是 MTA,來自其他執行緒的呼叫一定會被轉送到那個 STA 執行緒。轉送是以視窗訊息的形式,送到 COM 為該 Apartment 建立的隱藏視窗(視窗類別
OleMainThreadWndClass) - 要接下這個轉送,STA 執行緒必須在跑訊息幫浦 Microsoft 的文件也明確寫著「每個 STA 都必須具備訊息迴圈,才能處理來自其他處理程序或同一處理程序內其他 Apartment 的呼叫」
因此,沒有在跑訊息的 STA 執行緒接不到呼叫,呼叫端會一直等回覆,結果就是停止回應。
flowchart TB
accTitle: 走向停止回應的流程
accDescr: 說明來自其他執行緒的呼叫會以送往隱藏視窗的視窗訊息形式轉送到建立物件的 STA 執行緒,但 STA 執行緒沒有跑訊息幫浦就接不到,呼叫端一直等回覆而停止回應的流程的圖。
caller["來自其他執行緒的呼叫"] --> fwd["轉送到建立物件的 STA 執行緒"]
fwd -.-> hidden["以送往隱藏視窗的訊息形式抵達"]
fwd --> nopump["訊息幫浦沒有在跑"]
nopump --> norecv["STA 這一側接不到呼叫"]
norecv --> wait["呼叫端一直等回覆"]
wait --> hang["停止回應"]
圖 15: 轉送是以訊息的形式抵達,所以幫浦停住的 STA 就沒有人來接收。
順帶一提,這在 .NET 上也一樣。Single-Threaded Apartments 的文件放了一段警告:如果用 Task.Wait()、Task.Result、Thread.Sleep()、ManualResetEvent.WaitOne() 之類的方式阻塞 STA 執行緒,COM 的回呼或跨 Apartment 的呼叫就無法完成,會形成死結。下一節 6.3 的失敗例停在 WaitOne(),正是這個形式。
另一方面,UI 執行緒為了處理視窗事件,從一開始就在跑訊息迴圈,不必額外實作就滿足了 STA 的要求。UI 執行緒之所以是執行 STA COM 物件的自然選項,原因就在這裡。
6.3. 虛擬碼(典型的失敗模式)
using System;
using System.Runtime.InteropServices;
using System.Threading;
internal static class StaHangDemo
{
private const uint COINIT_APARTMENTTHREADED = 0x2;
[DllImport("ole32.dll")]
private static extern int CoInitializeEx(IntPtr pvReserved, uint dwCoInit);
[DllImport("ole32.dll")]
private static extern void CoUninitialize();
public static void Run(string progId)
{
var ready = new AutoResetEvent(false);
var done = new AutoResetEvent(false);
object comObj = null;
var staThread = new Thread(() =>
{
// 以 STA 初始化
CoInitializeEx(IntPtr.Zero, COINIT_APARTMENTTHREADED);
// 假設是以 ThreadingModel=Apartment 註冊的 COM 類別
Type type = Type.GetTypeFromProgID(progId, throwOnError: true);
comObj = Activator.CreateInstance(type);
ready.Set();
// 在沒有訊息迴圈的情況下等待 -> 這裡就是致命傷
done.WaitOne();
CoUninitialize();
});
staThread.SetApartmentState(ApartmentState.STA);
staThread.Start();
ready.WaitOne();
try
{
// 從其他執行緒(不論 STA/MTA)呼叫時,呼叫會被轉送到 STA
// 但 STA 這一側不處理訊息,所以這裡很容易停止回應
dynamic obj = comObj;
obj.AnyMethod();
}
finally
{
// 即使呼叫有回來 ── 類別本身是 agile、
// 呼叫沒有被轉送、AnyMethod 立刻回傳 ── 也
// 一定要釋放 STA 執行緒。省掉這裡,就會留下一直等 done 的
// 前景執行緒,「沒有重現」時處理程序同樣不會結束,
// 於是無法從症狀分辨到底有沒有重現
done.Set();
staThread.Join();
}
}
}
這段程式碼的前提是傳入以 ThreadingModel=Apartment(= STA)註冊的 COM 類別的 ProgID。AnyMethod 請換成該類別實際擁有的方法名稱。並不是任何 COM 類別都會重現。 重現需要的條件有下列三個。
| 條件 | 查看方式 |
|---|---|
目標類別的 ThreadingModel 是 Apartment |
查看登錄檔 HKEY_CLASSES_ROOT\CLSID\{CLSID}\InprocServer32 的 ThreadingModel 值。如果是 Both 或 Free,呼叫不會被轉送,就不會重現 |
| STA 執行緒沒有在跑訊息 | 上面例子裡的 done.WaitOne() 就是這種情形 |
| 從其他執行緒發出呼叫 | 只要是在同一個 STA 執行緒內呼叫就是直接呼叫,不會停止回應 |
把 obj.AnyMethod() 夾在 try / finally 之間,讓 done.Set() 與 staThread.Join() 一定會被執行到,是為了分辨這種「沒有重現的情況」。STA 執行緒預設是前景執行緒,只要 done 沒有被設定,處理程序就不會結束。省掉 finally,重現時(呼叫回不來)和沒有重現時(呼叫回來了,卻沒有人設定 done),從外面看到的症狀都會是「處理程序不會結束」。用來確認重現條件的工具,反而把條件成不成立藏了起來。用上面的寫法,沒有重現時就會正常結束。
要確認確實卡住了,最快的方式是用偵錯器暫停處理程序,查看呼叫端執行緒的堆疊停在 COM 的等待上,而 STA 執行緒停在 WaitOne。
sequenceDiagram
participant Main as 主執行緒
participant STA as STA 執行緒
participant COM as COM 執行階段
Main->>STA: 啟動執行緒
STA->>STA: CoInitializeEx(STA)
STA->>STA: 建立 COM 物件
STA->>Main: ready.Set()
STA->>STA: 用 done.WaitOne() 等待
Note over STA: 沒有訊息迴圈<br/>卡在這裡
Main->>COM: CallComObject()
COM->>STA: 試著轉送呼叫
Note over COM: 用訊息轉送,但是……
Note over STA: 正在 WaitOne<br/>無法處理訊息
Note over Main: 呼叫端也一直在等
Note over Main,STA: 兩邊都在等待 → 停止回應
圖 16: 停在 WaitOne 的 STA 執行緒,與等待轉送回覆的主執行緒,兩邊都無法再往前。
圖的中間,「用訊息轉送,但是……」這兩行,就是 6.2 寫的兩個前提崩掉的位置。
6.4. 避開的要點
- 要接收來自其他執行緒的呼叫時,STA 執行緒必須跑訊息迴圈
- 可以的話在 UI 執行緒上建立與使用(UI 執行緒一開始就有訊息迴圈)
- 不需要 STA 的話一開始就用 MTA
補充: 如果一切都在同一個執行緒內完成,並不是永遠都需要 Application.Run()。
不過 UI 相關與 COM 相關的處理常常牽涉到其他執行緒的呼叫,實務上幾乎是必備的。
flowchart TB
accTitle: 避免停止回應的三個方向
accDescr: 說明要接收來自其他執行緒的呼叫就在 STA 執行緒上跑訊息迴圈,可以的話在一開始就有訊息迴圈的 UI 執行緒上建立與使用,不需要 STA 就一開始改用 MTA,這三個避免停止回應的方向的圖。
q["如何避免 STA 的停止回應"] --> a1["在 STA 執行緒上跑訊息迴圈"]
q --> a2["在 UI 執行緒上建立與使用"]
q --> a3["不需要 STA 就一開始用 MTA"]
a2 -.-> why["UI 執行緒一開始就有迴圈"]
圖 17: 回避方式可以歸納成三個方向:跑迴圈、集中到有迴圈的地方、不用 STA。
6.5.「讓訊息迴圈跑起來」到底是什麼
就是 Win32 的 UI 執行緒在做的那件事。
while (GetMessage(out var msg, IntPtr.Zero, 0, 0))
{
TranslateMessage(ref msg);
DispatchMessage(ref msg);
}
在 STA 中,來自其他執行緒的呼叫會被「轉送」過來。 把這個轉送接下來並送去執行的,就是這個迴圈(訊息幫浦)。
flowchart TB
accTitle: 訊息幫浦的角色
accDescr: 說明用 GetMessage 接下從其他執行緒轉送過來的呼叫,再用 DispatchMessage 送去執行,這樣的反覆就是訊息幫浦的圖。
fwd["轉送過來的呼叫"] --> gm["用 GetMessage 接下來"]
gm --> dm["用 DispatchMessage 送去執行"]
dm --> gm
圖 18: 所謂「讓訊息迴圈跑起來」,就是接下轉送再送去執行的這個反覆。
6.6. 方向正確的例子(隨手寫大概是這樣)
如果是「想在背景的 STA 上使用 COM」,會寫成這樣。
var ready = new AutoResetEvent(false);
object comObj = null;
var staThread = new Thread(() =>
{
CoInitializeEx(IntPtr.Zero, COINIT_APARTMENTTHREADED);
comObj = new SomeStaComObject();
ready.Set();
// STA 執行緒還活著的期間持續跑訊息
Application.Run();
CoUninitialize();
});
staThread.SetApartmentState(ApartmentState.STA);
staThread.Start();
ready.WaitOne();
CallComObject(comObj);
(※ 忘記呼叫 CoInitializeEx / CoUninitialize 一樣會出事。CoInitializeEx 的 P/Invoke 宣告要用和 6.3 相同的那一份)
Application.Run() 不帶引數的形式,是為了 不持有表單,只跑訊息迴圈。用途上這裡正是它設想的使用場面,但下面兩點要先弄清楚。
- 需要加入對
System.Windows.Forms的參考。如果是主控台應用程式,就在專案檔加上<UseWindowsForms>true</UseWindowsForms> - 這個迴圈不會自己結束。 要停下來,就在那個執行緒上呼叫
Application.ExitThread()(或是結束整個應用程式的Application.Exit())。上面的例子若想執行到CoUninitialize(),還需要另外做一套機制,把「結束」告訴 STA 執行緒並讓它呼叫ExitThread
不想相依於 WinForms 時,就自己寫 6.5 的 GetMessage / DispatchMessage 迴圈,或是使用能同時等待訊息與同步物件的 MsgWaitForMultipleObjects。後者是想做成「用事件接收結束通知,同時又不漏接 COM 呼叫」時的首選。
flowchart TB
accTitle: 在背景 STA 上跑迴圈的三種手段
accDescr: 說明要在背景 STA 上跑訊息迴圈,有需要 WinForms 參考的 Application.Run、自己寫 GetMessage 與 DispatchMessage 迴圈,以及能同時等待訊息與同步物件的 MsgWaitForMultipleObjects 這三種手段的圖。
want["在背景 STA 上跑迴圈"] --> ar["Application.Run"]
want --> gml["自己寫 GetMessage 迴圈"]
want --> mw["MsgWaitForMultipleObjects"]
ar -.-> dep["需要 WinForms 參考與結束手段"]
mw -.-> both["能同時等待訊息與同步物件"]
圖 19: 跑迴圈的方式有三種,依結束方式與相依的輕重來選。
6.7. 另一個停止回應的例子:同步呼叫進行中的回呼
STA 不只是「呼叫會被轉送」,在某些情況下還會有反方向(伺服器→用戶端)的回呼進來。其中同步呼叫進行中發生回呼的模式,是死結的經典款。
sequenceDiagram
participant UI as UI 執行緒(STA)
participant Server as COM 伺服器
UI->>Server: DoWork()(同步呼叫)
Note over UI: 正在等 DoWork 回傳<br/>(沒有在處理訊息)
Server->>UI: ProgressCallback()(回呼)
Note over UI: 正在等待<br/>接不到回呼
Note over Server: 正在等回呼完成
Note over UI,Server: 雙方互相等待對方 → 死結
圖 20: 等待同步呼叫回傳的 UI 執行緒,與等待回呼完成的伺服器,互相等著對方。
為什麼容易變成死結:
- UI 執行緒同步呼叫
DoWork()(阻塞) - UI 執行緒正在等回傳(沒有在處理訊息)
- 伺服器把
ProgressCallback()送到 UI 執行緒 - UI 執行緒正在等待,所以接不到回呼
- 伺服器正在等回呼完成
- 雙方互相等待對方 → 永遠不會往前
與處理時間的長短無關。同步呼叫進行中收到回呼這個模式本身就很容易出問題。
補充: COM 也有依情況跑訊息、重新進入(reentrancy)的機制,行為會隨元件與呼叫形式而不同。 不是每次都一定會死結,不過還是避開這個模式比較保險。
7. 大致的取捨
| 情況 | 選擇 | 判斷的理由 | 一併需要做的事 |
|---|---|---|---|
| 牽涉 UI(WinForms / WPF) | STA | 因為 UI 控制項是單一執行緒親和性,而 UI 執行緒本來就有訊息迴圈(3.1) | 沒有特別要做的事。預設就是 STA |
| 想做大量平行處理 | MTA | 因為多個執行緒共用一個 Apartment,不必轉送就能直接呼叫(2.2) | COM 物件本身要設計成執行緒安全。這不是呼叫端做互斥就好,而是物件實作這一側的責任 |
| 想在背景使用 STA 的 COM | STA + 訊息迴圈 | 既然會被其他執行緒呼叫,就需要一個接收轉送的入口(6.2) | Application.Run() 或 GetMessage 迴圈。結束手段(ExitThread 等)也要一起設計(6.6) |
| 要用的 COM 元件已經有既定要求 | 配合對方 | 因為 Apartment 就是呼叫規則本身,之後無法變更(第 5 章) | 查看 ThreadingModel 的值。如果是 Apartment,就以 STA 為前提來組 |
| 高頻率呼叫 | 集中到與呼叫端相同的 Apartment | 因為每次跨越邊界都會插入封送處理(2.4) | 做不到的話,就改成減少呼叫次數本身(一次整批傳遞)的設計 |
猶豫時的優先順序,按 「對方的要求 > 有沒有 UI > 平行度」 的順序來看比較容易決定。Apartment 在第一次初始化時就確定,之後無法變更,所以只有這一點要在開始實作之前先決定好。
flowchart LR
accTitle: 猶豫時查看的順序
accDescr: 說明在 STA 與 MTA 的取捨上猶豫時,依所使用的 COM 元件那一側的要求、有沒有 UI、平行度的順序確認比較容易決定的圖。
p1["對方的要求"] -->|"接著"| p2["有沒有 UI"]
p2 -->|"接著"| p3["平行度"]
圖 21: 取捨上猶豫時,依對方的要求、有沒有 UI、平行度的順序確認。
8. 總結
STA/MTA 是為了 COM 而存在的執行緒模型,STA 採一個執行緒 = 一個 Apartment,MTA 採多個執行緒共用一個 Apartment。跨 Apartment 的呼叫由 COM 經由 Proxy/Stub 替你轉送(標準介面以外的,需要用 MIDL 等產生並註冊),但這會伴隨封送處理的額外負擔,所以在預期會高頻率呼叫的場面,Apartment 的設計要慎重決定。
從停止回應的角度看,重點只有一句:「會接收其他執行緒呼叫的 STA 執行緒,前提是要跑訊息幫浦」。對沒有在跑訊息的 STA 執行緒發出呼叫就容易停止回應,同步呼叫進行中收到回呼的模式也容易變成死結。UI 執行緒一開始就同時具備「單一執行緒親和性」與「訊息迴圈」,不必額外實作就滿足這個前提,所以和 STA 的 COM 特別合。
9. 參考資料
- Apartment Model https://learn.microsoft.com/en-us/windows/win32/com/com-apartments
- CoInitializeEx https://learn.microsoft.com/en-us/windows/win32/api/objbase/nf-objbase-coinitializeex
- Single-Threaded Apartments(訊息迴圈為何必要、隱藏視窗、.NET 上的死結警告) https://learn.microsoft.com/en-us/windows/win32/com/single-threaded-apartments
- Multithreaded Apartments https://learn.microsoft.com/en-us/windows/win32/com/multithreaded-apartments
- InprocServer32(
ThreadingModel的值) https://learn.microsoft.com/en-us/windows/win32/com/inprocserver32 - Application.Run 方法(不使用表單就跑訊息迴圈的形式,以及停止的方法) https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.application.run
- MsgWaitForMultipleObjects(同時等待訊息與同步物件) https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-msgwaitformultipleobjects
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
WinRT 就是 COM —— IInspectable、.winmd、語言投影,以及 WinUI 至今仍立在二進位契約之上的原因
WinRT 不是受管理的執行階段,而是在 COM 之上加了中繼資料(.winmd)與語言投影的 ABI。本文從 IUnknown 與 IInspectable 的關係,一路談到桌面應用程式裡 HWND 初始化與 package identity 的卡關之處。
什麼是 OLE 物件 —— 內嵌與連結的機制以及業務文件中的陷阱
在 Word 中內嵌 Excel 表格的功能,本質就是 OLE 物件。本文從內嵌與連結的差異、複合檔案與結構化儲存體、In-Place Activation 的機制,一路談到連結中斷、檔案膨脹與資安對策,全部立足於實務視角。
剪貼簿與拖放的運作方式 ── 在業務應用程式中正確處理 OLE 資料傳輸
貼上 Excel 表格就散掉、關掉複製來源就貼不上──真正的原因,是剪貼簿把同一份內容以多種格式同時放上。本文從標準格式、延遲轉譯、OLE 拖放,一路談到剪貼簿歷程與雲端同步的原則。
今日的 Windows 殼層整合 ── 右鍵選單、檔案關聯,以及 Windows 11 的變化
說明 Windows 11 的右鍵選單為何會藏進「顯示更多選項」,並整理副檔名→ProgID→verb 這套關聯基礎、傳統殼層擴充功能的注意事項,以及 IExplorerCommand 與 MSIX/sparse package 的新做法。
Arm 版 Windows 能執行業務應用程式嗎 ── x64 模擬(Prism)與原生 DLL・COM 的現實
本文為開發者・資訊系統部門解答「Arm 版 Windows 能執行業務應用程式嗎」這個問題,整理 x64 模擬(Prism)的運作原理、驅動程式等無法執行的層級、.NET 的 AnyCPU 與 P/Invoke 問題,以及 Arm 對應檢查清單。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
ActiveX 遷移
整理保留、包裝或替換 COM / ActiveX / OCX 資產的階段性判斷的主題頁面。
UI 執行緒 & 計時器
整理 WPF / WinForms UI 執行緒、非同步流程、Dispatcher 使用、計時器判斷的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
技術諮詢 & 設計審查
梳理 STA / MTA、訊息迴圈與封送處理,直接關係到實作前的職責劃分與執行緒邊界審查。
既有資產活用 & 遷移支援
處理含有 COM 的既有資產時,這是很難繞開的基礎,因此也很適合搭配既有資產活用與遷移支援的諮詢。
常見問題
整理諮詢這個主題時常見的問題。
- STA 與 MTA 應該選哪一個?
- 基本的取捨是:牽涉 UI 的處理選 STA,大量平行處理選 MTA。STA 採取一個執行緒對一個 Apartment 的形式,執行緒親和性高,但呼叫端一多就容易塞車。MTA 由多個執行緒共用一個 Apartment,平行度高,但 COM 物件本身必須設計成執行緒安全。兩者都不明顯適用時,實際的做法是配合所使用的既有函式庫或 COM 伺服器的要求。
- 為什麼 UI 執行緒是 STA?
- 因為 UI 執行緒與 STA 的設計一致。按鈕、文字方塊等 UI 控制項不是執行緒安全的,只能由建立它的執行緒安全操作;STA 同樣是單一執行緒親和性的模型。此外,UI 執行緒為了處理視窗事件一定會跑訊息迴圈,不必額外實作就滿足了 STA 所需的訊息幫浦。因此 WinForms/WPF 的 UI 執行緒預設就是 STA。
- 為什麼呼叫 STA 的 COM 物件會停止回應?
- 對 STA COM 物件的呼叫,會在建立它的那個 STA 執行緒上處理。來自其他執行緒的呼叫由 COM 透過訊息或 RPC 轉送,但如果那個 STA 執行緒沒有跑訊息迴圈,就收不到轉送過來的呼叫,呼叫端只能一直等下去而停止回應。要避開,就讓會被其他執行緒呼叫的 STA 執行緒跑訊息迴圈,或是在 UI 執行緒上建立與使用物件;若不需要 STA,就一開始改用 MTA。
- .NET 的 [STAThread] 屬性是做什麼用的?
- 它是設定 COM Apartment Model 的包裝層。加在 Main 方法上,使用 COM 時該執行緒就會以 STA 初始化。不過在實際呼叫 COM 之前並不會初始化,對完全不用 COM 的應用程式沒有作用。它對額外建立的執行緒也無效,那些執行緒要在啟動前用 Thread.SetApartmentState 設定。還要注意 Apartment 由第一次初始化決定,之後無法變更。