C#基础-多线程

C# 多线程

闲得蛋疼,记录一下线程的基础概念和常规用法

基础概念

**进程:**应用程序(typerpa、word、IDEA)运行的时候进入到内存,程序在内存中占用的内存空间(进程)。

  • 是程序(任务)的一个执行过程,是动态的。
  • 持有资源(共享内存,共享文件)和线程。

线程:进程中负责程序运行的执行单元,也叫执行路径。

  • 可以将进程看作一个班级,线程看作班级里的学生,学生通过使用共享资源(教师、桌椅、黑板)进行学习。
  • 多线程技术解决了多部分代码同时执行的需求,能够更好的利用cpu的资源。

线程任务:线程执行的代码内容。

并行:多个cpu实例或者多台机器同时执行处理逻辑,实现真正的同时。

  • 如果电脑的CPU是多核处理器(4核),那就可以做到同时处理4个线程,从真正意义上做到多线程并行。线程的最大并行数量上限是CPU核心的数量,但是,往往电脑运行的线程的数量远大于CPU核心的数量,所以 还是需要CPU时间片的切换。

并发:通过cpu的调度算法,快速的切换线程执行线程任务,并不是真正意义上的同时。

线程安全:同一段代码在多线程使用下,不会因为线程的调度顺序而影响任何结果。在出现共享资源的情况下容易出现线程安全问题。确保线程的安全要优先于性能。

争用条件:当多个线程共享访问同一数据时,每个线程都尝试操作该数据,从而导致数据被破坏,这种现象称为争用条件。


注意,尽管多线程的使用可以提升程序的运行效率,充分发挥CPU的作用,但多线程并不是所有时候都可以随便使用的,多线程在时间和空间上都是有开销的,当超过一定限度之后,多线程开得越多,性能反而会下降。因此使用时应该根据实际需求,谨慎使用。

空间上的开销

  • Thread内核数据结构:线程的ID和线程环境都在CPU寄存器中占用空间。例如在CPU进行时间片切换的时候,没有处理完的数据会存储在CPU的相关空间中。
  • 用户堆栈模式:用户程序中的局部变量和参数传递所使用的堆栈。常见的异常:StackOverFlowException(堆栈溢出)就是内存占存过多,导致溢出异常。

时间上的开销

  • 资源使用通知的开销:因为一个程序会调用很多的托管和非托管的dll或exe资源,使用前需要先进行通知确认,这都是需要时间的。
  • 时间片切换开销。

Thread

一、线程的简单使用

在C#中启动多线程需要实例化一个 Thread对象,然后在构造方法中传入委托对象,为了方便一般会直接传入lambda表达式, 这个lambda表达式中执行的代码片段就是新的线程中所需要执行的任务。

最后,调用Thread对象Start()方法来启动新线程。

static void Main(string[] args)
{
    var thread = new Thread(() =>
    {
        Print();
    });
    thread.Start();
    Console.WriteLine($"主线程ID:{Thread.CurrentThread.ManagedThreadId}");
    Console.WriteLine("主线程结束!");
}

private static void Print()
{
    for (var i = 0; i< 100; i++)
    {
        Console.WriteLine($"子线程ID:{Thread.CurrentThread.ManagedThreadId}------{i}");
        Thread.Sleep(1000);
    }
}

另外提一下,因为线程的构造函数Thread()中所需要传入的参数是一个委托对象,所以如果任务函数不需要传入任何参数,可以直接将任务函数的名称作为参数传给构造函数。

var thread = new Thread(Print);

此外,如果需要提前终结线程,可以使用Thread对象的Abort()方法。

Abort():在调用此方法的线程上引发 System.Threading.ThreadAbortException,通过处理异常,去决定程序的终止与否(例如直接不理会异常导致整个程序终止),并不能直接终止线程。

  • 该方法已经过时弃用,不建议使用。

二、前台线程&后台线程

从上面的例子中可以看到,当程序运行起来后,主线程已经运行结束了,但是程序却没有停止,子线程依然在运行,依然每隔1秒输出一次。

这种情况下,持续运行的子线程就被称作前台线程(Foreground Thread)。

一般来说,在前台线程中执行的任务都比较重要,所以,只有等待所有的前台线程结束以后程序才能关闭。

相对而言,后台线程执行的任务就不是那么重要了,只要主线程和所有的前台线程执行结束,就算后台线程的任务还没完成,也会强行打断直接退出。

