COM STA/MTA 基礎 - 執行緒模型與避免 Hang 的思考方式

· · COM, Windows 開發, STA, MTA, 執行緒

COM STA/MTA 基礎 - 執行緒模型與避免 Hang 的思考方式

COM 的 STA/MTA,在從 Windows 或 .NET 碰 COM 時是難以避開的基礎知識。
特別常在搜尋中出現的問題是:UI 執行緒為什麼是 STA、跨 Apartment 會發生什麼事、為什麼會 Hang。

目次

  1. 先說結論(一句話)
  2. Apartment 模型的呼叫模式(圖)
  3. STA(Single-Threaded Apartment)
  4. MTA(Multi-Threaded Apartment)
  5. STA/MTA 在哪裡決定
  6. STA 處理錯誤時會發生的 Hang 具體例
  7. 大致的使用分類
  8. 總結
  9. 參考資料

使用 COM 時,「在哪一個執行緒上跑」 是無法迴避的議題。
核心就是 Apartment 模型(STA/MTA)

STA/MTA 是 COM 專用的執行緒模型
它不是 Windows 一般的執行緒概念,而是決定 COM 物件呼叫規則的機制。

本文會用 圖來整理 STA、MTA 與 COM 的關係,並接續說明 「為什麼會 Hang」

1. 先說結論(一句話)

  • COM 物件以「屬於哪個 Apartment」來決定呼叫規則
  • STA 是 1 執行緒 = 1 Apartment,MTA 是 多執行緒 = 1 Apartment 這樣理解最輕鬆
  • 跨 Apartment 的呼叫,COM 會經由 Proxy/Stub 進行 Marshaling

2. Apartment 模型的呼叫模式(圖)

COM 物件的呼叫大致可分為三種模式。

2.1. 模式1:同一 STA 執行緒內的呼叫

在同一個 STA 執行緒內可以 直接呼叫。沒有額外負擔。

flowchart LR
    subgraph STA[STA 執行緒]
        Caller[呼叫方程式碼]
        Obj[COM 物件]
        Caller -->|直接呼叫| Obj
    end

2.2. 模式2:同一 MTA 內的呼叫

MTA 內的多個執行緒,任何一個執行緒都可以直接呼叫
不過物件本身必須 設計為執行緒安全

flowchart LR
    subgraph MTA[MTA(一個 Apartment)]
        Thread1[工作執行緒 1]
        Thread2[工作執行緒 2]
        Obj[COM 物件]
        Thread1 -->|直接呼叫| Obj
        Thread2 -->|直接呼叫| Obj
    end

2.3. 模式3:跨 Apartment 的呼叫

在不同 Apartment 之間 COM 會用 Proxy/Stub 轉送。
若是標準介面,COM Runtime 會自動處理。

注意: Proxy/Stub 並不是「什麼都會自動生成」。
只是實務上大多數情況不需要明確生成。

模式 Proxy/Stub 準備
IDispatch 基底(Automation) 不需要。由 oleaut32.dll 處理
已註冊的型別程式庫 不需要。由 Type Library Marshaler 處理
.NET COM Interop 通常不需要。透過型別程式庫運作
IUnknown 直接派生的自訂 IF 需要用 MIDL 生成並註冊 Proxy/Stub

換句話說,需要用 MIDL 生成 Proxy/Stub 的情況,主要是不使用 IDispatch、而是製作 IUnknown 直接派生介面時
從 .NET 或腳本語言使用一般的 COM 元件時,這種工作並不常見。

flowchart LR
    subgraph STA[STA 執行緒]
        StaCaller[呼叫方程式碼]
    end

    subgraph RT[COM Runtime(自動)]
        Proxy[Proxy]
        RPC[RPC/IPC]
        Stub[Stub]
        Proxy --> RPC --> Stub
    end

    subgraph MTA[MTA 執行緒]
        MtaObj[COM 物件]
    end

    StaCaller -->|呼叫| Proxy
    Stub -->|轉送| MtaObj

重點:
跨 Apartment 會產生 Marshaling 的額外負擔
高頻率呼叫時會影響效能,設計時必須考量。

2.4. Marshaling 的負擔大致估計

以下是一般參考值(非實測,會依情境與參數複雜度有很大差異)。

呼叫模式 大約時間 相對感覺
同一 Apartment 內(直接) 10~100 奈秒 和一般函式呼叫差不多
不同 Apartment(同一程序內) 1~10 微秒 直接呼叫的 100~1000 倍
不同程序(Out-of-proc) 100~1000 微秒 直接呼叫的 1 萬~10 萬倍

相對比較:

  • 同一 Apartment:約一次記憶體存取
  • 不同 Apartment:約一次系統呼叫
  • 不同程序:約一次 localhost 的網路通訊

在迴圈中呼叫一萬次的場景,這個差距會非常明顯。

3. STA(Single-Threaded Apartment)

