哪些阻塞操作会导致STA线程泵送COM消息?

19

当在单线程单元 (STA) 中实例化 COM 对象时,线程通常必须实现消息泵以便于调用来回其他线程(见这里)。

可以手动泵送消息,也可以依赖于某些阻塞操作自动泵送与等待相关的 COM 消息,但不是所有阻塞操作都会自动泵送(参见此相关问题),文档通常无法帮助决定哪种情况。

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

目前部分已知列表:

自动泵送 的阻止操作*:

不会造成阻塞的操作:

  • Thread.Sleep(线程休眠)
  • Console.ReadKey(在某个地方读取)

*注意Noseratio的答案表示,即使是那些涉及COM的具有泵功能的操作,也只能为一组极少量且未公开的COM特定消息实现泵功能。


1
断言“通常情况下,COM对象必须在STA上实例化”是不正确的。没有“通常情况”和“必须”,因为它真正取决于COM对象如何声明给COM。事实上,COM会为您完成所有工作,以避免这些“必须”(有时会付出不必要的调用代价)。 - Simon Mourier
@SimonMourier - 感谢您的更正 - 我会更新问题。 - bavaza
封装未见同步对象的类,如BlockingCollection,属于WaitHandle.Wait括号内。 - Hans Passant
@HansPassant - 一个人如何判断封装类使用的是哪种同步对象?例如,文档没有说明BlockingCollection.Take()实际上是如何阻塞的。 - bavaza
您可以查看参考源代码或使用反编译器。实际上并不重要,任何等待同步对象的操作最终都会使用CLR内部相同的代码。 - Hans Passant
3个回答

6
BlockingCollection确实在阻塞时会进行消息泵操作。我在回答以下问题时学到了这一点,该问题涉及STA泵送的一些有趣细节:StaTaskScheduler and STA thread message pumping。然而,它只会泵送一组非常有限且未公开的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毫秒即可完成。这是因为使用了“WindowsFormsSynchronizationContext.Post”调度“await”继续回调,该回调使用“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毫秒,因为通过使用Application.DoEvents()来推送await继续消息,任务完成并且其句柄被信号标记。
尽管如此,我不建议在生产代码中使用类似WaitOneAndPump的东西(除非是极少数特定情况)。它会导致各种问题,比如UI重新进入。这些问题是微软限制标准泵送行为仅适用于某些COM特定消息的原因。

这个能调整一下来解决http://stackoverflow.com/questions/22794774/how-to-handle-this-thread-issue吗? - LCJ
2
@Lijo,我想我已经给了你这个链接。但是如果你没有足够的异步编程技能(顺便说一句,它没有涉及到线程,只涉及异步)那么这并没有什么帮助。我建议你编辑那个问题,并公开表示你需要在没有async/await学习曲线的情况下完成该项目。然后我或其他人可能能够提供另一种解决方案(例如简单回调)。 - noseratio - open to work
1
@Lijo,你可以从这里复制MsgWaitForMultipleObjectsEx。但是,你又走了错误的路径。 - noseratio - open to work
1
@Lijo,这将是一个ManualResetEvent对象,您可以从DocumentCompleted处理程序中发出信号。 WaitOneAndPump将创建一个嵌套的消息循环,并带来所有危险后果。 - noseratio - open to work
1
@Lijo,尽管我很想接受你的悬赏,但我不能推荐WaitOneAndPump作为最终解决方案,那是不正确的。我对这个问题的处理方式是这样的,请随意点赞。无论如何,如果我能帮到你,我很高兴。保留这个问题,以防有人发布更好的解决方案。 - noseratio - open to work
显示剩余5条评论

3
如何进行泵送实际上是公开的。有对.NET运行时的内部调用,然后使用CoWaitForMultipleHandles在STA线程上执行等待操作。该API的文档非常缺乏,但阅读一些COM书籍和Wine源代码可能会给您一些大致的想法。
内部调用使用QS_SENDMESSAGE | QS_ALLPOSTMESSAGE | QS_PAINT标志调用MsgWaitForMultipleObjectsEx。让我们分解每个标志的用途。
QS_PAINT最明显,WM_PAINT消息在消息泵中被处理。因此,在paint处理程序中进行任何锁定都是一个非常糟糕的想法,因为它很可能会进入可重入循环并导致堆栈溢出。

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

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


2

我最近通过艰难的经历学到了Process.Start可能会泵送。我没有等待进程,也没有请求其pid,我只是想让它在旁边运行。

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

虽然整个STA泵送已经足够令人惊讶,但这个发现可以说是非常令人惊讶!


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接