一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

c#基于task的异步模式的定义及实现

时间:2015-10-15 编辑:简简单单 来源:一聚教程网

基于Task的异步模式的定义


命名,参数和返回类型

在TAP(Task-based Asynchronous Pattern)中的异步操作的启动和完成是通过一个单独的方法来表现的,因此只有一个方法要命名。这与IAsyncResult模式或者APM(Asynchronous Programming Model,异步编程模型)模式形成对比,后者必须要有开始方法名和结束方法名;还与基于事件(event-based)的异步模式(EAP)不同,它们要求方法名以Async为后缀,而且要求一个或多个事件,事件句柄委托类型和派生自Event参数的类型。TAP中的异步方法使用“Async”后缀命名,跟在操作名称的后面(例如MethodNameAsync)。TAP中的异步方法返回一个Task类型或者Task,基于相应的同步方法是否分别返回一个void或者TResult类型。

比如,思考下面的“Read”方法,它将特定数量的数据读取到一个以特定偏移量的buffer中:

public class MyClass
{
    public int Read(byte [] buffer, int offset, int count);
}

这个方法对应的APM版本则有下面两个方法:

public class MyClass
 {
    public IAsyncResult BeginRead(byte[] buffer, int offset, int count,AsyncCallback callback, object state);
    public int EndRead(IAsyncResult asyncResult);
}

EAP版本对应的方法是这样的:


public class MyClass
{
    public void ReadAsync(byte[] buffer, int offset, int count);
    public event ReadCompletedEventHandler ReadCompleted;
}
public delegate void ReadCompletedEventHandler(object sender, ReadCompletedEventArgs eventArgs);
public class ReadCompletedEventArgs: AsyncCompletedEventArgs
{
    public int Result {
        get;
    }
}


TAP对应的版本只有下面一个方法:

public class MyClass
{
    public Task ReadAsync(byte [] buffer, int offset, int count);
}

一个基本的TAP方法的参数应该和同步方法的参数相同,且顺序相同。然而,“out”和“ref”参数不遵从这个规则,并且应该避免使用它们。通过out或者ref返回的任何数据可以作为返回的Task结果的一部分,可以利用一个元组或者一个自定义数据结构容纳多个值。

纯粹致力于创建,操作,或组合的任务方法(该方法的异步目的在方法名上或者在方法上以类型命名是明确的)不需要遵循上述命名模式;这些方法通常被称为"组合子"。这种方法的例子包括Task. WhenAll和Task.WhenAny,本文档后面的会更深入地讨论。

表现

初始化异步操作

在返回结果的任务之前,基于TAP异步方法允许同步地处理少量的工作。这项工作应保持在所需的最低数量,执行如验证参数和启动异步操作的操作。很可能从用户界面线程将调用异步方法,因此所有长时间运行的异步方法的同步前期部分工作可能会损害响应能力。很有可能同时将启动多个异步方法,因此所有长时间运行的异步方法的同步前期部分工作可能会推迟启动其他异步操作,从而减少并发的好处。

在某些情况下,完成操作所需的工作量小于异步启动操作需要的工作量(例如,从流中读取数据,这个读取操作可以被已经缓冲在内存中的数据所满足)。在这种情况下,操作可能同步完成,返回一个已经完成的任务。

异常

一个异步方法只应该直接捕获一个MethodNameAsync 调用时抛出的异常以响应用法错误。对于其他所有的错误,在异步方法执行期间发生的异常应该分配给返回的任务。这种情况是在Task返回之前,异步方法同步完成下发生的。一般地,一个Task至多包含一个异常。然而,对于一个Task表示多个操作(如,Task.WhenAll)的情况,单个Task也会关联多个异常。

【*每个.Net设计指南都指出,一个用法错误可以通过改变调用方法的码来避免。比如,当把null作为一个方法的参数传递时,错误状态就会发生,错误条件通常被表示为ArgumentNullException,开发者可以修改调用码来确保null没有传递过。换言之,开发者可以并且应该确保用法错误从来没有在生产代码中发生过】。

目标环境