STA 是 「1 執行緒 = 1 Apartment」 的模型。

  • 該 Apartment 內的 COM 物件 原則上只在那個執行緒上執行
  • 其他執行緒呼叫時,COM 會經由訊息佇列/RPC 轉送
  • 常用於 UI 執行緒(WinForms/WPF)(UI 也是「1 執行緒親和性+訊息迴圈」,所以很搭)

3.1. 為什麼 UI 執行緒用 STA

因為 UI 執行緒和 STA 設計一致

  • UI 控制項不是執行緒安全的
    Button、TextBox 只能由建立它的執行緒安全操作
  • STA 同樣具有「1 執行緒親和性」
    COM 物件只在建立它的執行緒上直接執行
  • UI 執行緒必定有訊息迴圈
    為了處理視窗事件必須有,和 STA 的前提(訊息幫浦)一致

因此 WinForms/WPF 的 UI 執行緒 預設就是 STA

重點:
STA 執行緒親和性高,相對地 呼叫者多的時候容易塞車

4. MTA(Multi-Threaded Apartment)

MTA 是 「多執行緒 = 1 Apartment」 的模型。

  • COM 物件會被多個執行緒同時呼叫
  • 物件本身 必須設計為執行緒安全
  • 適合伺服器端處理或背景處理

重點:
MTA 的並行性高,但 物件實作的責任也比較重

5. STA/MTA 在哪裡決定

COM 的 Apartment 是 透過在每個執行緒上初始化 來決定。

  • 呼叫 CoInitialize / CoInitializeEx 的當下,該執行緒的 Apartment 就決定了
  • STA:COINIT_APARTMENTTHREADED
  • MTA:COINIT_MULTITHREADED

5.1. .NET 裡的 STA/MTA

.NET 也有 [STAThread] / [MTAThread] 屬性或 ApartmentState,但這些都是 設定 COM Apartment 模型的包裝

  • [STAThread]標註在 Main 方法(進入點)。使用 COM 時會以 STA 初始化
  • [MTAThread] → 同樣用於 Main 方法。會以 MTA 初始化
  • Thread.SetApartmentState(ApartmentState.STA)用於額外建立的執行緒。必須在執行緒啟動前設定

注意:

  • 即便有 [STAThread]在實際呼叫 COM 之前並不會初始化(不用 COM 就沒效果)
  • 額外的執行緒 [STAThread] 無效。要用 Thread.SetApartmentState

也就是說,.NET 的 STA/MTA 就是 COM 的 STA/MTA 本身
它不是 .NET 自己的執行緒模型,而是為了 COM Interop 而準備的機制。

重要:
初始化之後不能再改 Apartment。第一次的初始化就是全部

6. STA 處理錯誤時會發生的 Hang 具體例

以下這種配置 實際上很容易 Hang

6.1. 常見情境

  • 在背景建立一個 STA 執行緒並生成 COM 物件
  • 該執行緒 沒有在跑訊息迴圈
  • 從其他執行緒(不論 STA/MTA)呼叫那個 COM 物件

6.2. 到底發生了什麼

STA 的 COM 物件 必須在那個 STA 執行緒上處理呼叫
無論呼叫方是 STA 還是 MTA,只要是其他執行緒,COM 都會用訊息/RPC 轉送

但 STA 執行緒 不處理訊息 的情況下,呼叫會一直等下去,結果就是 Hang

6.3. 擬似碼(典型的失敗模式)

var ready = new AutoResetEvent(false);
var done = new AutoResetEvent(false);

object comObj = null;
var staThread = new Thread(() =>
{
    // 以 STA 初始化
    CoInitializeEx(IntPtr.Zero, COINIT_APARTMENTTHREADED);

    comObj = new SomeStaComObject();
    ready.Set();

    // 沒有訊息迴圈就等待 -> 這裡是致命傷
    done.WaitOne();
});

staThread.SetApartmentState(ApartmentState.STA);
staThread.Start();

ready.WaitOne();

// 從別的執行緒(STA/MTA 都一樣)呼叫,呼叫會轉送到 STA
// 但 STA 那邊不處理訊息,所以這裡很容易 Hang
CallComObject(comObj);
sequenceDiagram
    participant Main as 主執行緒
    participant STA as STA 執行緒
    participant COM as COM Runtime

    Main->>STA: 執行緒啟動
    STA->>STA: CoInitializeEx(STA)
    STA->>STA: 生成 COM 物件
    STA->>Main: ready.Set()
    STA->>STA: done.WaitOne() 等待
    Note over STA: 沒有訊息迴圈
卡在這裡 Main->>COM: CallComObject() COM->>STA: 試圖轉送呼叫 Note over COM: 用訊息轉送,但... Note over STA: 正在 WaitOne
無法處理訊息 Note over Main: 呼叫方也繼續等 Note over Main,STA: 雙方都在等 → Hang