在 .Net中,除了主线程以外,任何一个线程都可以在任何时候在前台和后台之间随意切换。代码也非常简单,只需要将它的IsBackground属性设置为true,那么这个线程就可以被切换为后台线程了。

var thread = new Thread(() =>
{
    Print();
});
thread.IsBackground = true;
thread.Start();

特点:

后台线程不会影响进程的终止,属于某个进程的所有前台线程都终止后,该进程就会被终止,所有剩余的后台线程都会被强制停止。

可以在任何时候(线程开始前或开始后都可以)将前台线程修改为后台线程,方式是设置IsBackground属性。

不管是前台线程还是后台线程,如果线程内出现了异常,都会导致进程的终止。

托管线程池中的线程都是后台线程,使用new Thread方式创建的线程默认都是前台线程。

三、线程池

线程是非常消耗资源的,如果我们每次需要子线程来执行任务,就去创建一个新的线程,那当我们执行1千次1万次甚至是100万次的时候,那么估计电脑硬件也受不了这么大的资源消耗。

对于这种情况我们就需要使用线程池来管理线程了。只要是通过线程池创建的线程,那么它的所有的生命周期都会托管在线程池内。所有线程池中的线程都是后台线程,线程池会检查程序当前的运行状态,当程序启动时,线程池内的线程也会开始工作;而当Main方法结束以后,线程池会自动关闭,同时中断所有正在运行的线程。

线程池的使用方式很简单,直接调用线程池ThreadPool类中的静态方法QueueUserWorkItem()来创建线程池内的线程就可以了。

static bool QueueUserWorkItem(WaitCallback callBack):参数为一个带一个object类型参数的委托,最后返回bool值成功则返回true

static void Main(string[] args)
{
    for(var i = 0; i<100; i++)
    {
        ThreadPool.QueueUserWorkItem((state) =>
        {
            Console.WriteLine($"子线程ID:{Thread.CurrentThread.ManagedThreadId}");
        });
    }
    Console.WriteLine($"主线程ID:{Thread.CurrentThread.ManagedThreadId}");
    Console.WriteLine("主线程结束!");
}

在这里插入图片描述可以看到,上面的代码执行之后,出现了很多重复的线程ID,这就是线程池的厉害之处,他可以在不新建线程的情况下重复使用已经完成的线程,这样可以保证线程维持在一个很低的水平,极大的节约我们的硬件资源。

其次,能够看到当主线程结束之后,线程池中的线程也没有再执行下去了,这是因为线程池中的线程全部默认为后台线程。所以,在实际工作中一定要小心处理业务场景,对于重要、并发量小的线程,我们需要手动来创建和管理,而对于并发量大、而又不那么重要的线程最好托管在线程池中。

四、取消令牌—CancellationToken

思考一个问题,如何在特定的节点取消子线程的任务?

  • 比如说我们的子线程执行while循环,然后主线程执行一个文件下载的任务。这个下载任务需要5秒(用 Thread.Sleep(5000) 来代替),要求在文件下载完成以后,要在程序结束前关闭子线程。

为应对这种需求,C#对线程取消的代码做了一个高层次的抽象,把整个任务的取消方式通过令牌token的形式封装成为了一个更加通用的取消机制,这就是CancellationTokenCancellationToken不仅可以使用在多线程中,也可以用来取消异步操作。

取消令牌资源对象的创建:

使用取消令牌首先需要实例化一个令牌资源CancellationTokenSource对象。

var cancelToken = new CancellationTokenSource();

取消令牌对象的传递:

从本质上来说,取消令牌就是一个变量,我们需要把这个令牌传递到子线程中去。但是,线程任务传递的是取消令牌CancellationToken对象,而不是CancellationTokenSource对象,令牌对象可以通过CancellationTokenSource对象的Token属性获取。

var thread = new Thread(()=>{ Print(cancelToken.Token); });

调用取消令牌:

调用取消令牌可以使用CancellationTokenSource对象中的Cancle()CancleAfter()方法:

  • Cancle():立即调用取消令牌,将取消令牌对象的IsCancellationRequested属性设置为true
  • CancleAfter(int milliseconds),传入毫秒作为参数,表示延迟多少毫秒后调用取消令牌,将取消令牌对象的IsCancellationRequested属性设置为true
