COM STA/MTA 基础 - 线程模型与避免 Hang 的思路

· 更新日期: · · COM, Windows 开发, STA, MTA, 线程

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276553)
补充了日文原文中已有的咨询引导(consultation_services),并删除了正文开头与标题重复的一级标题。版面本来就会显示标题,读者原先会看到两次。 查看更新前的版本 (DOI: 10.5281/zenodo.21615411)
首次发布
引用本文(DOI: 10.5281/zenodo.21615410)

本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。

小村 豪(2026)。《COM STA/MTA 基础 - 线程模型与避免 Hang 的思路》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615410 https://comcomponent.com/zh-CN/blog/2026/01/31/000-sta-mta-com-relationship/

DOI(最新版本)
10.5281/zenodo.21615410
DOI(此版本)
10.5281/zenodo.22281917

COM 的 STA/MTA,是做 Windows 开发、或者从 .NET 接触 COM 时很难绕开的基础知识。 搜索里出现得特别多的疑问是:UI 线程为什么是 STA、跨 Apartment 会发生什么、为什么会 Hang。

目录


使用 COM 时,“在哪个线程上运行”是绕不开的问题。处在这个问题中心的,就是 Apartment Model(STA/MTA)。STA/MTA 不是 Windows 通用的线程概念,而是为了确定 COM 对象的调用规则而存在的线程模型。

本文一边用图梳理 STA、MTA 与 COM 的关系,一边一直讲到“为什么有时候会 Hang”。

Apartment Model 的定位说明 STA/MTA 不是 Windows 通用的线程概念,而是为了确定 COM 对象调用规则而存在的线程模型 Apartment Model 的两种形式的图。COM 对象的调用规则Apartment ModelSTAMTA不是 Windows 通用的线程概念

图1:STA/MTA 是决定 COM 对象调用规则的 Apartment Model 的两种形式。

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 20 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

1. 先说结论(一句话)

  • COM 对象的调用规则,由“它属于哪个 Apartment”决定
  • 把 STA 理解成 1 个线程 1 个 Apartment、把 MTA 理解成 多个线程共用 1 个 Apartment,就容易抓住要点
  • 跨 Apartment 的调用,由 COM 经 Proxy/Stub 做封送处理
STA、MTA 与跨 Apartment 的关系说明 STA 采用 1 个线程 1 个 Apartment、MTA 采用多个线程共用 1 个 Apartment 的形式,而跨 Apartment 的调用由 COM 经 Proxy/Stub 做封送处理这一结论的图。跨 Apartment 的调用跨 Apartment 的调用STA(1 个线程 1 个 Apartment)经 Proxy / Stub 做封送处理MTA(多个线程共用 1 个 Apartment)

图2:所属的 Apartment 决定调用规则,只有跨 Apartment 时才会插入封送处理。

2. Apartment Model 的调用模式(图)

COM 对象的调用大致分为三种模式。

调用的三种模式说明 COM 对象的调用分为同一 STA 线程内的调用、同一 MTA 内的调用、跨 Apartment 的调用这三种模式的图。COM 对象的调用模式1:同一 STA 线程内模式2:同一 MTA 内模式3:跨 Apartment

图3:调用模式分为三种,落在哪一种,决定了开销和需要注意的地方。

2.1. 模式1:同一 STA 线程内的调用

在同一个 STA 线程内可以 直接调用。没有额外开销。

STA 线程直接调用调用方代码COM 对象

图4:同一 STA 线程内的调用是直接调用,没有额外开销。

2.2. 模式2:同一 MTA 内的调用

MTA 内的多个线程,任何一个线程都可以直接调用。 不过对象这一侧 必须做线程安全设计。

MTA(一个 Apartment)直接调用直接调用工作线程 1COM 对象工作线程 2

图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 的代码等

注意: Proxy/Stub 并不是什么都会自动备好,不过在实务中,绝大多数情况都不需要显式生成。

模式 Proxy/Stub 的准备
基于 IDispatch(Automation) 不需要。由 oleaut32.dll 处理
已注册类型库 不需要。由类型库封送器处理
.NET COM Interop 通常不需要。通过类型库运作
从 IUnknown 直接派生的自定义接口 需要用 MIDL 生成并注册 Proxy/Stub