簡言之:
這裡說的「前提」,是為了解釋 「為什麼在 STA 跨執行緒呼叫會 Hang」 而提到的前提。
STA 的前提有 兩點

  • COM 物件在建立它的 STA 執行緒上處理
    其他執行緒的呼叫一定會被轉送到那個 STA 執行緒
  • 為了接收那些轉送,STA 執行緒必須跑訊息幫浦
    沒跑就接不到

所以,

  • 沒跑訊息的 STA 執行緒 接不到呼叫
  • 接不到的結果就是呼叫方一直等,最後 Hang

另一方面,UI 執行緒 本來就為了處理視窗事件而一直跑訊息迴圈,不需要額外實作就符合 STA 的要求。
所以 UI 執行緒自然成為執行 STA COM 物件的最佳場所。

6.4. 避開的要點

  • 要接收其他執行緒的呼叫時,STA 執行緒必須跑訊息迴圈
  • 可能的話 在 UI 執行緒上建立與使用(UI 執行緒本來就有訊息迴圈)
  • 不需要 STA 時 一開始就用 MTA

補充: 如果只在同一執行緒內結束,不一定需要 Application.Run()
不過 UI 系、COM 系經常會跟其他執行緒的呼叫糾纏,實務上幾乎都需要。

6.5.「讓訊息迴圈轉起來」究竟是什麼

Win32 UI 執行緒平常在跑的就是這段:

while (GetMessage(out var msg, IntPtr.Zero, 0, 0))
{
    TranslateMessage(ref msg);
    DispatchMessage(ref msg);
}

STA 上其他執行緒的呼叫會被「轉送」過來。
接下這些轉送並派發去執行 的,就是這個迴圈(訊息幫浦)。

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 會直接出事)

6.7. 另一個 Hang 例子:同步呼叫中的 Callback

STA 除了「呼叫會被轉送過來」之外,某些情境下還會有 反方向(伺服器 → 客戶端)的 Callback
同步呼叫中發生 Callback 的模式 很容易造成死結。

sequenceDiagram
    participant UI as UI 執行緒(STA)
    participant Server as COM 伺服器

    UI->>Server: DoWork()(同步呼叫)
    Note over UI: 等待 DoWork 回傳
(不處理訊息) Server->>UI: ProgressCallback()(回呼) Note over UI: 等待中
收不到回呼 Note over Server: 等回呼完成 Note over UI,Server: 彼此在等對方 → 死結

為什麼容易死結:

  1. UI 執行緒 同步呼叫(阻塞)DoWork()
  2. UI 執行緒在等回傳(不處理訊息)
  3. 伺服器對 UI 執行緒送 ProgressCallback()
  4. UI 執行緒等待中,無法接收回呼
  5. 伺服器在等回呼完成
  6. 彼此在等對方 → 永遠不前進

處理時間的長短無關。同步呼叫中來回呼 的模式本身就容易出問題。

補充: COM 在某些情境下也會轉訊息、再入,依元件與呼叫形態行為不同。
不一定會死結,但這種模式最好避免。

7. 大致的使用分類

  • UI 相關 → STA
  • 大量並行處理 → MTA
  • 都不算 → 依現有函式庫或 COM 伺服器的需求決定

8. 總結

STA/MTA 是什麼:

  • STA/MTA 是 COM 專用的執行緒模型(不是 Windows 一般的執行緒概念)
  • STA 是 1 執行緒 = 1 Apartment,MTA 是 多執行緒 = 1 Apartment
  • 跨 Apartment 時 COM 會透過 Proxy/Stub 轉送(標準介面以外要用 MIDL 等生成並註冊)

STA 的前提與陷阱:

  • 要接收其他執行緒的呼叫時,STA 必須跑訊息幫浦
  • 沒跑訊息的 STA 執行緒收到呼叫 很容易 Hang
  • 同步呼叫中來回呼 的模式 很容易死結

UI 執行緒與 STA 的關係:

  • UI 執行緒本來就有「1 執行緒親和性」與「訊息迴圈」
  • 所以不需要額外實作就滿足 STA 的條件,和 STA COM 很搭

設計時要注意:

  • 跨 Apartment 的呼叫有 Marshaling 的額外負擔
  • 高頻率呼叫時會影響效能,Apartment 設計必須謹慎

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

下載本文的 Word 檔

共用相同標籤的最新文章。能以相近的主題延伸理解。

ActiveX / OCX 現在如何處理 - 保留・包裝・取代的判斷表

整理在實務專案中遇到 ActiveX 或 OCX 時的判斷流程,從 UI 部件、機器控制、報表、瀏覽器依賴到 32bit 與 64bit 的牆壁,依照保留・包裝・取代三種選項列出對照表與決策流程,並說明註冊發佈與 STA 等容易絆倒的細節,幫讀者選出最低成本的下一步。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