天天看点

C# 学习笔记(8) 控件的跨线程访问C# 学习笔记(8) 控件的跨线程访问

C# 学习笔记(8) 控件的跨线程访问

本文参考博客

C#多线程 https://www.cnblogs.com/dotnet261010/p/6159984.html

C# 线程与进程 https://www.cnblogs.com/craft0625/p/7496682.html

C# 跨线程调用控件https://www.cnblogs.com/TankXiao/p/3348292.html

对于c#中的线程和进程,这两篇文章讲的相当到位了,本文只是为了学习做的摘要。

线程

  • 线程是什么?
线程(Thread)是进程中的基本执行单元,一个进程可以包含若干个线程,在.NET应用程序中,调用Main()方法时系统就会自动创建一个主线程。
  • 为什么要多线程?
在知道为什么要多线程前,先要知道什么是多线程?假设现在CPU有A、B、C、D、E五个任务,单线程就是A任务执行完毕接着执行B任务,B任务执行完毕接着执行C…就是一个一个的执行。多线程就是将时间分成时间片,假设一个时间片1ms,CPU在执行任务时,第一个1ms执行A任务,当第二个1ms来临时,CPU保存A任务的工作环境,然后去执行B任务…通过时间片任务ABCDE轮流执行,由于CPU很快,时间片时间很短,给我们一种ABCDE五个任务同时在运行的假象,这个就是多线程。
  1. 目前电脑都是多核多CPU的,在同一个时间片内,每一个CPU都可以运行一个线程,多线程可以提高CPU效率。
  2. 在多线程程序中,一个线程必须等待的时候,CPU可以运行其它的线程而不是等待,这样就大大提高了程序的效率。

    简单来说,多线程主要是为了提高效率的,但是多线程需要注意 线程之间对共享资源的访问会相互影响,必须解决共享资源的竞争冒险问题。

单线程窗体卡死

创建一个窗体,在窗体上拖两个控件,textbox和按钮

C# 学习笔记(8) 控件的跨线程访问C# 学习笔记(8) 控件的跨线程访问

将textbox滚动条属性打开

C# 学习笔记(8) 控件的跨线程访问C# 学习笔记(8) 控件的跨线程访问

双击按钮,添加一个按钮单击事件,当按钮单击,textbox中打印10000一行数据。

private void btnPrint_Click(object sender, EventArgs e)
 {
     for(int i = 0; i < 10000; i++)
     {
         txbLog.AppendText("这是第" +  i + "行\r\n");
     }
 }
           

发现当按钮按下后,textbox中有数据不断显示上去,但是这时无法操作窗体,窗体的移动,关闭等都无法操作,也就是俗称的窗体卡死。其实这就是单线程的弊端,当按键单击事件没有执行完毕,就不去响应其他的操作。

创建多线程

void PrintfLog()
{
    for (int i = 0; i < 10000; i++)
    {
        txbLog.AppendText("这是第" + i + "行\r\n");
    }
}

private void btnPrint_Click(object sender, EventArgs e)
{
    //创建线程
    Thread printThread = new Thread(PrintfLog);
    //告诉系统,这个线程准备好了,可以开始执行了,至于什么时候执行,看系统安排
    printThread.Start();
}
           

多线程创建十分简单,只需要创建和标记为开始状态即可。但是上面的代码如果直接仿真,会发现系统会抛出异常

C# 学习笔记(8) 控件的跨线程访问C# 学习笔记(8) 控件的跨线程访问

根据异常提示,我们可以知道 txbLog控件是主线程创建的,不允许在其他线程直接调用它(在.NET上执行的是托管代码,C#强制要求这些代码必须是线程安全的,即不允许跨线程访问Windows窗体的控件。)

控件的跨线程访问

  1. 在窗体的加载事件中,将C#内置控件(Control)类的CheckForIllegalCrossThreadCalls属性设置为false,屏蔽掉C#编译器对跨线程调用的检查。
实际的软件开发中,做如此设置是不安全的(不符合.NET的安全规范)
public partial class Form1 : Form
 {
     public Form1()
     {
         InitializeComponent();
         Control.CheckForIllegalCrossThreadCalls = false;
     }

     void PrintfLog()
     {
         for (int i = 0; i < 10000; i++)
         {
             txbLog.AppendText("这是第" + i + "行\r\n");
         }
     }

     private void btnPrint_Click(object sender, EventArgs e)
     {
         //创建线程
         Thread printThread = new Thread(PrintfLog);
         //告诉系统,这个线程准备好了,可以开始执行了,至于什么时候执行,看系统安排
         printThread.Start();
     }
 }
           
  1. 使用delegate和invoke来从其他线程中调用控件
public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
    }

    //声明委托类
    delegate void txbLogPrintDelegate(string str);

    void PrintfLog()
    {
        for (int i = 0; i < 10000; i++)
        {
            //通过txbLog的InVoke 告诉创建txbLog的线程,需要操作txbLog控件
            txbLog.Invoke((txbLogPrintDelegate)TxbLogAppendText, "这是第" + i + "行\r\n");
        }
    }

    void TxbLogAppendText(string str)
    {
    	//创建txbLog的线程也就是主线程 操作txbLog控件
        this.txbLog.AppendText(str);
    }

    private void btnPrint_Click(object sender, EventArgs e)
    {
        //创建线程
        Thread printThread = new Thread(PrintfLog);
        //告诉系统,这个线程准备好了,可以开始执行了,至于什么时候执行,看系统安排
        printThread.Start();
    }
}
           

