NET中的并行编程(TPL)——多线程、异步、任务和并行计算

https://masuit.com/1201

谈一谈.NET中的并行编程(TPL)——多线程、异步、任务和并行计算

懒得勤快 发表于 2018-04-26 19:41:00 | 最后修改于 2018-06-27 23:44:40
分类: .NET开发技术 | 评论总数: 0条 | 热度: 2243
 

写在前面:

在做了几个月的高并发项目的过程中,其实发现自己真的提升了不少,所以也想把这段时间的收获分享给大家,然后写这篇文章发现,写下来是一发不可收拾,所以这篇文章的内容可能会很长,当然也希望能够给大家带来收获。

在开发过程中,有很多工作我们都需要去开线程来解决,但是多线程往往会带来更多棘手的问题,但又不得不使用多线程,由多线程带来的传值、取值、资源同步、线程取消或暂停、异常的捕获等都会困扰着我们每一个编写这类代码的开发者。微软也在这方面做了巨大的努力,以至于到现在的.Net Framework和.NetCore都有非常丰富的多线程API可以选择,方便去编写多线程代码,同时又带来了一个问题:线程、异步、任务、并行计算等太多了,我该选择哪个?

接下来就让我们一起来由浅入深的去熟悉线程、异步、任务,从是什么到为什么,追溯事物的本质,以及任务为什么还衍生出了并行计算(Parallel),同时还告诉大家如何优雅的去控制线程,以及处理异步、任务和并行计算中的异常。

多线程编程(TPL)是我们所有开发人员职业生涯中曾经或是现在的一道坎,所以,我们必须战胜它!不过,在阅读本文章之前,你还是必须得有基本的TPL编程基础,最起码,线程、异步、任务的基本使用还是要会的。

 

多线程和异步

相信很多初学者学过多线程和异步之后,都会把异步和多线程混为一谈,如果对它们之间的区别不是很清楚的话,就很容易写出下面的代码:

private void button1_Click(object sender, EventArgs e)
{
    new Thread(() =>
    {
        var client = new WebClient();
        var s = client.DownloadString("https://www.google.com");
        MessageBox.Show(s);
    }).Start();
}

以上代码模拟在一个WinForm程序中,单击按钮获取某个网页的html代码并弹窗显示出来,可以预见,如果网页的内容特别多,或者因为特殊的网络原因,获取网页内容时间比较长,所以我们开线程去完成这项工作,防止阻塞UI线程。

确实,这样解决了UI线程被阻塞的问题吗,但是,它高效么?答案是否定的,要理解这一点,需要从计算机组成原理说起,其实我们的电脑主机的硬件里面,有很多零件是具备“IO操作的DMA(Direct Memory Access)模式”,DMA即直接内存访问,顾名思义,就是一种不经过CPU就可以直接访问内存数据的一种数据交换模式。通过DMA模式的数据交互几乎不耗CPU资源,比如我们电脑机箱里面的硬盘、声显网卡等都具有DMA功能,而我们.NET中CLR所提供的异步编程模型就是让我们充分利用硬件DMA功能来转移CPU的压力。

知道了这一点,我们再来分析下上面的这个例子,我们可以画图来阐述下:

为了下载网页内容,CPU新起了一个线程,然后在下载网页的整个过程中,该线程会始终占用着CPU资源,直到网页被下载完成。这就意味着CPU的资源一直被消耗,浪费,等待着。

如果我们改用异步去实现,代码如下:

扫描二维码关注公众号,回复: 8239370 查看本文章
private void button1_Click(object sender, EventArgs e)
{
    var client = new WebClient();
    client.DownloadStringCompleted+=(ssender, ee) =>
    {
        MessageBox.Show(ee.Result);
    };
    client.DownloadStringAsync(new Uri("https://www.google.com"));
}

上面的代码工作机制就可以这样描述了:

经过改造后的代码采用了异步模式,它的底层使用线程池进行管理,异步操作启动时,CLR会将下载网页操作这部分工作丢给线程池中的某个线程去执行。当开始IO操作时,异步会把工作线程还给线程池,这时候就相当于下载网页的这个工作不会再占用CPU资源了。直到异步完成,即网页的html下载完成,WebClient会通知下载完成事件,让CLR响应异步操作完成,由此可见,异步模式借助线程池,极大的节约了CPU资源。

所以,异步和多线程的执行流程图可以这样表示:

明白了多线程和异步的区别后,我们来确定下二者的具体使用场景:

CPU密集型采用多线程;

I/O密集型采用异步。

如果你区分不来什么是CPU密集型还是I/O密集型的,你就记住一点:涉及到任何读写操作和数据传输有关的,就属于I/O密集型,否则就是CPU密集型,也叫计算密集型。

 

关于线程同步

所谓线程同步,就是多线程访问共享资源时的一种等待(也可以理解为锁定某个对象),直到该共享资源被解除锁定,面向对象语言中的数据类型都分为值类型和引用类型。所以多线程在这两种数据类型上的等待是不一样的,有编程基础的都知道值类型不能被锁定,即不能在值类型上做等待操作。而在引用类型上的等待机制,又分为了锁定和同步。

在C#里面,锁定我们使用微软提供的关键字语法糖lock或者使用Monitor对象,其实前者就是后者的语法糖,两者没有什么实质差别,这就是我们最常用的锁技术。

不过,我们主要来讨论信号同步,而信号同步机制中涉及的类型都继承自抽象类WaitHandle,这些类型有Semaphore、Mutex以及EventWaitHandle,而EventWaitHandle又分为AutoResetEvent和ManualResetEvent,关系图如下:

所以它们的底层原理都是一样的,维护的都是一个系统内核句柄。不过还是要简单的区别三者的关系。

EventWaitHandle所维护的是一个由操作系统内核产生的bool值(称之为“阻塞状态”),如果为false,则表示线程被阻塞,可以通过调用Set方法将其置为true而解除线程阻塞。而它的子类AutoResetEvent和ManualResetEvent区别也不大,接下来会针对二者讲述下如何正确地使用信号量。

Semaphore维护的是一个由系统内核产生的整型变量,如果其值为0,则表示等待,如果大于0,则解除阻塞,同时,每解除一个线程阻塞,其值就减1。初始化时就限制了最多能等待几个线程。

上面两个提供的都是单应用程序域的线程同步,而Mutex则解决的是跨应用程序域线程阻塞和解锁的能力。

使用线程同步的一个简单例子:

public AutoResetEvent AutoResetEvent { get; set; } = new AutoResetEvent(false);
private void button2_Click(object sender, EventArgs e)
{
    new Thread(() =>
    {
        label1.Text = "线程开启,等待信号...";
        AutoResetEvent.WaitOne();
        //todo:处理一些复杂工作
        label1.Text = "继续工作...";
    }).Start();
}
private void button3_Click(object sender, EventArgs e)
{
    AutoResetEvent.Set();
}