cancleToken.CancelAfter(3000);

取消令牌的使用:

由于取消令牌的状态可以根据取消令牌对象的IsCancellationRequested属性获取,因此线程任务可以根据该属性来控制任务的结束与否。

static void Main(string[] args)
{
    var cancleToken = new CancellationTokenSource();
    var thread = new Thread(()=>
    {
        Print(cancleToken.Token);
    });
    thread.Start();
    Console.WriteLine("开始下载");
    Thread.Sleep(5000);
    cancleToken.CancelAfter(3000);
    Console.WriteLine("下载完成");
}

private static void Print(CancellationToken token)
{
    while(!token.IsCancellationRequested)
    {
        Thread.Sleep(1000);
        Console.WriteLine("测试取消令牌!");
    }
}

注意

上述方法,尽管看起来可行,但是在Thread中这样使用是有BUG的,实际上跟使用标识符来决定线程任务的结束没什么区别。

取消令牌更正确的使用方式是配合Task来使用(详看下文)。

五、Join&IsAlive

当主线程的任务依赖于子线程的计算结果,比如希望子线程执行完任务后,主线程才继续执行代码。这个时候我们可以使用Thread对象的Join()方法。

Join():调用此方法后,主线程和子线程绑定起来,主线程会被暂时挂起,等待子线程执行完毕以后主线程才会继续执行。

  • 需要注意的是,Join()方法的调用必须在线程开始之后。
static void Main(string[] args)
{
    var thread = new Thread(() => PrintHello());
    thread.Start();
    thread.Join();
    Console.WriteLine("文件下载完成");
    Console.WriteLine("退出主程序");
}

static void PrintHello()
{
    int i = 0;
    while (i++ < 10)
    {
        Thread.Sleep(new Random().Next(100, 1000));
        Console.WriteLine("Join test");
    }
}

除了使用Join()方法,其实还有一个比较笨的方法来探测子线程的执行状态。那就是使用一个while循环来判断线程的IsAlive属性。

static void Main(string[] args)
{
    var thread = new Thread(() => PrintHello());
    thread.Start();
    while(thread.IsAlive)
    {
        Thread.Sleep(100);
    }
    Console.WriteLine("文件下载完成");
    Console.WriteLine("退出主程序");
}

六、资源竞争与线程锁

使用线程,我们可以并发或并行的在CPU核心中执行任务,最大化CPU的利用率,但是这样也可能导致产生各种各样奇怪的资源竞争问题。例如运行下列代码,程序报错。

static void Main(string[] args)
{
    for(int i = 0; i< 10; i++)
    {
        var thread = new Thread(AddText);
        thread.Start();
    }
    Console.WriteLine("文件下载完成");
    Console.WriteLine("退出主程序");
}

static void AddText()
{
    File.AppendAllText(@"ThreadTest.txt", $"开始:{Thread.CurrentThread.ManagedThreadId}");
    Thread.Sleep(1000);
    File.AppendAllText(@"ThreadTest.txt", $"结束:{Thread.CurrentThread.ManagedThreadId}");
}

C#基础-多线程_第1张图片发生这种错误的原因就是不同的线程之间发生了资源的争抢。在其中一个线程打开文件后,但还没完成操作时,CPU就切换到另外一个线程上,这个线程也要打开文件,由于上一个线程还没有将文件流关闭,就导致异常崩溃了。

这种现象就叫做资源竞争。资源竞争在多线程编程中是一个非常常见问题,最严重的情况就是现在这种系统直接崩溃。

那么,避免这种资源的恶性竞争的关键就在于文件必须每次是能由一个线程打开,当前线程操作文件的时候,其他线程需要排队等待。在C#中,有一个关键词叫做lock,可以帮我们锁定资源来排除资源竞争的现象。

lock块语法:

  • 需要注意,传给lock块的参数不能是值类型和string类型,必须是除了string外的引用类型,而且这个引用类型对象必须是所有线程都能访问到的,否则锁不住。
  • 其中作为锁的可以是任意的对象,一般情况下会创建一个object对象来作为指定的锁对象。
lock(lockObj)
{
	......
}

将上述例子中共享资源部分的代码加上锁,就可以避免资源竞争带来的问题。

static object lockObj = new object();

......