也就是说,需要用 MIDL 生成 Proxy/Stub 的,是不使用 IDispatch、而要做从 IUnknown 直接派生的接口的情况。 从 .NET 或脚本语言使用一般的 COM 组件时,很少会碰到这项工作。

是否需要自己准备 Proxy/Stub 的分岔点说明在基于 IDispatch 或类型库封送器等 COM 标准封送处理能够覆盖时不需要生成 Proxy/Stub,只有标准封送处理覆盖不了、要做从 IUnknown 直接派生的自定义接口时,才需要用 MIDL 生成并注册 Proxy/Stub 这一分岔点的图。能不能标准的封送处理能否覆盖不需要生成 Proxy/Stub用 MIDL 生成并注册 Proxy/Stub由 IDispatch 或类型库处理实务中绝大多数属于这一侧

图6:需要显式生成 Proxy/Stub 的,是标准封送处理覆盖不了、从 IUnknown 直接派生的自定义接口。

MTA 线程COM 运行时(自动)STA 线程调用转发COM 对象ProxyRPC/IPCStub调用方代码

图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 内是直接调用,一旦越过边界就必定插入封送处理”这个结构,是微软文档里写明的规格。Single-Threaded Apartments 一节里明确写着:在同一个 Apartment 内可以不做封送处理就传递接口指针;跨 Apartment 时即便在同一进程内,也使用与进程间相同的封送机制;以及调用会以发往隐藏窗口(OleMainThreadWndClass)的窗口消息的形式送达。数量级之所以会变,原因就在这个结构,而数值本身会随环境和参数复杂度浮动。

想针对自己的情况下判断时,最可靠的办法是把同一个接口分别从同一 Apartment 和另一个 Apartment 调用,测出来对比。

数量级变化的结构性原因说明同一 Apartment 内是直接调用,而跨 Apartment 即便在同一进程内也必定插入封送处理,并且目标是 STA 时调用会以窗口消息送达隐藏窗口,这就是数量级变化的结构性原因的图。同一 Apartment 内的调用直接调用跨 Apartment 的调用必定插入封送处理发往 STA 的调用会送达隐藏窗口

图8:数量级之所以会变,原因在于越过边界就必定插入封送处理这一规格上的结构。