同样在一个WinForm程序里面,一个按钮开启线程,另一个按钮给这个线程发送信号,这期间发生了什么?

首先创建了一个AutoResetEvent同步类型对象,初始状态为阻塞状态false,这意味着所有的在它上面的的等待都会被阻塞,即线程中应用:

AutoResetEvent.WaitOne();

这说明线程到这里就被阻塞,直到有人给它发信号才会继续执行,否则就一直等,而UI线程中的:

AutoResetEvent.Set();

相对于其他线程来说,就是“另一个线程”,UI线程通过Set方法将阻塞状态置为true,等待的线程才继续执行,虽然例子很简单,但已经完全的解释了信号机制的工作原理。

而AutoResetEvent和ManualResetEvent的区别在于,前者在发送完信号后会立即置为false,而后者需要手动指定,我们来看下面的例子:

public AutoResetEvent AutoResetEvent { get; set; } = new AutoResetEvent(false);
private void button2_Click(object sender, EventArgs e)
{
    new Thread(() =>
    {
        label1.Text = "线程1开启,等待信号...";
        AutoResetEvent.WaitOne();
        //todo:处理一些复杂工作
        label1.Text = "继续1工作...";
    }).Start();
    new Thread(() =>
    {
        label2.Text = "线程2开启,等待信号...";
        AutoResetEvent.WaitOne();
        //todo:处理一些复杂工作
        label2.Text = "继续2工作...";
    }).Start();
}
private void button3_Click(object sender, EventArgs e)
{
    AutoResetEvent.Set();
}

按钮2同时开启2个线程,按钮3发送信号,运行时我们发现,在线程都被阻塞的时候,点按钮3之后,只有一个线程被唤醒了,另一个线程仍然还在等待,根本没收到信号,要再唤醒另一个线程,那就再点一下按钮3再发一个信号,所以AutoResetEvent在发送完信号后立马把阻塞状态置为了false,要想多个线程同时被唤醒,那就是ManualResetEvent了。

 

只要是引用类型,就可以随便加锁吗?

加锁我相信大家都很熟悉了,这也是让线程同步的一种方式,其原理就是锁住一个共享资源,使得程序在多线程访问这个共享资源的时候只能有一个线程占用,通俗的讲就是让多线程变成单线程,但是,只要是对象,就可以加锁吗?

既然加锁的是个对象,那我们不妨思考一下,到底要什么样的对象才能被锁,我在这儿整理了一下,选择锁对象的时候我们应该注意什么:

  1. 锁对象应该是在多个线程中可见的同一对象;

  2. 在非静态方法中,静态变量不应该作为锁对象;

  3. 值类型不能作为锁对象;

  4. 避免将字符串作为锁对象;

  5. 降低锁对象的可见性。

下面就分别详细的解释下这几点。

首先第一个,锁对象必须在多线程中是可见的,且必须是同一对象。前半句很好理解,如果不可见那肯定也不能锁啊,至于“同一对象”,也很好理解,如果锁的不是同一对象,那加锁还有什么意义呢,但是,这却是我们经常会犯的一个错误,为了好理解,举个我们一定会遇到的场景:在遍历集合的时候,同时另一个线程又在修改这个集合,就像下面的代码,如果没有lock,会抛异常InvalidOperationException:集合已被修改,可能无法执行枚举。

static void Main(string[] args)
{
    object lockObj = new object();
    AutoResetEvent are = new AutoResetEvent(false);
    List<string> list = new List<string>() { "1", "2", "3", "4", "5", "6" };
    new Thread(() =>
    {
        are.WaitOne();
        lock (lockObj)
        {
            foreach (var i in list)
            {
                Thread.Sleep(100);
            }
        }
    }).Start();
    new Thread(() =>
    {
        are.Set();//保证这个线程已经开始了才能执行上面的线程
        Thread.Sleep(200);
        lock (lockObj)
        {
            list.RemoveAt(0);
        }
    }).Start();
}

上面的代码锁定的是同一对象,肯定没有问题,如果将代码改造成这样:

class Program
{
    static void Main(string[] args)
    {
        var m1 = new MyClass();
        var m2 = new MyClass();
        m1.T1();
        m2.T2();
        Console.ReadKey();
    }
}
public class MyClass
{
    object lockObj = new object();
    AutoResetEvent are = new AutoResetEvent(false);
    static List<string> list = new List<string>() { "1", "2", "3", "4", "5", "6" };
    public void T1()
    {
        new Thread(() =>
        {
            are.WaitOne();
            lock (lockObj)
            {
                foreach (var i in list)
                {
                    Thread.Sleep(100);
                }
            }
        }).Start();
    }
    public void T2()
    {
        new Thread(() =>
        {
            are.Set();//保证这个线程已经开始了才能执行上面的线程
            Thread.Sleep(200);
            lock (lockObj)
            {
                list.RemoveAt(0);
            }
        }).Start();
    }
}

很显然,MyClass被实例化了两次,也就是锁对象lockObj也被实例化了两次,而多线程操作的却是MyClass的静态字段,运行则会抛InvalidOperationException:集合已被修改,可能无法执行枚举。

也就是说,上面的代码锁定的是两个不同的对象,如果要改掉这个bug,那么把lockObj也改成静态的就OK了,另外,也思考下能不能lock(this),试想刚才我们用lockObj,同理lock(this)在多实例的时候也不是锁的同一对象,也不能达到锁同步的目的。

那刚才的把lockObj改成了静态的之后,确实达到了锁的目的,但是,有读者可能发现了,非静态方法中使用了静态变量作为锁对象,这不矛盾了么,那好,接下来就说第二点了。

针对第二点,事实上,刚才的代码也是出于演示的目的,其次,实际项目中强烈建议不要这么写,如果要,必须遵守这个原则:

类型的静态方法应当保证线程安全,非静态方法不需要保证线程安全。

.NetFramework和.NetCore底层绝大部分类都遵循了这个原则,上一个示例中,如果将lockObj改为静态的,Name就相当于让非静态方法具备了线程安全性,带来的问题就是:如果应用程序中该类型存在多实例,在遇到这个锁的时候,就会产生同步,试想,如果高并发使用这个类的时候,你愿意看到你的应用程序或者网站被卡死在这里吗?!

第三点,值类型不能作为锁对象,这很好理解,值类型肯定不能作为锁对象啊,学基础的时候也是三令五申过的,但是为什么值类型不能作为锁对象你真的能解释清楚么?因为值类型都是在栈内存,当值类型被传递到另一个线程时,会创建一个副本,相当于每个线程锁定的都是不同的对象,因此值类型不能作为锁对象。

第四点,不能锁字符串,其实基础够扎实的也应该知道,字符串在所有的面向对象语言中都是一种特殊的引用类型,如果把字符串作为锁对象,是相当危险的。这似乎看上去和值类型正好相反,但字符串在内存中作为常量存在,如果有两个变量被赋值了相同的字符串,它们引用的将是同一块内存空间,所以,如果把字符串作为锁对象,那就相当于锁定了一个全局的对象,这可能造成整个应用程序被阻塞掉。如果非有一定要用字符串作为锁对象的,也不是不可以,但是,这样做之前,一定得考虑清楚。

