C# 多线程UI更新与程序安全退出的终极解决方案

426 阅读8分钟

前言

WinForm 应用程序开发中,跨线程更新UI和程序的优雅退出是两个非常常见且重要的技术点。当我们在后台线程中执行耗时操作(如从PLC或远程服务器读取数据)并需要将结果显示在UI界面上时,必须处理跨线程访问的问题。

同时,在程序退出时,如果后台线程仍在运行,很容易引发"对象已释放"等异常。

本文将深入探讨这些问题的根源,并提供一系列逐步优化的解决方案,最终实现一个既安全又友好的程序退出机制。

问题场景与初始实现

我们考虑一个典型的实时数据监控场景:一个窗体程序需要定时从PLC或远程服务器获取数据,并更新界面上的标签。

为了不阻塞UI线程,数据获取操作通常在后台线程中执行。

public partial class Form1 : Form
{
    public Form1( )
    {
        InitializeComponent( );
    }

    private void Form1_Load( object sender, EventArgs e )
    {
        thread = new System.Threading.Thread( new System.Threading.ThreadStart( ThreadCapture ) );
        thread.IsBackground = true;
        thread.Start( );
    }

    private void ThreadCapture( )
    {
        System.Threading.Thread.Sleep( 200 );
        while (true)
        {
            // 我们假设这个数据是从PLC或是远程服务器获取到的,因为可能比较耗时,我们放在了后台线程获取,并且处于一直运行的状态
            // 我们还假设获取数据的频率是200ms一次,然后把数据显示出来
            int data = random.Next( 1000 );

            // 接下来是跨线程的显示
            Invoke( new Action( ( ) =>
             {
                 label1.Text = data.ToString( );
             } ) );


            System.Threading.Thread.Sleep( 200 );
        }
    }

    private System.Threading.Thread thread;
    private Random random = new Random( );
}

这种实现方式在程序正常运行时工作良好,但当用户点击窗口关闭按钮时,经常会遇到异常。这是因为窗体关闭过程中,UI组件开始释放资源,而后台线程可能仍在尝试访问这些已被释放的组件。

优化方案一 避免频繁创建委托实例

首先,我们可以优化代码,避免在循环中频繁创建委托实例,以减少内存开销和GC压力。

private void ThreadCapture( )
{
    showInfo = new Action<string>( m =>
    {
        label1.Text = m;
    } );
    System.Threading.Thread.Sleep( 200 );
    while (true)
    {
        // 我们假设这个数据是从PLC或是远程服务器获取到的,因为可能比较耗时,我们放在了后台线程获取,并且处于一直运行的状态
        // 我们还假设获取数据的频率是200ms一次,然后把数据显示出来
        int data = random.Next( 1000 );

        // 接下来是跨线程的显示
        Invoke( showInfo, data.ToString( ) );


        System.Threading.Thread.Sleep( 200 );
    }
}

private Action<string> showInfo;
private System.Threading.Thread thread;
private Random random = new Random( );

优化方案二:增加安全检查

为了防止在窗体已释放后仍尝试更新UI,可以在更新前进行安全检查。

private void ThreadCapture( )
{
    showInfo = new Action<string>( m =>
    {
        label1.Text = m;
    } );
    System.Threading.Thread.Sleep( 200 );
    while (true)
    {
        // 我们假设这个数据是从PLC或是远程服务器获取到的,因为可能比较耗时,我们放在了后台线程获取,并且处于一直运行的状态
        // 我们还假设获取数据的频率是200ms一次,然后把数据显示出来
        int data = random.Next( 1000 );

        // 接下来是跨线程的显示
        if(IsHandleCreated && !IsDisposed) Invoke( showInfo, data.ToString( ) );


        System.Threading.Thread.Sleep( 200 );
    }
}

优化方案三:使用异常捕获

虽然不推荐作为主要手段,但可以使用try-catch来捕获ObjectDisposedException异常。

