UI WindowsFormsSynchronizationContext 和 System.Events.UserPreferenceChanged 引起的冻结

UI Freeze caused by WindowsFormsSynchronizationContext and System.Events.UserPreferenceChanged

我花了几天时间发现了一个导致我公司应用程序冻结的错误。可怕的 UserPreferenceChanged UI 冻结。这不是一个复杂的错误,但很难在相当大的应用程序中找到。有很多文章介绍了这个错误是如何展开的,但没有介绍如何指出错误代码。我以来自多个旧票证的日志记录机制的形式组合了一个解决方案,并且(我希望)对它们进行了一些改进。可能会为下一个遇到此问题的程序员节省一些时间。

如何识别bug?

应用程序完全冻结。只需创建一个内存转储然后通过 TaskManager 将其关闭即可。如果您在 VisualStudio 或 WinDbg 中打开 dmp 文件,您可能会看到类似这样的堆栈跟踪

WaitHandle.InternalWaitOne
WaitHandle.WaitOne
Control.WaitForWaitHandle
Control.MarshaledInvoke
Control.Invoke
WindowsFormsSynchronizationContext.Send
System.EventInvokeInfo.Invoke
SystemEvents.RaiseEvent
SystemEvents.OnUserPreferenceChanged
SystemEvents.WindowProc
:

这里重要的两行是“OnUserPreferenceChanged”和“WindowsFormsSynchronizationContext.Send”

这是什么原因?

SynchronizationContext 是在 .NET2 中引入的,用于一般化线程同步。它为我们提供了诸如“BeginInvoke”之类的方法。

UserPreferenceChanged 事件不言自明。它将由用户更改其背景、登录或注销、更改 Windows 强调色和许多其他操作来触发。

如果在后台线程上创建 GUI 控件,则在所述线程上安装 WindowsFormsSynchronizationContext。一些 GUI 控件在创建或使用某些方法时订阅 UserPreferenceChanged 事件。如果此事件由​​用户触发,则主线程会向所有订阅者发送一条消息并等待。在描述的场景中:没有消息循环的工作线程!应用程序被冻结。

查找冻结的原因可能特别困难,因为错误的原因(在后台线程上创建 GUI 元素)和错误状态(应用程序冻结)可能相隔几分钟。有关详细信息和略有不同的场景,请参阅这篇非常好的文章。 https://www.ikriv.com/dev/dotnet/MysteriousHang

例子

出于测试目的,如何引发此错误?

示例 1

private void button_Click(object sender, EventArgs e)
{
    new Thread(DoStuff).Start();
}

private void DoStuff()
{
    using (var r = new RichTextBox())
    {
        IntPtr p = r.Handle; //do something with the control
    }

    Thread.Sleep(5000); //simulate some work
}

不错,但也不好。如果 UserPreferenceChanged 事件在您使用 RichTextBox 的几毫秒内被触发,您的应用程序将冻结。有可能发生,虽然不太可能。

示例 2

private void button_Click(object sender, EventArgs e)
{
    new Thread(DoStuff).Start();
}

private void DoStuff()
{
    var r = new RichTextBox();
    IntPtr p = r.Handle; //do something with the control

    Thread.Sleep(5000); //simulate some work
}

这很糟糕。 WindowsFormsSynchronizationContext 没有被清除,因为 RichTextBox 没有被释放。如果在线程运行期间发生 UserPreferenceChangedEvent,您的应用程序将冻结。

示例 3

private void button_Click(object sender, EventArgs e)
{
    Task.Run(() => DoStuff());
}

private void DoStuff()
{
    var r = new RichTextBox();
    IntPtr p = r.Handle; //do something with the control
}

这是一场噩梦。 Task.Run(..) 将在线程池的后台线程上执行工作。 WindowsFormsSynchronizationContext 未被清理,因为 RichTextBox 未被释放。 Threadpool 线程不会被清理。这个后台线程现在潜伏在您的线程池中,等待 UserPreferenceChanged 事件冻结您的应用程序,甚至在您的任务返回很久之后!

结论:当您知道自己在做什么时,风险是可控的。但只要有可能:避免在后台线程中使用 GUI 元素!

如何处理这个错误?

我从旧票中整理了一个解决方案。非常感谢那些人!

WinForms application hang due to SystemEvents.OnUserPreferenceChanged event

https://codereview.stackexchange.com/questions/167013/detecting-ui-thread-hanging-and-logging-stacktrace

此解决方案会启动一个新线程,该线程不断尝试检测订阅 OnUserPreferenceChanged 事件的任何线程,然后提供一个调用堆栈来告诉您原因。

public MainForm()
{
    InitializeComponent();

    new Thread(Observe).Start();
}

private void Observe()
{
    new PreferenceChangedObserver().Run();
}


internal sealed class PreferenceChangedObserver
{
    private readonly string _logFilePath = $"filePath\FreezeLog.txt"; //put a better file path here

