1. 首页
  2. 文章列表
  3. 谈一谈.NET中的并行编程(TPL)——...
  4. 历史版本
  5. 谈一谈.NET中的多线程、异步、任务和并行计算(上)

本来想写一篇关于多线程异步的文章,结果写下来之后感觉一发不可收拾,文章内容会很长,所以打算分成多篇文章来发布了。

写在前面:

在开发过程中,有很多工作我们都需要去开线程来解决,但是多线程往往会带来更多棘手的问题,但又不得不使用多线程,由多线程带来的传值、取值、资源同步、线程取消或暂停、异常的捕获等都会困扰着我们每一个编写这类代码的开发者。微软也在这方面做了巨大的努力,以至于到现在的.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的资源一直被消耗,浪费,等待着。

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

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维护的是一个由系统内核产生的布尔值类型(称之为“阻塞状态”),如果为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了。

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

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

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

锁对象应该是在多个线程中可见的同一对象;
在非静态方法中,静态变量不应该作为锁对象;
值类型不能作为锁对象;
避免将字符串作为锁对象;
降低锁对象的可见性。

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

首先第一个,锁对象必须在多线程中是可见的,且必须是同一对象。前半句很好理解,如果不可见那肯定也不能锁啊,至于“同一对象”,也很好理解,如果锁的不是同一对象,那加锁还有什么意义呢,但是,这却是我们经常会犯的一个错误,为了好理解,举个我们一定会遇到的场景:在遍历集合的时候,同时另一个线程又在修改这个集合,就像下面的代码,如果没有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完成取消控制。

控制好线程数量!

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

懒得勤快的博客_全栈开发者_互联网分享精神

现在小编的电脑运行着102个进程,1679个线程,除一下,一个应用程序平均也就16个线程左右,所以每个应用程序的线程不会太多。

错误创建过多线程的一个场景:就是我们当初学习编程的时候,网络编程写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。

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

await后面跟的的必须是一个返回值为Task的方法;
main方法不能被修饰为异步方法;
方法体里有lock关键字不能作为异步方法;
方法参数不能带ref和out关键字;
异步方法不是在同一个线程中执行的。

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

懒得勤快的博客_全栈开发者_互联网分享精神

并行计算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踩坑之旅

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简化了Task的同步操作,但不等同于Task的默认行为

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

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

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

未完待续...


前一版本: 没有了
分享按钮