欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

C# 彻底搞懂async/await

程序员文章站 2022-02-14 11:10:39
前言talk is cheap, show you the code first!private void button1_click(object sender, eventargs e){...

前言

talk is cheap, show you the code first!

private void button1_click(object sender, eventargs e)
{
    console.writeline("111 balabala. my thread id is :" + thread.currentthread.managedthreadid);
    asyncmethod();
    console.writeline("222 balabala. my thread id is :" + thread.currentthread.managedthreadid);
}

private async task asyncmethod()
{
    var resultfromtimeconsumingmethod = timeconsumingmethod();
    string result = await resultfromtimeconsumingmethod + " + asyncmethod. my thread id is :" + thread.currentthread.managedthreadid;
    console.writeline(result);
    //返回值是task的函数可以不用return
}

//这个函数就是一个耗时函数,可能是io操作,也可能是cpu密集型工作。
private task<string> timeconsumingmethod()
{            
    var task = task.run(()=> {
        console.writeline("helo i am timeconsumingmethod. my thread id is :" + thread.currentthread.managedthreadid);
        thread.sleep(5000);
        console.writeline("helo i am timeconsumingmethod after sleep(5000). my thread id is :" + thread.currentthread.managedthreadid);
        return "hello i am timeconsumingmethod";
    });

    return task;
}

我靠,这么复杂!!!竟然有三个函数!!!竟然有那么多行!!!

别着急,慢慢看完,最后的时候你会发现使用async/await真的炒鸡优雅。

异步方法的结构

上面是一个的使用async/await的例子(为了方便解说原理我才写的这样复杂的)。
使用async/await能非常简单的创建异步方法,防止耗时操作阻塞当前线程。
使用async/await来构建的异步方法,逻辑上主要有下面三个结构:

调用异步方法

private void button1_click(object sender, eventargs e)
{
    console.writeline("111 balabala. my thread id is :" + thread.currentthread.managedthreadid);
    asyncmethod();//这个方法就是异步方法,异步方法的调用与一般方法完全一样
    console.writeline("222 balabala. my thread id is :" + thread.currentthread.managedthreadid);
}

注意:微软建议异步方法的命名是在方法名后添加aysnc后缀,示例是我为了读起来方便做成了前缀,在真正构建异步方法的时候请注意用后缀。(好吧我承认是我忘记了,然后图片也都截好了再修改太麻烦了。。。。就懒得重新再修改了)

异步方法的返回类型只能是voidtasktask<tresult>。示例中异步方法的返回值类型是task

另外,上面的asyncmethod()会被编译器提示报警,如下图:
C# 彻底搞懂async/await
因为是异步方法,所以编译器提示在前面使用await关键字,这个后面再说,为了不引入太多概念导致难以理解暂时就先这么放着。

异步方法本体

private async task asyncmethod()
{
    var resultfromtimeconsumingmethod = timeconsumingmethod();
    string result = await resultfromtimeconsumingmethod + " + asyncmethod. my thread id is :" + thread.currentthread.managedthreadid;
    console.writeline(result);
    //返回值是task的函数可以不用return
}

async来修饰一个方法,表明这个方法是异步的,声明的方法的返回类型必须为:voidtasktask<tresult>。方法内部必须含有await修饰的方法,如果方法内部没有await关键字修饰的表达式,哪怕函数被async修饰也只能算作同步方法,执行的时候也是同步执行的。

被await修饰的只能是task或者task<tresule>类型,通常情况下是一个返回类型是task/task<tresult>的方法,当然也可以修饰一个task/task<tresult>变量,await只能出现在已经用async关键字修饰的异步方法中。上面代码中就是修饰了一个变量resultfromtimeconsumingmethod

关于被修饰的对象,也就是返回值类型是tasktask<tresult>函数或者task/task<tresult>类型的变量:如果是被修饰对象的前面用await修饰,那么返回值实际上是void或者tresult(示例中resultfromtimeconsumingmethodtimeconsumingmethod()函数的返回值,也就是task<string>类型,当resultfromtimeconsumingmethod在前面加了await关键字后 await resultfromtimeconsumingmethod实际上完全等于 resultfromtimeconsumingmethod.result)。如果没有await,返回值就是task或者task<tresult>

耗时函数

//这个函数就是一个耗时函数,可能是io密集型操作,也可能是cpu密集型工作。
private task<string> timeconsumingmethod()
{            
    var task = task.run(()=> {
        console.writeline("helo i am timeconsumingmethod. my thread id is :" + thread.currentthread.managedthreadid);
        thread.sleep(5000);
        console.writeline("helo i am timeconsumingmethod after sleep(5000). my thread id is :" + thread.currentthread.managedthreadid);
        return "hello i am timeconsumingmethod";
    });

    return task;
}

这个函数才是真正干活的(为了让逻辑层级更分明,我把这部分专门做成了一个函数,在后面我会精简一下直接放到异步函数中,毕竟活在哪都是干)。

在示例中是一个cpu密集型的工作,我另开一线程让他拼命干活干5s。如果是io密集型工作比如文件读写等可以直接调用.net提供的类库,对于这些类库底层具体怎么实现的?是用了多线程还是dma?或者是多线程+dma?这些问题我没有深究但是从表象看起来和我用task另开一个线程去做耗时工作是一样的。

await只能修饰task/task<tresult>类型,所以这个耗时函数的返回类型只能是task/task<tresult>类型。

总结:有了上面三个结构就能完成使用一次异步函数。

async/await异步函数的原理

在开始讲解这两个关键字之前,为了方便,对某些方法做了一些拆解,拆解后的代码块用代号指定:
C# 彻底搞懂async/await
上图对示例代码做了一些指定具体就是:

  • caller代表调用方函数,在上面的代码中就是button1_click函数。
  • calleeasync代表被调用函数,因为代码中被调用函数是一个异步函数,按照微软建议的命名添加了async后缀,在上面示例代码中就是asyncmethod()函数。
  • callerchild1代表调用方函数button1_click在调用异步方法calleeasync之前的那部分代码。
  • callerchild2代表调用方函数button1_click在调用异步方法calleeasync之后的那部分代码。
  • calleechild1代表被调用方函数asyncmethod遇到await关键字之前的那部分代码。
  • calleechild2代表被调用方函数asyncmethod遇到await关键字之后的那部分代码。
  • timeconsumingmethod是指被await修饰的那部分耗时代码(实际上我代码中也是用的这个名字来命名的函数)

示例代码的执行流程

C# 彻底搞懂async/await
为了方便观看我模糊掉了对本示例没有用的输出。
这里涉及到了两个线程,线程id分别是1和3。

caller函数被调用,先执行callerchild1代码,这里是同步执行与一般函数一样,然后遇到了异步函数calleeasync。

在calleeasync函数中有await关键字,await的作用是打分裂点。

编译器会把整个函数(calleeasync)从这里分裂成两个函数。await关键字之前的代码作为一个函数(按照我上面定义的指代,下文中就叫这部分代码calleechild1)await关键字之后的代码作为一个函数(calleechild2)。

calleechild1在调用方线程执行(在示例中就是主线程thread1),执行到await关键字之后,另开一个线程耗时工作在thread3中执行,然后立即返回。这时调用方会继续执行下面的代码callerchild2(注意是caller不是callee)。

在callerchild2被执行期间,timeconsumingmethod也在异步执行(可能是在别的线程也可能是cpu不参与操作直接dma的io操作)。

当timeconsumingmethod执行结束后,calleechild2也就具备了执行条件,而这个时候callerchild2可能执行完了也可能没有,由于callerchild2与calleechild2都会在caller的线程执行,这里就会有冲突应该先执行谁,编译器会在合适的时候在caller的线程执行这部分代码。示意图如下:
C# 彻底搞懂async/await

请注意,calleechild2在上图中并没有画任何箭头,因为这部分代码的执行是由编译器决定的,暂时无法具体描述是什么时候执行。

总结一下:

整个流程下来,除了timeconsumingmethod函数是在thread3中执行的,剩余代码都是在主线程thread1中执行的.

也就是说异步方法运行在当前同步上下文中,只有激活的时候才占用当前线程的时间,异步模型采用时间片轮转来实现(这一点我没考证,仅作参考)。

你也许会说,明明新加了一个thread3线程怎么能说是运行在当前的线程中呢?这里说的异步方法运行在当前线程上的意思是由calleeasync分裂出来的calleechild1和calleechild2的确是运行在thread1上的。

带返回值的异步函数

之前的示例代码中异步函数是没有返回值的,作为理解原理足够了,但是在实际应用场景中,带返回值的应用才是最常用的。那么,上代码:

private void button1_click(object sender, eventargs e)
{
    console.writeline("111 balabala. my thread id is :" + thread.currentthread.managedthreadid);
    var resulttask  = asyncmethod();
    console.writeline(resulttask.result);
    console.writeline("222 balabala. my thread id is :" + thread.currentthread.managedthreadid);
}

private async task<string> asyncmethod()
{
    var resultfromtimeconsumingmethod = timeconsumingmethod();
    string result = await resultfromtimeconsumingmethod + " + asyncmethod. my thread id is :" + thread.currentthread.managedthreadid;
    console.writeline(result);
    return result;
}

//这个函数就是一个耗时函数,可能是io操作,也可能是cpu密集型工作。
private task<string> timeconsumingmethod()
{            
    var task = task.run(()=> {
        console.writeline("helo i am timeconsumingmethod. my thread id is :" + thread.currentthread.managedthreadid);
        thread.sleep(5000);
        console.writeline("helo i am timeconsumingmethod after sleep(5000). my thread id is :" + thread.currentthread.managedthreadid);
        return "hello i am timeconsumingmethod";
    });

    return task;
}

主要更改的地方在这里:

C# 彻底搞懂async/await
按理说没错吧?然而,这代码一旦执行就会卡死。

死锁

是的,死锁。分析一下为什么:
C# 彻底搞懂async/await

按照之前我划定的代码块指定,在添加了新代码后callerchild2与calleechild2的划分如上图。

这两部分代码块都是在同一个线程上执行的,也就是主线程thread1,而且通常情况下callerchild2是会早于calleechild2执行的(毕竟calleechild2得在耗时代码块执行之后执行)。

console.writeline(resulttask.result);(callerchild2)其实是在请求calleechild2的执行结果,此时明显calleechild2还没有结束没有return任何结果,那console.writeline(resulttask.result);就只能阻塞thread1等待,直到calleechild2有结果。

然而问题就在这,calleechild2也是在thread1上执行的,此时callerchild2一直占用thread1等待calleechild2的结果,耗时程序结束后轮到calleechild2执行的时候calleechild2又因thread1被callerchild2占用而抢不到线程,永远无法return,那么callerchild2就会永远等下去,这就造成了死锁。

解决办法有两种一个是把console.writeline(resulttask.result);放到一个新开线程中等待(个人觉得这方法有点麻烦,毕竟要新开线程),还有一个方法是把caller也做成异步方法:
C# 彻底搞懂async/await
resulttask.result变成了resulttask 的原因上面也说了,await修饰的task/task<tresult>得到的是tresult。

之所以这样就能解决问题是因为嵌套了两个异步方法,现在的caller也成了一个异步方法,当caller执行到await后直接返回了(await拆分方法成两部分),calleechild2执行之后才轮到caller中await后面的代码块(console.writeline(resulttask.result);)。

另外,把caller做成异步的方法也解决了一开始的那个警告,还记得么?
C# 彻底搞懂async/await

这样没省多少事啊?

到现在,你可能会说:使用async/await不比直接用task.run()来的简单啊?比如我用tasktaskcontinuewith方法也能实现:

private void button1_click(object sender, eventargs e)
{
    var resulttask = task.run(()=> {
        console.writeline("helo i am timeconsumingmethod. my thread id is :" + thread.currentthread.managedthreadid);
        thread.sleep(5000);
        console.writeline("helo i am timeconsumingmethod after sleep(5000). my thread id is :" + thread.currentthread.managedthreadid);
        return "hello i am timeconsumingmethod";
    });

    resulttask.continuewith(ondosomthingiscomplete);

}

private void ondosomthingiscomplete(task<string> t)
{
    action action = () => {
        textbox1.text = t.result;
    };
    textbox1.invoke(action);
    console.writeline("continue thread id :" + thread.currentthread.managedthreadid);
}

是的,上面的代码也能实现。但是,async/await的优雅的打开方式是这样的:

private async void button1_click(object sender, eventargs e)
{
    var t = task.run(() => {
        thread.sleep(5000);
        return "hello i am timeconsumingmethod";
    });
    textbox1.text = await t;
}

看到没,惊不惊喜,意不意外,寥寥几行就搞定了,不用再多写那么多函数,使用起来也很灵活。最让人头疼的跨线程修改控件的问题完美解决了,再也不用使用invoke了,因为修改控件的操作压根就是在原来的线程上做的,还能不阻塞ui。

参考:
死锁问题 https://www.cnblogs.com/opencoder/p/4434574.html
该博主是翻译的英文资料,英文原文:
https://www.cnblogs.com/zhili/archive/2013/05/15/csharp5asyncandawait.html