为啥在 UI 线程上输入锁会触发 OnPaint 事件?

Posted

技术标签:

【中文标题】为啥在 UI 线程上输入锁会触发 OnPaint 事件?【英文标题】:Why did entering a lock on a UI thread trigger an OnPaint event?为什么在 UI 线程上输入锁会触发 OnPaint 事件? 【发布时间】:2021-04-18 00:39:03 【问题描述】:

我遇到了一些我完全不明白的事情。 在我的应用程序中,我有几个线程都将项目添加(和删除)到共享集合(使用共享锁)。 UI 线程使用一个计时器,并且在每个滴答声中它使用集合来更新它的 UI。

由于我们不希望 UI 线程长时间持有锁而阻塞其他线程,所以我们这样做的方式是,首先获取锁,复制集合,然后释放锁定,然后处理我们的副本。 代码如下所示:

public void GUIRefresh()

    ///...
    List<Item> tmpList;
    lock (Locker)
    
         tmpList = SharedList.ToList();
    
    // Update the datagrid using the tmp list.

虽然它运行良好,但我们注意到有时应用程序会变慢,当我们设法捕获堆栈跟踪时,我们看到了:

....
at System.Windows.Forms.DataGrid.OnPaint(PaintEventArgs pe)
at MyDataGrid.OnPaint(PaintEventArgs pe)
at System.Windows.Forms.Control.PaintWithErrorHandling(PaintEventArgs e, Int16 layer, Boolean disposeEventArgs)
at System.Windows.Forms.Control.WmPaint(Message& m)
at System.Windows.Forms.Control.WndProc(Message& m)
at System.Windows.Forms.Control.ControlNativeWindow.OnMessage(Message& m)
at System.Windows.Forms.Control.ControlNativeWindow.WndProc(Message& m)
at System.Windows.Forms.NativeWindow.Callback(IntPtr hWnd, Int32 msg, IntPtr wparam, IntPtr lparam)
at System.Threading.Monitor.Enter(Object obj)
at MyApplication.GuiRefresh()   
at System.Windows.Forms.Timer.OnTick(EventArgs e)
at System.Windows.Forms.Timer.TimerNativeWindow.WndProc(Message& m)
at System.Windows.Forms.NativeWindow.Callback(IntPtr hWnd, Int32 msg, IntPtr wparam, IntPtr lparam)
at System.Windows.Forms.UnsafeNativeMethods.DispatchMessageW(MSG& msg)
at System.Windows.Forms.Application.ComponentManager.System.Windows.Forms.UnsafeNativeMethods.IMsoComponentManager.FPushMessageLoop(Int32 dwComponentID, Int32 reason, Int32 pvLoopData)
at System.Windows.Forms.Application.ThreadContext.RunMessageLoopInner(Int32 reason, ApplicationContext context)
at System.Windows.Forms.Application.ThreadContext.RunMessageLoop(Int32 reason, ApplicationContext context)
at System.Windows.Forms.Application.Run(Form mainForm)
....

请注意,输入锁 (Monitor.Enter) 之后是 NativeWindow.Callback,它会导致 OnPaint。

这怎么可能? UI 线程是否被劫持以检查其消息泵?那有意义吗?还是这里有别的东西?

有没有办法避免它?我不希望从锁中调用 OnPaint。

谢谢。

【问题讨论】:

如何捕获堆栈跟踪? 我们持有对线程的引用。然后我们做 thread.Suspend();日志(新 StackTrace(线程,真).ToString());线程.Resume(); 在 Visual Studio 中的 Debug 下运行我的应用程序时,我得到了类似的堆栈跟踪。当 DataGridView 开始非常缓慢地绘制时,我刚刚点击了暂停(这样您就可以看到每个单独的单元格突然被绘制)并注意到它正在处理来自lockOnPaint(我的堆栈跟踪说“管理到本机转换”而不是“Monitor.Enter()”)在 OnScroll 处理程序中。我认为速度缓慢是由于 OnScroll 处理程序未完成导致一些魔法(例如,缓存值?)丢失,并且 this 导致 OnPaint 运行缓慢。 【参考方案1】:

GUI 应用程序的主线程是一个 STA 线程,即单线程单元。请注意程序的 Main() 方法上的 [STAThread] 属性。 STA 是一个 COM 术语,它为从根本上是线程不安全的组件提供了一个好客的家,允许从工作线程调用它们。 COM 在 .NET 应用程序中仍然非常活跃。拖放、剪贴板、外壳对话框(如 OpenFileDialog)和常用控件(如 WebBrowser)都是单线程 COM 对象。 STA 是 UI 线程的硬性要求。

STA 线程的行为约定是它必须泵送消息循环并且不允许阻塞。阻塞很可能导致死锁,因为它不允许这些单元线程 COM 组件的编组继续进行。您正在使用 lock 语句阻塞线程。

CLR 非常了解该要求并对此采取了一些措施。像 Monitor.Enter()、WaitHandle.WaitOne/Any() 或 Thread.Join() 这样的阻塞调用会导致消息循环。执行此操作的本地 Windows API 类型是 MsgWaitForMultipleObjects(). 该消息循环调度 Windows 消息以保持 STA 活动,包括绘制消息。当然,这可能会导致重入问题,Paint 应该不是问题。

Chris Brumme blog post 中有很好的背景资料。

