找出从后台线程访问的 winforms 控件
Posted
技术标签:
【中文标题】找出从后台线程访问的 winforms 控件【英文标题】:Find out which winforms controls are accessed from a background thread 【发布时间】:2018-06-15 06:44:23 【问题描述】:我们已经建立了一个巨大的 winforms 项目,已经进行了多年。
有时,我们的用户会收到类似 this one 的异常。
这个问题的解决方法好像是:
不要从后台线程访问 UI 组件
.
但是由于我们的项目是一个非常大的项目,有很多不同的线程,我们无法找到所有这些。
有没有办法检查(使用一些工具或调试选项)从后台线程调用哪些组件?
澄清一下:
我创建了一个带有单个Form
的示例winforms 项目,其中包含两个Button
public partial class Form1 : Form
public Form1()
InitializeComponent();
private void button1_Click(object sender, EventArgs e)
button1.Text = "Clicked!";
private void button2_Click(object sender, EventArgs e)
Task.Run(() =>
button2.BackColor = Color.Red; //this does not throw an exception
//button2.Text = "Clicked"; //this throws an exception when uncommented
);
单击按钮时button2
的背景颜色设置为红色。这发生在后台线程中(这被认为是不良行为)。但是,它不会(立即)抛出异常。我想要一种将其检测为“不良行为”的方法。最好通过扫描我的代码,但如果只能通过调试来实现,(所以一旦从后台线程访问 UI 组件就暂停)也可以。
【问题讨论】:
您显示的是NullReferenceException
。似乎不是 UI 线程问题。
这可能很乏味,但您应该检查您的代码库并根据需要使用InvokeRequired
。
@doubleYou 我很确定这个空引用是由 UI 线程问题引起的。另请参阅我链接的问题的答案。然而,这并不重要,因为我不是要求直接解决这个空引用。
@close voter:你能具体说明为什么你认为这个问题超出了范围吗?
@Zorkind 我添加了一些代码以进行澄清
【参考方案1】:
为了您的方便,我建议您更新您的 GUI 以自动处理这种情况。您改为使用一组继承的控件。
这里的一般原则是覆盖属性 Set 方法以使其成为线程安全的。因此,在每个被覆盖的属性中,不是直接更新基本控件,而是检查是否需要调用(这意味着我们在 GUI 的单独线程上)。然后,Invoke 调用更新 GUI 线程上的属性,而不是辅助线程。
因此,如果使用继承的控件,则尝试从辅助线程更新 GUI 元素的表单代码可以保持原样。
这里是文本框和按钮。您可以根据需要添加更多属性,并根据需要添加其他属性。而不是将代码放在单个表单上。
您不需要进入设计器,您可以只对设计器文件进行查找/替换。例如,在所有 Designer.cs 文件中,您可以将 System.Windows.Forms.TextBox 替换为 ThreadSafeControls.TextBoxBackgroundThread,将 System.Windows.Forms.Button 替换为 ThreadSafeControls.ButtonBackgroundThread。
可以使用相同的原理创建其他控件,基于从后台线程更新的控件类型和属性。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows.Forms;
namespace ThreadSafeControls
class TextBoxBackgroundThread : System.Windows.Forms.TextBox
public override string Text
get
return base.Text;
set
if (this.InvokeRequired)
this.Invoke((MethodInvoker)delegate base.Text = value; );
else
base.Text = value;
public override System.Drawing.Color ForeColor
get
return base.ForeColor;
set
if (this.InvokeRequired)
this.Invoke((MethodInvoker)delegate base.ForeColor = value; );
else
base.ForeColor = value;
public override System.Drawing.Color BackColor
get
return base.BackColor;
set
if (this.InvokeRequired)
this.Invoke((MethodInvoker)delegate base.BackColor = value; );
else
base.BackColor = value;
class ButtonBackgroundThread : System.Windows.Forms.Button
public override string Text
get
return base.Text;
set
if (this.InvokeRequired)
this.Invoke((MethodInvoker)delegate base.Text = value; );
else
base.Text = value;
public override System.Drawing.Color ForeColor
get
return base.ForeColor;
set
if (this.InvokeRequired)
this.Invoke((MethodInvoker)delegate base.ForeColor = value; );
else
base.ForeColor = value;
public override System.Drawing.Color BackColor
get
return base.BackColor;
set
if (this.InvokeRequired)
this.Invoke((MethodInvoker)delegate base.BackColor = value; );
else
base.BackColor = value;
【讨论】:
您应该已经解释了 Control.InvokeRequired 的工作原理!它检查当前执行是否在 GUI 线程上。 Control.Invoke 使用 SynchronisationContext 来安排在 GUI 线程上执行回调代码。 我当然可以添加它......但基于上面他似乎已经知道了 *** 的目标是成为未来几年的资源,通过将您分享给 OP 代表(很高)的知识限制在您的答案与知识较少的人的相关性降低.考虑edit,祝你好运 一定会这样做 我希望这是对代码试图完成的内容的更好解释【参考方案2】:试试看:
public static void Main(string[] args)
// Add the event handler for handling UI thread exceptions to the event.
Application.ThreadException += new ThreadExceptionEventHandler(exception handler);
// Set the unhandled exception mode to force all Windows Forms errors to go through the handler.
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
// Add the event handler for handling non-UI thread exceptions to the event.
AppDomain.CurrentDomain.UnhandledException += // add the handler here
// Runs the application.
Application.Run(new ......);
然后您可以记录消息和调用堆栈,这将为您提供足够的信息来解决问题。
【讨论】:
这是不正确的。请参阅我的答案和方法 2。您无法在这些事件中记录消息,为时已晚,堆栈已展开。 那行不通,@Radin。我们可以看到的堆栈跟踪总是源自 UI 线程,而根本原因是来自后台线程的调用【参考方案3】:您的应用是否设置为有意忽略跨线程?
跨线程操作应该在 winforms 中一直被炸毁。它几乎在每种方法中都疯狂地检查它们。作为起点,请查看https://referencesource.microsoft.com/#System.Windows.Forms/winforms/Managed/System/WinForms/Control.cs。
在您的应用程序的某个地方,有人可能已经放置了这行代码:
Control.CheckForIllegalCrossThreadCalls = False;
注释掉并运行应用程序,然后遵循例外情况。
(通常,您可以通过将更新包装在调用中来解决问题,例如,如果您看到 textbox1.text=SomeString;
在工作线程中将其更改为 `textbox.invoke(()=>textbox1.text=SomeString; );.
您可能还必须添加对 InvokeRequired 的检查,使用 BeginInvoke 避免死锁,并从调用返回值,这些都是单独的主题。
这是假设即使是适度的重构也不可能,即使是中型企业应用程序也几乎总是如此。
注意:通过静态分析(即不运行应用程序)无法保证成功发现此案例。除非你能解决停机问题……https://cs.stackexchange.com/questions/63403/is-the-halting-problem-decidable-for-pure-programs-on-an-ideal-computer 等等……
【讨论】:
如果您设置CheckForIllegalCrossThreadCalls = False;
,这是错误的,它会使确定问题变得更加困难。不可预知的事情发生并证明了这一点;堆栈跟踪会有所不同。
CheckForIllegalCrossThreadCalls 属性永远不会被调用。
感谢您指出停止问题。我对此很熟悉,但我不明白这个问题与停机问题有何关系。你能详细说明一下吗?
关于停机问题 - 它不是。但这是不可能解决的。不运行代码也无法可靠地发现此类问题(这就是我所说的静态分析)。对红鲱鱼感到抱歉,我只是在这里确认您需要运行应用程序以发现交叉线程(我很确定 ;-) 现在也存在问题 - 您如何覆盖每条路径?这也是一个巨大的挑战。我相信你已经感受到了那种痛苦。【参考方案4】:
我有 2 个建议一起使用,第一个是名为 DebugSingleThread 的 Visual Studio 插件。
您可以冻结所有线程并一次处理一个(显然是非主 UI 线程)并查看每个线程对控件的访问。我知道很乏味但第二种方法没那么糟糕。
第二种方法是获取步骤以重现问题。如果您知道重现它的步骤,将更容易看出是什么原因造成的。为此,我在 Github 上创建了这个 User Action Log 项目。
它会记录用户所做的每一个动作,你可以在这里阅读它:User Activity Logging, Telemetry (and Variables in Global Exception Handlers)。
我建议您同时记录线程 ID,然后当您能够重现问题时,转到日志末尾并制定确切的步骤。它并不像看起来那么痛苦,并且非常适合获取应用程序遥测。
您也许可以自定义此项目,例如捕获 DataSource_Completed 事件或添加一个虚拟 DataSource 属性,该属性设置真正的 Grids DataSource 属性并引发 INotifyPropertyChanged
事件 - 如果它是非主线程 ID,则 @987654326 @。
我的直觉是您正在后台线程中更改控件(例如网格)的数据源(为了那种非冻结的感觉),这会导致同步问题。这就是其他经历过这种情况的 DevExpress 客户的情况。它在与您引用的线程不同的线程中讨论了here。
【讨论】:
【参考方案5】:我这样做是为了搜索特定情况,但当然需要根据您的需要进行调整,但这样做的目的是至少给您一种可能性。
我将此方法称为SearchForThreads
,但由于它只是一个示例,因此您可以随意调用它。
这里的主要思想可能是将此方法调用添加到基类并在构造函数上调用它,使其更加灵活。
然后使用反射在从这个基派生的所有类上调用这个方法,如果在任何类中发现这种情况,就会抛出异常或其他东西。
有一个先决条件,那就是使用 Framework 4.5。
此版本的框架添加了CompilerServices
属性,该属性为我们提供了有关方法调用者的详细信息。
这方面的文档是here
有了它,我们可以打开源文件并深入研究它。
我所做的只是使用基本的文本搜索来搜索您在问题中指定的情况。
但它可以让您深入了解如何在您的解决方案中执行此操作,因为我对您的解决方案知之甚少,我只能使用您在帖子中发布的代码。
public static void SearchForThreads(
[System.Runtime.CompilerServices.CallerMemberName] string memberName = "",
[System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "",
[System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0)
var startKey = "this.Controls.Add(";
var endKey = ")";
List<string> components = new List<string>();
var designerPath = sourceFilePath.Replace(".cs", ".Designer.cs");
if (File.Exists(designerPath))
var designerText = File.ReadAllText(designerPath);
var initSearchPos = designerText.IndexOf(startKey) + startKey.Length;
do
var endSearchPos = designerText.IndexOf(endKey, initSearchPos);
var componentName = designerText.Substring(initSearchPos, (endSearchPos - initSearchPos));
componentName = componentName.Replace("this.", "");
if (!components.Contains(componentName))
components.Add(componentName);
while ((initSearchPos = designerText.IndexOf(startKey, initSearchPos) + startKey.Length) > startKey.Length);
if (components.Any())
var classText = File.ReadAllText(sourceFilePath);
var ThreadPos = classText.IndexOf("Task.Run");
if (ThreadPos > -1)
do
var endThreadPos = classText.IndexOf("", ThreadPos);
if (endThreadPos > -1)
foreach (var component in components)
var search = classText.IndexOf(component, ThreadPos);
if (search > -1 && search < endThreadPos)
Console.WriteLine($"Found a call to UI thread component at pos: search");
while ((ThreadPos = classText.IndexOf("Task.Run", ++ThreadPos)) < classText.Length && ThreadPos > 0);
希望对你有帮助。
如果您拆分文本以便输出它,您可以获得行号,但我不想遇到麻烦,因为我不知道什么对您有用。
string[] lines = classText.Replace("\r","").Split('\n');
【讨论】:
如果用 someStaticClass.StaticMethod(Button btn) Task.Run() 调用会怎样 是的,我相信你的观点。这就是为什么我说我这样做是为了满足他的特定需求。我确信它可以适应不同的场景,但无论如何访问静态 ui 类中的控件会有问题,我没有看到这种情况发生。我不是在假设,我只是真的不知道这是否会发生在他的代码库中 @Zorkind 我不完全理解这种方法。我应该在我使用的每个(winform)组件的构造函数中调用它吗?由于我没有很多组件的基类,这意味着我必须从很多不同的地方调用它?只是为了确保我理解正确 @Zorkind 看起来它正在检查 Task.Run() 调用中的代码。但是在这段代码中,还有很多其他的调用,将winforms控件传递给其他方法…… 方法本身你可以放在你的静态program.cs中,bjt你必须在你的每个表单构造函数中调用它。以上是关于找出从后台线程访问的 winforms 控件的主要内容,如果未能解决你的问题,请参考以下文章
C#winform使用了多线程,有时候程序再运行中直接就退出了!
C# SkiaSharp OpenTK Winform - 如何从后台线程中绘制?