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

11
我遇到了一些我完全不理解的东西。 在我的应用程序中,我有多个线程都向共享集合中添加(和删除)项目(使用共享锁)。 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。

谢谢。


1
你如何捕获堆栈跟踪? - Tigran
1
我们持有线程的引用。然后我们执行 thread.Suspend(); log(new StackTrace(thread, true).ToString()); thread.Resume(); - tzachs
当我在Visual Studio的调试模式下运行我的应用程序时,我得到了一个类似的堆栈跟踪。当DataGridView开始绘制变得非常缓慢(以便您可以看到每个单元格突然被涂上)时,我只是按了暂停,注意到它正在处理lock中的OnPaint(我的堆栈跟踪说“由托管到本机转换”而不是“Monitor.Enter()”),这是在一个OnScroll处理程序中。我认为缓慢的原因是OnScroll处理程序未完成导致某些魔法(例如,缓存值?)丢失,从而导致OnPaint运行缓慢。 - binki
3个回答

16
GUI应用程序的主线程是STA线程,即单线程公寓。请注意程序的Main()方法上的[STAThread]属性。STA是COM术语,它为根本上不安全的组件提供了一个友好的家,允许它们从工作线程中调用。在.NET应用程序中,COM仍然非常活跃。拖放、剪贴板、像OpenFileDialog这样的shell对话框和像WebBrowser这样的常见控件都是单线程COM对象。对于UI线程,STA是一个硬性要求。
STA线程的行为契约是它必须泵送一个消息循环,并且不允许阻塞。由于它不允许这些公寓线程化的COM组件的编组进展,因此阻塞很可能会导致死锁。您正在使用lock语句阻止线程。
CLR非常清楚这个要求并采取了一些措施。像Monitor.Enter()、WaitHandle.WaitOne/Any()或Thread.Join()这样的阻塞调用会泵送一个消息循环。执行此操作的本机Windows API是MsgWaitForMultipleObjects()。该消息循环分派Windows消息以保持STA处于活动状态,包括绘制消息。当然,这可能会导致再入问题,但绘画不应该是一个问题。
这里有一篇Chris Brumme博客文章提供了很好的背景信息。
也许这一切都很熟悉,你可能会注意到这听起来非常像应用程序调用Application.DoEvents()。这可能是解决UI冻结问题可用的最令人害怕的方法。这是一个在幕后发生的非常准确的心理模型,DoEvents()还会泵送消息循环。唯一的区别是CLR的等效物更加有选择性地允许分派哪些消息,过滤它们。不像DoEvents()会分派所有内容。不幸的是,Brumme的帖子和SSCLI20源代码都没有足够详细的信息来知道正在分派什么,实际的CLR函数并不在源代码中,并且太大而无法反编译。但是显然你可以看到它不过滤WM_PAINT。它将过滤真正的麻烦制造者,输入事件通知,例如允许用户关闭窗口或单击按钮的通知。

功能而非错误。通过移除阻塞并依赖于已编排的回调,避免重入困扰。BackgroundWorker.RunWorkerCompleted是一个经典的例子。


感谢您提供详细的答案。因此,如果我采用您和尼古拉斯的回复(并忽略可能的可重入问题),我可以安全地假设UI线程不会阻塞其他线程,因为它没有持有锁,它像所有其他线程一样等待锁(我可以看到它们的堆栈跟踪都在Monitor.Enter中)。是这样吗? - tzachs
不,它绝对持有锁。只是这个锁不能阻止代码执行。代码响应于从Windows消息生成的事件运行。就像Paint一样。 - Hans Passant
“锁并不能阻止代码执行” - 依我之见,这并不是描述情况的恰当方式。锁本身确实可以防止代码执行;这就是为什么当UI线程死锁时,通常无法获得屏幕刷新。但在获取锁的过程中,COM可以进行一些调度,导致某些消息通过。没有人应该依赖可警报的调度来“修复”UI阻塞行为(并不是你在建议这样做,只是有人可能会从描述中得到错误的想法)。 - Peter Duniho

5

好问题!

.NET中的所有等待都是“可警报的”。这意味着,如果等待阻塞,Windows可以在等待堆栈顶部运行“异步过程调用”。这可以包括处理一些窗口消息。我没有尝试过特定的WM_PAINT,但从你的观察中,我猜它也被包含在内。

一些MSDN链接:

等待函数

异步过程调用

Joe Duffy的书“Windows并发编程”也涵盖了这个主题。


1

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

 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="https://dev59.com/4Woy5IYBdhLWcg3wnfUi">
    /// 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的一部分。 - MineR
注意 - 不要使用这种方法。这不是Dzmitry的错,但它导致了JIT编译器在随机情况下崩溃 - 虽然被自动化测试检测到,但无法可靠地复制。我们在画图消息中锁定UI线程以与后台线程同步加载数据到图形卡。我们当前的解决方案是使用消息过滤器在锁定时丢弃绘图命令。 - MineR
JIT编译器崩溃?我敢打赌你的应用程序代码中存在严重的内存管理错误 :) - Dzmitry Lahoda

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