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
此解决方案会启动一个新线程,该线程不断尝试检测订阅 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 对性能的影响。我以为它会很大。令我惊讶的是,它几乎不引人注目。不过在其他系统上可能会有所不同
我花了几天时间发现了一个导致我公司应用程序冻结的错误。可怕的 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
此解决方案会启动一个新线程,该线程不断尝试检测订阅 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 对性能的影响。我以为它会很大。令我惊讶的是,它几乎不引人注目。不过在其他系统上可能会有所不同