.NET 异步编程知多少

1. 引言

最近在学习Abp框架,发现Abp框架的很多Api都提供了同步异步两种写法。异步编程说起来,大家可能都会说异步编程性能好。但好在哪里,引入了什么问题,以及如何使用,想必也未必能答的上来。
自己对异步编程也不是很了解,今天就以学习的目的,来梳理下同步异步编程的基础知识,然后再来介绍下如何使用async/await进行异步编程。下图是一张大纲,具体可查看脑图分享链接

脑图摘要

2. 同步异步编程

同步编程是对于单线程来说的,就像我们编写的控制台程序,以main方法为入口,顺序执行我们编写的代码。
异步编程是对于多线程来说的,通过创建不同线程来实现多个任务的并行执行。

3. 线程

.Net 1.0就发布了System.Threading,其中提供了许多类型(比如Thread、ThreadStart等)可以显示的创建线程。
说到Thread,我们需要了解以下几个概念:

3.1. 什么是主线程

每一个Windows进程都恰好包含一个用作程序入口点的主线程。进程的入口点创建的第一个线程被称为主线程。.Net执行程序(控制台、Windows Form、Wpf等)使用Main()方法作为程序入口点。当调用该方法时,主线程被创建。

3.2. 什么是工作者线程

由主线程创建的线程,可以称为工作者线程,用来去执行某项具体的任务。

3.3. 什么是前台线程

默认情况下,使用Thread.Start()方法创建的线程都是前台线程。前台线程能阻止应用程序的终结,只有所有的前台线程执行完毕,CLR才能关闭应用程序(即卸载承载的应用程序域)。前台线程也属于工作者线程。

3.4. 什么是后台线程

后台线程不会影响应用程序的终结,当所有前台线程执行完毕后,后台线程无论是否执行完毕,都会被终结。一般后台线程用来做些无关紧要的任务(比如邮箱每隔一段时间就去检查下邮件,天气应用每隔一段时间去更新天气)。后台线程也属于工作者线程。

说了这么多概念不如来段代码:

 //主线程入口
 static void Main(string[] args)
 {
     Console.WriteLine("主线程开始!");

     //创建前台工作线程
     Thread t1 = new Thread(Task1);
     t1.Start();

     //创建后台工作线程
     Thread t2= new Thread(new ParameterizedThreadStart(Task2));
     t2.IsBackground = true;//设置为后台线程
     t2.Start("传参");
 }

 private static void Task1()
 {
     Thread.Sleep(1000);//模拟耗时操作,睡眠1s
     Console.WriteLine("前台线程被调用!");
 }

 private static void Task2(object data)
 {
     Thread.Sleep(2000);//模拟耗时操作,睡眠2s
     Console.WriteLine("后台线程被调用!" + data);
 }

执行发现,【后台线程被调用】将不会显示。因为当所有的前台线程执行完毕后,应用程序就关闭了,不会等待所有的后台线程执行完毕,所以不会显示。

4. ThreadPool(线程池)

线程池是为突然大量爆发的线程设计的,通过有限的几个固定线程为大量的操作服务,减少了创建和销毁线程所需的时间,从而提高效率,这也是线程池的主要好处。
ThreadPool适用于并发运行若干个任务且运行时间不长且互不干扰的场景。
还有一点需要注意,通过线程池创建的任务是后台任务。

举个例子:

//主线程入口
static void Main(string[] args)
{
    Console.WriteLine("主线程开始!");
    //创建要执行的任务
    WaitCallback workItem = state => Console.WriteLine("当前线程Id为:" + Thread.CurrentThread.ManagedThreadId);

    //重复调用10次
    for (int i = 0; i < 10; i++)
    {
        ThreadPool.QueueUserWorkItem(workItem);
    }
    Console.ReadLine();
}

执行结果

从图中可以看出,程序并没有每次执行任务都创建新的线程,而是循环利用线程池中维护的线程。
如果去掉最后一句Consoler.ReadLine(),会发现程序仅输出【主线程开始!】就直接退出,从而确定ThreadPool创建的线程都是后台线程。

5. System.Threading.Tasks

.Net 4.0引入了System.Threading.Tasks,简化了我们进行异步编程的方式,而不用直接与线程和线程池打交道。
System.Threading.Tasks中的类型被称为任务并行库(TPL)。TPL使用CLR线程池(说明使用TPL创建的线程都是后台线程)自动将应用程序的工作动态分配到可用的CPU中。

5.1. Parallel(数据并行)

数据并行是指使用Parallel.For()或Parallel.ForEach()方法以并行方式对数组或集合中的数据进行迭代。
看怎么用:

