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

Posted

技术标签:

【中文标题】哪些阻塞操作会导致 STA 线程泵送 COM 消息?【英文标题】:Which blocking operations cause an STA thread to pump COM messages? 【发布时间】:2014-03-01 13:12:38 【问题描述】:

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

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

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

到目前为止的部分列表:

阻止进行抽水的操作*:

Thread.Join WaitHandle.WaitOne/WaitAny/WaitAll(虽然不能从 STA 线程调用WaitAllGC.WaitForPendingFinalizers Monitor.Enter(因此是lock) - 在某些情况下 ReaderWriterLock BlockingCollection

阻止抽水的操作:

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

*注意Noseratio's answer 表示即使是进行泵送的操作,也会针对一组非常有限的未公开的特定于 COM 的消息。

【问题讨论】:

断言“一般来说,COM 对象必须在 STA 上实例化”是不正确的。没有“一般性”也没有“必须”,因为它实际上取决于 COM 对象,它是如何向 COM 声明的。事实上,COM 会为您完成所有工作以避免这些“必须”(有时会以不必要的编组为代价)。 @SimonMourier - 感谢您的更正 - 我会更新问题。 包装看不见的同步对象的类,如 BlockingCollection,属于 WaitHandle.Wait 括号。 @HansPassant - 如何判断包装类使用哪种类型的 sych 对象?例如,文档没有指定 BlockingCollection.Take() 实际阻塞的方式。 您可以查看参考源或使用反编译器。这实际上并不重要,对同步对象的任何等待最终都会使用 CLR 中的相同代码。 【参考方案1】:

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 毫秒即可完成。

发生这种情况是因为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 编组至关重要。

【讨论】:

可以调整这个来解决***.com/questions/22794774/…吗? @Lijo,我愿意,我想我已经给了你link。但是,如果您没有足够的异步编程技能(顺便说一句,不涉及线程,只有异步),那将无济于事。我建议您编辑该问题并公开表示您需要在没有async/await 学习曲线的情况下完成该项目。然后我或其他人可能能够提供替代解决方案(例如单纯的回调)。 @Lijo,你可以从here复制MsgWaitForMultipleObjectsEx。但是,你又走错了路。 @Lijo,这将是一个 ManualResetEvent 对象,您将从 DocumentCompleted 处理程序发出信号。 WaitOneAndPump 会创建一个嵌套消息循环,带来所有危险后果。 @Lijo,尽管我想接受你的赏金,但我不能推荐 WaitOneAndPump 作为最终解决方案,那是错误的。我解决这个问题的方法是this,请随意投票。无论如何,如果我能帮助到你,我很高兴。保持这个问题,以防有人发布你会更喜欢的解决方案。【参考方案2】:

实际披露了抽水的工作原理。对 .NET 运行时的内部调用又使用 CoWaitForMultipleHandles 在 STA 线程上执行等待。该 API 的文档非常缺乏,但阅读一些 COM books 和 Wine source code 可能会给您一些粗略的想法。

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

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

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

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

【讨论】:

【参考方案3】:

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

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

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

【讨论】:

以上是关于哪些阻塞操作会导致 STA 线程泵送 COM 消息?的主要内容,如果未能解决你的问题,请参考以下文章

在长时间运行期间泵送 Windows 消息?

为啥将托管 .NET 客户端设置为使用 STA 线程会导致本机 COM 服务器中出现异常问题?

RocketMQ与Dubbo之间线程之间如何阻塞和唤醒

Java线程唤醒与阻塞常用方法有哪些?

Java线程唤醒与阻塞常用方法有哪些?

Java线程唤醒与阻塞常用方法有哪些?