async/await到底该怎么用?如何理解多线程与异步之间的关系?

06/22 10:10
阅读数 329

前言

        如标题所诉,本文主要是解决是什么,怎么用的问题,然后会说明为什么这么用。因为我发现很多萌新都会对之类的问题产生疑惑,包括我最初的我,网络上的博客大多知识零散,刚开始看相关博文的时候,就这样。然后博文也不一定正确,又变成这样,当然我的观点也不一定正确,所以,以免误导萌新,有疑问,欢迎提出!有错误,欢迎指正!

一、首先看几个问题

    • 多线程程序比单线程程序效率高?
    • 什么是IO密集型程序?计算密集型程序又是什么?
    • IO密集型程序和计算密集型程序与多线程和单线程有什么关系?
    • 同步、异步、阻塞、非阻塞又是啥?
    • 多线程与异步到底是啥关系?

二、先了解操作系统的演变过程

在早期的计算机时代,那时候硬件宝贵,什么硬件资源都要精打细算,操作系统刚开始也非常简单,之后一步步发展,大概经过了以下阶段:

1.单道批处理系统(一次只能处理一个任务,任务排队,串行执行,无交互能力,系统资源利用率不高)

2.多道批处理系统(外存中有一个后备任务队列,一次取多个任务到内存,当任务处于IO时,会切换到其他任务,无交互能力,系统资源利用率较高)

3.分时操作系统(每个任务都能尽可能的得到调度,有交互能力,系统资源利用率比多道批处理系统略低,因为要花很多时间在调度任务上,例如Windows)

4.实时操作系统(每个任务都能实时调度,有交互能力)


下面简单的了解一下单道批处理系统和多道批处理系统。

单道批处理系统处理任务过程

image

image

      可以看到在执行任务的过程中,有一部分时间被IO(比如等待用户输入,读取文件内容)占用了,而CPU无事可做,浪费系统资源,为什么IO不需要CPU

参与呢?因为有相应的IO设备,相当于是一个小型的计算机,在有了DMA(直接存储器)后,IO设备访问内存也不需要CPU参与了,大大减少了中断次数,

CPU基本上只要发出IO指令,通知IO设备工作,然后就可以做其他事情了,等待IO处理完,会发出一个中断,然后CPU接着处理未完成的任务。为了提高C

PU利用率,于是便有了多道批处理操作系统。

多道批处理系统处理任务过程

image

        相比于单道批处理系统,可以看到,在完成T1、T2、T3任务过程中,实际只花了CPU10S,其中额外的调度时间花了1S,总共11S。CPU利用率大大提高,但是还是有一个致

命缺点——无法交互。只有在IO时或者任务已经完成的情况下,才会发生调度。这个对用户体验就非常不好了,只要其中一个程序产生死循环或者什么原因,就会导致后面

的任务无法调度,想要恢复执行,必须使用万能的“重启大法”,并且找BUG还不能实时调试,必须重启计算器再重新启动程序,这就很难受。所以分时操作系统就出

现了。

分时操作系统处理任务过程


        在多道批处理系统过程中,是没有分时的这个概念的,它的唯一目的是提高CPU的利用率,不让CPU空转。但这个用户体验就非常的差了,为了提高用户

体验,换句话说就是为了让每个任务都能得到CPU的眷顾,CPU就发表了以下观点:


CPU:以前你们老是说我不照顾你们,现在我决定了,我给你们每个人固定的时间,然后轮流照顾你们,这样行不?(时间片轮转调度算法)

众任务:好好好!

……

过了许久之后,

任务A:我有急事!我有急事!呼叫呼叫!此时CPU还在服务其他任务,按照规则,任务A还要轮转999次才能到达A,于是任务A卒。

过了N久,CPU过来了,发现了这个情况,于是它又改变了规则,按照某种优先级算法,然后进行轮转,例如高优先级任务优先低优先级任务轮转等等。

这个时候,才很好的解决了用户体验差的问题。


        拿上面的场景,对应现代操作系统。任务(或者说作业),可以抽象成线程。例如windows,一个基于线程优先级抢占式的分时操作系统。现在可以回答