ParallelLoopResult result = Parallel.For(0, 10000, i => {
    Console.WriteLine("{0}, task: {1} , thread: {2}", i, Task.CurrentId, Thread.CurrentThread.ManagedThreadId);
});

5.2. PLINQ(并行LINQ查询)

为并行运行而设计的LINQ查询为PLINQ。System.Linq命名空间的ParallelEnumerable中包含了一些扩展方法来支持PINQ查询。
使用举例:

int[] modThreeIsZero = (from num in source.AsParallel()
                        where num % 3 == 0
                        orderby num descending
                        select num).ToArray();

5.3. Task

Task,字面义,任务。使用Task类可以轻松地在次线程中调用方法。

static void Main(string[] args)
{
    Console.WriteLine("主线程ID:" + Thread.CurrentThread.ManagedThreadId);
    Task.Factory.StartNew(() => Console.WriteLine("Task对应线程ID:" + Thread.CurrentThread.ManagedThreadId));
    Console.ReadLine();
}

可以看见,使用Task我们不必理会具体线程的创建。
我们也可以使用.NET 4.5引入的Task.Run静态方法来启动一个线程。

static void Main(string[] args)
{
    Console.WriteLine("主线程ID:" + Thread.CurrentThread.ManagedThreadId);
    Task.Run(() => Console.WriteLine("Task对应线程ID:" + Thread.CurrentThread.ManagedThreadId));
    Console.ReadLine();
}

Task类提供了Wait()方法,用来等待线程task执行完毕。

5.4. Task<TResult>

Task<TResult>是Task的泛型版本,可以接收一个返回值。

static void Main(string[] args)
{
    Console.WriteLine("主线程ID:" + Thread.CurrentThread.ManagedThreadId);
    Task<string> task = Task.Run(() =>
    {
        return Thread.CurrentThread.ManagedThreadId.ToString();
    });
    Console.WriteLine("创建Task对应的线程ID:" + task.Result);

    Console.ReadLine();
}

Task提供了很多方法,帮助我们进行异步任务。了解更多,可参考MSDN

5.5. async/await 特性

C# async关键字用来指定某个方法、Lambda表达式或匿名方法自动以异步的方式来调用。

咱们先来看一个具体的示例吧。

private static void Main(string[] args)
{
    Console.WriteLine("主线程启动,当前线程为:" + Thread.CurrentThread.ManagedThreadId);
    var task = GetLengthAsync();

    Console.WriteLine("回到主线程,当前线程为:" + Thread.CurrentThread.ManagedThreadId);

    Console.WriteLine("线程[" + Thread.CurrentThread.ManagedThreadId + "]睡眠5s:");
    Thread.Sleep(5000); //将主线程睡眠5s

    var timer = new Stopwatch();
    timer.Start(); //开始计算时间

    Console.WriteLine("task的返回值是" + task.Result);

    timer.Stop(); //结束点,另外stopwatch还有Reset方法,可以重置。
    Console.WriteLine("等待了:" + timer.Elapsed.TotalSeconds + "秒"); //显示时间

    Console.WriteLine("主线程结束,当前线程为:" + Thread.CurrentThread.ManagedThreadId);
}

private static async Task<int> GetLengthAsync()
{
    Console.WriteLine("GetLengthAsync()开始执行,当前线程为:" + Thread.CurrentThread.ManagedThreadId);

    var str = await GetStringAsync();

    Console.WriteLine("GetLengthAsync()执行完毕,当前线程为:" + Thread.CurrentThread.ManagedThreadId);

    return str.Length;
}

private static Task<string> GetStringAsync()
{
    Console.WriteLine("GetStringAsync()开始执行,当前线程为:" + Thread.CurrentThread.ManagedThreadId);
    return Task.Run(() =>
    {
        Console.WriteLine("异步任务开始执行,当前线程为:" + Thread.CurrentThread.ManagedThreadId);

        Console.WriteLine("线程[" + Thread.CurrentThread.ManagedThreadId + "]睡眠10s:");
        Thread.Sleep(10000); //将异步任务线程睡眠10s

        Console.WriteLine("GetStringAsync()执行完毕,当前线程为:" + Thread.CurrentThread.ManagedThreadId);
        return "GetStringAsync()执行完毕";
    });
}
执行结果