// C#。把同一个调用重复 N 次,算出每次调用的耗时
static void Measure(string label, Action call, int iterations = 100_000)
{
    call(); // 把首次的延迟(JIT、代理生成、建立连接)排除在测量之外

    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 是 “1 个线程 = 1 个 Apartment” 的模型。

  • 该 Apartment 内的 COM 对象,原则上只在那个线程上执行
  • 从其他线程调用时,COM 会经消息队列 / RPC 转发调用
  • 常用于 UI 线程(WinForms/WPF)(UI 同样是“1 线程亲和性 + 消息循环”,所以很搭)
STA 的执行模型说明在 STA 中,Apartment 内的 COM 对象原则上只在创建它的线程上执行,其他线程发来的调用由 COM 经消息队列或 RPC 转发到那个线程的图。STA 的 COM 对象只在创建它的线程上执行来自其他线程的调用经消息队列 / RPC 转发

图9:STA 的对象只在它的主人线程上运行,外面来的调用要经过转发才能送达。

3.1. 为什么 UI 线程用 STA

因为 UI 线程和 STA 设计是一致的。

  • UI 控件不是线程安全的 按钮、文本框这些,只能由创建它的线程安全操作
  • STA 同样是“1 线程亲和性” COM 对象只在创建它的线程上直接执行
  • UI 线程必定在运行消息循环 为了处理窗口事件,这是必需的。它和 STA 的前提(消息泵)一致

所以 WinForms/WPF 的 UI 线程 默认就是 STA。

UI 线程与 STA 在设计上的一致说明 UI 控件只能由创建它的线程安全操作,STA 同样是 1 线程亲和性,而 UI 线程必定运行的消息循环与 STA 的前提消息泵一致,因此 UI 线程默认就是 STA 的图。UI 控件的 1 线程亲和性设计一致STA 的 1 线程亲和性UI 线程的消息循环STA 的前提(消息泵)UI 线程默认是 STA

图10:正因为在 1 线程亲和性和消息循环这两点上设计一致,UI 线程才是 STA。

要点: STA 线程亲和性高,代价是 调用方一多就容易堵车。

4. MTA(Multi-Threaded Apartment)

MTA 是 “多个线程共用 1 个 Apartment” 的模型。

  • COM 对象会被多个线程同时调用
  • 对象这一侧 必须做线程安全设计
  • 适合服务端处理或后台处理

要点: MTA 并行度高,但 对象实现方的责任更重。

MTA 的执行模型说明 MTA 由多个线程共用一个 Apartment,COM 对象会被多个线程同时调用,因此对象这一侧必须做线程安全设计的图。MTA(多个线程共用 1 个 Apartment)被多个线程同时调用对象这一侧必须做线程安全设计适合服务端或后台处理

图11:MTA 可以并行调用,代价是安全性的责任转移到了对象实现一侧。

5. STA/MTA 在哪里决定

COM 的 Apartment,是 通过在每个线程上做初始化 来决定的。

  • 调用 CoInitialize / CoInitializeEx 的那一刻,该线程的 Apartment 就定了
  • STA:COINIT_APARTMENTTHREADED
  • MTA:COINIT_MULTITHREADED
Apartment 定下来的那一刻说明线程调用 CoInitialize 或 CoInitializeEx 的那一刻该线程的 Apartment 就定了,指定 COINIT_APARTMENTTHREADED 就是 STA,指定 COINIT_MULTITHREADED 就是 MTA 的图。COINIT_APARTMENTTHREADEDCOINIT_MULTITHREADED线程调用 CoInitialize / CoInitializeEx定为 STA定为 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 Interop 准备的机制。

重要: Apartment 事后无法更改。第一次初始化就决定了一切。

在 .NET 中设置 Apartment 的位置说明 Main 方法上标注 STAThread 或 MTAThread 属性,额外创建的线程在启动前用 Thread.SetApartmentState 设置,两者都在第一次初始化时确定 Apartment 且之后无法更改的图。Main 方法〔STAThread〕/〔MTAThread〕属性额外创建的线程启动前调用 Thread.SetApartmentState第一次初始化时确定 Apartment事后无法更改

图13:入口点用属性,额外线程用 SetApartmentState,两者都在第一次就定死。

6. STA 用错时会发生的 Hang 具体例子

下面这种配置 实际上很容易引发 Hang。

6.1. 常见场景

  • 在后台创建 STA 线程并生成 COM 对象
  • 该线程 没有在运行消息循环
  • 从其他线程(不论 STA/MTA)调用那个 COM 对象
容易引发 Hang 的配置说明在后台创建的 STA 线程生成了 COM 对象却没有运行消息循环,而其他线程又去调用那个 COM 对象,这种容易引发 Hang 的配置的图。调用后台的 STA 线程生成 COM 对象没有在运行消息循环其他线程(不论 STA / MTA)

图14:“不跑消息循环的 STA 持有的对象,被其他线程调用”,这个组合很危险。

6.2. 到底发生了什么

Hang 的原因可以归结为 STA 的两个前提。本节就是原因的说明,后面的小节不再重复。

  • COM 对象在创建它的那个 STA 线程上处理 无论调用方是 STA 还是 MTA,来自其他线程的调用一定会被转发到那个 STA 线程。转发以窗口消息的形式送达 COM 为该 Apartment 创建的隐藏窗口(窗口类 OleMainThreadWndClass)
  • 要接收这些转发,STA 线程必须在运行消息泵 微软文档里也明确写着:“每个 STA 都必须拥有消息循环,以处理来自其他进程以及同一进程内其他 Apartment 的调用”

因此,没有在跑消息的 STA 线程接不到调用,调用方就一直等回复,结果就是 Hang。

走向 Hang 的过程说明来自其他线程的调用会以发往隐藏窗口的窗口消息的形式转发给创建对象的 STA 线程,但 STA 线程没有运行消息泵就接不到,调用方一直等回复从而 Hang 的过程的图。来自其他线程的调用转发到创建对象的 STA 线程以发往隐藏窗口的消息送达消息泵没有在运行STA 一侧接不到调用调用方一直等回复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 一侧不处理消息,所以这里很容易 Hang
            dynamic obj = comObj;
            obj.AnyMethod();
        }
        finally
        {
            // 即使调用返回了 —— 类本身是 agile 的、调用没有被转发、
            // AnyMethod 立即返回了 —— 也一定要释放 STA 线程。
            // 省掉这里,就会留下一个一直等 done 的前台线程,
            // “没有复现”的时候进程同样不会结束,
            // 于是无法凭症状分辨到底有没有复现
            done.Set();
            staThread.Join();
        }
    }
}