private void ThreadCapture( )
{
    showInfo = new Action<string>( m =>
    {
        label1.Text = m;
    } );
    System.Threading.Thread.Sleep( 200 );
    while (true)
    {
        // 我们假设这个数据是从PLC或是远程服务器获取到的,因为可能比较耗时,我们放在了后台线程获取,并且处于一直运行的状态
        // 我们还假设获取数据的频率是200ms一次,然后把数据显示出来
        int data = random.Next( 1000 );

        try
        {
            // 接下来是跨线程的显示
            if (IsHandleCreated && !IsDisposed) Invoke( showInfo, data.ToString( ) );
        }
        catch (ObjectDisposedException)
        {
            break;
        }
        catch
        {
            throw;
        }


        System.Threading.Thread.Sleep( 200 );
    }
}

优化方案四:使用退出标志

引入一个布尔标志来指示窗体是否仍处于显示状态。

private void ThreadCapture( )
{
    showInfo = new Action<string>( m =>
    {
        label1.Text = m;
    } );
    isWindowShow = true;

    System.Threading.Thread.Sleep( 200 );
    while (true)
    {
        // 我们假设这个数据是从PLC或是远程服务器获取到的,因为可能比较耗时,我们放在了后台线程获取,并且处于一直运行的状态
        // 我们还假设获取数据的频率是200ms一次,然后把数据显示出来
        int data = random.Next( 1000 );

        // 接下来是跨线程的显示
        if (isWindowShow) Invoke( showInfo, data.ToString( ) );
        else break;


        System.Threading.Thread.Sleep( 200 );
    }
}

private bool isWindowShow = false;
private Action<string> showInfo;
private System.Threading.Thread thread;
private Random random = new Random( );

private void Form1_FormClosing( object sender, FormClosingEventArgs e )
{
    isWindowShow = false;
}

优化方案五:使用同步事件

为了确保后台线程完全停止后再关闭窗体,可以使用AutoResetEvent进行同步。

private void ThreadCapture( )
{
    showInfo = new Action<string>( m =>
    {
        label1.Text = m;
    } );
    isWindowShow = true;

    System.Threading.Thread.Sleep( 200 );
    while (true)
    {
        // 我们假设这个数据是从PLC或是远程服务器获取到的,因为可能比较耗时,我们放在了后台线程获取,并且处于一直运行的状态
        // 我们还假设获取数据的频率是200ms一次,然后把数据显示出来
        int data = random.Next( 1000 );

        // 接下来是跨线程的显示,并检测窗体是否关闭
        if (isWindowShow) Invoke( showInfo, data.ToString( ) );
        else break;


        System.Threading.Thread.Sleep( 200 );
        // 再次检测窗体是否关闭
        if (!isWindowShow) break;
    }

    // 通知主界面是否准备退出
    resetEvent.Set( );
}

private System.Threading.AutoResetEvent resetEvent = new System.Threading.AutoResetEvent( false );
private bool isWindowShow = false;
private Action<string> showInfo;
private System.Threading.Thread thread;
private Random random = new Random( );

private void Form1_FormClosing( object sender, FormClosingEventArgs e )
{
    isWindowShow = false;
    resetEvent.WaitOne( );
}

优化方案六:使用异步调用避免死锁

为了避免Invoke可能引起的死锁,可以改用BeginInvoke

private void ThreadCapture( )
{
    showInfo = new Action<string>( m =>
    {
        label1.Text = m;
    } );
    isWindowShow = true;

    System.Threading.Thread.Sleep( 200 );
    while (true)
    {
        // 我们假设这个数据是从PLC或是远程服务器获取到的,因为可能比较耗时,我们放在了后台线程获取,并且处于一直运行的状态
        // 我们还假设获取数据的频率是200ms一次,然后把数据显示出来
        int data = random.Next( 1000 );

        // 接下来是跨线程的显示,并检测窗体是否关闭
        if (isWindowShow) BeginInvoke( showInfo, data.ToString( ) );
        else break;


        System.Threading.Thread.Sleep( 200 );
        // 再次检测窗体是否关闭
        if (!isWindowShow) break;
    }

    // 通知主界面是否准备退出
    resetEvent.Set( );
}

