.Net Core WinForms工作线程更新UI线程控件相关问题解决

 最近在工作中需要做一个小工具给其他部门的同事使用,遂决定用WinForms,.NET5.0平台框架来开发,由于好久没用WinForms了,于是就简单了解了下.net framework和.net core平台下WinForms的区别,发现还是有不少区别的,印象最深的就是.net core下可以将运行环境和程序一起打包发布,不需要在额外安装运行时环境支持的,但.net core 下也取消了不少.net framework下的功能,比如不支持BeginInvoke。

在做这个小工具的时候碰到了不少问题,主要都是围绕子线程更新主线程UI这个问题展开的,在这里记录下碰到问题的过程和解决的方法,好让自己以后遗忘时可以再回顾下,也给碰到相关问题的朋友一点帮助。如果有错误不当的地方还望指正。
项目开发环境:Visual Studio 2019 , .NET5.0 , WinForms

该项目使用MVVM模式开发,先上简单代码
// MainFormViewModel.cs
	public partial class MainFormViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;
        
        private string _log1;
        private string _log2;
        
        public string Log1{
            get { return _log1; }
            set {
                if (_log1 != value) {
                    _log1 = value;
                    NotifyPropertyChanged();
                }
            }
        }
        
        public string Log2{
            get { return _log2; }
            set{
                if (_log2 != value){
                    _log2 = value;
                    NotifyPropertyChanged();
                }
            }
        }

        private void NotifyPropertyChanged([CallerMemberName] string propertyName = ""){
            if(PropertyChanged!=null)PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }

// MainFormViewModel.Action.cs
	public partial class MainFormViewModel
    {
        public void ShowLog1() {
            SetLog1("log1 info");
        }

        public void ShowLog2() {
            SetLog2("log2 info");        
        }

        private void SetLog1(string message) {
            message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
            this.Log1 = message + this.Log1;
        }

        private void SetLog2(string message) {
            message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
            this.Log2 = message + this.Log2;
        }
    }

// MainForm.cs
	public partial class MainForm : Form
    {
        private MainFormViewModel _mainFormViewModel;
        public MainForm(){
            InitializeComponent();
            Init();
        }

        private void Init() {
            _mainFormViewModel = new MainFormViewModel();
            //进行数据绑定
            this.textBox_Log1.DataBindings.Add(new Binding(nameof(textBox_Log1.Text), _mainFormViewModel, nameof(_mainFormViewModel.Log1))) ;
            this.textBox_Log2.DataBindings.Add(new Binding(nameof(textBox_Log2.Text), _mainFormViewModel, nameof(_mainFormViewModel.Log2)));
        }

        // button  写log1
        private void button_log1_Click(object sender, EventArgs e){
            _mainFormViewModel.ShowLog1();
        }

        // button  写log2
        private void button_log2_Click(object sender, EventArgs e){
            _mainFormViewModel.ShowLog2();
        }
    }

这是界面设计
在这里插入图片描述
点击按钮“写Log1”和“写Log2”则对应TextBox会显示对应的内容
在这里插入图片描述
到此,一个简单的MVVM模式搭建完成,下一步,我有一个很耗时的任务,我需要新开一个线程来执行,并在结束后输出log日志,修改代码

// MainFormViewModel.Action.cs
// 使用Task.Run()执行耗时任务,并在执行完输入日志(在执行任务线程中)
	public partial class MainFormViewModel
    {
        public void ShowLog1() {
            Task.Run(()=> {
                //模拟耗时任务
                System.Threading.Thread.Sleep(2000);
                //任务完成,输入日志
                SetLog1("log1 任务完成");
            });          
        }

        public void ShowLog2() {
            SetLog2("log2 info");        
        }

        private void SetLog1(string message) {
            message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
            this.Log1 = message + this.Log1;
        }

        private void SetLog2(string message) {
            message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
            this.Log2 = message + this.Log2;
        }
    }

编译执行代码,不出所料报如下错误:
在这里插入图片描述
想了下,既然用了Task,那就用下异步编程吧async ,await。修改代码

 	public partial class MainFormViewModel
    {
        public async void ShowLog1() {
        	SetLog1("log1 开始任务");
            await Task.Run(()=> {
                //模拟耗时任务
                System.Threading.Thread.Sleep(2000);                              
            });
            //任务完成,输入日志 
            SetLog1("log1 任务完成");
        }

        public void ShowLog2() {
            SetLog2("log2 info");        
        }

        private void SetLog1(string message) {
            message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
            this.Log1 = message + this.Log1;
        }

        private void SetLog2(string message) {
            message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
            this.Log2 = message + this.Log2;
        }
    }