这段代码的前提是:传入以 ThreadingModel=Apartment(= STA)注册的 COM 类的 ProgID。AnyMethod 请替换成该类实际拥有的方法名。并不是任何 COM 类都能复现。 复现所需的条件有下面 3 个。

条件 确认方法
目标类的 ThreadingModel 是 Apartment 查看注册表 HKEY_CLASSES_ROOT\CLSID\{CLSID}\InprocServer32 下的 ThreadingModel 值。如果是 Both 或 Free,调用不会被转发,也就复现不了
STA 线程没有在跑消息 上例中的 done.WaitOne() 就属于这种情况
从 另一个线程 发起调用 只要是在同一个 STA 线程内调用,就是直接调用,不会 Hang

之所以把 obj.AnyMethod() 夹在 try / finally 之间,让 done.Set() 和 staThread.Join() 一定被执行到,就是为了分辨出这种“没有复现的情况”。STA 线程默认是前台线程,所以只要 done 不被置位,进程就不会结束。省掉 finally,复现时(调用不返回)和没复现时(调用返回了,但没人置位 done)从外面看到的症状都会是“进程不结束”。用来确认复现条件的工具,反而把条件成立与否给藏了起来。写成上面这种形式,没复现时就会正常结束。

要确认它确实卡住了,最快的办法是用调试器暂停进程,看调用方线程的栈是不是停在 COM 的等待上,以及 STA 线程是不是停在 WaitOne 上。

COM 运行时STA 线程主线程COM 运行时STA 线程主线程没有消息循环卡在这里用消息转发,但是……正在 WaitOne无法处理消息调用方也一直等下去双方都在等待 → Hang启动线程CoInitializeEx(STA)生成 COM 对象ready.Set()用 done.WaitOne() 等待CallComObject()试图转发调用

图16:停在 WaitOne 上的 STA 线程和等待转发回复的主线程,互相都推进不下去。

图中间“用消息转发,但是……”那两行,就是 6.2 里写的两个前提被打破的地方。

6.4. 避免的要点

  • 要接收其他线程的调用时,STA 线程必须运行消息循环
  • 可能的话 在 UI 线程上创建和使用(UI 线程本来就有消息循环)
  • 不需要 STA 时 一开始就用 MTA

补充: 如果一切都在同一线程内完成,并不是任何时候都需要 Application.Run()。 不过 UI 相关、COM 相关经常牵涉到其他线程的调用,所以实务上几乎是必需的。

避免 Hang 的三个方向说明要接收其他线程的调用就在 STA 线程上运行消息循环、可能的话在本来就有消息循环的 UI 线程上创建和使用、不需要 STA 就一开始用 MTA 这三个规避方向的图。怎样避开 STA 的 Hang在 STA 线程上运行消息循环在 UI 线程上创建和使用不需要 STA 就一开始用 MTAUI 线程本来就有循环

图17:规避办法可以归成三个方向:把循环跑起来、挪到本来就有循环的地方、干脆不用 STA。

6.5.“让消息循环转起来”究竟是指什么

就是 Win32 的 UI 线程一直在做的那段东西。

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

在 STA 上,来自其他线程的调用会被“转发”过来。 把这些转发 接下来并派发去执行 的,就是这个循环(消息泵)。

消息泵的作用说明把从其他线程转发过来的调用用 GetMessage 接收、再用 DispatchMessage 派发去执行的这种反复,就是消息泵的图。转发过来的调用用 GetMessage 接收用 DispatchMessage 派发执行

图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 调用”这种形式时的常用手段。

在后台 STA 上跑循环的三种手段说明在后台 STA 上运行消息循环有三种手段:使用需要引用 WinForms 的 Application.Run、自己写 GetMessage 与 DispatchMessage 的循环、使用能同时等待消息和同步对象的 MsgWaitForMultipleObjects 的图。在后台 STA 上跑循环Application.Run自己写 GetMessage 循环MsgWaitForMultipleObjects需要引用 WinForms,还要有结束手段能同时等待消息和同步对象

图19:跑循环有三种做法,按怎么结束和依赖有多重来选。

6.7. 另一个 Hang 的例子:同步调用中的回调

STA 不只是“调用会被转发过来”,某些情况下还会有反方向(服务端 → 客户端)的回调。其中 同步调用过程中发生回调的模式,是死锁的经典款。