是不是对执行结果感到惊讶?惊讶是对的,且听我们下面娓娓道来。

  1. 被async标记的方法,意味着可以在方法内部使用await,这样该方法将会在一个await point(等待点)处被挂起,并且在等待的实例完成后该方法被异步唤醒。【注意:await point(等待点)处被挂起,并不是说在代码中使用await SomeMethodAsync()处就挂起,而是在进入SomeMethodAsync()真正执行异步任务时被挂起,切记,切记!!!】
  2. async标记的方法,返回值类型为voidTaskTask<T>
  3. 被async标记的方法,方法的执行结果或者任何异常都将直接反映在返回类型中。
  4. 不是被async标记的方法,就会被异步执行,刚开始都是同步开始执行。换句话说,方法被async标记不会影响方法是同步还是异步的方式完成运行。事实上,async使得方法能被分解成几个部分,一部分同步运行,一些部分可以异步的运行(而这些部分正是使用await显示编码的部分),从而使得该方法可以异步的完成。
  5. await关键字告诉编译器在async标记的方法中插入一个可能的挂起/唤醒点。 逻辑上,这意味着当你写await someMethod();时,编译器将生成代码来检查someMethod()代表的操作是否已经完成。如果已经完成,则从await标记的唤醒点处继续开始同步执行;如果没有完成,将为等待的someMethod()生成一个continue委托,当someMethod()代表的操作完成的时候调用continue委托。这个continue委托将控制权重新返回到async方法对应的await唤醒点处。
    返回到await唤醒点处后,不管等待的someMethod()是否已经经完成,任何结果都可从Task中提取,或者如果someMethod()操作失败,发生的任何异常随Task一起返回或返回给SynchronizationContext

从第4点可以解释为什么上面的demo当调用GetLengthAsync();方法时,输出GetLengthAsync()开始执行,当前线程为:1
从第1点可以解释调用await GetStringAsync();后,为什么程序会继续同步执行输出GetStringAsync()开始执行,当前线程为:1
当执行到Task.Run的时候,就回到了主线程,从而输出回到主线程,当前线程为:1,这说明Task.Run就是我们所说的await point(等待点)。紧接着代码将主线程睡眠5s,这时异步任务可不会歇啊,所以会输出异步任务开始执行,当前线程为:3
紧接着为了模拟异步任务耗时,我们在异步任务中调用Thread.Sleep(10000)将异步任务睡眠10s。
同样异步任务睡眠的时候,不会影响到我们的同步任务,主线程睡眠5s后,要去输出task.Result,这时异步任务还没有执行完毕,所以主线程会等待,直到结果返回,当异步任务完成后会输出GetStringAsync()执行完毕,当前线程为:3
从第5点可以解释,await等待异步任务完成后,GetLengthAsync()方法被异步唤醒,从而异步执行后续代码而输出GetLengthAsync()执行完毕,当前线程为:3
代码中我们用StopWatch来计算大致等待了多久,从结果看等待了5.0004334秒,符合预期(异步线程睡眠了10s,主线程睡眠了5s,两个线程是并行运行的,所以大致耗时应该为10s - 5s = 5s)。

那为什么执行到task.Result时,主线程会等待呢,你可能会说异步任务没有完成。
那异步任务没有完成不应该影响主线程的继续执行啊,那主线程究竟是被谁挂起进行等待的呢?
首先Task和Task<T>是awaitable的,这里就要理解下awaitable这个概念,详参await anything,这里就不再赘述(讲清楚估计得另开一篇)。
这里就暂且把awaitable理解为可等待的,就是说如果这个task没执行完毕,在去取结果的时候它就会等待。

我们直接来看一下看下源码吧:


从代码中我们可以清楚看见,在去取task的返回值时,程序回去判断对应的任务是否执行完毕(IsCompleted),若没有则继续等待,也就是在InternalWait方法中执行等待,而InternalWait方法中指定等待的方式为TaskWaitBehavior.Synchronous也就是同步等待,所以就会挂起主线程。
其实task.Wait()也是类似的逻辑,会同步阻塞主线程去等待异步线程执行完毕。
那我们就可以这样理解task.Result,task.Result相当于执行task.Wait();后再去取值task.Result;

6. 总结

本文主要梳理了以下几点:

  1. 默认创建的Thread是前台线程,创建的Task为后台线程。
  2. ThreadPool创建的线程都是后台线程。
  3. 任务并行库(TPL)使用的是线程池技术。
  4. 调用async标记的方法,刚开始是同步执行的,只有当执行到await标记的方法中的异步任务时,才会挂起。

异步编程的水很深,标题起大了,有很多知识点没有讲全讲到。
文章中所写是个人理解,难免有纰漏之处,请大家以怀疑的精神阅读此文,也恳请大家多多指教!!!

参考自:
Async/Await FAQ
await anything
Async/Await异步编程中的最佳做法

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,583评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,669评论 2 374
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,684评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,682评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,533评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,396评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,814评论 3 387
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,458评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,745评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,789评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,565评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,410评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,828评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,050评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,342评论 1 253
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,793评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,010评论 2 337

推荐阅读更多精彩内容