c# Task 篇幅二
上面一篇https://i.cnblogs.com/EditPosts.aspx?postid=10444773我们介绍了Task的启动,Task的一些方法以及应用,今天我们着重介绍一下Task其它概念以及用法,具体说说下面三大块
- 多异常处理和线程取消
- 多线程的临时变量
- 线程安全和锁lock
一:多线程异常
多线程异常捕获一般都是使用AggregateException这个异常类来捕获
我们先通过代码详细介绍:
1 try 2 { 3 List<Task> taskList = new List<Task>(); 4 for (int i = 0; i < 4; i++) 5 { 6 string name = $"btnThreadCore_Click_{i}"; 7 taskList.Add(Task.Run(() => 8 { 9 if (name.Equals("btnThreadCore_Click_1")) 10 { 11 throw new Exception("btnThreadCore_Click_1异常"); 12 } 13 Console.WriteLine($"This is {name}成功 ThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}"); 14 })); 15 } 16 Task.WaitAll(taskList.ToArray());//1 可以捕获到线程的异常 17 } 18 catch (AggregateException aex) //2 需要try-catch-AggregateException 19 { 20 foreach (var exception in aex.InnerExceptions) 21 { 22 Console.WriteLine(exception.Message); 23 } 24 } 25 catch (Exception ex)//可以多catch 先具体再全部 26 { 27 Console.WriteLine(ex); 28 }
下面是图一是没有加 Task.WaitAll(taskList.ToArray());
从上面结果我们可以得出:线程异常不会被捕获,但是线程之间互不影响,一个线程出现问题不会影响其它的线程。
如果增加了 Task.WaitAll(taskList.ToArray());如下图:
则会捕获到异常。所以通过上面能够说明:
- 多线程里面抛出的异常,会终结当前线程;但是不会影响别的线程;
- 那线程异常可以通过Task.WaitAll(taskList.ToArray());被捕获(说明必须要等到线程执行完task.Wait()或者得到线程的结果task.result的时候才会捕获到异常),没有使用Task.WaitAll(taskList.ToArray()),子线程出现的异常则会被吞掉,
我们上面一章Task晓得,Task.WaitAll和Task.WaitAny()都是会线程堵塞的,这样会大大影响用户的体验,所以我们一般项目中多线程里面的委托里面不允许异常,则会在委托里面包一层try-catch,然后记录下来异常信息,完成需要的操作,可以参考下面代码:
1 try 2 { 3 List<Task> taskList = new List<Task>(); 4 for (int i = 0; i < 4; i++) 5 { 6 string name = $"btnThreadCore_Click_{i}"; 7 taskList.Add(Task.Run(() => 8 { 9 try 10 { 11 if (name.Equals("btnThreadCore_Click_1")) 12 { 13 throw new Exception("btnThreadCore_Click_1异常"); 14 } 15 Console.WriteLine($"This is {name}成功 ThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}"); 16 } 17 catch (Exception ex) 18 { 19 Console.WriteLine($"委托里面Exception捕获的异常:{ex.Message}"); 20 } 21 })); 22 } 23 } 24 catch (AggregateException aex) //2 需要try-catch-AggregateException 25 { 26 foreach (var exception in aex.InnerExceptions) 27 { 28 Console.WriteLine($"任务外面AggregateException捕获到的异常:{exception.Message}"); 29 } 30 } 31 catch (Exception ex)//可以多catch,先具体再全部,如果具体的catch捕获到,则其它的异常将不会再次捕获 32 { 33 Console.WriteLine(ex); 34 }
二:线程如何取消
之前我们有讲过Thread,这个方法中有一个Abort,字面意思是取消线程,向当前线程抛一个异常然后终结任务,但是我们知道线程是由资源系统OS调度的,即使我们调用了这个方法,也没有办法立即去执行这件事情,所以我们不建议随便使用abort方法来取消线程,但是我们在工作中又会遇到这种情况,某个线程出现了问题,然后其他运行的线程要取消或者没有开始运行的线程不运行,那如果这样我们该如何实现呢?请看下面代码:
1 try 2 { 3 CancellationTokenSource cts= new CancellationTokenSource(); 4 List<Task> taskList = new List<Task>(); 5 for (int i = 0; i < 100; i++) 6 { 7 string name = $"btnThreadCore_Click_{i}"; 8 taskList.Add(Task.Run(() => 9 { 10 try 11 { 12 if (!cts.IsCancellationRequested) 13 { 14 Console.WriteLine($"This is {name} 开始 ThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}"); 15 } 16 17 Thread.Sleep(new Random().Next(50, 100)); 18 19 if (name.Equals("btnThreadCore_Click_11")) 20 { 21 throw new Exception("btnThreadCore_Click_11异常"); 22 } 23 else if (name.Equals("btnThreadCore_Click_13")) 24 { 25 cts.Cancel(); //可以多次调用,这个是只能设置IsCancellationRequested 属性为true,没有办法变为false 26 } 27 if (!cts.IsCancellationRequested) 28 { 29 Console.WriteLine($"This is {name}成功结束 ThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}"); 30 } 31 else 32 { 33 Console.WriteLine($"This is {name}中途停止 ThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}"); 34 } 35 } 36 catch (Exception ex) 37 { 38 Console.WriteLine(ex.Message); 39 cts.Cancel(); 40 } 41 }, cts.Token)); 42 } 43 Task.WaitAll(taskList.ToArray()); 44 } 45 catch (AggregateException aex) 46 { 47 foreach (var exception in aex.InnerExceptions) 48 { 49 Console.WriteLine($"外部AggregateException={exception.Message}"); 50 } 51 } 52 catch (Exception ex) 53 { 54 Console.WriteLine($"外部Exception={ex.Message}"); 55 }
运行结果如下:
我们发现结果中有中途停止的线程(可以使用一个变量的改变来实现),实现这个是下面三个步骤:
- 1 准备CancellationTokenSource ,里面有个bool属性IsCancellationRequested 初始化是false,调用Cancel方法后变成true(IsCancellationRequested 只能变为true,不能再次变回false),可以重复cancel
- 2 try-catch中捕获到异常然后调用Cancel方法,把 IsCancellationRequested 只能变为true
- 3 Action要随时判断IsCancellationRequested,如果这个值为true,则线程需要停止。
另外我们还有发现有捕获到已取消一个任务的提示,实现的方法如下:
- 1 启动线程Task.Run()方法中传递CancellationTokenSource 的Token这个参数,这能做到:在调用方法Cancel后即IsCancellationRequested变为true时,还没有启动的任务,就不启动了;也是抛异常,cts.Token.ThrowIfCancellationRequested
- 2 异常抓取 ,此时是捕获子线程的异常,如果要抓取异常则一定要使用Task.WaitAll()方法,或者线程异常捕获不到。
注意:
- CancellationTokenSource则是能外部对Task的控制,如取消、定时取消
- Task不能外部终止任务,只能自己终止自己
三:线程中的临时变量和全局变量
1 for (int i = 0; i < 5; i++) 2 { 3 int k = i; 4 Task.Run(() => 5 { 6 Console.WriteLine($"This is btnThreadCore_Click_{i}_{k} ThreadId={Thread.CurrentThread.ManagedThreadId.ToString("00")}"); 7 }); 8 }
运行结果:
是不是发现 i一直为5,然后k为正规的0-4,为啥为出现这样的结果,分为以下2点来说明:
- 线程是非阻塞的,延迟启动的;线程执行的时候,i已经是5了
- k是闭包里面的变量,每次循环都有一个独立的k,即是5个k变量, 1个i变量
四:线程锁lock
上面我们说的全局变量i会发生改变,其实就可以说是线程安全的问题,有时候我们使用多线程,发现我们写的代码和实际得到的结果不一致,比如:
1 for (int i = 0; i < 10000; i++) 2 { 3 this.iNumSync++; 4 } 5 for (int i = 0; i < 10000; i++) 6 { 7 Task.Run(() => 8 { 9 this.iNumAsync++; 10 }); 11 } 12 for (int i = 0; i < 10000; i++) 13 { 14 int k = i; 15 Task.Run(() => this.iListAsync.Add(k)); 16 } 17 18 Thread.Sleep(5 * 1000); 19 Console.WriteLine($"iNumSync={this.iNumSync} iNumAsync={this.iNumAsync} listNum={this.iListAsync.Count}");
运行得到结果如下:
我们发现同步方法是我们想要的结果,但是其它的两个都小于10000,这就是我们所说的线程安全问题,比如线程并发同时操作一个变量,会把这个变量同时覆盖掉为同一个值,才会出现累计加仍然不到10000,如果多运行几次会发现iNumSync一直是10000,然后 iNumAsync会是1-10000之间的值。
所以如果想要保证线程安全一定要加锁。Lock是语法糖,Monitor.Enter,占据一个引用,别的线程就只能等着,一般推荐锁为:private static readonly object这种格式。lock(objectA){codeB}看似简单,实际上有三个意思,这对于适当地使用它至关重要:
- objectA被lock了吗?没有则由我来lock,否则一直等待,直至objectA被释放。
- lock以后在执行codeB的期间其他线程不能调用codeB,也不能使用objectA。
- 执行完codeB之后释放objectA,并且codeB可以被其他线程访问。
加锁一般避免使用如下几种格式:
1:不能Lock(Null),可以编译但是不能运行;
1 try 2 { 3 List<Task> tasks = new List<Task>(); 4 for (int i = 0; i <5; i++) 5 { 6 tasks.Add(Task.Run(() => 7 { 8 lock (null)//任意时刻只有一个线程能进入方法块儿,这不就变成了单线程 9 { 10 this.iNumAsync++; 11 } 12 })); 13 } 14 Task.WaitAll(tasks.ToArray()); 15 } 16 catch (AggregateException ex) 17 { 18 foreach (var exception in ex.InnerExceptions) 19 { 20 Console.WriteLine($"AggregateException={exception.Message}"); 21 } 22 } 23 catch (Exception ex) 24 { 25 Console.WriteLine($"Exception={ex.Message}"); 26 }
运行会报如下错误:
2:不推荐lock(this),外面如果也要用实例,就冲突了
比如:
1 public class Test 2 { 3 private int iDoTestNum = 0; 4 /// <summary> 5 /// 测试lock(this) 6 /// </summary> 7 public void DoTest() 8 { 9 //这里是同一个线程,这个引用就是被这个线程所占据,所以不会发生死锁 10 lock (this) 11 { 12 Thread.Sleep(500); 13 this.iDoTestNum++; 14 if (DateTime.Now.Day < 29 && this.iDoTestNum < 10) 15 { 16 Console.WriteLine($"This is {this.iDoTestNum}次 {DateTime.Now.Day}"); 17 this.DoTest(); 18 } 19 else 20 { 21 Console.WriteLine("结束!!!!"); 22 } 23 } 24 } 25 }
执行:
1 Test test = new Test(); 2 Task.Delay(1000).ContinueWith(t => 3 { 4 lock (test) 5 { 6 Console.WriteLine("*********Start**********"); 7 Thread.Sleep(5000); 8 Console.WriteLine("*********End**********"); 9 } 10 }); 11 test.DoTest();
会出现:
通过上面我们看到,我们Test类中的方法使用的lock(this),即是相当于Test的实例test,然后我们发现的结果是只有把test.DoTest()里面的方法全部执行后,才会执行ContinueWith里面的方法,如果我们把把代码修改为如下:
1 public class Test 2 { 3 private int iDoTestNum = 0; 4 private static readonly object obj=new object(); 5 /// <summary> 6 /// 测试lock(this) 7 /// </summary> 8 public void DoTest() 9 { 10 //这里是同一个线程,这个引用就是被这个线程所占据,所以不会发生死锁 11 lock (obj) 12 { 13 Thread.Sleep(500); 14 this.iDoTestNum++; 15 if (DateTime.Now.Day < 29 && this.iDoTestNum < 10) 16 { 17 Console.WriteLine($"This is {this.iDoTestNum}次 {DateTime.Now.Day}"); 18 this.DoTest(); 19 } 20 else 21 { 22 Console.WriteLine("结束!!!!"); 23 } 24 } 25 } 26 }
执行:
1 Test test = new Test(); 2 Task.Delay(1000).ContinueWith(t => 3 { 4 lock (test) 5 { 6 Console.WriteLine("*********Start**********"); 7 Thread.Sleep(5000); 8 Console.WriteLine("*********End**********"); 9 } 10 }); 11 test.DoTest();
会出现:
这样两个锁则不会发生冲突。
注意:
- 1.lock(this)的缺点就是在一个线程锁定某对象之后导致整个对象无法被其他线程访问。
- 2.锁定的不仅仅是lock段里的代码,锁本身也是线程安全的。
- 3.我们应该使用不影响其他操作的私有对象作为locker。
- 4.在使用lock的时候,被lock的对象(locker)一定要是引用类型的,如果是值类型,将导致每次lock的时候都会将该对象装箱为一个新的引用对象(事实上如果使用值类型,c#编译器在编译时会给出个错误)
3:不推荐使用lock(string)
1 public class Test 2 { 3 private int iDoTestNum = 0; 4 private string Name = "wss"; 5 public void DoTestString() 6 { 7 lock (this.Name) 8 //递归调用,lock this 会不会死锁? 98%说会! 不会死锁! 9 //这里是同一个线程,这个引用就是被这个线程所占据 10 { 11 Thread.Sleep(500); 12 this.iDoTestNum++; 13 if (DateTime.Now.Day < 29 && this.iDoTestNum < 10) 14 { 15 Console.WriteLine($"This is {this.iDoTestNum}次 {DateTime.Now.Day}"); 16 this.DoTestString(); 17 } 18 else 19 { 20 Console.WriteLine("28号,课程结束!!"); 21 } 22 } 23 } 24 }
执行:
1 Test test = new Test(); 2 string student = "wss"; 3 Task.Delay(1000).ContinueWith(t => 4 { 5 lock (student) 6 { 7 Console.WriteLine("*********Start**********"); 8 Thread.Sleep(5000); 9 Console.WriteLine("*********End**********"); 10 } 11 }); 12 test.DoTestString();
结果:
也是会出现跟lock(this)的问题,因为string字符串在c#代码中内存是分配引用是复用的,即是你声明的两个string s=“wss” 和string s1=“wss”,然后引用内存是统一的,下图则可以证明:
五:线程安全
如果想要保证线程安全,会有以下几种途径
1:使用lock来加锁,具体使用参考第四条
2: 线程安全集合,这个一般命名空间System.Collections.Concurrent.ConcurrentQueue<int>,这个可以直接拿来使用,是能够直接保证线程安全的,不需要我们额外去做一些操作,它们对应的类型很多。
3:因为加lock和使用线程安全数据类型都会损耗性能的,如果环境允许的话,可以使用数据分拆,避免多线程操作同一个数据;又安全又高效,比如操作数据库或者文件,可以让每个线程去做不重复的东西,则不会有安全问题,当然这个拆分是有局限性的,是要从场景出发的。