static void AddText()
{
    lock (lockObj)
    {
        File.AppendAllText(@"./ThreadTest.txt", $"开始:{Thread.CurrentThread.ManagedThreadId}\n");
        Thread.Sleep(1000);
        File.AppendAllText(@"./ThreadTest.txt", $"结束:{Thread.CurrentThread.ManagedThreadId}\n");
    }
}

PS:这里讲的是最简单的一种线程锁,lock块是Monitor语法糖,本质是解决资源的争用问题,至于其他锁有空再整理。

八、线程的其他常用方法

void Suspend():将线程(执行中或等待状态下)挂起,进入暂停状态。

void Resume():继续已经挂起的线程。

void Interrupt():中断线程处于WaitSleepJoin状态的线程。

void Abort():在调用此方法的线程上引发ThreadAbortException,通常用来终止线程(已过时)。

Task

C#4.0时代,出现了TaskTask可以简单看作相当于Thead+TheadPool,其性能比直接使用Thread要更好,在工作中更多的是使用Task来处理多线程任务。

一、Task开启线程的三中方式

1、方式一

通过创建Task对象后调用其Start()函数。

Task task = new Task(() => { Console.WriteLine("线程1"); });
task.Start();

2、方式二

调用Task的静态方法Run()

Task task = Task.Run(()=>{Console.WriteLine("线程1"); });

3、方式三

通过Task工厂,开新建一个线程。

Task task = Task.Factory.StartNew(()=>{Console.WriteLine("线程1"); });

二、Task阻塞的三种方式

Thread的阻塞是通过对象的Join()函数来完成,如果有多个线程需要按序执行,就会出现多个Join()的情况,且不说这样做效率上会怎么样,代码光看着就不舒服。Task对于阻塞的处理有着更好的方案。

1、方式1(不推荐)

使用Task对象的Wait()函数分别等待单个线程任务完成,这种方式跟ThreadJoin没啥区别,不推荐使用。

Task task1 = new Task(()=>{...});
Task task2 = new Task(()=>{...});
task1.Start();
task2.Start();
task1.Wait();
task2.Wait();

2、方式2(推荐)

通过Task的静态方法WaitAll()来指定等待的一个或多个线程结束。

Task task1 = new Task(()=>{...});
Task task2 = new Task(()=>{...});
task1.Start();
task2.Start();
Task.WaitAll(task1, task2);

现在假设有三个方法Calculate1、Calculate2和Calculate3,其中Calculate3方法需要依赖Calculate1和Calculate2的计算结果,此时可以通过下列代码来实现。

  • TaskAwaiter GetAwaiter()Task类的实例方法,返回TaskAwaiter对象。
  • TResult GetResult()TaskAwaiter类的实例方法,返回线程任务的返回结果。
Task<int> task1 = Task.Run(Calculator1);
Task<int> task2 = Task.Run(Calculator2);
Task.WaitAll(task1, task2);
TaskAwaiter<int> task1Awaiter = task1.GetAwaiter();
TaskAwaiter<int> task2Awaiter = task2.GetAwaiter();
Task.Run(()=> { Calculator3(task1Awaiter.GetResult(), task2Awaiter.GetResult()); });
Console.Read();

int Calculator1()
{
    return 1*1;
}

int Calculator2()
{
    return 1 * 2;
}

void Calculator3(int a, int b)
{
    Console.WriteLine(a*b);
}

3、方式3(推荐)

通过Task的静态方法WaitAny()来指定等待任意一个线程任务结束。

Task task1 = new Task(()=>{...});
Task task2 = new Task(()=>{...});
task1.Start();
task2.Start();
Task.WaitAny(task1, task2);

三、Task任务的延续

在上面的例子中,虽然可以使用TaskWaitAll()等静态方法来阻塞线程,等待完成后获取结果,从而实现Calculate1、Calculate2和Calculate3

三个方法的依赖关系。现在需求有变化,在实现Calculate1、Calculate2和Calculate3时,不希望主线程有阻塞。

在这种需求下,WaitAll()方法显然是行不通的,此时就需要使用到WhenAll().ContinueWith(),其作用是当WhenAll()中指定的线程任务完成后再执行ContinueWith()中的任务,也就是线程任务的延续。而由于这个等待是异步的,因此不会给主线程造成阻塞。

  • WhenAll(task1,task2,...)Task的静态方法,作用是异步等待指定任务完成后,返回结果。当线程任务有返回值时,返回Task对象,否则返回Task对象。
  • WhenAny()用法与WhenAll()是一样的,不同的是只要指定的任意一个线程任务完成则立即返回结果。
  • ContinueWith()Task类的实例方法,异步创建当另一任务完成时可以执行的延续任务。也就是当调用对象的线程任务完成后,执行ContinueWith()中的任务。