使用delegate和invoke来从其他线程中调用控件本质上还是通过主线程来操作控件,但是窗体并没有像单线程那样直接卡死,为什么? 对比在主线程里面操作控件和其他线程通过回调操作控件消耗时间,发现其他线程通过回调操作控件花费的时间远远大于主线程直接操作控件,猜测其他线程通过回调操作控件时,会发一个通知告诉主线程,主线处理完后就去处理其他UI事件了,表现也就是窗体没有卡死。 换了一台电脑卡死了,操作UI界面最终都要回到主线程,因此在频繁操作界面时,UI卡死嗯 挺正常的,毕竟你不能让他一边不停显示,一边又要移动,优化的话只能一边不全速的显示,另一半才能进行其他操作。

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

        //声明委托类
        delegate void txbLogPrintDelegate(string str);

        void PrintfLog()
        {
            Stopwatch stopwatch = new Stopwatch();
            stopwatch.Start();
            for (int i = 0; i < 10000; i++)
            {
                //通过txbLog的InVoke 告诉创建txbLog的线程,需要操作txbLog控件
                txbLog.Invoke((txbLogPrintDelegate)TxbLogAppendText, ":这是第" + i + "行\r\n");
            }
            stopwatch.Stop();
            txbLog.Invoke((txbLogPrintDelegate)TxbLogAppendText, stopwatch.ElapsedMilliseconds + "\r\n");
        }

        void TxbLogAppendText(string str)
        {
            //创建txbLog的线程也就是主线程 操作txbLog控件
            this.txbLog.AppendText(Thread.CurrentThread.ManagedThreadId.ToString() + str);
        }

        private void btnPrint_Click(object sender, EventArgs e)
        {
            txbLog.AppendText("主线程ID:" + Thread.CurrentThread.ManagedThreadId.ToString()+ "\r\n");

            Stopwatch stopwatch = new Stopwatch();

            stopwatch.Start();
            for (int i = 0; i < 10000; i++)
            {
                //通过txbLog的InVoke 告诉创建txbLog的线程,需要操作txbLog控件
                txbLog.AppendText(Thread.CurrentThread.ManagedThreadId.ToString() + ":这是第" + i + "行\r\n");
            }
            stopwatch.Stop();
            txbLog.AppendText(stopwatch.ElapsedMilliseconds + "\r\n");

            //创建线程
            Thread printThread = new Thread(PrintfLog);
            //告诉系统,这个线程准备好了,可以开始执行了,至于什么时候执行,看系统安排
            printThread.Start();
            
        }
    }
           
  1. 使用delegate和BeginInvoke来从其他线程中控制控件
这个和方法二类似,只不过将 txbLog.Invoke 换成 txbLog.BeginInvoke 区别就是 Invoke方法是同步的, 它会等待主线程完成,BeginInvoke方法是异步的, 它会另起一个线程去完成工作线程 它会给主线程发生一个消息,等主线程空闲时再去执行

用上面代码 实际测试发现 txbLog.BeginInvoke也会导致界面卡死,不过想想也知道,操作UI界面最终都要回到主线程,因此在频繁操作界面时,UI卡死嗯 挺正常的,毕竟你不能让他一边不停显示,一边又要移动,优化的话只能一边不全速的显示,另一半才能进行其他操作。后面在别的电脑上测试,发现用 Invoke 和 BeginInvoke都不能避免UI卡死,最终给线程加了一个休眠,不让UI全速显示,嗯正常了,笔者初学乍到,如果大佬有什么更好的方法,敬请告知。

void PrintfLog()
{
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    for (int i = 0; i < 1000; i++)
    {
        //通过txbLog的InVoke 告诉创建txbLog的线程,需要操作txbLog控件
        txbLog.BeginInvoke((txbLogPrintDelegate)TxbLogAppendText, ":这是第" + i + "行\r\n");
        Thread.Sleep(1);
    }
    stopwatch.Stop();
    txbLog.Invoke((txbLogPrintDelegate)TxbLogAppendText, stopwatch.ElapsedMilliseconds + "\r\n");
}
           
  • 这样的写法有一个烦人的地方:对不同的控件写法不同。对于TextBox,要TextBoxObject.Invoke,对于Label,又要LabelObject.Invoke。有没有统一一点的写法呢?