最后一点,降低锁对象的可见性。其实上面的第四点提到的锁字符串,字符串就相当于是一种可见范围最广的锁对象,其次还有typeof(class),typeof返回的结果是class的所有实例共有的,也就是说:所有实例的Type都指向typeof返回的结果。这样一来,如果我们也lock了typeof(class),其结果也可能就像刚才第四点一样了。这样的编码没有必要存在。

一般来说,锁对象也不应该是一个公共变量或属性。在.NET的早期版本中,一些常用的集合类型提供了公有属性SyncRoot,让我们可以实现线程安全的集合操作,所以你可能会认为我们刚才的结论可能不对,然而,集合操作的大部分应用场景都不是多线程的,更多的是单线程操作,而且线程同步本身是一种耗时的操作,如果集合的所有的非静态方法都需要考虑线程安全,那么完全没有必要整个公开的SyncRoot,私有即可啊,而现在把它公开是为了让调用者去决定它操作时是否需要线程安全。除非你有这样的需求,否则就应该考虑锁对象的可见性,况且现在.NET较高版本的都已经提供了线程安全的集合了,如:ConcurrentBag、ConcurrentDictionary等。

 

线程的IsBackground的坑

在.NET中线程分为前台线程和后台线程,每个线程都有IsBackground属性,如果通过该属性将线程标记为后台线程,那么应用程序在退出的时候就会连线程一并退出;如果为前台线程,那么就只有等到所有线程都结束了,应用程序才算是真正的退出了。

WinForm中有如下代码:

private void button4_Click(object sender, EventArgs e)
{
    var t = new Thread(() =>
    {
        while(true)
        {
            Thread.Sleep(1000);
        }
    });
    t.IsBackground=false;
    t.Start();
}

,在VS中启动调试,在单击按钮开启这个前台线程,VS进入调试模式,这时如果叉掉应用程序,你会发现VS仍然还在调试,如果你在上面的while循环里打个断点,你仍然可以看到它命中断点,这就意味着应用程序并没有退出。

所以如果我们使用线程的话,我们要注意应该更多地将线程标记为后台线程,如果是需要执行事务或者占有某些非托管资源需要释放时,才使用前台线程。

 

线程并不会立即开始

市面上绝大部分的操作系统都不是一个实时操作系统,Windows也是如此,所以我们期望不了线程开启后能立刻执行,Windows系统有它自己调度线程的算法,什么时候该执行哪个线程,从操作系统的角度讲,就是每个线程都被分配了一定的CPU时间,可以执行一小段的工作,由于被分配的时间都非常短,所以即使你的系统现在有几千个线程再运行,你感觉到的也是他们都同时在运行,系统会在适当的时机根据自己的算法决定下一个时间点去调度哪个线程。

线程本身就不是编程语言自身就有的东西,它的调度也是一个非常复杂的过程,但我们需要理解的就是:线程之间的切换一定需要花时间和空间,而且,它不实时。不妨我们用代码检验一下:

for(int i = 0; i < 10; i++)
{
    new Thread(() =>
    {
        Console.WriteLine(i);
    }).Start();
}

我们期望的结果是0-9依次输出,但是结果却是如此:

这就印证了刚才所说的线程不是立即启动的,也许后开的线程会先于先开的线程,而for循环传入线程的值,比如当前循环到5,可能线程真正执行的时候,早已到8了。

要让刚才的代码按我们预想的结果输出,我们把开启线程的代码提取到方法:

static void Main(string[] args)
{
    for(int i = 0; i < 10; i++)
    {
        NewMethod(i);
    }
    Console.ReadKey();
}
private static void NewMethod(int i)
{
    new Thread(() => { Console.WriteLine(i); }).Start();
}

由于在for循环外部启动线程,这就是我们预想的结果了:

 

关于线程的优先级

线程在C#中有5个优先级:Lowest,BelowNormal,Normal,AboveNormal,Highest,优先级就涉及到操作系统对线程的调度,Windows系统是一个基于线程优先级的抢占式调度模式,线程优先级高的总是比优先级低的获取得更多的CPU时间,如果有一个优先级高的线程,并且已经就绪,系统总是会优先执行。

我们启动的所有线程,包括ThreadPool和Task,线程的优先级默认都是Normal级别,虽然可以去修改线程的优先级,但是我们不建议这么做,如果是一些非常关键的线程,我们还是可以考虑提升线程优先级的。这些高优先级的线程应该具备运行时间短,能立刻进入等待状态的特征。

 

取消线程的正确姿势

有时候我们总是想更大程度的去控制线程,比如,我想在线程还在执行的某个时候,把它取消了,最典型的场景就是,开线程发起http请求,有时网络很差就会导致线程执行时间过长,所以我们就想等待一定时间,回不来就取消了吧,然而,这并不是我们想怎样就怎样的,这涉及到两个问题:

1.正如线程不能立即启动,当然线程也不能立即停止,不是你想停就能停的。无论采用哪种方式通知线程停止,线程都会忙完最紧要的事情之后在它觉得合适的时候退出。以传统的Thread.Abort,如果线程执行的是一段非托管代码,就不会抛线程取消异常,只有当代码回到CLR中,才会引发线程取消异常,当然,异常也不是立即引发的。

2.取消线程不在于采用何种手段,更多的是依赖于线程能否主动响应发起者的停止请求。也就是说:如果线程需要被停止,那么线程需要给调用者开放Canceled的接口,线程在工作的同时还要去检测Canceled的状态,如被检测到Canceled,线程才会负责退出。

.NET给我们提供了标准的取消模式:协作式取消(Cooperative Cancellation)。机制就是上面提到的这种机制。直接上代码:

var cts = new CancellationTokenSource();
var t = new Thread(() =>
{
    while(true)
    {
        if(cts.IsCancellationRequested)
        {
            Console.WriteLine("线程被取消");
            break;
        }
        Thread.Sleep(100);
    }
});
t.Start();
Console.ReadKey();
cts.Cancel();

主线程通过CancellationTokenSource的Cancel方法通知工作线程退出,工作线程以100ms的频率一边工作一边检测外界是否有Cancel的信号传入,若有,则退出,可以看出正确停止工作线程的机制中,真正起到主要作用的是线程自身,虽然上面的代码简单,但也阐述清楚了问题。更复杂的计算式工作,也应该是这样的方式,去妥善正确的退出线程。

其实CancellationTokenSource还有一个方法值得注意,就是Register方法,它负责传递一个Action委托,线程被停止时会执行回调:

cts.Token.Register(() =>
{
    Console.WriteLine("线程已经停止");
});

虽然是用Thread在演示,但如果是ThreadPool,也是一样的模式,后面还会讲到Task的取消,它依赖于CancellationTokenSource和CancellationToken完成取消控制。

 

