COM STA/MTA 基础 - 线程模型与避免 Hang 的思路
· 更新日期: · 小村 豪 · COM, Windows 开发, STA, MTA, 线程
更新记录(2 条,最后更新 2026年09月03日)
本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。
引用本文(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。
目录
- 1. 先说结论(一句话)
- 2. Apartment Model 的调用模式(图)
- 3. STA(Single-Threaded Apartment)
- 4. MTA(Multi-Threaded Apartment)
- 5. STA/MTA 在哪里决定
- 6. STA 用错时会发生的 Hang 具体例子
- 7. 大致的选用方式
- 8. 总结
- 9. 参考资料
使用 COM 时,“在哪个线程上运行”是绕不开的问题。处在这个问题中心的,就是 Apartment Model(STA/MTA)。STA/MTA 不是 Windows 通用的线程概念,而是为了确定 COM 对象的调用规则而存在的线程模型。
本文一边用图梳理 STA、MTA 与 COM 的关系,一边一直讲到“为什么有时候会 Hang”。
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 理解成 1 个线程 1 个 Apartment、把 MTA 理解成 多个线程共用 1 个 Apartment,就容易抓住要点
- 跨 Apartment 的调用,由 COM 经 Proxy/Stub 做封送处理
flowchart TB
accTitle: STA、MTA 与跨 Apartment 的关系
accDescr: 说明 STA 采用 1 个线程 1 个 Apartment、MTA 采用多个线程共用 1 个 Apartment 的形式,而跨 Apartment 的调用由 COM 经 Proxy/Stub 做封送处理这一结论的图。
sta["STA(1 个线程 1 个 Apartment)"] -->|"跨 Apartment 的调用"| m["经 Proxy / Stub 做封送处理"]
mta["MTA(多个线程共用 1 个 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 的代码等 |
注意: 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 内是直接调用,一旦越过边界就必定插入封送处理”这个结构,是微软文档里写明的规格。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、代理生成、建立连接)排除在测量之外
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 线程亲和性 + 消息循环”,所以很搭)
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 同样是“1 线程亲和性” COM 对象只在创建它的线程上直接执行
- UI 线程必定在运行消息循环 为了处理窗口事件,这是必需的。它和 STA 的前提(消息泵)一致
所以 WinForms/WPF 的 UI 线程 默认就是 STA。
flowchart TB
accTitle: UI 线程与 STA 在设计上的一致
accDescr: 说明 UI 控件只能由创建它的线程安全操作,STA 同样是 1 线程亲和性,而 UI 线程必定运行的消息循环与 STA 的前提消息泵一致,因此 UI 线程默认就是 STA 的图。
ui["UI 控件的 1 线程亲和性"] --> match["设计一致"]
sta["STA 的 1 线程亲和性"] --> match
loop["UI 线程的消息循环"] --> pre["STA 的前提(消息泵)"]
pre --> match
match --> def["UI 线程默认是 STA"]
图10:正因为在 1 线程亲和性和消息循环这两点上设计一致,UI 线程才是 STA。
要点: STA 线程亲和性高,代价是 调用方一多就容易堵车。
4. MTA(Multi-Threaded Apartment)
MTA 是 “多个线程共用 1 个 Apartment” 的模型。
- COM 对象会被多个线程同时调用
- 对象这一侧 必须做线程安全设计
- 适合服务端处理或后台处理
要点: MTA 并行度高,但 对象实现方的责任更重。
flowchart TB
accTitle: MTA 的执行模型
accDescr: 说明 MTA 由多个线程共用一个 Apartment,COM 对象会被多个线程同时调用,因此对象这一侧必须做线程安全设计的图。
mta["MTA(多个线程共用 1 个 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 Interop 准备的机制。
重要: 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 用错时会发生的 Hang 具体例子
下面这种配置 实际上很容易引发 Hang。
6.1. 常见场景
- 在后台创建 STA 线程并生成 COM 对象
- 该线程 没有在运行消息循环
- 从其他线程(不论 STA/MTA)调用那个 COM 对象
flowchart TB
accTitle: 容易引发 Hang 的配置
accDescr: 说明在后台创建的 STA 线程生成了 COM 对象却没有运行消息循环,而其他线程又去调用那个 COM 对象,这种容易引发 Hang 的配置的图。
bg["后台的 STA 线程"] --> gen["生成 COM 对象"]
bg --> noloop["没有在运行消息循环"]
other["其他线程(不论 STA / MTA)"] -->|"调用"| gen
图14:“不跑消息循环的 STA 持有的对象,被其他线程调用”,这个组合很危险。
6.2. 到底发生了什么
Hang 的原因可以归结为 STA 的两个前提。本节就是原因的说明,后面的小节不再重复。
- COM 对象在创建它的那个 STA 线程上处理
无论调用方是 STA 还是 MTA,来自其他线程的调用一定会被转发到那个 STA 线程。转发以窗口消息的形式送达 COM 为该 Apartment 创建的隐藏窗口(窗口类
OleMainThreadWndClass) - 要接收这些转发,STA 线程必须在运行消息泵 微软文档里也明确写着:“每个 STA 都必须拥有消息循环,以处理来自其他进程以及同一进程内其他 Apartment 的调用”
因此,没有在跑消息的 STA 线程接不到调用,调用方就一直等回复,结果就是 Hang。
flowchart TB
accTitle: 走向 Hang 的过程
accDescr: 说明来自其他线程的调用会以发往隐藏窗口的窗口消息的形式转发给创建对象的 STA 线程,但 STA 线程没有运行消息泵就接不到,调用方一直等回复从而 Hang 的过程的图。
caller["来自其他线程的调用"] --> fwd["转发到创建对象的 STA 线程"]
fwd -.-> hidden["以发往隐藏窗口的消息送达"]
fwd --> nopump["消息泵没有在运行"]
nopump --> norecv["STA 一侧接不到调用"]
norecv --> wait["调用方一直等回复"]
wait --> hang["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 上。
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: 双方都在等待 → Hang
图16:停在 WaitOne 上的 STA 线程和等待转发回复的主线程,互相都推进不下去。
图中间“用消息转发,但是……”那两行,就是 6.2 里写的两个前提被打破的地方。
6.4. 避免的要点
- 要接收其他线程的调用时,STA 线程必须运行消息循环
- 可能的话 在 UI 线程上创建和使用(UI 线程本来就有消息循环)
- 不需要 STA 时 一开始就用 MTA
补充: 如果一切都在同一线程内完成,并不是任何时候都需要 Application.Run()。
不过 UI 相关、COM 相关经常牵涉到其他线程的调用,所以实务上几乎是必需的。
flowchart TB
accTitle: 避免 Hang 的三个方向
accDescr: 说明要接收其他线程的调用就在 STA 线程上运行消息循环、可能的话在本来就有消息循环的 UI 线程上创建和使用、不需要 STA 就一开始用 MTA 这三个规避方向的图。
q["怎样避开 STA 的 Hang"] --> 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. 另一个 Hang 的例子:同步调用中的回调
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 线程在等返回(没有处理消息)
- 服务端向 UI 线程发出
ProgressCallback() - UI 线程正在等待,接不到回调
- 服务端在等回调完成
- 双方都在等对方 → 永远推进不下去
和处理时间的长短无关。同步调用过程中来了回调 这个模式本身就容易出问题。
补充: 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 在第一次初始化时确定、之后无法更改,所以唯独这一点要在动手实现之前先定好。
flowchart LR
accTitle: 拿不定主意时的查看顺序
accDescr: 说明在 STA 与 MTA 的选用上拿不定主意时,按所用 COM 组件一侧的要求、有没有 UI、并行性的顺序确认,比较容易定下来的图。
p1["对方的要求"] -->|"其次"| p2["有没有 UI"]
p2 -->|"其次"| p3["并行性"]
图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
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
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 采用 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 由第一次初始化决定,之后不能更改。