var task1 = Task.Run(Calculator1);
var task2 = Task.Run(Calculator2);
Task.WhenAll(task1, task2).ContinueWith((data) => { Calculator3(data.Result[0], data.Result[1]); });

Console.WriteLine("主线程已经完成");
Console.Read();

int Calculator1()
{
    Thread.Sleep(1000);
    return 1*1;
}

int Calculator2()
{
    Thread.Sleep(1000);
    return 1 * 2;
}

void Calculator3(int a, int b)
{
    Console.WriteLine(a*b);
}

显然,ContinueWith()作为Task的实例函数,可以通过Task对象直接调用,由于是异步创建的延续任务因此不会对主线程造成阻塞。

var task1 = Task.Run(Test);
task1.ContinueWith((data) => { Console.WriteLine("线程1的延续"); });

Console.WriteLine("主线程已经完成");
Console.Read();

int Test()
{
    Thread.Sleep(1000);
}

四、Task枚举TaskCreationOptions

TaskCreationOptions枚举类型中多个成员,其中最常用的为AttachedToParentLongRunning,前者用于将子线程依附到父线程中,后者用于声明耗时运行的线程任务。

1、父子任务

假设遇到如下需求,线程parentTask中开启了线程task1和task2,希望在开启parentTask线程任务的主线程中阻塞等待parentTask、task1和task2的任务完成。

此时可以将task1和task2线程依附到parentTask线程上作为parentTask的子线程,这样主线程在等待parentTask线程完成时,就必须同步等待task1和task2线程的任务完成。

子线程的依附操作很简单,在创建线程对象时,传入参数TaskCreationOptions.AttachedToParent即可。

Task parentTask = new Task(() => {
    Task task1 = new Task(() => { Console.WriteLine("task1任务。。。。。。"); }, TaskCreationOptions.AttachedToParent);
    Task task2 = new Task(() => { Console.WriteLine("task2任务。。。。。。"); }, TaskCreationOptions.AttachedToParent);
    task1.Start();
    task2.Start();
});
parentTask.Start();
parentTask.Wait();
Console.WriteLine("这里是主线程");
Console.Read();

2、耗时任务

当要执行的线程任务比较耗时时,建议在创建线程对象时传入参数TaskCreationOptions.LongRunning,以此来声明为长时间运行的线程任务。

默认情况下,新建Task线程是从线程池ThreadPool中分配出来的,当使用TaskCreationOptions.LongRunning声明后则是直接新建一个线程。这样就可以避免耗时任务一直占用线程池资源的情况。当然了,也可以直接使用Thread,效果上是一样的。

Task task = new Task(()=>{...}, TaskCreationOptions.LongRunning);
task.Start();

五、Task中使用取消令牌

Task中的取消功能使用的是CanclelationTokenSource,即取消令牌源对象,可用于解决多线程任务中协作取消和超时取消。

  • CancellationToken TokenCanclelationTokenSource类的属性成员,返回CancellationToken对象,可以在开启或创建线程时作为参数传入。
  • bool IsCancellationRequestedCanclelationTokenSource类的属性成员,表示当前任务是否已经请求取消。Token类中也有此属性成员,两者互相关联。
  • void Cancel()CanclelationTokenSource类的实例方法,取消线程任务,同时将自身以及关联的Token对象中的IsCancellationRequested属性置为true
  • void CancelAfter(int millisecondsDelay)CanclelationTokenSource类的实例方法,用于延迟取消线程任务。
  • CancellationTokenRegistration Register(Action callback)Token类的实例方法,用于注册取消任务后的回调任务。

1、Task任务的取消和判断

CancellationTokenSource cst = new CancellationTokenSource();
Task task = Task.Run(() => {
    while (!cst.IsCancellationRequested)
    {
        Console.WriteLine("持续时间:" + DateTime.Now);
    }
}, cst.Token);//这里第二个参数传入取消令牌

Thread.Sleep(2000);
cst.Cancel(); //两秒后结束

2、Task任务延时取消