控制好线程数量!

这是一个很严肃的事情,如果线程过多,这意味着我们项目的架构设计存在缺陷。那到底一个应用程序应该使用多少个线程合适,我们打开计算机的任务管理器,切到性能界面,我们来算一下:

现在小编的电脑运行着98个进程,1436个线程,使用1.9GB内存,除一下,一个应用程序平均也就14个线程左右,每个线程大约占1.5MB内存,所以每个应用程序的线程不会太多。

错误创建过多线程的一个场景:就是我们当初学习编程的时候,网络编程我们都写socket聊天室,相信绝大部分朋友都写过,那时我们会为每个socket开一个线程去监听请求,假设这个聊天室我们要对外开放用户,那就意味着随着用户数的增多,线程就会变多,如果达到一定数量,就意味着计算机管理不过来了,而开线程也需要内存来支持的,CLR默认会给每个线程分配差不多1MB的内存空间,如果你的电脑又恰好是32位的,那就意味着当你电脑里面线程数达到4096的时候,内存就被耗尽了,这都是理想情况,而且32位系统往往只能支持2.xGB-3.xGB的内存,再加之每种型号的CPU其实都有线程数在多少合适这种说法的,比如i5处理器在1000个线程左右是最高效的,i7处理器在2000线程左右。过多的线程会造成CPU在线程之间的切换到开销过大,相当的损耗CPU时间,像Socket这类I/O密集型应该使用异步去完成。

其实过多的线程带来的问题不仅仅如此,还会有另外的问题,就是:新开的线程可能需要等待相当长的时间才会开始执行,我们很无奈,我相信这也是你们无法忍受的结果,我们可以来实测一下,下面的代码,第501个线程会等待好几分钟才会开始执行:

for (int i = 0; i < 500; i++)
{
    new Thread(() =>
    {
        int j = 0;
        while (true)
        {
            j++;
            Thread.Sleep(1);
        }
    }).Start();
}
Thread.Sleep(5000);
new Thread(() =>
{
    while (true)
    {
        Console.WriteLine("第501个线程正在运行...");
        Thread.Sleep(1000);
    }
}).Start();

其实除了启动问题外,还有线程切换的问题,也就是说上面的第501个线程被切换走了之后,也需要相当长的时间才会再次切换回来。

所以,不要滥用线程,不要滥用过多的线程,当有工作需要新开线程去解决的时候,要仔细考虑这项工作是否真的需要开线程去解决,即使需要使用线程,也推荐大家使用线程池技术,比如之前的连接socket那样的I/O密集型场景,使用异步去管理,异步其实底层也是使用的线程池技术,成百上千个线程使用异步或者线程池技术后,实际上在工作的只有几个线程。

 

继续讨论线程池

使用线程池能极大地提升我们的打码体验和用户体验,但是我们作为开发者也应该要注意,线程是要产生开销的。

线程的空间开销主要来自:

1)线程内核对象(Thread Kernel Object)。每个线程都会创建一个这样的对象,它主要包含线程上下文信息,占用的内存在700字节左右。

2)线程环境块(Thread Environment Block)。占用4KB内存。

3)用户模式栈(User Mode Stack),即线程栈。线程栈用于保存方法的参数、局部变量和返回值。每个线程栈占用1MB的内存。要用完这些内存很简单,写一个不能结束的递归方法,让方法参数和返回值不停地消耗内存,很快就会发生OutOfMemoryException。

4)内核模式栈(Kernel Mode Stack)。当调用操作系统的内核模式函数时,系统会将函数参数从用户模式栈复制到内核模式栈。会占用12KB内存。

线程的时间开销来自:

1)线程创建的时候,系统相继初始化以上这些内存空间。

2)接着CLR会调用所有加载DLL的DLLMain方法,并传递连接标志(线程终止的时候,也会调用DLL的DLLMain方法,并传递分离标志)。

3)线程上下文切换。一个系统中会加载很多的进程,而一个进程又包含若干个线程。但是一个CPU在任何时候都只能有一个线程在执行。为了让每个线程看上去都在运行,系统会不断地切换“线程上下文”:每个线程大概得到几十毫秒的执行时间片,然后就会切换到下一个线程了。这个过程大概又分为以下5个步骤:

步骤1 进入内核模式。

步骤2 将上下文信息(主要是一些CPU 寄存器信息)保存到正在执行的线程内核对象上。

步骤3 系统获取一个 Spinlock,并确定下一个要执行的线程,然后释放 Spinlock。如果下一个线程不在同一个进程内,则需要进行虚拟地址交换。

步骤4 从将被执行的线程内核对象上载入上下文信息。

步骤5 离开内核模式。

所以线程的创建和销毁是需要付出时间和空间的代价的,而微软为了防止我们开发者无节制的使用线程,就封装了线程池这种技术,简单说就是帮助我们开发者来管理线程,随着工作的完成,线程不会被销毁,而是回到线程池中,看别的工作会不会继续使用线程,而具体何时被销毁或者创建,由CLR自己的算法来决定,所以真实项目中,我们更多的应该考虑使用线程池来替代Thread,线程池主要有ThreadPool和BackgroundWorker这两个类,使用也蛮简单的:

ThreadPool.QueueUserWorkItem(state =>
{
    //todo
});
var bw = new BackgroundWorker();
bw.DoWork += (sender, e) =>
{
    //todo
};
bw.RunWorkerAsync();

而ThreadPool和BackgroundWorker的区别在于:BackgroundWorker在WinForm和WPF中还提供了和UI线程交互的能力,而ThreadPool没有这种能力,BackgroundWorker的能力还包括:通知进度、完成回调、取消任务、暂停任务等功能。

 

久等了的Task终于登场

前面做了这么多的铺垫,其实就是为了给Task登场做准备的,Task是.NET4.5之后提供的线程的更高级的一种技术,虽然前面刚说了ThreadPool和BackgroundWorker比Thread更有优势,那么Task更是超越ThreadPool和BackgroundWorker更强大的概念。为线程池提供了更多的API可以调用,管理一个线程简直颠覆传统了:

Task.Run(() =>
{
    Console.WriteLine("我是异步线程...");
}).ContinueWith(t =>
{
    if (t.IsCanceled)
    {
        Console.WriteLine("线程被取消了");
    }
    if (t.IsFaulted)
    {
        Console.WriteLine("发生异常而被取消了");
    }
    if (t.IsCompleted)
    {
        Console.WriteLine("线程成功执行完成了");
    }
});

我们可以看出,Task具有以下属性:

IsCanceled:线程被取消而完成;

IsFailed:线程因发生未捕获的异常而完成;

IsCompleted:成功完成。

需要注意的是:Task并没有提供成功回调的事件功能,它是启动一个新的task来实现BackgroundWorker类似的事件回调功能,而ContinueWith正是这样的功能,这种方式天然就支持了任务的状态检查,而且还能在新任务中获得原任务返回的值。