编译执行,成功在异步任务执行完之后写入log
在这里插入图片描述
但这个必须在异步任务完全结束后才能执行,那么现在我需要再异步任务执行过程中就需要输出一些日志,比如执行进度百分比等等信息了?
修改代码

	public partial class MainFormViewModel
    {
        public async void ShowLog1() {
            SetLog1("log1 开始任务");
            await Task.Run(()=> {
                int i = 0;
                while (i++ < 5) {
                    //模拟耗时任务
                    System.Threading.Thread.Sleep(2000);
                    //需要在此输出执行进度
                    //代码该如何写?  SetLog1($"log1 任务进度{i}/5");
                }                       
            });
            //任务完成,输入日志 
            SetLog1("log1 任务完成");
        }
        
        private void SetLog1(string message) {
            message = $"[{DateTime.Now.ToString("HH:mm:ss")}] {message} \r\n";
            this.Log1 = message + this.Log1;
        }
		//省略其他不相关方法
    }

翻阅了下微软的文档(https://docs.microsoft.com/zh-cn/dotnet/desktop/winforms/controls/how-to-make-thread-safe-calls?view=netdesktop-6.0),此处说有两种方式在工作线程调用UI线程的控件,我们尝试第一种,将System.Windows.Forms.Control引入到该类中
在这里插入图片描述
修改代码如下

// MainFormViewModel.cs  
 	public partial class MainFormViewModel : INotifyPropertyChanged
    {
        private System.Windows.Forms.Control _uiControl;
        public MainFormViewModel(System.Windows.Forms.Control uiControl) {
            _uiControl = uiControl;
        }
     }
     
// MainForm.cs
	private void Init() {
         _mainFormViewModel = new MainFormViewModel(this);
		// 其他代码省略
    }

// MainFormViewModel.Action.cs
	public async void ShowLog1() {
        SetLog1("log1 开始任务");
        await Task.Run(()=> {
            int i = 0;
            while (i++ < 5) {
                //模拟耗时任务
                System.Threading.Thread.Sleep(2000);
                //需要在此输出执行进度                    
                _uiControl.Invoke((Action)delegate
                {
                    SetLog1($"{i}/5");
                });
            }                       
        });
        //任务完成,输入日志 
        SetLog1("log1 任务完成");
    }

编译代码执行
在这里插入图片描述
可以看到日志顺利打印出来,但是在ViewModel中依赖System.Windows.Forms.Control并不是一个好的方法,所以我们改变一下,修改代码如下

//MainFormViewModel.cs
	public partial class MainFormViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;
        private readonly Action<Action> _uiInvoker;

        public MainFormViewModel(Action<Action> uiInvoker) {
            _uiInvoker = uiInvoker;
        }

        private string _log1;
        private string _log2;

        public string Log1{
            get { return _log1; }
            set {
                if (_log1 != value) {
                    _log1 = value;
                    _uiInvoker(()=> NotifyPropertyChanged()) ;
                }
            }
        }

        public string Log2{
            get { return _log2; }
            set{
                if (_log2 != value){
                    _log2 = value;
                    _uiInvoker(() => NotifyPropertyChanged());
                }
            }
        }

        private void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
        {
            if(PropertyChanged != null)PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }

// MainFormViewModel.Action.cs
	public async void ShowLog1() {
        SetLog1("log1 开始任务");
        await Task.Run(()=> {
            int i = 0;
            while (i++ < 5) {
                //模拟耗时任务
                System.Threading.Thread.Sleep(2000);
                //需要在此输出执行进度                    
                SetLog1($"{i}/5");
            }                       
        });
        //任务完成,输入日志 
        SetLog1("log1 任务完成");
    }

// MainForm.cs
	private void Init() {
        _mainFormViewModel = new MainFormViewModel(a=> this.Invoke(a));
        //其他代码省略...
    }

编译代码执行
在这里插入图片描述
到这里,在工作线程更新UI线程控件的功能就完成了,但是又遇到了下一个问题,当有两个工作线程在执行耗时任务,并更新UI线程的控件,却发生了界面卡死的问题,代码如下(在这里先注释了几行代码,下面会说为什么)

// MainFormViewModel.Action.ca
	public async void ShowLog1() {
        //SetLog1("log1 开始任务");  //先注释掉工作线程之外的UI更新,具体原因下面会解释
        await Task.Run(()=> {
            int i = 0;
            while (i++ < 10000) {
                //需要在此输出执行进度                    
                SetLog1($"{i}/5");
            }                       
        });
        //任务完成,输入日志 
        //SetLog1("log1 任务完成"); //先注释掉工作线程之外的UI更新,具体原因下面会解释
    }

    public async void ShowLog2() {
        //SetLog2("log2 开始任务"); //先注释掉工作线程之外的UI更新,具体原因下面会解释
        await Task.Run(() => {
            int i = 0;
            while (i++ < 10000){
                //需要在此输出执行进度                    
                SetLog2($"{i}/5");
            }
        });
        //任务完成,输入日志 
        //SetLog2("log2 任务完成"); //先注释掉工作线程之外的UI更新,具体原因下面会解释
    }

这里为什么会发生界面卡死,我用vs分析了好久,由于能力有限没有找到最明显最有利的证据来证明问题所在,但经过分析后可以确定问题点是在这个地方

// MainForm.cs
	private void Init() {
        _mainFormViewModel = new MainFormViewModel(a=> this.Invoke(a));  //问题就在 this.Invoke(a) 
        //其他代码省略...
    }

查了下微软文档Control.Invoke()是线程安全的,没明白为什么会有这个问题,但大概知道了是由两个工作线程同时访问所造成的,于是修改代码

// MainForm.cs
	private static object lockObj = new object();
	private void Init() {
        _mainFormViewModel = new MainFormViewModel(a=> {
        	lock(lockObj ){
        		this.Invoke(a)
        	}
		});
        //其他代码省略...
    }

编译运行代码,可以正常运行程序,所以此处大胆猜测问题根源:工作线程1将委托Invoke到UI线程执行,并阻塞等待结果,而此时工作线程2也将委托Invoke到UI线程执行,并阻塞等待结果,而此时在哪里发生了死锁(还请明白真相的朋友不吝赐教)。

到这里就要说下上面的代码为什么有几行要先注释掉,我们将注释掉的代码放开,编译运行,发现程序依然会死锁,这是为什么了? 此处只比之前的代码多了几行在UI线程更新控件的方法。所以大胆猜测原因:工作线程1将委托Invoke到UI线程执行,并阻塞等待结果,而此时点击另一个按钮运行另个一方法,先执行的是UI线程的操作,在UI线程也调用了Invoke,从而导致了死锁,于是决定修改代码,工作线程走Invoke,UI线程的直接运行

// MainForm.cs
	private static object lockObj = new object();
	private void Init() {
        _mainFormViewModel = new MainFormViewModel(a=> {
        	lock (lockObj){
                if (this.InvokeRequired){ //判断是否需要invoke执行,只有在工作线程才需要,
                    this.Invoke(a);
                }
                else{ //在UI线程可以执行操作
                    a();
                }
            }
		});
        //其他代码省略...
    }

编译运行,发现程序依然死锁,经测试发现应该是this.InvokeRequired并不是原子操作,导致UI线程到这里也返回true,依然执行的是Invoke,(后来想想这里可能分析的不对,这里的死锁可能是由lock引起的,工作线程到此获得锁,并invoke到UI线程执行并阻塞等待返回,而此时另一个操作是在UI线程执行到此发现被加锁,于是阻塞等待锁的释放,结果形成了死锁。)再修改代码,采用双判断

// MainForm.cs
	private static object lockObj = new object();
	private void Init() {
        _mainFormViewModel = new MainFormViewModel(a=> {
        	if(this.InvokeRequired){
	        	lock (lockObj){
	                if (this.InvokeRequired){ //判断是否需要invoke执行,只有在工作线程才需要,
	                    this.Invoke(a);
	                }else{ //在UI线程可以执行操作
	                    a();
	                }
	            }
        	}else{
        		a();
			}        	
		});
        //其他代码省略...
    }

编译运行,程序运行正常,满心欢喜,感觉到这终于结束了,于是关闭程序,不成想又报错了,不能访问已释放的对象
在这里插入图片描述
这个问题应该是工作线程没有及时释放调用了已释放资源的原因,修改代码如下

// MainFormViewModel.Action.cs
 	public partial class MainFormViewModel : IDisposable
    {
        public CancellationTokenSource cts = new CancellationTokenSource();
        public async void ShowLog1() {
            SetLog1("log1 开始任务");
            await Task.Run(()=> {
                try{
                    int i = 0;
                    while (i++ < 10000){
                        cts.Token.ThrowIfCancellationRequested();
                        SetLog1($"{i}/5");
                    }
                }
                catch (Exception err) { }
            },cts.Token);
            //任务完成,输入日志 
            SetLog1("log1 任务完成");
        }

        public async void ShowLog2() {
            SetLog2("log2 开始任务");
            await Task.Run(() => {
                try{
                    int i = 0;
                    while (i++ < 10000){
                        cts.Token.ThrowIfCancellationRequested();
                        SetLog2($"{i}/5");
                    }
                }
                catch (Exception err) { }
            },cts.Token);
            //任务完成,输入日志 
            SetLog2("log2 任务完成");
        }
     
        public void Dispose(){
            cts.Cancel();
        }
		//忽略一些没有修改的代码

    }

编译运行,这次总算是运行正常,关闭正常,一切正常了。。。
补:不过在之前的调试时,关闭窗口有报此异常
在这里插入图片描述
只需增加一行代码即可解决,代码如下

// MainForm.cs  
	_mainFormViewModel = new MainFormViewModel(a=> {
         if (!this.IsHandleCreated) return;   //增加此行代码
         if (this.InvokeRequired){
             lock (lockObj){
                 if (this.InvokeRequired){
                     this.Invoke(a);
                 }else{
                     a();
                 }
             }
         }else {
             a();
         }
     });

总结:虽然问题解决了,但是其中还有好多问题没有彻底弄明白,还须更进一步!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

凌风游

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值