异步执行的发生取决于TAP方法的实现。TAP方法的开发人员可能选择在线程池上执行工作负载,也可能选择使用异步 I/O实现它,因而没有被绑定到大量操作执行的线程上,也可以选择在特定的线程上运行,如UI线程,或者其他一些潜在的上下文。甚至可能是这种情况,TAP方法没有东西执行,简单返回一个在系统中其他地方情况发生的Task(如Task表示TData到达一个排队的数据结构)。

TAP方法的调用者也可能阻塞等待TAP方法的完成(通过在结果的Task上同步地等待),或者利用延续在异步操作完成时执行附加代码。延续创建者在延续代码执行的地方有控制权。这些延续代码要么通过Task类(如ContinueWith)显示地创建,要么使用语言支持隐式地建立在延续代码之上(如C#中的“await”)。


Task状态

Task类提供了异步操作的生命周期,该生命周期通过TaskStatus枚举表示。为了支持派生自Task和Task类型的案例,以及来自调度的构建分离,Task类暴露了一个Start方法。通过public构造函数创建的Tasks被称为“冷”任务,在“冷”任务中,它们以非调度(non-scheduled)的TaskStatus.Created状态开始生命周期。直到在这些实例上Start调用时,它们才促使被调度。所有在“热”状态开始生命周期的其他task,意味着它们表示的异步操作已经初始化了,它们的TaskStatus是一个除Created之外的其它枚举值。

所有从TAP方法返回的tasks肯定是“热的”。如果TAP方法内部使用一个Task的构造函数来实例化要返回的task,那么此TAP方法必须在返回task之前在Task对象上调用Start方法。TAP方法的消费者可以安全地假定返回的task是“热的”,并不应该尝试在任何返回自TAP方法的Task上调用Start。在“热的”task上调用Start会导致InvalidOperationException (Task类自动处理这个检查)。

可选:撤销

TAP中的撤销对于异步方法的实现者和异步方法的消费者都是选择加入的。如果一个操作将要取消,那么它会暴露

一个接受System.Threading.CancellationToken的MethodNameAsync 的重载。异步操作会监视对于撤销请求的这个token,如果接收到了撤销请求,可以选择处理该请求并取消操作。如果处理请求导致任务过早地结束,那么从TAP方法返回的Task会以TaskStatus.Canceled状态结束。

为了暴露一个可取消的异步操作,TAP实现提供了在同步对应的方法的参数后接受一个CancellationToken的重载。按照惯例,该参数命名为“cancellationToken”。

public Task ReadAsync(
    byte [] buffer, int offset, int count,
    CancellationToken cancellationToken);

如果token已经请求了撤销并且异步操作尊重该请求,那么返回的task将会以TaskStatus.Canceled状态结束,将会产生没有可利用的Result,并且没有异常。Canceled状态被认为是一个伴随着Faulted和RanToCompletion 状态的任务最终或完成的状态。因此,Canceled 状态的task的IsCompleted 属性返回true。当一个Canceled 状态的task完成时,任何用该task注册的延续操作都会被调度或执行,除非这些延续操作通过具体的TaskContinuationOptions 用法在被创建时取消了(如TaskContinuationOptions.NotOnCanceled)。任何异步地等待一个通过语言特性使用的撤销的task的代码将会继续执行并且收到一个OperationCanceledException(或派生于该异常的类型)。在该task(通过Wait 或WaitAll方法)上同步等待而阻塞的任何代码也会继续执行并抛出异常。

如果CancellationToken已经在接受那个token的TAP方法调用之前发出了取消请求,那么该TAP方法必须返回一个Canceled状态的task。然而,如果撤销在异步操作执行期间请求,那么异步操作不需要尊重该撤销请求。只有由于撤销请求的操作完成时,返回的Task才会以Canceled 状态结束。如果一个撤销被请求了,但是结果或异常仍产生了,那么Task将会分别以RanToCompletion或 Faulted 的状态结束。

首先,在使用异步方法的开发者心目中,那些渴望撤销的方法,需要提供一个接受CancellationToken变量的重载。对于不可取消的方法,不应该提供接受CancellationToken的重载。这个有助于告诉调用者目标方法实际上是否是可取消的。不渴望撤销的消费者可以调用一个接受CancellationToken的方法来把CancellationToken.None作为提供的参数值。CancellationToken.None功能上等价于default(CancellationToken)。

可选:进度报告

一些异步操作得益于提供的进度通知,一般利用这些进度通知来更新关于异步操作进度的UI。

在TAP中,进度通过IProgress接口传递给异步方法的名为“progress”的参数来处理。在该异步方法调用时提供这个进度接口有助于消除来自于错误的用法的竞争条件,这些错误的用法 是因为在此操作可能错过更新之后,事件句柄错误地注册导致的。更重要的是,它使变化的进度实现可被利用,因为由消费者决定。比如,消费者肯仅仅关心最新的进度更新,或者可能缓冲所有更新,或者可能仅仅想要为每个更新调用一个action,或者可能想控制是否调用封送到特定的线程。所有这些可能通过使用一个不同的接口的实现来完成,每一个接口可以定制到特殊的消费者需求。因为有了撤销,如果API支持进度通知,那么TAP实现应该只提供一个IProgress参数。

比如,如果我们上面提到的ReadAsync方法可以以迄今读取字节数的形式能报告中间的进度,那么进度的回调(callback)可以是一个IProgress:

public Task ReadAsync(
    byte [] buffer, int offset, int count,
    IProgress progress);

如果FindFilesAsync方法返回一个所有文件的列表,该列表满足一个特殊的搜索模式,那么进度回调可以提供完成工作的百分比和当前部分结果集的估计。它也可以这样处理元组,如:

public Task> FindFilesAsync(
    string pattern,
    IProgress>>> progress);

或者使用API具体的数据类型,如:

public Task> FindFilesAsync(
    string pattern,
    IProgress progress);

在后一种情况,特殊的数据类型以“ProgressInfo”为后缀。

如果TAP实现提供了接受progress参数的重载,那么它们必须允许参数为null,为null的情况下,进度不会报告。TAP实现应该同步地报告IProgress对象的进度,使得比快速提供进度的异步实现更廉价,并且允许进度的消费者决定如何以及在哪里最好地处理信息(例如进度实例本身可以选择在一个捕获的同步上下文上收集回调函数和引发事件)。
IProgreee实现

Progress作为.NET Framework 4.5的一部分,是IProgress的单一实现(未来会提供更多的实现)。Progress声明如下:


public class Progress : IProgress
{
    public Progress();
    public Progress(Action handler);
    protected virtual void OnReport(T value);
    public event EventHandler ProgressChanged;
}



Progress的实例公开了一个ProgressChanged事件,它是每次异步操作报告进度更新的时候触发。当Progress实例被实例化时,该事件在被捕获的同步上下文上触发(如果没有上下文可用,那么用默认的线程池上下文)。句柄可能会用这个事件注册;一个单独的句柄也可能提供给Progress实例的构造函数(这纯粹是为了方便,就像ProgressChanged 事件的事件句柄)。进度更新异步触发是为了事件句柄执行时避免延迟异步操作。其他的IProgress实现可能选择使用了不同的语义。
如何选择提供的重载函数

有了CancellationToken和IProgress参数,TAP的实现默认有4个重载函数:

public Task MethodNameAsync(…);
public Task MethodNameAsync(…, CancellationToken cancellationToken);
public Task MethodNameAsync(…, IProgress progress);
public Task MethodNameAsync(…,
    CancellationToken cancellationToken, IProgress progress);

然而,因为它们没有提供cancellation和progress的能力,许多TAP实现有了最短的重载的需求:

public Task MethodNameAsync(…);

如果一个实现支持cancellation或者progress但不同时支持,那么TAP实现可以提供2个重载:


public Task MethodNameAsync(…);
public Task MethodNameAsync(…, CancellationToken cancellationToken);

// … or …

public Task MethodNameAsync(…);
public Task MethodNameAsync(…, IProgress progress);


如果实现同时支持cancellation和progress,那么它可以默认提供4个重载。然而,只有2个有效:

public Task MethodNameAsync(…);
public Task MethodNameAsync(…,
    CancellationToken cancellationToken, IProgress progress);

为了得到那2个遗失的重载,开发者可以通过给CancellationToken参数传递CancellationToken.None(或者default(CancellationToken))和/或给progress参数传递null。

如果期望TAP方法的每一种用法都应该使用cancellation和/或progress,那么不接受相关参数的重载可以忽略。

如果TAP方法的多个重载公开了可选的cancellation和/或progress,那么不支持cancellation和/或progress的重载的表现应该像支持他们的重载已经传递了CancellationToken.None和null分别给cancellation和progress一样。


实现基于Task的异步模式


生成方法

编译器生成

在.NET Framework 4.5中,C#编译器实现了TAP。任何标有async关键字的方法都是异步方法,编译器会使用TAP执行必要的转换从而异步地实现方法。这样的方法应该返回Task或者Task类型。在后者的案例中,方法体应该返回一个TResult,且编译器将确保通过返回的Task是可利用的。相似地,方法体内未经处理的异常会被封送到输出的task,造成返回的Task以Faulted的状态结束。一个例外是如果OperationCanceledException(或派生类型)未经处理,那么返回的Task会以Canceled状态结束。

手动生成

开发者可以手动地实现TAP,就像编译器那样或者更好地控制方法的实现。编译器依赖来自System.Threading.Tasks命名空间暴露的公开表面区域(和建立在System.Threading.Tasks之上的System.Runtime.CompilerServices中支持的类型),还有对开发者直接可用的功能。当手动实现TAP方法时,开发者必须保证当异步操作完成时,完成返回的Task。

混合生成

在编译器生成的实现中混合核心逻辑的实现,对于手动实现TAP通常是很有用的。比如这种情况,为了避免方法直接调用者产生而不是通过Task暴露的异常,如:


public Task MethodAsync(string input)
{
    if (input == null) throw new ArgumentNullException("input");
    return MethodAsyncInternal(input);
}

private async Task MethodAsyncInternal(string input)
{
    … // code that uses await
}

参数应该在编译器生成的异步方法之外改变,这种委托有用的另一种场合是,当一个“快速通道”优化可以通过返回一个缓存的task来实现的时候。


工作负荷

计算受限和I/O受限的异步操作可以通过TAP方法实现。然而,当TAP的实现从一个库公开暴露时,应该只提供给包含I/O操作的工作负荷(它们也可以包含计算,但不应该只包含计算)。如果一个方法纯粹受计算限制,它应该只通过一个异步实现暴露,消费者然后就可以为了把该任务卸载给其他的线程的目的来选择是否把那个同步方法的调用包装成一个Task,并且/或者来实现并行。


计算限制

Task类最适合表示计算密集型操作。默认地,为了提供有效的执行操作,它利用了.Net线程池中特殊的支持,同时也对异步计算何时,何地,如何执行提供了大量的控制。

生成计算受限的tasks有几种方法。

    在.Net 4中,启动一个新的计算受限的task的主要方法是TaskFactory.StartNew(),该方法接受一个异步执行的委托(一般来说是一个Action或者一个Func)。如果提供了一个Action,返回的Task就代表那个委托的异步执行操作。如果提供了一个Func,就会返回一个Task。存在StartNew()的重载,该重载接受CancellationToken,TaskCreationOptions,和TaskScheduler,这些都对task的调度和执行提供了细粒度的控制。作用在当前调度者的工厂实例可以作为Task类的静态属性,例如Task.Factory.StartNew()。
    在.Net 4.5中,Task类型暴露了一个静态的Run方法作为一个StartNew方法的捷径,可以很轻松地使用它来启动一个作用在线程池上的计算受限的task。从.Net 4.5开始,对于启动一个计算受限的task,这是一个更受人喜欢的机制。当行为要求更多的细粒度控制时,才直接使用StartNew。
    Task类型公开了构造函数和Start方法。如果必须要有分离自调度的构造函数,这些就是可以使用的(正如先前提到的,公开的APIs必须只返回已经启动的tasks)。
    Task类型公开了多个ContinueWith的重载。当另外一个task完成的时候,该方法会创建新的将被调度的task。该重载接受CancellationToken,TaskCreationOptions,和TaskScheduler,这些都对task的调度和执行提供了细粒度的控制。
    TaskFactory类提供了ContinueWhenAll 和ContinueWhenAny方法。当提供的一系列的tasks中的所有或任何一个完成时,这些方法会创建一个即将被调度的新的task。有了ContinueWith,就有了对于调度的控制和任务的执行的支持。

思考下面的渲染图片的异步方法。task体可以获得cancellation token为的是,当渲染发生的时候,如果一个撤销请求到达后,代码可能过早退出。而且,如果一个撤销请求在渲染开始之前发生,我们也可以阻止任何的渲染。

public Task RenderAsync(
    ImageData data, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        var bmp = new Bitmap(data.Width, data.Height);
        for(int y=0; y        {
            cancellationToken.ThrowIfCancellationRequested();
            for(int x=0; x            {
                … // render pixel [x,y] into bmp
            }
        }
        return bmp;
    }, cancellationToken);
}

如果下面的条件至少一个是正确的,计算受限的tasks会以一个Canceled状态的结束:

    在Task过度到TaskStatus.Running状态之前,CancellationToken为一个发出撤销请求的创建方法的参数提供(如StartNew,Run)。
    有这样的一个Task,它内部有未处理的OperationCanceledException。该OperationCanceledException 包含和CancellationToken属性同名的CancellationToken传递到该Task,且该CancellationToken已经发出了撤销请求。

如果该Task体中有另外一个未经处理的异常,那么该Task就会以Faulted的状态结束,同时在该task上等待的任何尝试或者访问它的结果都将导致抛出异常。


I/O限制

使用TaskCompletionSource类型创建的Tasks不应该直接被全部执行的线程返回。TaskCompletionSource暴露了一个返回相关的Task实例的Task属性。该task的生命周期通过TaskCompletionSource实例暴露的方法控制,换句话说,这些实例包括SetResult, SetException, SetCanceled, 和它们的TrySet* 变量。

思考这样的需求,创建一个在特定的时间之后会完成的task。比如,当开发者在UI场景中想要延迟一个活动一段时间时,这可能使有用的。.NET中的System.Threading.Timer类已经提供了这种能力,在一段特定时间后异步地调用一个委托,并且我们可以使用TaskCompletionSource把一个Task放在timer上,例如:

public static Task Delay(int millisecondsTimeout)
{
    var tcs = new TaskCompletionSource();
    new Timer(self =>
    {
        ((IDisposable)self).Dispose();
        tcs.TrySetResult(DateTimeOffset.UtcNow);
    }).Change(millisecondsTimeout, -1);
    return tcs.Task;
}

在.Net 4.5中,Task.Delay()就是为了这个目的而生的。比如,这样的一个方法可以使用到另一个异步方法的内部,以实现一个异步的轮训循环:

public static async Task Poll(
    Uri url,
    CancellationToken cancellationToken,
    IProgress progress)
{
    while(true)
    {
        await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
        bool success = false;
        try
        {
            await DownloadStringAsync(url);
            success = true;
        }
        catch { /* ignore errors */ }
        progress.Report(success);
    }
}

没有TaskCompletionSource的非泛型副本。然而,Task派生自Task,因而,泛型的TaskCompletionSource可以用于那些 I/O受限的方法,它们都利用一个假的TResult源(Boolean是默认选择,如果开发者关心Task向下转型的Task的消费者,那么可以使用一个私有的TResult类型)仅仅返回一个Task。比如,开发的之前的Delay方法是为了顺着产生的Task返回当前的时间。如果这样的 一个结果值是不必要的,那么该方法可以通过下面的代码取而代之(注意返回类型的改变和TrySetresult参数的改变):

public static Task Delay(int millisecondsTimeout)
{
    var tcs = new TaskCompletionSource();
    new Timer(self =>
    {
        ((IDisposable)self).Dispose();
        tcs.TrySetResult(true);
    }).Change(millisecondsTimeout, -1);
    return tcs.Task;
}

混合计算限制和I/O限制的任务

异步方法不是仅仅受限于计算受限或者I/O受限的操作,而是可以代表这两者的混合。实际上,通常情况是不同性质的多个异步操作被组合在一起生成更大的混合操作。比如,思考之前的RenderAsync方法,该方法基于一些输入的ImageData执行一个计算密集的操作来渲染一张图片。该ImageData可能来自于一个我们异步访问的Web服务:

public async Task DownloadDataAndRenderImageAsync(
    CancellationToken cancellationToken)
{
    var imageData = await DownloadImageDataAsync(cancellationToken);
    return await RenderAsync(imageData, cancellationToken);
}

这个例子也展示了一个单独的CancellationToken是如何通过多个异步操作被线程化的。

热门栏目