Skip to main content

深入异步编程

分类:  .Net技术 标签:  #异步编程 #基础 #.Net 发布于: 2023-06-04 19:53:28

本篇是.Net异步编程的最后一篇了,希望这篇可以帮你总结前面几篇学习内容,并且在本章得到升华,也为大家将来在.Net中使用异步编程大大提高您应用吞吐量、性能。

.Net中编写IO或者CPU异步任务是非常直接的,.Net/.Net Core/Window Runtime支持在语言级别使用Task/Task<T>/async/await几个接口用于异步编程,本章向大家介绍async背后发生的一些基本原理,帮助大家更好的理解async以及await使用。

Task And Task<T>

简单的来讲,Task是一个Promise Model of Concurrency模式的实现,关于什么是Promise Model of Concurrency, 大家可以自行去搜索并学习一下该模式,.Net通过提供Task, 代表一个正在进行,即将完成的任务,并提供了良好的api让用户更易于进行异步编程:

  • Task代表一个没有返回值的操作
  • Task<T>代表一个返回值为T的操作。

对于基于asyncawait编程模型一个非常重要的概念就是,这个编程模型是对于用户的异步编程场景或者是任务的抽象,并不是对于基于工具或者操作系统底层线程的抽象,一句话,就是它和底层的线程关系不大,而且Task的运行和线程也没有必然的关系(当然我们也可以明确指定需要新的线程来与运行,例如CPU计算的任务,必须使用Task.Run来指定一个后台线程与运行), 默认情况下任务是运行在当前的执行线程下但是实际的工作是委托给了操作系统来进行,前面也说了,可以明确的指定一个新的线程来运行(CPU,Task.run)。

Task模型暴露很多接口用于监视,等待,取得返回值,在语言级别使用await。这个我们前面的文章也都讲过了。使用await两大作用:

  • 挂起当前方法,移交控制给调用者
  • 当前方法中block执行,等待完成并unwarp必要的返回值。

我们下一篇文章再详细的学习一下TAP, 你可以在这里多了解一下其他的交互方法。

深入IO操作的异步方法

我们前面有讲到基于IO操作的异步方法,任务的执行任务时在当前执行的线程里,但是实际的代码时委托给操作系统来进行,这样当使用await的时候,就能将方法体挂起,并且将控制权返回给调用者,这样当前的执行线程不会被block, 同时由CLR监控该任务的实际执行,当任务完成时,控制权再返回到方法中,继续完成方法的执行。

我们来先看两个简单的例子:

class DotNetFoundationClient
{
    // HttpClient is intended to be instantiated once per application, rather than per-use.
    private static readonly HttpClient s_client = new HttpClient();

    public Task<string> GetHtmlAsync()
    {
        // Execution is synchronous here
        var uri = new Uri("https://www.dotnetfoundation.org");

        return s_client.GetStringAsync(uri);
    }

    public async Task<string> GetFirstCharactersCountAsync(int count)
    {
        // Execution is synchronous here
        var uri = new Uri("https://www.dotnetfoundation.org");

        // Execution of GetFirstCharactersCountAsync() is yielded to the caller here
        // GetStringAsync returns a Task<string>, which is *awaited*
        var page = await s_client.GetStringAsync(uri);

        // Execution resumes when the client.GetStringAsync task completes,
        // becoming synchronous again.

        if (count > page.Length)
        {
            return page;
        }
        else
        {
            return page.Substring(0, count);
        }
    }
}

这里两个方法,一看就明白。知道这两个方法在干什么,下面我们继续深入一下,为什么可以委托给操作系统来完成方法的执行,而无需单独的线程。

GetStringAsync()方法被调用之后,.Net的底层库同时被调用,该请求通过.Net的底层库之后,可能到达操作系统的库,然后由该库调用系统调用,例如请求一个Socket等等,对于用户的代码,一个Task对象会被CLR创建出来,这个对象会被各层抽象的使用,无论如何怎么走,最终它还是会回到最初的调用者这里。

GetFirstCharactersCountAsync()方法的调用和之前也是一样的,不同点在于它使用了一个await, 在await这里会返回一个Task<T>的对象,这个对象运行调用者用于监视任务的执行情况。

前面粗略的讲了一下这两个方法的基本执行情况,我们详细的再看看,当.Net将请求发送到了系统调用之后,这个时候系统调用到了操作系统的内核空间,这个时候它可能需要调用网络子模块,而且操作系统需要管理网络的请求(注意是异步的), 具体的细节是依赖操作系统的实现,不管咋样,最终CLR会收到任务执行情况的通知,需要说明的是,在这个链路上每个操作都是异步的,而且可以发现,这些操作里没有哪一个操作需要CPU的参与,也就是在整个IO的操作中完全可以不依赖于线程,委托给操作系统和CLR就可以了。

从操作系统原理可以了解到,这些子系统的通讯首先是异步的,然后是通过系统的中断进行通讯的,虽然看起来有些复杂,但是实际执行时非常快,可以有一个简单的形容,假如我们一个任务的执行需要时间单位0-3个时间单位,那么:

  • 时间从0-1主要花在异步方法调用,并且移交控制权。
  • 时间1-2 花在IO操作,无CPU花费
  • 时间2-3,花在控制转会到异步方法内,完成其他代码执行。

我们知道服务基本都是IO之间的操作,由于async无需单独的线程用于处理任务的执行(因为委托给操作系统了), 因此用于服务端的编程是非常合适的。基于client的编程也是这样的。

深入基于CPU计算的异步

基于CPU计算的异步编程和基于IO是不一样的,因为我们这里的任务是完全依赖于CPU的,也就是需要CPU的执行时间,这样就必须明确的将任务放置于其他的线程中,所以我们是需要使用Task.Run来执行基于CPU的任务的。
如下的例子:

public async Task<int> CalculateResult(InputData data)
{
    // This queues up the work on the threadpool.
    var expensiveResultTask = Task.Run(() => DoExpensiveCalculation(data));

    // Note that at this point, you can do some other work concurrently,
    // as CalculateResult() is still executing!

    // Execution of CalculateResult is yielded here!
    var result = await expensiveResultTask;

    return result;
}

使用Task.run的好处是基于CPU的任务由被clr管理的线程池来管理和运行,而且是在后台线程中运行。

需要注意的是虽然使用async,await是基于CPU编程的最佳实践和最佳推荐,但是需要注意的是不适合紧凑的循环处理。

如果大家了解基于多进程编程,多线程编程,以及基于非阻塞IO编程,特别是非阻塞IO编程,应该就很容易理解asyncawait的编程模式了。