主窗口类本身也有Invoke方法。如果你不想对不同的控件写法不一样,可以全部用this.Invoke:

推荐使用主窗口类本身的Invoke方法再加上Lamda表达式进行控件跨线程访问

void PrintfLog()
{
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    for (int i = 0; i < 10000; i++)
    {
        //通过txbLog的InVoke 告诉创建txbLog的线程,需要操作txbLog控件
        //this.Invoke((txbLogPrintDelegate)TxbLogAppendText, ":这是第" + i + "行\r\n");
        this.Invoke(new Action<string>((string str) => { txbLog.AppendText(Thread.CurrentThread.ManagedThreadId.ToString() + str); }), ":这是第" + i + "行\r\n");
    }
    stopwatch.Stop();
    txbLog.Invoke((txbLogPrintDelegate)TxbLogAppendText, stopwatch.ElapsedMilliseconds + "\r\n");
}
           
  1. 使用BackgroundWorker组件
正常情况下,有一个比较耗时的操作比如说算法很复杂、要写入数据库等IO操作,CPU不想在这里傻等(只有主线程的话,傻等会导致界面假死)从而创建一个线程来做这个耗时操作,耗时操作结束后操作UI界面给用户一个反馈,上面介绍的方法2、3都是控件的跨线程访问方法。下面介绍一个官方为解决这种问题提供的一种法方。

仔细研究会发现这种方法是基于方法2实现的,只不过用了官方的壳,其本质是在后台线程里干一些耗时操作,耗时操作干完后,通过回调在主线程里面完成其他操作,自己完全可以通过方法2实现

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

    void PrintfLog(object sender, DoWorkEventArgs e)
    {
        // 这里系统会自动生成一个后台线程
        // 可以在这里做一些费时的,复杂的操作
        StringBuilder stringBuilder = new StringBuilder(1024 * 1024);
        for (int i = 0; i < 10000; i++)
        {
            //生成log信息
            stringBuilder.Append(Thread.CurrentThread.ManagedThreadId.ToString() + ":这是第" + i + "行\r\n");
        }

        //做完耗时操作后将结果通过 e.Result 传递出去
        e.Result = stringBuilder.ToString();
    }

    void TxbLogAppendText(object sender, RunWorkerCompletedEventArgs e)
    {
        //这时后台线程已经完成,并返回了主线程,所以可以直接使用UI控件了 
        this.txbLog.AppendText(e.Result.ToString());
    }

    private void btnPrint_Click(object sender, EventArgs e)
    {
        txbLog.AppendText("主线程ID:" + Thread.CurrentThread.ManagedThreadId.ToString() + "\r\n");

        using (BackgroundWorker bw = new BackgroundWorker())
        {
            //后台线程需要执行的委托
            bw.RunWorkerCompleted += new RunWorkerCompletedEventHandler(TxbLogAppendText);
            //后台线程结束后 会调用该委托
            bw.DoWork += new DoWorkEventHandler(PrintfLog);
            //如果线程需要参数,可以传入参数  DoWorkEventArgs e.Argument调用参数
            bw.RunWorkerAsync();
        }
    }
}
           

UI假死总结

上面2、3举的例子说明了一个问题,UI控件都在主线程 创建的情况下,频繁操作UI导致界面卡死是正常操作,可以通过给频繁操作的控件加上一定休眠时间在一定程度上解决

前后台线程

前台线程:只有所有的前台线程都结束,应用程序才能结束。默认情况下创建的线程都是前台线程

后台线程:只要所有的前台线程结束,后台线程自动结束。通过Thread.IsBackground设置后台线程。必须在调用Start方法之前设置线程的类型,否则一旦线程运行,将无法改变其类型。

  • 通过BeginXXX方法运行的线程都是后台线程。
C# 学习笔记(8) 控件的跨线程访问C# 学习笔记(8) 控件的跨线程访问
  • 我们在使用上面delegate和invoke来从其他线程中调用控件 的代码进行打印时,再尚未打印结束的时候点击窗体上的叉号关掉窗体,会发现系统抛出异常,告诉我们目标不存在,为什么目标不存在?
创建的线程默认是前台线程,关闭窗体后前台线程并不会结束,因此还在调用控件txbLog,但是txbLog控件是主线程的,主线程已经关闭了,控件自然也就销毁了,因此在创建线程时,我们可以根据线程的重要程度,将线程设置为前台或者后台,一般情况下设置为后台线程即可(主线程结束后,后台线程就会自动结束);重要的线程,设置为前台线程,如果需要可以在窗体FormClosing事件中处理
//创建线程
Thread printThread = new Thread(PrintfLog);
//设置为后台线程
printThread.IsBackground = true;
//告诉系统,这个线程准备好了,可以开始执行了,至于什么时候执行,看系统安排
printThread.Start();