下面来个稍微复杂的例子,同时支持任务完成的通知,数据返回,任务被取消,异常的发生等情况:

using (HttpClient client = new HttpClient() { BaseAddress = new Uri("https://www.baidu.com") })
{
    var result = client.GetStringAsync("/").ContinueWith(t =>
    {
        if (t.IsCanceled)
        {
            Console.WriteLine("线程被取消了");
        }
        if (t.IsFaulted)
        {
            Console.WriteLine("发生异常而被取消了");
        }
        if (t.IsCompleted)
        {
            return t.Result;
        }
        return null;
    }).Result;
    Console.WriteLine(result);
}

Task的Result属性可以拿到线程执行完返回的值,同时阻塞线程直到拿到返回的结果,上面的代码调用HttpClient的GetStringAsync方法,即创建了一个Task来等待http响应,当IsCompleted属性为true,就可以拿到http请求返回的html代码,最后存到上一层的Task的Result属性中。

如果我们把http请求的地址改成Google,在我们现在这样的网络环境下,HttpClient请求不了,那肯定就只有抛异常咯,所以当HttpClient请求的地址是Google的时候,Task的IsFailed则为true。

如果要模拟线程被取消,上面的代码把最后的Result去掉,就让Task不阻塞,这段代码很快就结束,而HttpClient内部却认为请求被取消了,所以会触发Task的取消行为。如果要真实模拟Task的取消,可以这样做:

var cts = new CancellationTokenSource();
Task.Run(() =>
{
    Console.WriteLine("我是异步线程...");
}, cts.Token).ContinueWith(t =>
{
    if (t.IsCanceled)
    {
        Console.WriteLine("线程被取消了");
    }
    if (t.IsFaulted)
    {
        Console.WriteLine("发生异常而被取消了");
    }
    if (t.IsCompleted)
    {
        Console.WriteLine("线程成功执行完成了");
    }
});
cts.Cancel();

我们声明一个CancellationTokenSource传入Task,在调用CancellationTokenSource的Cancel方法即可提前终止掉线程。

Task还支持工厂的概念,且支持多个任务之间共享相同的状态,也就是说,如果刚才的想取消任务,可以同时取消一组任务:

var cts = new CancellationTokenSource(1000);
Task[] tasks = {Task.Factory.StartNew(() =>
{
    Console.WriteLine("我是异步线程...");
    Thread.Sleep(1000);
}, cts.Token),Task.Factory.StartNew(() =>
{
    Console.WriteLine("我是异步线程...");
    Thread.Sleep(1000);
}, cts.Token),Task.Factory.StartNew(() =>
{
    Console.WriteLine("我是异步线程...");
    Thread.Sleep(1000);
}, cts.Token)};
cts.Cancel();
Task.Factory.ContinueWhenAll(tasks, ts =>
{
    Console.WriteLine($"线程被取消{ts.Count(t => t.IsCanceled) }次");
});

Task的工厂方法进一步优化了线程池的调度,所以我们更应该用Task来开启线程。

如果想让Task异步变为同步,只需要接着调用Task的Wait方法即可。

 

async和await关键字

这是.NET4.5和C#6为我们提供的Task的两个关键字,它们几乎是成对存在的,使用这两个关键字,能够将我们定义的方法变成异步方法去执行。

如下代码:

static void Main(string[] args)
{
    MyMethod();
    Console.WriteLine("world");
    Console.ReadKey();
}
public static void MyMethod()
{
    Console.WriteLine("hello");
    Thread.Sleep(5000);
}

我们看到的结果是先输出hello,等待5秒后才输出world,那么我不想改代码,又不想等待呢?这时我们把MyMethod改造成异步方法即可:

static void Main(string[] args)
{
    MyMethod();
    Console.WriteLine("world");
    Console.ReadKey();
}
public static async void MyMethod()
{
    Console.WriteLine("hello");
    await Task.Delay(5000);
}

运行之后就会看到先输出hello然后立马数出了world,并没有等待5秒了,来看一下我们做了哪些改动,首先方法签名加了async进行修饰,Thread.Sleep换成了Task.Delay,并且在前面加了await关键字,这表示异步等待,而我们常用的Thread.Sleep是同步等待,当然这里也只是为了模拟一个耗时操作。

现在来说下异步方法的声明,异步方法必须被async关键字修饰,且方法体里有存在await关键字才能算是异步方法,否则仅被async修饰的方法也是同步方法,其次,方法的返回值只能是void、Task或者Task<>,三者的区别在于:

void:执行一个任务,不需要返回值,不需要与任务进行任何的交互;

Task:执行一个任务,不需要返回值,但需要与任务进行交互,比如取消、执行状态的检测等;

Task<>:同上,但需要返回值,返回值被包含在Task的Result属性里。

改造刚才的代码,我们对MyMethod方法不需要返回值,但需要与Task进行交互,直接将void改成Task即可,不需要在方法体里加return,因为await已经代替我们return了:

static void Main(string[] args)
{
    var task = MyMethod();
    Console.WriteLine("world");
    Console.WriteLine(task.IsCompleted);
    Console.ReadKey();
}
public static async Task MyMethod()
{
    Console.WriteLine("hello");
    await Task.Delay(5000);
}

输出结果:

为什么是false?因为输出world之后还没有5秒钟,而MyMethod需要执行5秒钟才完成,所以在此刻的执行完成状态是false。

接下来我们再以有返回值的异步方法检验一下:

static void Main(string[] args)
{
    var task = MyMethod();
    Console.WriteLine("world");
    Console.WriteLine(task.Result);
    Console.ReadKey();
}
public static async Task<string> MyMethod()
{
    Console.WriteLine("hello");
    await Task.Delay(5000);
    return "aaa";
}

输出结果:

需要等到5秒之后才会输出aaa。

使用异步方法需要注意的是:

  1. await后面跟的的必须是一个返回值为Task的方法;

  2. main方法不能被修饰为异步方法;

  3. 方法体里有lock关键字不能作为异步方法;

  4. 方法参数不能带ref和out关键字;

  5. 异步方法不是在同一个线程中执行的。

异步方法的执行过程如下:

 

同步等待和异步等待

下面的代码,不考虑Console输出的CPU时间消耗,输出的结果多少?大家用自己的大脑运行一下这段代码:

static void Main(string[] args)
{
    var sw = Stopwatch.StartNew();
    var task = MyMethod();
    Thread.Sleep(4000);
    var result = task.Result;
    Console.WriteLine(sw.ElapsedMilliseconds);
}
public static async Task<string> MyMethod()
{
    await Task.Delay(5000);
    return "aaa";
}

其实是一道面试题,有的人回答4000,有人回答9000,有人回答5000,还有的人肯定说不出个答案,那么正确答案到底是多少?

我们我们理一下代码的执行过程:

首先调用了MyMethod异步方法,即开启了一个线程,而这个线程里面,异步等待了5000ms,然后主线程中同步等待了4000ms,在主线程等待4000ms之后,又有个变量在等着异步线程返回一个值,到这里之前,也就是主线程和一个工作线程都在同时进行等待,而主线程等待了4000ms,那么另一个工作线程也已经等待了4000ms,那么在等待MyMethod返回“aaa”给result的时候,就还需要1000ms的等待,最后,MyMethod返回后,输出了程序执行的时间:5000。

我们来运行代码检验一下:

5054,跟我们预想的5000很接近,我们的想法是正确的,多出来的54ms自然就是Console输出的CPU消耗咯。

那么,如果MyMethod返回void或者Task,结果又是多少?

static void Main(string[] args)
{
    var sw = Stopwatch.StartNew();
    var task = MyMethod();
    Thread.Sleep(4000);
    Console.WriteLine(sw.ElapsedMilliseconds);
}
public static async Task MyMethod()
{
    await Task.Delay(5000);
}

我想大家已经知道答案了:4000。没错,就是4000,因为现在MyMethod没有与主线程进行交互了,即使刚才的MyMethod如果没有与主线程进行交互,那么结果仍然是4000,因为线程开启了就再也跟主线程没有关系了。

刚才的代码也是模拟我们在同步线程和异步线程同时都在执行耗时操作,并且需要线程间进行通信的一个场景,最典型的一个场景就是我们在代码里面使用HttpClient发送http请求,有时我们需要在前一个请求完成后才能进行发下一个请求,并且保证两次请求都成功这样的一个场景。

所以,如果异步线程执行之后,如果和主线程没有交互,是不会阻塞主线程执行的,只有当主线程需要等异步方法返回结果,而异步线程还没执行完的情况下,会造成主线程阻塞。

 

并行计算Parallel

在和Task的同命名空间下,有一个叫Parallel的静态类简化了Task在同步状态下的操作,主要提供了Invoke、For和ForEach三个方法的多个重载。

Invoke传入可变长度参数的Action委托,For主要用于做类似于传统for循环时对数组元素的并行操作,ForEach主要用于做类似于传统foreach循环时对集合迭代的并行操作。

Parallel.Invoke(() =>
{
    Console.WriteLine("线程1");
    Thread.Sleep(1000);
}, () =>
{
    Console.WriteLine("线程2");
    Thread.Sleep(1000);
}, () =>
{
    Console.WriteLine("线程3");
    Thread.Sleep(1000);
});
Console.WriteLine("-------------");
Parallel.For(0, 4, i =>
{
    Console.WriteLine(i);
});
Console.WriteLine("-------------");
var list = new List<int>() { 1, 2, 3, 4, 5 };
Parallel.ForEach(list, item =>
{
    Console.WriteLine(item);
});

我们看出,不管哪种方式,其调用顺序是无序的,这说明如果我们对集合元素顺序输出的情况下,并行计算显然不合适了。

而且,Parallel启动后是阻塞状态的,所以Parallel它是同步的。也就是说:

 

Parallel简化了Task的同步操作,但不等同于Task的默认行为

不知道大家刚才注意仔细阅读没有,上文中提到的Parallel简化Task的使用,特别的加个了“同步”来做定语修饰。所以说Parallel调用的线程是被阻塞的,虽然说Parallel把任务交给了Task去处理,但会等到所有的Task把任务执行完成了才会继续后面的操作,而且Parallel只提供了Invoke方法,并没有提供一个叫BeginInvoke的方法,这也说明了一定的问题。

使用Task的时候,我们习惯于直接调Run方法开启一个任务,这个任务是异步的,如果要同步,则继续调用Wait方法,而Parallel所包装的,也就是这么一个过程。

既然叫并行计算,也就意味着运行时在后台将任务尽可能地分配在CPU上,虽然是基于Task实现的,但这并不表示它等同于异步!

 

Parallel踩坑之旅

Parallel的循环操作还支持一些复杂的操作,比如它可以在每个任务启动时做一些初始化操作,结束时做一些扫尾操作。还允许监控任务状态,请注意,刚才这个说法是错误的,应该把“任务”改成“线程”,这就是坑所在。

所以我们必须深刻地去理解Parallel的操作和应用,不然你跳到坑里去了还没人能把你拉出来,体会一下这段代码输出什么:

var list = new List<int>() { 1, 2, 3, 4, 5 ,6 };
int sum=0;
Parallel.For(0,list.Count,() => 1,(i, state, total) =>
{
    total+=i;
    return total;
},i => Interlocked.Add(ref sum,i));
Console.WriteLine(sum);

代码可能输出16,也可能输出17,理论上也可能是18、19,但概率比较小了,为什么?要究其原因,我们看看Parallel的for方法的声明:

对于前两个参数很容易理解,分别是开始和结束的索引。

body参数也很容易理解,即循环体。

而localinit和localFinally就比较难理解了,并且坑就在这里,要理解这两个参数,就必须先理解Parallel.For的运作模式,该方法采用并发的方式启动for循环的循环体,就意味着循环的循环体是交给线程池去处理的,而上面的代码循环了6次,实际调度的线程可能没有6个,这就是并发的优势,也是线程池的优势,Parallel通过内部的调度算法,节约了线程的开销,localinit的作用就是如果Parallel新开线程,那么就会在localinit里面对线程开启进行一些初始化操作,比如上面的代码就是为新线程启动时为sum返回为1。

LocalFinally的作用就是线程结束的时候做一些扫尾操作,比如刚才的代码:

i => Interlocked.Add(ref sum,i)

代表的就是线程结束的时候为sum进行原子性的加i操作,相当于:sum=sum+i,而原子操作就是为了避免并发带来的问题。

所以现在我们能很好理解上面的代码为什么输出会不确定,Parallel启动了6个任务,但是不确定启动了多少个线程,这是由运行时根据自己算法来决定的,如果只有一个线程,那么结果是16,如果两个线程,那就是17了,三个就是18……,如果我们把初始值localinit返回0的话,你可能永远也不知道你在坑里面。

为了更清晰的理解这个坑,我们不妨再来一段代码试下:

var list = new List<string>() { "aa", "bb", "cc", "dd", "ee", "ff", "gg" };
string str = String.Empty;
Parallel.For(0, list.Count, () => "-", (i, state, total) => total += list[i], s =>
{
str += s;
Console.WriteLine(“end:”+s);
});
Console.WriteLine(str);

可能的输出结果:

 

并行计算一定比串行快?

现在我们都知道了并行计算其实也是基于线程的,也知道线程的创建和销毁都需要时间和空间的开销,那么你还会想并行它就一定比串行代码更快?如果循环的循环体很小,那么并行的速度也许比串行更慢,下面这个例子,我们测试一下在并行和串行的情况下的时间消耗:

var sw = Stopwatch.StartNew();
for (int i = 0; i < 2000; i++)
{
    for (int j = 0; j < 10; j++)
    {
        var sum = i + j;
    }
}
Console.WriteLine("串行循环耗时:" + sw.ElapsedMilliseconds);
sw.Restart();
Parallel.For(0, 2000, i =>
{
    for (int j = 0; j < 10; j++)
    {
        var sum = i + j;
    }
});
Console.WriteLine("并行循环耗时:" + sw.ElapsedMilliseconds);

输出结果如下:

我们发现,当循环体很小的时候,串行循环几乎不耗时,而并行循环耗时123毫秒,并行循环所消耗的性能是串行循环的好几倍。如果我们把循环体里面的10改到更大的时候:

var sw = Stopwatch.StartNew();
for (int i = 0; i < 2000; i++)
{
    for (int j = 0; j < 100000; j++)
    {
        var sum = i + j;
    }
}
Console.WriteLine("串行循环耗时:" + sw.ElapsedMilliseconds);
sw.Restart();
Parallel.For(0, 2000, i =>
{
    for (int j = 0; j < 100000; j++)
    {
        var sum = i + j;
    }
});
Console.WriteLine("并行循环耗时:" + sw.ElapsedMilliseconds);

输出结果显然并行循环优于串行循环了:

所以只有当我们要在循环体里做更多的工作的时候,才能考虑使用并行计算。

 

并行加锁需谨慎!

既然是多线程,那么我们有时候不得不考虑加锁来解决共享资源的原子性操作,以保证数据的一致性。但在并行里面加锁是需要谨慎的,这包括:操作本身就需要使用同步代码去完成;或者需要长时间占用共享资源的场景。

在对整数变量进行原子操作的时候,.NET提供了Interlocked的Add方法,这就极大的避免了我们需要对整数进行原子操作时所带来的同步性能损耗,回想我们刚才的Parallel踩坑之旅:

var list = new List<int>() { 1, 2, 3, 4, 5 ,6 };
int sum=0;
Parallel.For(0,list.Count,() => 1,(i, state, total) =>
{
    total+=i;
    return total;
},i => Interlocked.Add(ref sum,i));
Console.WriteLine(sum);

如果扫尾的时候我们不进行原子操作,那么最后导致的结果可能就是汇编语言在最后mov操作时内存地址的对齐问题,而Interlocked解决了这样的问题,同时.NET还提供了volatile关键字来解决变量的原子性操作问题,当然这也不是我们要深入研究的重点,.NET提供的原生的对变量进行原子性操作,带来了性能上的提升,但有些场景,我们不得不加锁来实现同步:

var mc = new MyClass();
Parallel.For(0,100000,i => mc.AddCount());
Console.WriteLine(mc.Count);

public class MyClass
{
    public int Count { get; set; }
    public void AddCount()
    {
        Count++;
    }
}

输出的结果如下:

显然和我们预想的100000还有一定的差距,为了保证输出的正确性,我们就必须在并行里面加锁了(假设MyClass的AddCount是第三方提供的API,不允许修改源码):

var mc = new MyClass();
Parallel.For(0, 100000, i =>
{
    lock (mc)
    {
        mc.AddCount();
    }
});
Console.WriteLine(mc.Count);

这样就能得到我们预想的结果:

但是,带来了另外的问题,由于同步锁的存在,系统的开销也增加了,线程在上下文的切换也增加了,牺牲了更多的CPU时间和内存,也就是说,这段代码因为同步锁,其实已经是串行代码了,并且还不如串行代码的性能,所以,如果我们要考虑数据的一致性,选择并行还需谨慎,如果循环体里面的全部代码都需要加锁,那么完全不能考虑使用并行。

 

又一个好东西:并行linq(plinq)

这可是所有编程语言中C#的一大神器,linq可谓是无所不能,对我们的编程体验确实很爽。Linq最基本的功能就是对集合的各种复杂操作,其实仔细想想你会发现,并行编程简直就是专门为这一类应用而准备的。所以,微软不仅有linq,还有plinq,也就是微软专门为linq扩展了一个叫ParallelEnumerable的类,该类也在System.Linq命名空间下,所提供的功能就是让linq支持并行计算,这就是plinq。

我们传统的linq是单线程的,而plinq是并发的、多线程的,通过下面的例子我们可以看出区别:

var list = new List<int>() { 1, 2, 3, 4, 5, 6 };
var query = from i in list select i;
foreach(var i in query)
{
    Console.WriteLine(i);
}
Console.WriteLine("----------");
var query2 = from i in list.AsParallel() select i;
foreach(var i in query2)
{
    Console.WriteLine(i);
}

输出结果如下:

可以看出我们传统的linq是顺序输出的,而plinq是无序的。

其实并行输出还有另一种方式可以,那就是ForAll:

query2.ForAll(Console.WriteLine);

但是如果以这种方式进行循环输出的话,ForAll会忽略掉查询的AsOrdered请求:

var query2 = from i in list.AsParallel().AsOrdered() select i;
query2.ForAll(Console.WriteLine);

AsOrdered可以对并行之后的结果进行重新排序,以保证数据的顺序,而在ForAll方法中,仍然是无序的,如果要保证排序,我们还是只有老老实实的用普通的for循环了,即:

var query2 = from i in list.AsParallel().AsOrdered() select i;
foreach (var i in query2)
{
    Console.WriteLine(i);
}

然而在并行之后再排序,会牺牲掉一定性能的,排序操作包括:OrderBy(Descding)、ThenBy(Descding),所以如果我们要考虑使用并行linq,我们必须考虑集合元素的顺序性是否是重要的,以便程序按照我们预想的结果运行。

还有一些其他的操作,比如Take,如果我们有类似于“随机推荐”的功能,我们可以这么干:

foreach(var i in list.AsParallel().Take(5))
{
    Console.WriteLine(i);
}

如果是顺序的,那就会取出前5个元素,而这儿因为Parallel是无序的,所以取前5个相对于源数据也是随机的5个。

虽然使用plinq来迭代集合元素比linq更高效,但一定要注意,不是所有的并行都比串行更快,linq的某些方法串行比并行快,比如ElementAt等。所以实际开发中我们应该根据我们的使用场景去衡量和决定使用串行linq还是并行linq。找到最佳的解决方案。

 

并行编程的异常处理

关于异常处理,这是并行编程中最头疼的一个问题,异常的处理也是非常重要的一个环节,而且,由于并行的存在,使得我们在并行编程的时候,调试起来也是比较难的,程序出问题了找到问题所在也是非常苦恼的一件事情,所以,在并行编程的时候,我们就必须攻下它们。

 

从一道面试题说起

这是小编我们公司的一道面试题,也是各大公司喜欢考察的一道面试题,其实很简单,但可能很多人会迷糊,下面这个方法,调用会不会抛异常?

public static async void MyMethod()
{
    await Task.CompletedTask;
    throw new Exception();
}

很多人只有猜会或者不会,但说不出理由,答案肯定是不会抛异常,那为什么不会抛异常?

因为这是个异步方法,发生异常是在另一个线程里面发生的,并没有抛给调用者,因为它没有与调用线程进行交互,调用者只是调用了它,但有没有发生异常调用者不知道。