如下几个问题的一部分。

什么是IO密集型程序?什么是计算密集型程序?

        IO密集型就是指一个程序的执行时间中,IO操作占了绝大多数时间,比如Web服务器,涉及了大量IO操作,HTTP请求,数据库读取,模板渲染引擎

读取模板文件(通过缓存可以解决)等IO操作,实际要CPU参与计算的部分很少,反之就是计算密集型程序,例如视频编码输出,加密解密、科学计算等等。

多线程效率比单线程效率高?

        不讨论多核或者多CPU的情况下,对于计算密集型程序来讲,单线程效率一般是最高的,因为它不需要进行线程调度,就不会产生调度开销,调度开销

包括调度算法的执行,线程上下文的切换(堆栈寄存器和相关寄存器以及程序计数器的还原)等等,你可能会问,不是还有其他线程吗?的确是有,但

一般来讲,大多数线程是处于挂起状态的,而挂起的线程是不会分到CPU时间片,就算有些许线程处于活动状态,但是基本上分配到的时间片很少(看下图),而

且由于活动线程数其实很少,所以调度开销也很小,所以单线程效率比较高,如果开多线程来执行这个计算密集型程序,情况就不一样了,因为是同等

优先级,所以会发生频繁的线程调度,产生额外开销,当计算任务很长时,这个就非常明显了,虽然对于整体时间来说不明显。

image

那如果是IO密集型呢???这就跟异步有关系了。

三、阻塞、非阻塞、同步、异步

这里的A和B的主体是不确定的,并且 需要注意,这里至少有两个角色

同步:A和B有顺序,A完成工作之后B才能继续工作。

异步:A和B无顺序,A和B可以同时工作

阻塞和非阻塞是有一定语境的,它是专门针对线程来说的,它指的是状态

阻塞:意思是指线程被挂起,不能做其他任务

非阻塞:意思是指线程未被挂起,处于就绪或运行状态,可以做其他任务

有常说的以下四种组合:

同步阻塞、同步非阻塞(不存在)、异步阻塞(不存在)、异步非阻塞


现在假设一个场景:

线程A在执行代码的过程中,其中执行到了一个ReadFile()函数,这个任务最后交由IO设备B完成,很明显,线程A可以继续执行其他代码,在IO设备B完成之后,线程A继续执行依赖于ReadFile()的代码块。很明显,他们之间是异步的,也就是CPU于IO设备之间是异步的,因为他们能同时工作。那么代码看起来就可能像下面这样

result = ReadFile();//首先发起异步请求,以便IO设备能尽早处理

flag = true;

if(result.IsComplied && flag){做依赖于读文件的操作();flag = false;}

吃西瓜();

if(result.IsComplied && flag){做依赖于读文件的操作();flag = false;}

打War3();



或者这样

result = ReadFile();//首先发起异步请求,以便IO设备能尽早处理

吃西瓜();

打War3();

while(!result.IsComplied);

做依赖于读文件的操作();


或者这样

ReadFile(callBack:做依赖于读文件的操作);//首先发起异步请求,以便IO设备能尽早处理

吃西瓜();

打War3();


可以看到:

在第一种模式下,写代码复杂丑陋;

在第二种模式下,代码相对于比较优雅,但可能需要轮询,忙等,浪费CPU时间。

第三种模式,好像非常好,但其实是回调层数不够深,也就是所谓的回调地狱,虽然有办法可以把嵌套式改成平坦式,例如then.then.then的形式,但是代码还是不够优雅,所以出现了async/await形式,也就是号称的用写同步代码的方式写异步代码,不理解的看下面就理解了。


为什么不够优雅?究其原因是因为它们之间是异步的,那有没有一种办法能让它们之间同步进行呢?只要IO设备没完成,线程A就不能执行代码,不能工作,待IO设备完成之后,线程继续执行代码,继续工作,那么代码看起来就像这样。


吃西瓜();

打War3();

result = ReadFileSync();

做依赖于读文件的操作();