任务的延时取消可以用于访问超时、执行超时等情况下的任务强制终止。

延时取消任务实现起来很简单,就是将CancellationTokenSourceCancel()方法换成CancelAfter(milliseconds)就好了。

  • CancelAfter(milliseconds)也是异步的,这点请注意。
CancellationTokenSource cst = new CancellationTokenSource();
Task task = Task.Run(() => {
    while (!cst.IsCancellationRequested)
    {
        Console.WriteLine("持续时间:" + DateTime.Now);
    }
}, cst.Token);//这里第二个参数传入取消令牌

cst.CancelAfter(2000); //两秒后结束

3、Task任务取消回调

如果取消任务后希望做一些处理工作。此时可以使用CancellationToken类的Register()函数来注册一个委托(回调函数),用于取消线程后调用。

CancellationTokenSource cst = new CancellationTokenSource();
Task task = Task.Run(() => {
    while (!cst.IsCancellationRequested)
    {
        Console.WriteLine("持续时间:" + DateTime.Now);
        Thread.Sleep(500);
    }
}, cst.Token);//这里第二个参数传入取消令牌
cst.Token.Register(() => {
    Console.WriteLine("开始处理工作......");
    Thread.Sleep(2000);
    Console.WriteLine("处理工作完成......");
});
Thread.Sleep(2000);
cst.Cancel(); //两秒后结束

六、Task跨线程访问控件

在使用Winform或WPF编写程序时,经常会遇到跨线程访问控件的情况,除了使用Invoke和委托等方法外,还可以有以下两种解决方法。

1、方式一

直接将TaskScheduler对象做为参数传给Start()函数(使用TaskScheduler.FromCurrentSynchronizationContext()可以获得TaskScheduler对象),以此来将线程任务传送到指定的调度程序中运行,结合WPF编程宝典多线程章节中的内容,应该是将线程任务丢给控件元素所在线程的调度程序中运行。这样做虽然可以跨线程访问控件,但是带来的弊端就是,如果线程任务耗时,就会让整个窗体卡住。

private void Button_Click(object sender, RoutedEventArgs e)
{
    Task task = new Task(() =>
    {
        Thread.Sleep(5000);//模拟耗时处理
        txt_Info.Text = "test"; //此为文本控件
    });
    task.Start(TaskScheduler.FromCurrentSynchronizationContext());
}

2、方式二

针对线程耗时的情况,如果直接使用方式一,会导致整个UI界面都卡住,等到控件处理完成才恢复,这样显然是不可以的。因此要改变一下用法,利用线程延续,将耗时的任务与访问UI控件的任务分为两个线程,访问UI的线程放到延续的线程中。

private void Button_Click(object sender, RoutedEventArgs e)
{
		txt_Info.Text = "数据正在处理中......";
		txt_Info.Text = "数据正在处理中......";
		Task.Run(() =>
		{
			Thread.Sleep(5000);
		}).ContinueWith(t => {
			txt_Info.Text = "test";
		}, TaskScheduler.FromCurrentSynchronizationContext());
}

七、Task的异常处理

Task线程的异常处理不能直接将线程对象相关代码try-catch来捕获,那样是捕获不到异常的,需要通过调用线程对象的wait()函数,通过wait()函数来进行线程的异常捕获。

此外,线程的异常会聚合到AggregateException异常对象中(AggregateException是专门用来收集线程异常的异常类),需要通过遍历该异常对象,获取正确的异常信息。

如果捕获到线程异常之后,还想继续往上抛出,就需要调用AggregateException对象的Handle函数,并返回false。(Handle函数遍历了一下AggregateException对象中的异常)

Task task1 = Task.Run(() =>
{
    throw new Exception("线程1的异常抛出");
});
Task task2 = Task.Run(() =>
{
    throw new Exception("线程2的异常抛出");
});
Task task3 = Task.Run(() =>
{
    throw new Exception("线程3的异常抛出");
});

try
{
    task1.Wait();
    task2.Wait();
    task3.Wait();
}
catch (AggregateException ex)
{
    foreach (var item in ex.InnerExceptions)
    {
        Console.WriteLine(item.Message);
    }
    //如果希望再将异常往外抛出,可以调用AggregateException的Handle函数
    //ex.Handle(p => false);
}
Console.Read();

你可能感兴趣的:(C#基础,c#,.net)