奇怪的InvokeRequired问题

5

我有一个UserControl,其中包含一个名为mTreeView的TreeView控件。我可以从多个不同的线程获取数据更新,这些更新将导致TreeView被更新。为此,我设计了以下模式:所有数据更新事件处理程序必须获得锁并检查InvokeRequired;如果是,则通过调用Invoke来完成工作。下面是相关代码:

  public partial class TreeViewControl : UserControl
  {  
    object mLock = new object();
    void LockAndInvoke(Control c, Action a)
    {
      lock (mLock)
      {
        if (c.InvokeRequired)
        {
          c.Invoke(a);
        }
        else
        {
          a();
        }
      }
    }

    public void DataChanged(object sender, NewDataEventArgs e)
    {
      LockAndInvoke(mTreeView, () =>
        {
          // get the data
          mTreeView.BeginUpdate();
          // perform update
          mTreeView.EndUpdate();
        });
    }    
  }

我的问题是,在启动时,有时会在mTreeView.BeginUpdate()上出现InvalidOperationException,提示mTreeView正在从不同于创建它的线程更新。我回溯调用堆栈到我的LockAndInvoke,看到c.InvokeRequired是true,但else分支被执行了!就好像在else分支执行后,InvokeRequired在不同的线程上被设置为true一样。
我的方法是否有问题,我该如何防止这种情况发生?
编辑:我的同事告诉我,问题在于InvokeRequired在控件创建之前为false,因此这就是为什么它会在启动时出现的原因。他不确定该怎么办。有什么想法吗?

因为有多个不同的线程调用这些事件处理程序,我只希望一次只执行一个。 - Asik
3
不需要锁 - 这个方法的目的是将所有调用都调度到UI线程上,因此根据定义,在任何时候只会有一个线程执行它(即UI线程)。 - Justin
你关于锁不必要的说法肯定是正确的,但这并不能解释为什么我会收到这个异常。 - Asik
我在想问题是否在于传递给LockAndInvoke方法的匿名函数没有引用mTreeView本身,这会导致在您执行Invoke之前在其他线程上调用它。 - David W
刚刚用多个线程进行了多次测试,但我无法复现。 - ken2k
3个回答

7
这是一个标准的线程竞争。您启动线程过早,在创建TreeView之前启动,因此您的代码将看到InvokeRequired为false,并在稍后的一瞬间本机控件被创建时失败。修复方法是仅在窗体的Load事件触发时启动线程,这是保证所有控件句柄有效的第一个事件。
代码中存在一些误解。使用锁(lock)是不必要的,Begin/Invoke和InvokeRequired都是线程安全的。而且,InvokeRequired是一种反模式。您几乎总是知道该方法将由工作线程调用。因此,只有在InvokeRequired为false时才使用它来抛出异常。这样可以早日诊断出这个问题。

关于“仅在InvokeRequired为false时抛出异常”的问题:我尝试了,但它每次都会抛出异常(在启动时),即使我在Load事件中启动了工作线程。不过,在调用Invoke之后立即调用仍然有效!看起来即使可以调用Invoke,InvokeRequired也会在一段时间内继续报告false。我真的不知道为什么。 - Asik
原来这些事件处理程序在启动时是从UI线程调用的:订阅代码(我无法控制)出于某种原因执行了这个操作。因此,似乎这是一个需要检查InvokeRequired的情况,因为代码可以从UI和工作线程中调用。 - Asik
不,那并不能解决我所描述的线程竞争问题。也许你需要一个计时器,我不知道为什么这很困难。 - Hans Passant
我认为现在我已经正确理解了。我从Load事件开始订阅事件,这可以保证InvokeRequired不会出错;然后在事件处理程序中,我执行InvokeIfRequired操作,因为事实证明它们最初是从UI线程调用的,只有后来才从工作线程调用。在理论和实践中,这似乎都很好地运作着。 - Asik

2
当您从其他线程返回UI线程进行调用时,只有一个线程可以做一件事情。调用Invoke时不需要任何锁。
使用Invoke的问题在于它会阻塞呼叫线程。该呼叫线程通常并不关心在UI线程上完成了什么。在这种情况下,建议使用BeginInvoke异步地将操作传回UI线程。有些情况下,后台线程可能在Invoke上被阻塞,而UI线程却在等待后台线程完成某些操作,导致死锁。例如:
private bool b;
public void EventHandler(object sender, EventArgs e)
{
  while(b) Thread.Sleep(1); // give up time to any other waiting threads
  if(InvokeRequired)
  {
    b = true;
    Invoke((MethodInvoker)(()=>EventHandler(sender, e)), null);
    b = false;
  }
}

上述代码中的 while 循环会发生死锁,因为 Invoke 方法不会在调用 EventHandler 方法之前返回,而 EventHandler 方法又要等到 b 为 false 才会返回...

注意我使用了一个布尔值来阻止某些代码段的运行。这与 lock 非常相似。因此,使用 lock 也可能导致死锁。

解决方法很简单:

public void DataChanged(object sender, NewDataEventArgs e)
{
      if(InvokeRequired)
      {
          BeginInvoke((MethodInvoker)(()=>DataChanged(sender, e)), null);
          return;
      }
      // get the data
      mTreeView.BeginUpdate();
      // perform update
      mTreeView.EndUpdate();
}

这只是在UI线程上异步重新调用DataChanged方法。


我使用Invoke是因为我特别想阻止调用线程;不过在一个简短的评论中解释这个原因可能有点困难。无论如何,还是谢谢! - Asik

1

从你上面展示的模式来看,我觉得完全没有问题(尽管有一些额外不必要的锁定,但我无法看出这会导致你描述的问题)。

正如David W所指出的,你所做的唯一区别和这个扩展方法之间的区别在于,在UI线程上直接访问mTreeView而不是将其作为参数传递给你的操作。然而,只有当mTreeView的值发生变化时,这才会有所不同,而且无论如何,你都必须努力才能使它导致你所描述的问题。

这意味着问题肯定是其他原因造成的。

我唯一能想到的是,你可能已经在与UI线程不同的线程上创建了mTreeView,如果是这种情况,则访问该树视图将是100%安全的,但是如果您尝试将该树视图添加到在不同线程上创建的窗体中,则会引发异常,类似于您所描述的那种异常。


我百分之百确定mTreeView及其父窗体(以及我的应用程序的所有UI控件)都是在UI线程上创建的。 - Asik
@Dr_Asik,您能发布一下实例化“mTreeView”的代码吗?我在您的代码中看不到任何“InitializeComponent()”。 - ken2k
@mTreeView是由设计师生成的代码实例化的,而InitializeComponent()则由构造函数调用。构造函数是由实例化窗体的UI线程调用的。 - Asik

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