private System.Threading.AutoResetEvent resetEvent = new System.Threading.AutoResetEvent( false );
private bool isWindowShow = false;
private Action<string> showInfo;
private System.Threading.Thread thread;
private Random random = new Random( );

private void Form1_FormClosing( object sender, FormClosingEventArgs e )
{
    isWindowShow = false;
    resetEvent.WaitOne( );
}

最终方案:友好的退出窗口

为了提供更好的用户体验,可以创建一个专门的退出窗口来处理清理工作。

public partial class FormQuit : Form
{
    public FormQuit( Action action )
    {
        InitializeComponent( );
        this.action = action;
    }

    private void FormQuit_Load( object sender, EventArgs e )
    {

    }

    // 退出前的操作
    private Action action;

    private void FormQuit_Shown( object sender, EventArgs e )
    {
        // 调用操作
        action.Invoke( );
        Close( );
    }
}

主窗体的最终实现:

public partial class Form1 : Form
{
    public Form1( )
    {
        InitializeComponent( );
    }

    private void Form1_Load( object sender, EventArgs e )
    {
        thread = new System.Threading.Thread( new System.Threading.ThreadStart( ThreadCapture ) );
        thread.IsBackground = true;
        thread.Start( );
    }


    private void ThreadCapture( )
    {
        showInfo = new Action<string>( m =>
        {
            label1.Text = m;
        } );
        isWindowShow = true;

        System.Threading.Thread.Sleep( 200 );
        while (true)
        {
            // 我们假设这个数据是从PLC或是远程服务器获取到的,因为可能比较耗时,我们放在了后台线程获取,并且处于一直运行的状态
            // 我们还假设获取数据的频率是200ms一次,然后把数据显示出来
            int data = random.Next( 1000 );

            // 接下来是跨线程的显示,并检测窗体是否关闭
            if (isWindowShow) Invoke( showInfo, data.ToString( ) );
            else break;


            System.Threading.Thread.Sleep( 200 );
            // 再次检测窗体是否关闭
            if (!isWindowShow) {System.Threading.Thread.Sleep(50);break;}
        }

        // 通知主界面是否准备退出
        resetEvent.Set( );
    }

    private System.Threading.AutoResetEvent resetEvent = new System.Threading.AutoResetEvent( false );
    private bool isWindowShow = false;
    private Action<string> showInfo;
    private System.Threading.Thread thread;
    private Random random = new Random( );

    private void Form1_FormClosing( object sender, FormClosingEventArgs e )
    {
        FormQuit formQuit = new FormQuit( new Action(()=>
        {
            isWindowShow = false;
            resetEvent.WaitOne( );
        } ));
        formQuit.ShowDialog( );
    }
}

总结

本文通过一个实际的开发场景,系统地探讨了WinForm应用中跨线程更新UI和程序退出的问题。我们从最简单的实现开始,逐步分析了各种潜在问题,并提出了相应的优化方案。

关键要点总结

1、避免资源浪费:在循环中避免频繁创建委托实例,应将其定义为成员变量。

2、安全检查:在跨线程更新UI前,检查IsHandleCreated!IsDisposed状态。

3、优雅退出:使用布尔标志和同步事件(如AutoResetEvent)确保后台线程完全停止后再关闭窗体。

4、避免死锁:在可能引起死锁的场景下,考虑使用BeginInvoke替代Invoke

5、用户体验:通过专门的退出窗口处理清理工作,可以提供更友好、更可靠的退出体验。

通过这些优化,我们可以构建出既稳定又用户友好的WinForm应用程序。

关键词

跨线程更新、UI界面、WinForms、线程安全、Invoke、BeginInvoke、AutoResetEvent、异步调用、程序退出、异常处理、后台线程

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号 [DotNet技术匠] 社区,与其他热爱技术的同行一起交流心得,共同成长!

优秀是一种习惯,欢迎大家留言学习!

作者:dathlin

出处:cnblogs.com/dathlin/p/10147736.html

声明:网络内容,仅供学习,尊重版权,侵权速删,歉意致谢!