COM 服务端UI 线程(STA)COM 服务端UI 线程(STA)正在等 DoWork 返回(没有处理消息)正在等待中接不到回调正在等回调完成双方都在等对方 → 死锁DoWork()(同步调用)ProgressCallback()(回调)

图20:等同步调用返回的 UI 线程和等回调完成的服务端,互相等着对方。

为什么容易死锁:

  1. UI 线程 同步调用(阻塞)DoWork()
  2. UI 线程在等返回(没有处理消息)
  3. 服务端向 UI 线程发出 ProgressCallback()
  4. UI 线程正在等待,接不到回调
  5. 服务端在等回调完成
  6. 双方都在等对方 → 永远推进不下去

和处理时间的长短无关。同步调用过程中来了回调 这个模式本身就容易出问题。

补充: COM 也有在某些情况下跑消息、允许重入的机制,行为会随组件和调用形态而变。 并不是一定会死锁,但这种模式还是避开为妙。

7. 大致的选用方式

场景 选什么 判断理由 需要一并做的事
涉及 UI(WinForms / WPF) STA UI 控件是 1 线程亲和性的,而 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 在第一次初始化时确定、之后无法更改,所以唯独这一点要在动手实现之前先定好。

拿不定主意时的查看顺序说明在 STA 与 MTA 的选用上拿不定主意时,按所用 COM 组件一侧的要求、有没有 UI、并行性的顺序确认,比较容易定下来的图。其次其次对方的要求有没有 UI并行性

图21:选用上拿不定主意时,按对方的要求、有没有 UI、并行性的顺序确认。

8. 总结

STA/MTA 是为 COM 而设的线程模型,STA 是 1 个线程 = 1 个 Apartment,MTA 是多个线程共用 1 个 Apartment。跨 Apartment 的调用由 COM 经 Proxy/Stub 转发(标准接口以外的,需要用 MIDL 等生成并注册),但这中间伴随着封送处理的额外开销,所以在预计会高频调用的场合,Apartment 的设计要谨慎决定。

从 Hang 的角度看,归根到底只有一句话:“要接收其他线程调用的 STA 线程,必须跑消息泵。”调用没有在跑消息的 STA 线程很容易 Hang,同步调用过程中来了回调的模式也容易死锁。UI 线程本来就同时具备“1 线程亲和性”和“消息循环”,无需额外实现就满足了这个前提,所以它和 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

下载本文的 Word 文件

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

STA 和 MTA 应该选哪一个?
基本的分工是:涉及 UI 的处理选 STA,大量并行处理选 MTA。STA 采用 1 个线程 1 个 Apartment 的形式,线程亲和性高,但调用方一多就容易堵车。MTA 由多个线程共用 1 个 Apartment,并行度高,但 COM 对象这一侧必须做线程安全设计。如果两边都谈不上,那么按照所用的既有库或 COM 服务器的要求来定,是比较现实的做法。
为什么 UI 线程是 STA?
因为 UI 线程和 STA 的设计是一致的。按钮、文本框这类 UI 控件不是线程安全的,只能由创建它的线程安全操作。STA 同样是 1 线程亲和性的模型。而且 UI 线程为了处理窗口事件必定在运行消息循环,无需额外实现就满足了 STA 的前提——消息泵。因此 WinForms/WPF 的 UI 线程默认就是 STA。
为什么调用 STA 的 COM 对象会 Hang?
对 STA 的 COM 对象的调用,会在创建它的那个 STA 线程上处理。其他线程发来的调用由 COM 经消息 / RPC 转发,但如果那个 STA 线程没有在运行消息循环,就接不到转发,调用方只能一直等下去,于是 Hang。要避开它,可以在被其他线程调用的 STA 线程上运行消息循环,或者在 UI 线程上创建和使用,或者在并不需要 STA 时一开始就用 MTA。
.NET 的 [STAThread] 属性是做什么用的?
它是用来设置 COM Apartment Model 的一层封装。标注在 Main 方法上,就会在使用 COM 时把该线程初始化为 STA。不过在实际调用 COM 之前并不会初始化,对不使用 COM 的应用也就没有效果。另外它对额外创建的线程无效,那些线程要在启动前用 Thread.SetApartmentState 设置。还要注意 Apartment 由第一次初始化决定,之后不能更改。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表