避免在控件被释放时调用Invoke方法

29

我在我的工作线程中有以下代码(下面的ImageListView是派生自Control):

if (mImageListView != null && 
    mImageListView.IsHandleCreated &&
    !mImageListView.IsDisposed)
{
    if (mImageListView.InvokeRequired)
        mImageListView.Invoke(
            new RefreshDelegateInternal(mImageListView.RefreshInternal));
    else
        mImageListView.RefreshInternal();
}

然而,有时我会在上面的Invoke方法中遇到ObjectDisposedException。似乎在我检查IsDisposed和调用Invoke之间,控件可能已被处理。我该如何避免这种情况?


为什么首先要处理它? - RvdK
@PoweRoy:我在控件的Dispose方法中发信号通知线程退出。我知道这不是最好的做法,但我找不到更好的地方来发信号通知线程退出。 - Ozgur Ozcitak
14个回答

19

你这里遇到了一个竞争条件。最好直接捕获ObjectDisposed异常并结束它。事实上,在这种情况下,我认为这是唯一有效的解决方案。

try
{
    if (mImageListView.InvokeRequired)
       mImageListView.Invoke(new YourDelegate(thisMethod));
    else
       mImageListView.RefreshInternal();
} 
catch (ObjectDisposedException ex)
{
    // Do something clever
}

我真的希望避免使用try/catch,但如果这是唯一的解决方案,我会这样做。 - Ozgur Ozcitak
你可以使用互斥锁或锁来解决这个问题,但这样做会更容易出错,并且可能会导致奇怪的错误,因为代码正在不断发展。你需要使用相同的互斥锁来保护所有对Dispose()的调用,随着代码的发展,这将变得更加困难... - Isak Savo

14
你的代码存在隐式竞态条件。 在IsDisposed测试和InvokeRequired测试之间,控件可能被释放。在InvokeRequired和Invoke()之间还有另一个竞态条件。如果没有确保控件生命周期超过线程生命周期,则无法解决此问题。考虑到你的线程正在为列表视图生成数据,在列表视图消失之前,它应该停止运行。
通过在FormClosing事件中设置e.Cancel并使用ManualResetEvent向线程发出停止信号,可以实现这一点。当线程完成后,再次调用Form.Close()。使用BackgroundWorker使线程完成逻辑变得容易,可以在此帖子中找到示例代码。

2
你能保证它只会是一个ObjectDisposedException吗?如果它是一个合法的ObjectDisposedException怎么办?这是一个兔子洞。解决问题,不要打击报告者。 - Hans Passant
1
这通常是死锁的一种情况,线程的完成回调无法运行,因为UI线程没有在处理消息。听起来你成功避免了它。 - Hans Passant
等待线程自己优雅地终止很可能会导致表单实际关闭的延迟。如果延迟时间过长,则用户体验较差。我需要一种更加用户友好的方法。 - goku_da_master
嗯,不及时响应终止请求的线程通常是一个设计问题。掩盖这个缺陷并不困难。调用Hide()方法即可。 - Hans Passant
当User Control被处理时,我遇到了相同的问题。因为我无法控制父窗体的关闭过程,所以我不得不重写User Control的Dispose方法,并实现一些策略来优雅地等待线程停止。这还不够,因为UI线程中有未决的Invokes。我发现唯一的刷新它们的方法是在最后一刻执行"Application.DoEvents()"。然后我的UserControl就可以正确地释放了。听起来很糟糕,但目前为止它运行得很好。 - Larry
显示剩余8条评论

4
事实上,使用Invoke及其相关方法,您无法完全防止在已释放的组件上调用Invoke,或因缺少句柄而导致InvalidOperationException异常。我还没有看到像下面更深入的答案在任何线程中真正解决了这个根本问题,这个问题无法通过预防性测试或使用锁语义来完全解决。
以下是正常的“正确”惯用语:
// the event handler. in this case preped for cross thread calls  
void OnEventMyUpdate(object sender, MyUpdateEventArgs e)
{
    if (!this.IsHandleCreated) return;  // ignore events if we arn't ready, and for
                                        // invoke if cant listen to msg queue anyway
    if (InvokeRequired) 
        Invoke(new MyUpdateCallback(this.MyUpdate), e.MyData);
    else
        this.MyUpdate(e.MyData);
}