好了,其实上面这段代码就反映了在我们实际项目中,有些异步方法发生了异常,但并没有被调用者捕获,导致项目出现一些bug,这样的bug其实很难找出来,所以在异步线程中必须正确地去处理异常。

 

Task中的异常处理

如果我们的Task是可以进行交互的,比如可以调用Task的Wait、WaitAny、WaitAll等阻塞方法,或者拿Task的Result属性,发生异常是可以被调用者捕获的,能捕获到AggregateException异常,而AggregateException可以看作是并行编程中的最顶层的异常,所以当异步线程中发生异常时,我们都应该把异常包装到AggregateException中,一个异步线程异常的简单处理如下:

var t = Task.Run(() => throw new Exception("我在异步线程中抛出的异常"));
try
{
    t.Wait();
}
catch (AggregateException e)
{
    foreach (var ex in e.InnerExceptions)
    {
        Console.WriteLine($"异常类型:{ex.GetType()},异常源:{ex.Source},异常信息:{ex.Message}");
    }
}

输出结果:

上面的代码以及运行效果大家可以看出,虽然调用Wait等方法以及获取Result属性能得到任务的异常信息,但是会阻塞调用线程,这不是我们想要的结果,凭什么就为了去捕获一个异常而去阻塞调用者线程,所以Task的ContinueWith也解决了这个问题,新开一个后续Task去捕获异常:

Task.Run(() => throw new Exception("我在异步线程中抛出的异常")).ContinueWith(t =>
{
    if (t.IsFaulted)
    {
        foreach (var ex in t.Exception?.InnerExceptions)
        {
            Console.WriteLine($"异常类型:{ex.GetType()},异常源:{ex.Source},异常信息:{ex.Message}");
        }
    }
});

输出结果同上。

这样的方式解决了调用者线程等待任务阻塞的问题,但仔细研究我们会发现,异常的处理没有往外抛,它还是在线程池里面,在某些场景下,比如在业务逻辑上特定的异常处理,我们就需要才去这种方式,而且也鼓励使用这种做法,但很明显,我们更多的时候需要更进一步的将异常封装。

而Task没有提供将任务中的异常抛到主线程。不过有一个可行的办法,仍然使用Wait方法来达到此目的,不过刚才我们说过这样的方式不可取,因为会阻塞,降低我们的编码体验,后来建议使用ContinueWith新开一个后续Task来处理异常,这样很好,所以,我们可以不妨将前两者结合一下得到下面这种方式将异步任务中的异常抛到主线程:

var task = Task.Run(() => throw new Exception("我在异步线程中抛出的异常")).ContinueWith(t =>
{
    if (t.IsFaulted)
    {
        throw t.Exception;
    }
});
try
{
    task.Wait();
}
catch (AggregateException e)
{
    foreach (var ex in e.InnerExceptions)
    {
        Console.WriteLine($"异常类型:{ex.GetType()},异常源:{ex.Source},异常信息:{ex.Message}");
    }
}

但到这里一切还没有结束,因为调用Wait会阻塞,而且CLR在后台会新开线程池线程来完成额外的工作,如果要把任务的异常抛给主线程,不妨试试时间通知的方式:

static event EventHandler<AggregateExceptionArgs> CatchedAggregateExceptionEventHandler;
public class AggregateExceptionArgs : EventArgs
{
    public AggregateException AggregateException { get; set; }
}
static void Main(string[] args)
{
    CatchedAggregateExceptionEventHandler += (sender, e) =>
    {
        foreach (var ex in e.AggregateException.InnerExceptions)
        {
            Console.WriteLine($"异常类型:{ex.GetType()},异常源:{ex.Source},异常信息:{ex.Message}");
        }
    };
    var task = Task.Run(() =>
    {
        try
        {
            throw new Exception("我在异步线程中抛出的异常");
        }
        catch (Exception e)
        {
            CatchedAggregateExceptionEventHandler(null, new AggregateExceptionArgs() { AggregateException = new AggregateException(e) });
        }
    });
    Console.ReadKey();
}

这样的方式,我们先定义了一个事件CatchedAggregateExceptionEventHandler,它接收标准事件的两个参数,而AggregateExceptionArgs则是包装异常的一个包装类型,所以当异常发生的时候,我们就将异常交给了CatchedAggregateExceptionEventHandler这个事件处理器去处理。

事件的调用完全没有阻塞主线程,如果是在Windows窗体程序里面,也可以将异常信息交给窗体程序的线程模型去处理,所以最终建议大家使用这种方式去处理异步任务的异常,这种方式不管任务能否与调用者进行交互,都能捕获到异常。

另外一个冷知识:虽然TaskScheduler静态类有一个类似的功能,但一般不建议这样做,因为它的事件回调只有在进行垃圾回收的时候才会被触发,如下:

TaskScheduler.UnobservedTaskException += (sender, e) =>
{
    Console.WriteLine($"异常类型:{e.Exception.GetType()},异常源:{e.Exception.Source},异常信息:{e.Exception.Message}");
};
var task = Task.Run(() =>
{
    throw new Exception("我在异步线程中抛出的异常");
});
Console.ReadKey();
task.Dispose();
task = null;
GC.Collect(0);

上面的代码,如果把GC.Collect去掉,那么异常信息就不会被输出,所以这种方式看似更简单,其实存在着局限性。

 

Parallel中的异常处理

相对于Task,Parallel的异常处理要简单很多,因为Parallel是会阻塞主线程的,它会等到所有的task执行完成了才会继续接下来的其他工作,而Task要采用非阻塞的方式去捕获异常,所以下面这样的代码就能把Parallel的异常抛给主线程:

var exs = new ConcurrentQueue<Exception>();
try
{
    Parallel.For(0, 2, i =>
    {
        try
        {
            throw new ArgumentException();
        }
        catch (Exception e)
        {
            exs.Enqueue(e);
        }
        if (exs.Any())
        {
            throw new AggregateException(exs);
        }
    });
}
catch (AggregateException e)
{
    foreach (var ex in e.InnerExceptions)
    {
        Console.WriteLine($"异常类型:{ex.GetType()},异常源:{ex.Source},异常信息:{ex.Message}");
    }
}

输出结果为:

我们专门声明了一个线程安全的集合ConcurrentQueue队列来存储异常信息,最终将这个异常集合包装给AggregateException再往外抛。

 

总结

其实并行编程没什么难的,主要是并行编程会给我们带来各种各样的坑,有时候我们踩坑了,可能永远不知道程序为什么出bug,可能为了一个bug,加了好几天班熬了好几天夜也不能解决。

只要我们在并行编程的时候避掉这些坑,我们都是大神!

其实大家眼中的大神跟自己差不多,也就是人家踩的坑多一点,而你踩的坑少一点而已,最后,祝大家踩坑快乐!

猜你喜欢

转载自www.cnblogs.com/xyyhcn/p/11936631.html