.net 多线程的使用(Thread)
上篇 net 同步异步
中篇 多线程的使用(Thread)
下篇 net 任务工厂实现异步多线程
Thread多线程概述
上一篇我们介绍了net 的同步与异步,我们异步演示的时候使用的是委托多线程来实现的。今天我们来细细的剖析下 多线程。
多线程的优点:可以同时完成多个任务;可以使程序的响应速度更快;可以让占用大量处理时间的任务或当前没有进行处理的任务定期将处理时间让给别的任务;可以随时停止任务;可以设置每个任务的优先级以优化程序性能。
然而,多线程虽然有很多优点,但是也必须认识到多线程可能存在影响系统性能的不利方面,才能正确使用线程。弊端主要有如下几点:
(1)线程也是程序,所以线程需要占用内存,线程越多,占用内存也越多。
(2)多线程需要协调和管理,所以需要占用CPU时间以便跟踪线程[时间空间转换,简称时空转换]。
(3)线程之间对共享资源的访问会相互影响,必须解决争用共享资源的问题。
(4)线程太多会导致控制太复杂,最终可能造成很多程序缺陷。
当启动一个可执行程序时,将创建一个主线程。在默认的情况下,C#程序具有一个线程,此线程执行程序中以Main方法开始和结束的代码,Main()方法直接或间接执行的每一个命令都有默认线程(主线程)执行,当Main()方法返回时此线程也将终止。
一个进程可以创建一个或多个线程以执行与该进程关联的部分程序代码。在C#中,线程是使用Thread类处理的,该类在System.Threading命名空间中。使用Thread类创建线程时,只需要提供线程入口,线程入口告诉程序让这个线程做什么。通过实例化一个Thread类的对象就可以创建一个线程。
多线程Thread
上一节我们通过了线程的异步了解了线程的等待和线程回调的区别、学习了不卡主线程的原理。这里我们简单学习下 Thread 类的使用.我们分别让主线程和子线程循环输出 1-100,我们来看下结果。
Thread类接收一个ThreadStart委托或ParameterizedThreadStart委托的构造函数,该委托包装了调用Start方法时由新线程调用的方法,示例代码如下:
Thread thread=new Thread(new ThreadStart(method));//创建线程
thread.Start(); //启动线程
1.线程的无序性质
Thread t = new Thread(()=> { for (int i = 0; i < 100; i++) { Console.WriteLine($"我是子线程({i})~~~~"); } });// 参数 ThreadStart 是一个委托类型的。凡是委托,我们都可以使用lambda 表达式代替 t.Start();//Start 通过Start开启一个线程 for (int i = 0; i < 100; i++) { Console.WriteLine($"我是主线程【{i}】"); }
输出结果:
通过执行结果我们会看到,主线程和子线程不是一味的执行,是兼续的。也就是说主线程和子线程在执行过程中是互相抢CPU资源进行计算的。
这里我们可以总结下, 异步多线程三大特点:
(1) 同步卡界面,UI线程被占用;异步多线程不卡界面,UI线程空闲,计算任务交给子线程。
(2) 同步方法慢,因为只有一个线程干活;异步多线程方法快,因为多个线程并发计算(空间换时间), 这里也会消耗更多的资源,不是线程的线性关系(倍数关系),不是线程越多越好(1资源有限 2线程调度耗资源 3不稳定)
(3) ※异步多线程是无序的,不可预测的:启动顺序不确定、消耗时间不确定、结束顺序不确定( 这里特别提醒下,我们不要试图控制执行的顺序)
异步与多线程的区别: 异步是主线程立即启动,启动完成以后,在执行子线程。但是多线程不一定是谁先启动。如果看不懂,请看本人上一篇文章。
关于线程的无续,这里就不过多的解释了继续往下看。
2.前台线程与后台线程
在上一篇文章我们简单的说到了,线程本身并不是任何高级语言的概念,本身是计算机的概念,只是高级语言给予封装了一层。
前台线程:窗体Ui主线程退出(销毁)以后,子线程必须计算完成才能退出。请下载demo 自行查看,还是上面的案列。
后台线程:窗体Ui主线程退出(销毁)以后,子线程就会退出。关闭窗体以后,控制台就退出了。我们通过Thread 类的 IsBackground属性进行设置;设置为true 的时候为后台线程,默认为false前台线程。看下边案列
private void button1_Click(object sender, EventArgs e) { Thread t = new Thread(()=> { for (int i = 0; i < 100; i++) { Thread.Sleep(100);//演示前台线程和后台线程,演示线程无序性质,请注释掉 Console.WriteLine($"我是子线程({i})~~~~"); } });// 参数 ThreadStart 是一个委托类型的。凡是委托,我们都可以使用lambda 表达式代替 t.IsBackground=true;//设置为true 的时候为后台线程,默认为false前台线程。关闭窗体会立即停止计算。 t.Start();//Start 通过Start开启一个线程 for (int i = 0; i < 100; i++) { Console.WriteLine($"我是主线程【{i}】"); } }
后台线程一般用于处理不重要的事情,应用程序结束时,后台线程是否执行完成对整个应用程序没有影响。如果要执行的事情很重要,需要将线程设置为前台线程。
3.线程的状态及属性方法的使用
这里简单列下线程的常用属性
属性名称 | 说明 |
---|---|
CurrentContext | 获取线程正在其中执行的当前上下文。 |
CurrentThread | 获取当前正在运行的线程。 |
ExecutionContext | 获取一个 ExecutionContext 对象,该对象包含有关当前线程的各种上下文的信息。 |
IsAlive | 获取一个值,该值指示当前线程的执行状态。 |
IsBackground | 获取或设置一个值,该值指示某个线程是否为后台线程。 |
IsThreadPoolThread | 获取一个值,该值指示线程是否属于托管线程池。 |
ManagedThreadId | 获取当前托管线程的唯一标识符。 |
Name | 获取或设置线程的名称。 |
Priority | 获取或设置一个值,该值指示线程的调度优先级。 |
ThreadState | 获取一个值,该值包含当前线程的状态。 |
通过ThreadState可以检测线程是处于Unstarted、Sleeping、Running 等等状态,它比 IsAlive 属性能提供更多的特定信息。
前面说过,一个应用程序域中可能包括多个上下文,而通过CurrentContext可以获取线程当前的上下文。
CurrentThread是最常用的一个属性,它是用于获取当前运行的线程。
Thread.CurrentThread.ManagedThreadId 获取线程的ID ,我们尽量不要使用 Thread.CurrentThread.Name,因为name 并不是唯一的
Thread 中包括了多个方法来控制线程的创建、挂起、停止、销毁,以后来的例子中会经常使用。
方法名称 | 说明 |
---|---|
Abort() | 终止本线程。 |
GetDomain() | 返回当前线程正在其中运行的当前域。 |
GetDomainId() | 返回当前线程正在其中运行的当前域Id。 |
Interrupt() | 中断处于 WaitSleepJoin 线程状态的线程。 |
Join() | 已重载。 阻塞调用线程,直到某个线程终止时为止。 |
Resume() | 继续运行已挂起的线程。 |
Start() | 执行本线程。 |
Suspend() | 挂起当前线程,如果当前线程已属于挂起状态则此不起作用 |
Sleep() | 把正在运行的线程挂起一段时间。 |
关于线程状态操作的方法,我们尽量不要使用,这里以销毁线程的方法为列子去演示。本图表除了第一个以外,都可以使用。
注意:
在我们net 中,语言分为托管代码和非托管代码。托管代码是可以控制的,非托管代码是不可控制的.我们线程销毁实际上是抛出了一个异常,当我们程序捕获到这个异常以后,线程因为异常而退出。所以销毁线程在某些(值非托管调用)时候会出现问题的,我们后续在任务工厂 FacTask 的时候,再去详细讲解销毁线程。如果非要"停止线程 靠的不是外部力量而是线程自身,外部修改信号量,线程检测信号量,当线程检测到信号量以后,我们抛出异常,在主线程中捕获异常即可".
4.线程等待
我们在学习异步的时候,知道UI主线程在等待子线程的时候,窗体是不可以移动的。并且我们可以有实时返回(有损耗)的等待(阻塞)和一直阻塞主线程(无损耗)等待子线程完事,再去执行主线程。下面我们来看看一个案列,案列以5名学生写作业为例子。
public void WriteJob(string name) { Console.WriteLine("********************** "+ name + " Start【" + Thread.CurrentThread.ManagedThreadId + "】等待............... ***"); Stopwatch watch = new Stopwatch(); watch.Start(); for (int i = 0; i < 10; i++) { Thread.Sleep(32); Console.WriteLine($"学生:{name}在做第{i+1}题作业"); } watch.Stop(); Console.WriteLine("********************** " + name + " End【" + Thread.CurrentThread.ManagedThreadId + "】 用时"+ watch.ElapsedMilliseconds + "毫秒............... ***"); }
/// <summary> /// 线程等待 /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void button2_Click(object sender, EventArgs e) { Console.WriteLine("**********************button2_Click Start【" + Thread.CurrentThread.ManagedThreadId + "】**********************************************"); List<Thread> threadList = new List<Thread>(); for (int i = 0; i < 5; i++) { string studentName = "甲" + i + "同学"; Thread thread = new Thread(() => WriteJob(studentName)); Console.WriteLine($"{studentName}开始写作业"); thread.Start(); threadList.Add(thread); } //无损耗阻塞主线程 //foreach (var thread in threadList) //{ // thread.Join();//表示把thread线程任务join到当前线程,也就是当前线程等着thread任务完成 //} //带返回,有损耗阻塞主线程 while (threadList.Count(t => t.ThreadState != System.Threading.ThreadState.Stopped) > 0) { Thread.Sleep(100); Console.WriteLine("请等待...."); } Console.WriteLine("**********************button2_Click end【" + Thread.CurrentThread.ManagedThreadId + "】**********************************************"); }
看了上面的代码,我们都发现无损耗阻塞使用的是 thread.Join(); ,有损耗阻塞,我们是需要自己去写计算逻辑的。
5.线程回调
在多线程中是不存在回调的,我们只能通过自己去包装,实现线程的回调。在包装之前,我们先回顾下异步回调的特点:
#region 异步回调回顾 { //回调 AsyncCallback callBack = param => { Console.WriteLine("当前状态:"+param.AsyncState); Console.WriteLine("你睡吧,我去吃好吃的去了"); }; Func<string, int> func = t => { Console.WriteLine(t); Thread.Sleep(5000); Console.WriteLine("等等吧,我在睡一会"); return DateTime.Now.Millisecond;//返回当前毫秒数 }; ///第一个参数是我们自定义的参数,第二个参数是我们回调的参数,第三个参数是状态参数 IAsyncResult iAsyncResult= func.BeginInvoke("张四火,起床吧", callBack, "runState"); int second= func.EndInvoke(iAsyncResult); Console.WriteLine("当前毫秒数:"+second); } { //回调 AsyncCallback callBack = param => { Console.WriteLine("当前状态:" + param.AsyncState); Console.WriteLine("妈妈说:孩子太小,睡会吧"); }; // 异步 Action<string> act = t => { Console.WriteLine(t); Thread.Sleep(5000); Console.WriteLine("等等吧,我在睡一会"); }; IAsyncResult iAsyncResult = act.BeginInvoke("梓烨,起床吧", callBack, "runState"); act.EndInvoke(iAsyncResult); } #endregion
异步回调:是分为有返回和无返回两种,返回值类型取决于我们委托的返回值类型,回调就是在子线程执行完成以后,执行一个代码块。多线程下如何使用回调呢?在Thread下是没有回调的。我们可以根据异步回调的特点自己封装一个线程回调或异步返回值。
#region 回调封装 /// <summary> /// 回调封装 无返回值 /// </summary> /// <param name="start"></param> /// <param name="callback">回调</param> private void ThreadWithCallback(ThreadStart start, Action callback) { Thread thread = new Thread(() => { start.Invoke(); callback.Invoke(); }); thread.Start(); } /// <summary> /// 有返回值封装(请根据本案例自行封装回调) /// </summary> /// <typeparam name="T">返回值类型</typeparam> /// <param name="func">需要子线程执行的方法</param> /// <returns></returns> private Func<T> ThreadWithReturn<T>(Func<T> func) { T t = default(T);//初始化一个泛型 ThreadStart newStart = () => { t = func.Invoke(); }; Thread thread = new Thread(newStart); thread.Start(); return new Func<T>(() => { thread.Join(); return t; }); } #endregion
代码调用
#region 多线程回调封装调用 { //无返回 ThreadWithCallback(()=>{ Console.WriteLine("梓烨,起床吧"); Thread.Sleep(5000); Console.WriteLine("等等吧,我在睡一会"); },()=> { Console.WriteLine("妈妈说:梓烨太小,睡会吧"); }); } { //有返回 int secound= ThreadWithReturn<int>(()=>{ Console.WriteLine("张四火,起床吧"); Thread.Sleep(5000); Console.WriteLine("等等吧,我在睡一会"); return DateTime.Now.Millisecond;//返回当前毫秒数 }).Invoke(); Console.WriteLine(secound); } #endregion
以上都是 C#1.0时代的多线程,这些现在基本已经没有人在使用了。注意:本文并没有介绍信号量,在没有介绍信号量退出线程的时候,我们还是使用net 自带的终止线程。下边扩展点技术:
6.线程同步
所谓同步:是指在某一时刻只有一个线程可以访问变量。
如果不能确保对变量的访问是同步的,就会产生错误。
c#为同步访问变量提供了一个非常简单的方式,即使用c#语言的关键字Lock,它可以把一段代码定义为互斥段,互斥段在一个时刻内只允许一个线程进入执行,而其他线程必须等待。在c#中,关键字Lock定义如下:
Lock(expression)
{
statement_block
}
expression代表你希望跟踪的对象:
如果你想保护一个类的实例,一般地,你可以使用this;
如果你想保护一个静态变量(如互斥代码段在一个静态方法内部),一般使用类名就可以了
而statement_block就算互斥段的代码,这段代码在一个时刻内只可能被一个线程执行。
以书店卖书为例
static void Main(string[] args) { BookShop book = new BookShop(); //创建两个线程同时访问Sale方法 Thread t1 = new Thread(new ThreadStart(book.Sale));//因为使用的同一个引用,所以书店库存量始终是一个地址的引用 Thread t2 = new Thread(new ThreadStart(book.Sale)); //启动线程 t1.Start(); t2.Start(); Console.ReadKey(); } class BookShop { public int num = 1;//库存量 public void Sale() { int tmp = num; if (tmp > 0)//判断是否有书,如果有就可以卖 { Thread.Sleep(1000); num -= 1; Console.WriteLine("售出一本图书,还剩余{0}本", num); } else { Console.WriteLine("没有了"); } } }
从运行结果可以看出,两个线程同步访问共享资源,没有考虑同步的问题,结果不正确(结果出现“-1”)。
如何做到线程的同步呢?我们需要使用线程锁 lock
class BookShop { public int num = 1;//库存量 public void Sale() { // 使用lock关键字解决线程同步问题。锁住当前对象 lock (this) { int tmp = num; if (tmp > 0)//判断是否有书,如果有就可以卖 { Thread.Sleep(1000); num -= 1; Console.WriteLine("售出一本图书,还剩余{0}本", num); } else { Console.WriteLine("没有了"); } } } }
7.跨线程访问
在很多实际应用中,子线程的计算百分比要时刻返回给主线程,列入进度条。我们以winform 的 textbox 输出1-100为列。
产生错误的原因:textBox1是由主线程创建的,thread线程是另外创建的一个线程,在.NET上执行的是托管代码,C#强制要求这些代码必须是线程安全的,即不允许跨线程访问Windows窗体的控件。
解决方案:
1、在窗体的加载事件中,将C#内置控件(Control)类的CheckForIllegalCrossThreadCalls属性设置为false,屏蔽掉C#编译器对跨线程调用的检查。如下:
System.Windows.Forms.Control.CheckForIllegalCrossThreadCalls = false;
使用上述的方法虽然可以保证程序正常运行并实现应用的功能,但是在实际的软件开发中,做如此设置是不安全的(不符合.NET的安全规范),在产品软件的开发中,此类情况是不允许的。如果要在遵守.NET安全标准的前提下,实现从一个线程成功地访问另一个线程创建的空间,要使用C#的方法回调机制。
2、使用回调函数
回调前边有讲解,这里直接上代码,注意,这里的回调,使用的是控件自带的回调哦。
private void button1_Click(object sender, EventArgs e) { Action<int> act = t=> { this.textBox1.Text = t.ToString(); }; //创建一个线程去执行这个方法:创建的线程默认是前台线程 Thread thread = new Thread(()=> { for (int i = 0; i < 100; i++) { Thread.Sleep(100); this.textBox1.Invoke(act, i); // this.textBox1.Invoke(t => { this.textBox1.Text = t.ToString(); }, i);这里不允许使用 lambda 表达式,因为里面传递的是一个委托类型,不是委托 } }); //Start方法标记这个线程就绪了,可以随时被执行,具体什么时候执行这个线程,由CPU决定 //将线程设置为后台线程 thread.IsBackground = true; thread.Start(); }
经过这两个扩展技术点,我们是不是就更牢固了我们的知识点。
线程池ThreadPool
1.线程池概述
线程池是C# 2.0以后才有的。确切的说是Net2.0以后,什么是线程池呢??先看图,然后在慢慢的聊。
.NET Framework的ThreadPool类提供一个线程池,该线程池可用于执行任务、发送工作项、处理异步 I/O、代表其他线程等待以及处理计时器。那么什么是线程池?线程池其实就是一个存放线程对象的“池子(pool)”,他提供了一些基本方法,如:设置pool中最小/最大线程数量、把要执行的方法排入队列等等。ThreadPool是一个静态类,因此可以直接使用,不用创建对象。
微软官网说法如下:许多应用程序创建大量处于睡眠状态,等待事件发生的线程。还有许多线程可能会进入休眠状态,这些线程只是为了定期唤醒以轮询更改或更新的状态信息。 线程池,使您可以通过由系统管理的工作线程池来更有效地使用线程。
说得简单一点,每新建一个线程都需要占用内存空间和其他资源,而新建了那么多线程,有很多在休眠,或者在等待资源释放;又有许多线程只是周期性的做一些小工作,如刷新数据等等,太浪费了,划不来,实际编程中大量线程突发,然后在短时间内结束的情况很少见。于是,就提出了线程池的概念。线程池中的线程执行完指定的方法后并不会自动消除,而是以挂起状态返回线程池,如果应用程序再次向线程池发出请求,那么处以挂起状态的线程就会被激活并执行任务,而不会创建新线程,这就节约了很多开销。只有当线程数达到最大线程数量,系统才会自动销毁线程。因此,使用线程池可以避免大量的创建和销毁的开支,具有更好的性能和稳定性,其次,开发人员把线程交给系统管理,可以集中精力处理其他任务。
2.线程池初始化
线程池位于 ThreadPool中,该类也是在 System.Threading 命名空间下,线程默认初始化大小是有CPU和操作系统决定的。 其中线程数最小不能小于CPU的核数。在我们使用ThreadPool类的时候。
int workerThreads = 0; int ioThreads = 0; ThreadPool.GetMaxThreads(out workerThreads, out ioThreads); Console.WriteLine(String.Format("可创建最大线程数: {0}; 最大 I/O 线程: {1}", workerThreads, ioThreads)); ThreadPool.GetMinThreads(out workerThreads, out ioThreads); Console.WriteLine(String.Format("最小线程数: {0}; 最小 I/O 线程: {1}", workerThreads, ioThreads)); ThreadPool.GetAvailableThreads(out workerThreads, out ioThreads); Console.WriteLine(String.Format("可以使用的工作线程: {0}; 可用 I/O 线程: {1}", workerThreads, ioThreads));
执行上面的代码可以得带结果如下:
可创建最大线程数: 2047; 最大 I/O 线程: 1000
最小线程数: 4; 最小 I/O 线程: 4
可以使用的工作线程: 2047; 可用 I/O 线程: 1000
3.设置线程池初始化大小
我们为什么要设置线程池的大小呢?其实我们计算机并不是只运行你一个软件,你的给其他软件预留线程池,一般我们开发的时候使用的线程数是使用 8条、16条、32条和64条。不建议大于64条,毕竟老式的计算机支持有限,如果你计算机线程有2000多,你完全可以分配256以下的线程数。下面我们来看下如何设置。
#region 设置初始化线程 ThreadPool.SetMaxThreads(8, 8);//最小也是CPU核数 ThreadPool.SetMinThreads(4, 4); #endregion #region 获取线程池当前设置 ,默认设置取决于操作系统和CPU int workerThreads = 0; int ioThreads = 0; ThreadPool.GetMaxThreads(out workerThreads, out ioThreads); Console.WriteLine(String.Format("可创建最大线程数: {0}; 最大 I/O 线程: {1}", workerThreads, ioThreads)); ThreadPool.GetMinThreads(out workerThreads, out ioThreads); Console.WriteLine(String.Format("最小线程数: {0}; 最小 I/O 线程: {1}", workerThreads, ioThreads)); ThreadPool.GetAvailableThreads(out workerThreads, out ioThreads); Console.WriteLine(String.Format("可以使用的工作线程: {0}; 可用 I/O 线程: {1}", workerThreads, ioThreads)); #endregion
执行结果如下:
可创建最大线程数: 8; 最大 I/O 线程: 8
最小线程数: 4; 最小 I/O 线程: 4
可以使用的工作线程: 8; 可用 I/O 线程: 8
4.线程池的使用
线程池可以理解为,我们预先创建好指定数量的线程,给程序使用,当前正在使用的子线程一旦使用完成以后,要立即归还,下一个任务使用的时候,我在分配给这个任务。我们使用ManualResetEvent类来控制线程的使用与归还。具体看代码。
#region 多线程的使用 Console.WriteLine("当前主线程id:{0}", Thread.CurrentThread.ManagedThreadId); //首先创建5个任务线程 ManualResetEvent[] mre = new ManualResetEvent[] { new ManualResetEvent(false), new ManualResetEvent(false), new ManualResetEvent(false), new ManualResetEvent(false), new ManualResetEvent(false) }; for (int i = 0; i < 5; i++) { ///false 默认是关闭的,TRUE 默认为打开的 Thread.Sleep(300); ThreadPool.QueueUserWorkItem(t => { //lambda任务 Console.WriteLine("参数的内容是"+t); Console.WriteLine("获取参数值((dynamic)t).num:" + ((dynamic)t).num); int num = ((dynamic)t).num; for (int j = 0; j < num; j++) { Thread.Sleep(2);//一件很耗时的事情 } Console.WriteLine("当前子线程id:{0} 的状态:{1}", Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.ThreadState); //这里是释放共享锁,让其他线程进入 mre[i].Set();//可以理解为打开一个线程 //这里是打开共享锁,让其他线程进入 // mre[i].Reset();//可以理解为关闭一个线程 Console.WriteLine(); }, new { num = (i + 1) * 10 }); mre[i].WaitOne(3000); //单独阻塞线程,因为我们使用的是池,索引这里不使用这个,用法同委托异步 } //注意这里,设定WaitAll是为了阻塞调用线程(主线程),让其余线程先执行完毕, //其中每个任务完成后调用其set()方法(收到信号),当所有 //的任务都收到信号后,执行完毕,将控制权再次交回调用线程(这里的主线程) // ManualResetEvent.WaitAll(mre);不建议这么使用 Console.ReadKey(); #endregion
执行结果:
当前主线程id:1
参数的内容是{ num = 10 }
获取参数值((dynamic)t).num:10
当前子线程id:3 的状态:Background
参数的内容是{ num = 20 }
获取参数值((dynamic)t).num:20
当前子线程id:3 的状态:Background
参数的内容是{ num = 30 }
获取参数值((dynamic)t).num:30
当前子线程id:4 的状态:Background
参数的内容是{ num = 40 }
获取参数值((dynamic)t).num:40
当前子线程id:3 的状态:Background
参数的内容是{ num = 50 }
获取参数值((dynamic)t).num:50
当前子线程id:3 的状态:Background
通过执行结果可以看出,第一次任务执行完成以后,把线程归还了,第二次任务分配的还是当前线程,但是第三次任务执行不过来了,我们从小分配了 一次线程。请根据结果分析。关于回调,线程等待使用方法同异步,救赎前一篇有介绍。
总结
1.本文主要演示了多线程和线程池的使用。夸线程访问(主要用于做进度条)。
2.本文介绍了使用多线程的利与弊及线程池的利与弊。