也许这一切都敲响了警钟,您可能不禁注意到这听起来很像一个调用 Application.DoEvents() 的应用程序。可能是解决 UI 冻结问题的最可怕的方法。对于幕后发生的事情,这是一个非常准确的心智模型,DoEvents() 还泵送消息循环。唯一的区别是,CLR 的等效项对于允许分派的消息更具选择性,它会过滤它们。与分派所有内容的 DoEvents() 不同。不幸的是,Brumme 的帖子和 SSCLI20 的源代码都没有足够详细,无法准确知道正在调度什么,执行此操作的实际 CLR 函数在源代码中不可用,而且太大而无法反编译。但很明显,您可以看到它过滤 WM_PAINT。它将过滤真正的麻烦制造者,输入事件通知,例如允许用户关闭窗口或单击按钮的那种。

功能,而不是错误。通过消除阻塞和依赖编组回调来避免重入问题。 BackgroundWorker.RunWorkerCompleted 就是一个经典的例子。

【讨论】:

感谢您的详细解答。因此,如果我接受你和 Nicholas 的回复(并且忽略可能的重入问题),我可以安全地假设 UI 线程没有阻塞其他线程,因为它没有持有锁,它像所有其他线程一样等待锁(我可以在他们的堆栈跟踪中看到它们都在 Monitor.Enter 中)。对吗? 不,肯定是持有锁。只是锁不会阻止代码执行。响应从 Windows 消息生成的事件而运行的代码。喜欢油漆。 “锁不会阻止代码执行” -- 恕我直言,这不是描述正在发生的事情的正确方式。锁本身确实会阻止代码执行。这就是为什么当 UI 线程死锁时,您无法正常刷新屏幕。但是作为获取锁的一部分,COM 可以进行一些调度,从而导致一些消息通过。没有人应该依赖警报调度作为 UI 阻塞行为的“修复”(不是你在建议这个,只是有人可能从描述中得到错误的想法)。【参考方案2】:

好问题!

.NET 中的所有等待都是“可提醒的”。这意味着如果等待阻塞,Windows 可以在等待堆栈的顶部运行“异步过程调用”。这可以包括处理一些 Windows 消息。我没有专门尝试过 WM_PAINT,但根据你的观察,我猜它是包括在内的。

一些 MSDN 链接:

Wait Functions

Asynchronous Procedure Calls

Joe Duffy 的《Windows 上的并发编程》一书也涵盖了这一点。

【讨论】:

【参考方案3】:

我在遇到等待句柄阻塞问题时发现了这个问题。对此的回答给了我下一步实施的提示:

 public static class NativeMethods

    [DllImport("kernel32.dll", SetLastError = true)]
    internal static extern UInt32 WaitForSingleObject(SafeWaitHandle hHandle, UInt32 dwMilliseconds);


public static class WaitHandleExtensions

    const UInt32 INFINITE = 0xFFFFFFFF;
    const UInt32 WAIT_ABANDONED = 0x00000080;
    const UInt32 WAIT_OBJECT_0 = 0x00000000;
    const UInt32 WAIT_TIMEOUT = 0x00000102;
    const UInt32 WAIT_FAILED = INFINITE;

    /// <summary>
    /// Waits preventing an I/O completion routine or an APC for execution by the waiting thread (unlike default `alertable`  .NET wait). E.g. prevents STA message pump in background. 
    /// </summary>
    /// <returns></returns>
    /// <seealso cref="http://***.com/questions/8431221/why-did-entering-a-lock-on-a-ui-thread-trigger-an-onpaint-event">
    /// Why did entering a lock on a UI thread trigger an OnPaint event?
    /// </seealso>
    public static bool WaitOneNonAlertable(this WaitHandle current, int millisecondsTimeout)
    
        if (millisecondsTimeout < -1)
            throw new ArgumentOutOfRangeException("millisecondsTimeout", millisecondsTimeout, "Bad wait timeout");
        uint ret = NativeMethods.WaitForSingleObject(current.SafeWaitHandle, (UInt32)millisecondsTimeout);
        switch (ret)
        
            case WAIT_OBJECT_0:
                return true;
            case WAIT_TIMEOUT:
                return false;
            case WAIT_ABANDONED:
                throw new AbandonedMutexException();
            case WAIT_FAILED:
                throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
            default:
                return false;
        
    

【讨论】:

谢谢 - 这对我帮助很大。这确实应该是 .NET 的一部分。 注意——不要使用这种方法。 Dzmitry 没有错,但它导致 JIT 编译器随机崩溃 - 由自动化测试拾取,但无法可靠地复制。我们在绘制消息中锁定了 UI 线程,以与将数据加载到显卡上的后台线程同步。我们当前的解决方案是使用消息过滤器在锁定时丢弃绘制命令。 jit 编译器崩溃?我敢打赌,您的应用程序代码的内存管理存在严重错误 :)

以上是关于为啥在 UI 线程上输入锁会触发 OnPaint 事件?的主要内容,如果未能解决你的问题,请参考以下文章

StateFlowImpl collect有一个while循环,如果我在UI线程上使用它,为啥它不会阻塞UI线程

为啥我的C#Winform自定义控件,继承Control类,然后重写onpaint事件,设计窗体拖动时只剩下外框!

Android消息机制

为啥我的 AsyncTask 会冻结我的 UI 线程?

为啥我尝试在 C# 中实现基本的自旋锁会得到这个结果?

用于Delphi的铬嵌入 - 当显示模态窗口时,不会触发TChromiumOSR.OnPaint