如何关闭多线程 .NET Windows Forms 应用程序并防止抛出 ObjectDisposedException
本文介绍如何正确关闭多线程 Windows Forms 应用程序,同时避免抛出 ObjectDisposedException。
假设
假设读者了解 .NET Windows Forms 事件处理和多线程知识,包括使用 lock
语句以及如何将事件委托给 GUI 线程。
概述
本文解释了如何正确关闭一个多线程的 .NET Windows Forms 应用程序,该应用程序有一个在后台运行的线程,该线程会触发事件来更新或修改 GUI。如果此类应用程序没有通过代码正确关闭,可能出现的主要问题是抛出 ObjectDisposedException
,并带有消息“无法访问已释放的对象”。出现此错误是因为 GUI 在后台线程有机会关闭之前就被释放了,导致触发了一个尝试更新已释放对象的事件。导致问题的关键在于 GUI 比后台线程先被释放,以及后台线程触发了更新 GUI 的事件。请注意,如果后台线程在 GUI 之前关闭,则不会出现此问题,这也是您可能注意到此问题并非每次都发生的原因。
下图展示了问题的发生情况
问题
以下代码展示了如何重现该问题。
void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
// Signal the BackgroundWorkerThread to end
this.CloseBackgroundWorker = true;
}
void BackgroundWorkerThread()
{
// Check to see if the background worker is signaled
// to end
while (this.CloseBackgroundWorker != true)
{
// Fire an event that updates the GUI
SomeEvent(null, null);
// Timeout (kept small on purpose)
Thread.Sleep(1);
}
}
void Form1_SomeEvent(object sender, EventArgs e)
{
if (this.InvokeRequired == true)
{
// Invoke the event onto the GUI thread
EventHandler eh = new EventHandler(Form1_SomeEvent);
this.Invoke(eh, new object[] { null, null });
}
else
{
// Event handler code that updates the GUI
label1.Text = "Counter value: " + _counter++;
}
}
基本上,当用户指示应用程序关闭时,后台运行的线程 BackgroundWorkerThread
会收到关闭信号,而 GUI Form1
会被释放。然而,问题在于,不能保证 BackgroundWorkerThread
会在 Form1
被释放之前关闭。这是一个大问题,因为如果 BackgroundWorkerThread
在 Form1
被释放后运行了很短的时间,那么更新 GUI 的事件 SomeEvent
很可能会被触发(请参阅 Form1_SomeEvent
事件处理程序中 label1.Text
的更改)。而此时 Form1
已经被释放,无法进行更新,从而会抛出 ObjectDisposedException
异常。
要重现此行为,只需下载、构建并运行应用程序,然后单击退出按钮关闭应用程序。如前所述,该异常可能不会每次都发生,因此如果没有任何反应,请再次尝试运行应用程序。请注意,BackgroundWorkerThread
中的 while
循环有一个非常短的超时时间,以增加抛出异常的几率。
解决方案
这个问题的解决方案概念上很简单。当用户尝试关闭 GUI 时,GUI 应该首先等待后台运行的线程关闭,然后在该过程完成后,再开始关闭自身。因此,在上面示例代码中,Form1
应该首先等待 BackgroundWorkerThread
关闭,只有在发生这种情况后,才继续关闭自身。
下图展示了解决方案
以下是解决方案的代码
bool _safeToCloseGUI = false;
void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
// Signal the BackgroundWorkerThread to end
this.CloseBackgroundWorker = true;
// The _safeToCloseGUI flag will be true when the
// background worker thread has finished
if (_safeToCloseGUI == false)
{
// The background worker thread has not closed yet,
// so don't close the GUI yet
e.Cancel = true;
}
}
void BackgroundWorkerThread()
{
// Check to see if the background worker is signaled
// to end
while (this.CloseBackgroundWorker != true)
{
// Fire an event that updates the GUI
SomeEvent(null, null);
// Timeout (kept small on purpose)
Thread.Sleep(1);
}
// If we get here, it means that the background worker
// thread is finished doing its work. It is now safe
// to shutdown the GUI (since SomeEvent cannot be called
// anymore). The GUI must be closed via an event because
// this code is running in a background thread.
// Fire the event to close the GUI
CloseGUI(null, null);
}
void Form1_SomeEvent(object sender, EventArgs e)
{
if (this.InvokeRequired == true)
{
// Invoke the event onto the GUI thread
EventHandler eh = new EventHandler(Form1_SomeEvent);
this.Invoke(eh, new object[] { null, null });
}
else
{
// Event handler code that updates the GUI
label1.Text = "Counter value: " + _counter++;
}
}
void Form1_CloseGUI(object sender, EventArgs e)
{
if (this.InvokeRequired == true)
{
// Invoke the event onto the GUI thread
EventHandler eh = new EventHandler(CloseGUI);
this.Invoke(eh, new object[] { null, null });
}
else
{
// Event handler code that tells the GUI it is
// safe to close, then closes it
_safeToCloseGUI = true;
this.Close();
}
}
对原始代码做了一些修改。
首先,在 Form1_FormClosing
方法中添加了一个新标志 _safeToCloseGUI
,只有当该标志设置为 true
时才能关闭 GUI。当用户单击关闭应用程序时,后台工作线程将像以前一样收到关闭信号。但是,现在 GUI 的关闭操作将被取消,因为 _safeToCloseGUI
标志最初设置为 false
。接下来将看到,一旦 GUI 可以安全关闭,此标志将被设置为 true
。
接下来,修改了 BackgroundWorkerThread
,使其一旦收到关闭信号,就会跳出 while
循环,然后触发一个事件来告知 GUI 关闭。在 BackgroundWorkerThread
跳出 while
循环后,可以保证它不会触发更新 GUI 的事件,因此此时 GUI 可以安全关闭。因此,BackgroundWorkderThread
将触发 CloseGUI
事件来告知 GUI 关闭。
最后,添加了事件处理程序 Form1_CloseGUI
来处理 CloseGUI
事件。首先,处理程序将 _safeToCloseGUI
标志设置为 true
,然后调用 Close
方法。这将导致 Form1_FormClosing
继续执行,因为 _safeToCloseGUI
现在为 true
,GUI 将安全且成功地关闭。
替代方案
以下是另一种解决此问题的方法,代码量大大减少,尽管解决方案不够优雅。基本上,可以在更新 GUI 的 invoke 行周围放置一个 try
/catch
块,这样如果抛出异常,它将被处理,应用程序也不会崩溃。请参阅以下代码
void Form1_SomeEvent(object sender, EventArgs e)
{
if (this.InvokeRequired == true)
{
// Invoke the event onto the GUI thread
EventHandler eh = new EventHandler(Form1_SomeEvent);
try
{
this.Invoke(eh, new object[] { null, null });
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
else
{
// Event handler code that updates the GUI
label1.Text = "Counter value: " + _counter++;
}
}
这种方法不够优雅,因为应用程序退出时仍然会发生异常(尽管已被处理且不会崩溃)。但是,如果您想完全避免异常,推荐的方法是最初提出的解决方案。最初的解决方案可能需要更多的工作,但我认为它才是“更整洁”的解决方案。