哪些阻止操作会导致 STA 线程抽取 COM 消息



当 COM 对象在 STA 线程上实例化时,该线程通常必须实现消息泵,以便封送对其他线程的调用(请参阅此处)。

可以手动泵送消息,也可以依赖于以下事实:某些(但不是全部)线程阻塞操作将在等待时自动泵送与 COM 相关的消息。文档通常无助于确定哪个是哪个(请参阅此相关问题)。

如何确定线程阻塞操作是否会在 STA 上泵送 COM 消息?

到目前为止的部分列表:

阻塞操作执行泵*:

  • Thread.Join
  • WaitHandle.WaitOne/WaitAny/WaitAll(WaitAll不能从 STA 线程调用)
  • GC.WaitForPendingFinalizers
  • Monitor.Enter(因此lock) - 在某些情况下
  • ReaderWriterLock
  • 阻塞集合

泵送的阻塞操作:

  • Thread.Sleep
  • Console.ReadKey(在某处阅读)

*请注意 Noseratio 的回答说,即使是执行泵送的操作,也会针对一组非常有限的未公开的 COM 特定消息执行此操作。

BlockingCollection确实会在阻塞时抽水。我在回答以下问题时了解到这一点,该问题有一些关于 STA 泵送的有趣细节:

StaTaskScheduler 和 STA 线程消息泵送

但是,它将抽取一组非常有限的未公开的特定于 COM 的消息,与您列出的其他 API 相同。它不会泵送通用 Win32 消息(特殊情况是WM_TIMER,也不会调度)。对于某些需要功能齐全的消息循环的 STA COM 对象,这可能是一个问题。

如果您想尝试此操作,请创建自己的SynchronizationContext版本,覆盖SynchronizationContext.Wait,调用SetWaitNotificationRequired并在 STA 线程上安装自定义同步上下文对象。然后在Wait中设置断点,看看哪些 API 会让它被调用。

WaitOne的标准泵送行为实际上在多大程度上受到限制?下面是导致 UI 线程死锁的典型示例。我在这里使用 WinForms,但同样的关注也适用于 WPF:

public partial class MainForm : Form
{
public MainForm()
{
InitializeComponent();
this.Load += (s, e) =>
{
Func<Task> doAsync = async () =>
{
await Task.Delay(2000);
};
var task = doAsync();
var handle = ((IAsyncResult)task).AsyncWaitHandle;
var startTick = Environment.TickCount;
handle.WaitOne(4000);
MessageBox.Show("Lapse: " + (Environment.TickCount - startTick));
};
}
}

消息框将显示 ~ 4000 毫秒的延时,尽管任务只需 2000 毫秒即可完成。

这是因为await继续回调是通过WindowsFormsSynchronizationContext.Post调度的,使用Control.BeginInvoke,而又使用PostMessage,发布在RegisterWindowMessage注册的常规Windows消息。此消息不会抽出,并且handle.WaitOne超时。

如果我们使用handle.WaitOne(Timeout.Infinite),我们将有一个经典的死锁。

现在让我们实现一个带有显式抽水的WaitOne版本(并称之为WaitOneAndPump):

public static bool WaitOneAndPump(
this WaitHandle handle, int millisecondsTimeout)
{
var startTick = Environment.TickCount;
var handles = new[] { handle.SafeWaitHandle.DangerousGetHandle() };
while (true)
{
// wait for the handle or a message
var timeout = (uint)(Timeout.Infinite == millisecondsTimeout ?
Timeout.Infinite :
Math.Max(0, millisecondsTimeout + 
startTick - Environment.TickCount));
var result = MsgWaitForMultipleObjectsEx(
1, handles,
timeout,
QS_ALLINPUT,
MWMO_INPUTAVAILABLE);
if (result == WAIT_OBJECT_0)
return true; // handle signalled
else if (result == WAIT_TIMEOUT)
return false; // timed-out
else if (result == WAIT_ABANDONED_0)
throw new AbandonedMutexException(-1, handle);
else if (result != WAIT_OBJECT_0 + 1)
throw new InvalidOperationException();
else
{
// a message is pending 
if (timeout == 0)
return false; // timed-out
else
{
// do the pumping
Application.DoEvents();
// no more messages, raise Idle event
Application.RaiseIdle(EventArgs.Empty);
}
}
}
}

并像这样更改原始代码:

var startTick = Environment.TickCount;
handle.WaitOneAndPump(4000);
MessageBox.Show("Lapse: " + (Environment.TickCount - startTick));

现在的延时为 ~2000 毫秒,因为await继续消息被Application.DoEvents()泵送,任务完成并发出其句柄信号。

也就是说,我从不建议在生产代码中使用类似WaitOneAndPump的东西(除了极少数特定情况)。它是各种问题的根源,例如 UI 重入。这些问题是Microsoft将标准泵送行为限制为仅某些特定于 COM 的消息的原因,这对于 COM 封送处理至关重要。

泵送的工作原理实际上是公开的。存在对 .NET 运行时的内部调用,这些调用反过来使用 CoWaitForMultipleHandle 在 STA 线程上执行等待。该API的文档非常缺乏,但是阅读一些COM书籍和Wine源代码可以为您提供一些粗略的想法。

在内部,它使用 QS_SENDMESSAGE |QS_ALLPOSTMESSAGE |QS_PAINT标志。让我们剖析每个用途。

QS_PAINT是最明显的,WM_PAINT消息在消息泵中处理。因此,在油漆处理程序中进行任何锁定都是非常糟糕的主意,因为它可能会进入可重入循环并导致堆栈溢出。

QS_SENDMESSAGE 适用于从其他线程和应用程序发送的消息。这实际上是进程间通信工作方式的一种方式。丑陋的部分是它也用于来自资源管理器和任务管理器的 UI 消息,因此它会泵送WM_CLOSE消息(右键单击任务栏中的无响应应用程序并选择关闭)、托盘图标消息以及可能的其他内容(WM_ENDSESSION)。

其余的QS_ALLPOSTMESSAGE。这些消息实际上是经过过滤的,因此仅处理隐藏公寓窗口的消息和 DDE 消息(WM_DDE_FIRST - WM_DDE_LAST)。

我最近了解到Process.Start可能会抽水的艰难方式。我没有等待这个过程,也没有问它的pid,我只是想让它一起运行。

在调用堆栈中(我手头没有),我看到它进入了特定于 ShellInvoke 的代码,所以这可能仅适用于 ShellInvoke = true。

虽然整个 STA 抽水已经足够令人惊讶了,但至少可以说,我发现这个非常令人惊讶!

最新更新