// the update function
void MyUpdate(Object myData)
{
    ...
}

基本问题:

使用Invoke设施时,将使用Windows消息队列,该队列会将消息放入队列中,以等待或立即触发跨线程调用,就像Post或Send消息一样。如果在Invoke消息之前有一个消息会使组件及其窗口句柄无效,或者该消息刚好放置在您尝试执行任何检查之后,那么您将会遇到麻烦。

 x thread -> PostMessage(WM_CLOSE);   // put 'WM_CLOSE' in queue
 y thread -> this.IsHandleCreated     // yes we have a valid handle
 y thread -> this.Invoke();           // put 'Invoke' in queue
ui thread -> this.Destroy();          // Close processed, handle gone
 y thread -> throw Invalid....()      // 'Send' comes back, thrown on calling thread y

没有真正的方式可以知道控件将要从队列中移除自己,也没有任何合理的方法可以“撤销”调用。无论您进行多少检查或额外的锁定,都无法阻止其他人发出类似关闭或停用的操作。这种情况有很多。

一种解决方案:

首先要意识到的是,调用将失败,就像(IsHandleCreated)检查会忽略事件一样。如果目标是保护非UI线程上的调用方,则需要处理异常,并将其视为未成功的任何其他调用(以避免应用程序崩溃或执行其他操作)。除非你打算重写/重新生成调用设施,否则catch是你唯一的方法来知道。

// the event handler. in this case preped for cross thread calls  
void OnEventMyWhatever(object sender, MyUpdateEventArgs e)
{
    if (!this.IsHandleCreated) return;
    if (InvokeRequired) 
    {
        try
        {
            Invoke(new MyUpdateCallback(this.MyUpdate), e.MyData);
        }
        catch (InvalidOperationException ex)    // pump died before we were processed
        {
            if (this.IsHandleCreated) throw;    // not the droids we are looking for
        }
    }
    else
    {
        this.MyUpdate(e.MyData);
    }
}

// the update function
void MyUpdate(Object myData)
{
    ...
}

异常过滤器可以根据需要进行定制。需要注意的是,工作线程通常没有像UI线程那样完善的外部异常处理和日志记录,因此您可能希望在工作端口只消耗掉任何异常。或者记录并重新抛出所有异常。对于许多人来说,工作线程中未捕获的异常意味着应用程序将崩溃。

2
我真的希望在对象尝试执行像“刷新”控件这样的操作以更新其可见部分以反映新数据的情况下,有“TryInvoke”和“TryBeginInvoke”方法。期望的后置条件是控件没有任何需要更新的部分。控件的处理意味着没有可见部分,因此也没有过时的可见部分;因此,后置条件将得到满足,并且该方法应成功返回。 - supercat

3
尝试使用 <\p>
if(!myControl.Disposing)
    ; // invoke here

我和你遇到的问题完全一样。自从我开始检查控件上的.Disposing,ObjectDisposedException就消失了。并不是说这种方法百分之百有效,只能说有99%的成功率;)在Disposing的检查和调用invoke之间仍然存在竞争条件的可能性,但在我的测试中,我还没有遇到过这种情况(我使用线程池和工作线程)。
以下是我在每次调用invoke之前使用的代码:
    private bool IsControlValid(Control myControl)
    {
        if (myControl == null) return false;
        if (myControl.IsDisposed) return false;
        if (myControl.Disposing) return false;
        if (!myControl.IsHandleCreated) return false;
        if (AbortThread) return false; // the signal to the thread to stop processing
        return true;
    }

+1 检查 IsHandleCreated 是否为 true/false。这并不能解决竞态条件,但需要记在心里。 - TamusJRoyce

1

Isak Savo 提出的解决方案

try
  {
  myForm.Invoke(myForm.myDelegate, new Object[] { message });
  }
catch (ObjectDisposedException)
  { //catch exception if the owner window is already closed
  }

在C# 4.0中可以正常工作,但由于某些原因,在C# 3.0中失败了(无论如何都会引发异常)