    private BindingFlags _flagsStatic = BindingFlags.NonPublic | BindingFlags.Static;
    private BindingFlags _flagsInstance = BindingFlags.NonPublic | BindingFlags.Instance;

    public void Run() => CheckSystemEventsHandlersForFreeze();

    private void CheckSystemEventsHandlersForFreeze()
    {
        while (true)
        {
            try
            {
                foreach (var info in GetPossiblyBlockingEventHandlers())
                {
                    var msg = $"SystemEvents handler '{info.EventHandlerDelegate.Method.DeclaringType}.{info.EventHandlerDelegate.Method.Name}' could freeze app due to wrong thread. ThreadId: {info.Thread.ManagedThreadId}, IsThreadPoolThread:{info.Thread.IsThreadPoolThread}, IsAlive:{info.Thread.IsAlive}, ThreadName:{info.Thread.Name}{Environment.NewLine}{info.StackTrace}{Environment.NewLine}";
                    File.AppendAllText(_logFilePath, DateTime.Now.ToString("dd.MM.yyyy HH:mm:ss") + $": {msg}{Environment.NewLine}");
                }
            }
            catch { }
        }
    }

    private IEnumerable<EventHandlerInfo> GetPossiblyBlockingEventHandlers()
    {
        var handlers = typeof(SystemEvents).GetField("_handlers", _flagsStatic).GetValue(null);

        if (!(handlers?.GetType().GetProperty("Values").GetValue(handlers) is IEnumerable handlersValues))
            yield break;

        foreach(var systemInvokeInfo in handlersValues.Cast<IEnumerable>().SelectMany(x => x.OfType<object>()).ToList())
        {
            var syncContext = systemInvokeInfo.GetType().GetField("_syncContext", _flagsInstance).GetValue(systemInvokeInfo);

            //Make sure its the problematic type
            if (!(syncContext is WindowsFormsSynchronizationContext wfsc))
                continue;

            //Get the thread
            var threadRef = (WeakReference)syncContext.GetType().GetField("destinationThreadRef", _flagsInstance).GetValue(syncContext);
            if (!threadRef.IsAlive)
                continue;

            var thread = (Thread)threadRef.Target;
            if (thread.ManagedThreadId == 1) //UI thread
                continue;

            if (thread.ManagedThreadId == Thread.CurrentThread.ManagedThreadId)
                continue;

            //Get the event delegate
            var eventHandlerDelegate = (Delegate)systemInvokeInfo.GetType().GetField("_delegate", _flagsInstance).GetValue(systemInvokeInfo);

            //Get the threads call stack
            string callStack = string.Empty;
            try
            {
                if (thread.IsAlive)
                    callStack = GetStackTrace(thread)?.ToString().Trim();
            }
            catch { }

            yield return new EventHandlerInfo
            {
                Thread = thread,
                EventHandlerDelegate = eventHandlerDelegate,
                StackTrace = callStack,
            };
        }
    }

    private static StackTrace GetStackTrace(Thread targetThread)
    {
        using (ManualResetEvent fallbackThreadReady = new ManualResetEvent(false), exitedSafely = new ManualResetEvent(false))
        {
            Thread fallbackThread = new Thread(delegate () {
                fallbackThreadReady.Set();
                while (!exitedSafely.WaitOne(200))
                {
                    try
                    {
                        targetThread.Resume();
                    }
                    catch (Exception) {/*Whatever happens, do never stop to resume the target-thread regularly until the main-thread has exited safely.*/}
                }
            });
            fallbackThread.Name = "GetStackFallbackThread";
            try
            {
                fallbackThread.Start();
                fallbackThreadReady.WaitOne();
                //From here, you have about 200ms to get the stack-trace.
                targetThread.Suspend();
                StackTrace trace = null;
                try
                {
                    trace = new StackTrace(targetThread, true);
                }
                catch (ThreadStateException) { }
                try
                {
                    targetThread.Resume();
                }
                catch (ThreadStateException) {/*Thread is running again already*/}
                return trace;
            }
            finally
            {
                //Just signal the backup-thread to stop.
                exitedSafely.Set();
                //Join the thread to avoid disposing "exited safely" too early. And also make sure that no leftover threads are cluttering iis by accident.
                fallbackThread.Join();
            }
        }
    }

    private class EventHandlerInfo
    {
        public Delegate EventHandlerDelegate { get; set; }
        public Thread Thread { get; set; }
        public string StackTrace { get; set; }
    }
}

关注

1)这是一个非常丑陋的 hack。它以一种非常侵入性的方式处理线程。它永远不应该看到实时客户系统。将它部署到客户测试系统时我已经很紧张了。

2) 如果您得到一个日志文件,它可能会非常大。任何线程都可能导致数百个条目。从最旧的条目开始,修复它并重复。(由于示例 3 中的“污染线程”场景,它也可能包含误报)

3) 我不确定此 hack 对性能的影响。我以为它会很大。令我惊讶的是,它几乎不引人注目。不过在其他系统上可能会有所不同