那重点来了!!!怎么做到呢?阻塞。在执行到ReadFileSync()时,把线程阻塞。也就是说操作系统其实是用阻塞模拟同步,所以说同步代表着阻塞。也就是同步阻塞的由来。那么同步非阻塞呢?没有!按照同步定义,A完成工作之后,B才能继续工作,如果是非阻塞,那么就不叫同步了,因为A和B可以同时工作,所以非阻塞只能搭配异步。这个模式的缺点就是,会导致创建许多线程。 ,在早期Web服务器中,针对每个请求,创建一个线程,请求结束之后,线程销毁,因为创建线程和销毁线程代价非常大,所以发明了线程池,虽然有了线程池,但如果线程执行了同步IO,那么还是会导致线程阻塞,从而依然会导致该线程不能及时回收利用,从而又会导致创建许多线程,所以我们要尽量写异步非阻塞代码,但是写异步非阻塞代码又不够优雅,怎么办呢?怎么办呢?怎么办呢?这时候主角async/await闪亮登场。

吃西瓜();

打War3();

result = await ReadFileAsync();

做依赖于读文件的操作();


看见没有!!!这个和同步版的是不是差不多?和同步版达到的效果是一样的,但不会导致线程被阻塞,可以让线程及时去处理其他任务。另外,由于CPU和IO设备是异步的,所以应尽早发起异步请求,正确的做法应该是下面这样的

result = ReadFileAsync();//首先发起异步请求,以便IO设备能尽早处理

吃西瓜();

打War3();

await result ;

做依赖于读文件的操作();


那么阻塞、非阻塞、同步、异步是啥的问题也解决了。

四、async/await怎么工作?

        在这里简述一下,在C#中,每个使用了await的异步方法,都会被编译器魔改成了一个状态机的实现,使用了n个await关键字,就会有n+1个状态,利用这个状态机,便可以实现异步函数的挂起和恢复(有没有感觉和线程很像?),以便异步任务完成之后,回到刚开始使用await的地方,然后继续执行。在具体一点点,每当一个线程执行await的地方,这个线程就会回到被调用的地方,如果被调用的地方也使用了await,然后继续上一步骤,最终会回到线程池,如果有其他任务就继续执行其他任务,否则会阻塞,这没关系,因为已经没事做了。等待某个时候,异步任务完成,触发状态机调用MoveNext(),然后回到调用这个await的地方,继续往下执行,需要注意的是,这时候执行线程不一定是刚开始调用await的线程了,这是由任务调度器决定的,在.NET中,默认的任务调度器使用了默认的线程池作为任务的消费者,也可以自定义任务调度器,让一个线程处理所有任务。

五、理解Task

        在C#中,async/await是与Task协同工作的,而异步函数就是一个Task,Task与async/await配合可以被挂起和恢复,是不是有点线程的味道?它其实就是用户模式下的“线程”,也就是协程,线程需要调度,协程同样需要调度,不同的是,线程是抢占式的,被动的。协程需要主动让出执行权,也就是非抢占式的,通过await便可以让出执行权,所以协程可以充分利用线程资源,执行用户代码,Task便可以理解为一个协程,最后,Task最终是要由线程来执行的,可以是一个线程,也可以是多个线程,这个是由任务调度器决定的。由于默认的任务调度器使用的是线程池中的线程来执行,所以await前后执行线程很可能不一样。要想使用自定义的任务调度器,通过创建TaskFactory实例指定任务调度器,或者创建Task实例,并在Start(TaskSheduler t)传入指定的任务调度器,通过Task.Run()方法或者不传参的Start()实例方法默认都使用的是默认的任务调度器。


      我写了一个单线程同步阻塞、多线程同步非阻塞、单线程异步非阻塞、多线程异步非阻塞的简单的Web服务器作为示例,并有大量注释。还有一个反编译异步方法的C#实现,和一个自定义的任务调度器,并有一个简单的发起并发Http请求的控制台程序,有兴趣的可以研究下,可以加深萌新对async/await的理解,最后还有开头的一些疑问没解决,理解这几个例子你就能知道答案了。

仓库地址:http://gitlab.fyfhk.cn/hekun3344/simplehttpserver

最后

觉得有收获的

image

展开阅读全文
打赏
0
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部