因此,我使用了另一种解决方案,基于一个标志来指示窗体是否正在关闭,并且如果设置了该标志,则防止使用invoke。

   public partial class Form1 : Form
   {
    bool _closing;
    public bool closing { get { return _closing; } }

    private void Form1_FormClosing(object sender, FormClosingEventArgs e)
    {
        _closing = true;
    }

 ...

 // part executing in another thread: 

 if (_owner.closing == false)
  { // the invoke is skipped if the form is closing
  myForm.Invoke(myForm.myDelegate, new Object[] { message });
  }

这样做的好处是完全避免了使用try/catch。


我认为添加和删除事件处理程序以及检查isDisposed是最常用的方法,但我必须说,这对我非常有效。摆脱try-catch并且不需要使用事件,只需多花费一个bool即可完成所有操作 :-) 感谢您的提示! - Xan-Kun Clark-Davis

1

你可以使用互斥锁。

在线程的开始处:

 Mutex m=new Mutex();

然后:

if (mImageListView != null && 
    mImageListView.IsHandleCreated &&
    !mImageListView.IsDisposed)
{
    m.WaitOne(); 

    if (mImageListView.InvokeRequired)
        mImageListView.Invoke(
            new RefreshDelegateInternal(mImageListView.RefreshInternal));
    else
        mImageListView.RefreshInternal();

    m.ReleaseMutex();
}

无论您将mImageListView放置在何处:

 m.WaitOne(); 
 mImageListView.Dispose();
 m.ReleaseMutex();

这将确保您无法同时处置和调用。


在IsDisposed检查和WaitOne调用之间,难道还存在竞态条件吗? - Ozgur Ozcitak
是的,你发现得很好,你是对的。你可以在 WaitOne 调用后再添加一个 IsDisposed 检查,或者可以在 IsDisposed 检查之前放置 WaitOne。如果你预计代码执行时 IsDisposed 为 false 的次数比为 true 的次数多,那么我会选择后者选项以节省额外的调用。 - Mongus Pong

1
如果BackGroundWorker是一种可能性,那么有一种非常简单的方法可以规避这个问题:
public partial class MyForm : Form
{
    private void InvokeViaBgw(Action action)
    {
        BGW.ReportProgress(0, action);
    }

    private void BGW_ProgressChanged(object sender, ProgressChangedEventArgs e)
    {
        if (this.IsDisposed) return; //You are on the UI thread now, so no race condition

        var action = (Action)e.UserState;
        action();
    }

    private private void BGW_DoWork(object sender, DoWorkEventArgs e)
    {
       //Sample usage:
       this.InvokeViaBgw(() => MyTextBox.Text = "Foo");
    }
}

1
处理窗体关闭事件。检查您的非 UI 线程工作是否仍在进行,如果是,则开始将其关闭,取消关闭事件,然后使用表单控件上的 BeginInvoke 重新安排关闭。
private void Form_FormClosing(object sender, FormClosingEventArgs e)
{
    if (service.IsRunning)
    {
        service.Exit();
        e.Cancel = true;
        this.BeginInvoke(new Action(() => { this.Close(); }));
    }
}

1

可能需要使用 lock(mImageListView){...} 吗?


这样做不行。无法保证在锁内部时Disposed不会被调用。 - Isak Savo
@Isak 锁的目的是阻止其他线程访问一个对象。如果他锁定这个对象,那么根据定义,它在被锁定时不能被处理掉。 - Jrud
你在Thread2上锁定了对象,但是该对象在Thread1上被处理。因此,你要么引入死锁,要么无操作。 - arul
@Jrud:这不是真的。锁定只是意味着你阻止其他线程尝试获取相同的锁。仍然可以调用“已锁定”对象上的任何方法。 - Isak Savo
可能它只是防止垃圾回收器将其处理掉,我猜在执行任何操作之前,它应该检查SyncBlockIndex对象的状态(以防用户代码未调用对象的Dispose方法)。 - oldUser

1

请参考以下问题:

避免跨线程WinForm事件处理中的Invoke/BeginInvoke困境?

产生EventHandlerForControl实用程序类可以解决事件方法签名的问题。您可以调整该类或者审查其中的逻辑以解决该问题。

这里真正的问题是nobugz正确地指出,winforms中提供的用于跨线程调用的API本质上并不是线程安全的。即使在对InvokeRequired和Invoke/BeginInvoke进行调用时,也存在多个竞争条件可能导致意外行为。


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