C# 异步编程基础(十) 取消(cancellation)、进度报告、TAP(Task-Based Asynchronous Pattern)、Task组合器

此入门教程是记录下方参考资料视频的过程
开发工具:Visual Studio 2019

参考资料:https://www.bilibili.com/video/BV1Zf4y117fs

目录

C# 异步编程基础(一)线程和阻塞

C# 异步编程基础(二)线程安全、向线程传递数据和异常处理

C# 异步编程基础(三)线程优先级、信号和线程池

C# 异步编程基础(四) 富客户端应用程序的线程 和 同步上下文 Synchronization Contexts

C# 异步编程基础(五)Task

C# 异步编程基础(六)Continuation 继续/延续 、TaskCompletionSource、实现 Task.Delay

C# 异步编程基础(七)异步原理

C# 异步编程基础(八) 异步函数

C# 异步编程基础(九) 异步中的同步上下文、ValueTask

C# 异步编程基础(十) 取消(cancellation)、进度报告、TAP(Task-Based Asynchronous Pattern)、Task组合器

取消 cancellation

  1. 使用取消标志来实现对部分进行取消,可以封装一个类:
class CancellationToken
{
    public bool IsCancellationRequested { get; private set; }
    public void Cancel()
    {
        this.IsCancellationRequested = true;
    }

    public void ThrowIfCancellationRequested()
    {
        if (this.IsCancellationRequested)
        {
            throw new OperationCanceledException();
        }
    }
}

用法

async Task Foo(CancellationToken cancellationToken)
{
    for (int i = 0; i < 10; i++)
    {
        Console.WriteLine(i);
        await Task.Delay(1000);
        cancellationToken.ThrowIfCancellationRequested();
    }
}

  当调用者想取消的时候,它调用CancellationToken上的Cancel方法。这就会把IsCancellationRequested设置为true,即会导致短时间后Foo会通过OperationCanceledException引发错误

CancellationToken 和 CancellationTokenSource

  1. 先不管线程安全(应该在读写IsCancellationRequested时进行lock),这个模式非常的有效,CLR也提供了CancellationToken类,它的功能和前面的例子类似
  2. 但是它缺少了一个Cancel方法,Cancel方法在另外一个类上进行暴露:CancellationTokenSource
  3. 这种分离的设计是出于安全考虑:只能对CancellationToken访问的方法可以检查取消,但是不能实例化取消

获取CancellationToken

  1. 想获得取消标志(cancellation token),先实例化CancellationTokenSource:var cancelSource=new CancellationTokenSource();
  2. 这会暴露一个Token属性,它会返回一个CancellationToken,所以我们可以这样调用:
var cancelSource=new CancellationTokenSource();
Task foo=foo(cancelSource.Token);
...
...(some time later)
cancelSource.Cancel();
//cancelSource.Token只能对取消进行检测,不能执行

Delay

  1. CLR里大部分的异步方法都支持CancellationToken,包括Delay方法
async Task Foo(CancellationToken cancellationToken)
{
    for(int i=0;i<10;i++)
    {
        Console.WriteLine(i);
        await Task.Delay(1000,cancellationToken);
    }
}
  1. 这时,task在遇到请求时会立即停止(而不是一秒钟后才停止)
  2. 这里我们无需调用ThrowIfCancellationRequested,因为Delay会替我们做
    取消标记在调用栈中很好地向下传播(就像是异常,取消请求在栈中向上级联一样)

同步方法

  1. 同步方法也支持取消(例如Task的Wait方法)。这种情况下,取消指令需要异步发出(例如,来自另一个Task)
var cancwlSource=new CancellationTokenSourec();
Task.Delay(5000).ContinueWith(ant=>cancwlSource.Cancel());
...

其它

  1. 事实上,您可以在构造CancellationTokenSource时指定一个时间间隔,以便在一段时间后启动取消。它对于实现超时非常有用,无论是同步还是异步:
var cancelSource=new CancellationSource(5000);
try
{
    await Foo(cancelSource.Token);
}
catch(OperationCanceledException e)
{
    Console.WriteLine("Cancelled");
}
  1. CancellationToken这个struct提供了一个Register方法,它可以让你注册一个回调委托,这个委托会在取消时触发。它会返回一个对象,这个对象在取消注册时可以被Dispose掉
  2. 编译器的异步函数生成的Task在遇到未处理的OperationCanceledException异常时会自动进入取消状态(IsCanceled返回true,IsFaulted返回false)
  3. 使用Task.Run创建的Task也是如此。这里是指向构造函数传递(相同的)CancellationToken
  4. 在异步场景中,故障Task和取消的Task之间的区别并不重要,因为它们在await时都会抛出一个OperationCanceledException。但这在高级并行编程场景(特别是条件continuation)中很重要

进度报告

  1. 有时,你希望异步操作在运行的过程中能实时反馈进度。一个简单的解决办法就是向异步方法传入一个Action委托,当进度变化的时候触发方法调用:
Task Foo(Action<int> onProgressPercentChanged)
{
    return Task.Run(()=>
    {
        for(int i=0;i<100;i++)
        {
            if(i%10==0)
            {
                onProgressPercentChanged(i/10);
                //Do Something compute-bound ...
            }
        }
    });
}
Action<int> progress=i=>Console.WriteLine(i+" %");
await Foo(progress);

  尽管这段代码可以在Console App很好的应用,但在富客户端应用却不理想,因为它是从worker线程报告进度,可能会导致消费者的线程安全问题

IProgress 和 Progress

  1. CLR提供了一对类型来解决此问题:
    IProgress接口
    Progress类(实现了上面的接口)
  2. 它们的目的就是包装一个委托,以便UI程序可以安全的通过同步上下文来报告进度
  3. 接口定义如下:
public interface IProgress<in T>
{
    void Report(T value);
}
  1. 使用IProgress
Task Foo(IProgress<int> onProgressPercentChanged)
{
    return Task.Run(()=>
    {
        for(int i=0;i<100;i++)
        {
            if(i%10==0)
            {
                onProgressPercentChanged.Report(i/10);
                //Do Something compute-bound ...
            }
        }
    });
}
  1. Progress的一个构造函数可以接受Action类型的委托:
var progress=new Progress<int> (i=>Console.WriteLine(i+" %"));
await Foo(progress);
  1. Progress还有一个ProgressChanged事件,您可以订阅它,而不是(或附加的)将Action委托传递给构造函数
  2. 在实例化Progress时,类捕获同步上下文(如果存在)
    当Foo调用Report时,委托是通过该上下文调用的
  3. 异步方法可以通过将int替换为公开一系列属性的自定义类型来实现更精细的进度报告

TAP(Task-Based Asynchronous Pattern)基于异步Task的模式

  1. .NET Core暴露了数百个返回Task且可以await的异步方法(主要和I/O相关)。大多数方法都遵循一个模式,叫做基于Task的异步模式(TAP)。这是我们迄今为止所描述的合理形式化。TAP方法执行以下操作:
    返回一个“热”(运行中的)Task或Task
    方法名以Async结尾(处理像Task组合器等情况)
    会被重载,以便接受CancellationToken或(和)IProgress,如果支持相关操作的话
    快速返回调用者(只有很小的初始化同步阶段)
    如果是I/O绑定,那么无需绑定线程
  2. C#来实现TAP还是比较简单的

Task组合器

  1. 异步函数有一个让其保持一致的协议(可以一致的返回Task),这能让其保持良好的结果:可以使用以及编写Task组合器,也就是可以组合Task,但是并不关心Task具体做什么的函数
  2. CLR提供了两个Task组合器:
    Task.WhenAny
    Task.WhenAll
  3. 本节课假设定义了以下方法:
async Task<int> Delay1(){await Task.Delay(1000);return 1;}
async Task<int> Delay2(){await Task.Delay(2000);return 3;}
async Task<int> Delay3(){await Task.Delay(3000);return 3;}

WhenAny

  1. 当一组Task中任何一个Task完成时,Task.WhenAny会返回完成的Task
Task<int> winningTask=await Task.WhenAny(Delay1(),Delay2(),Delay3());
Concole.WriteLine("Done");
Console.WriteLine(winningTask.Result);//1
  1. 因为Task.WhenAny本身就返回一个Task,我们对它进行await,就会返回最先完成的Task
  2. 上例完全是非阻塞的,包括最后一行(当访问Result属性时,winningTask已经完成),但最好还是对winningTask进行await,因为异常无需AggregateException包装就会重新抛出:Console.WriteLine(await winningTask);//1
  3. 实际上,我们可以在一步中执行两个await:int answer=await await Task.WhenAny(delay1(),Delay2(),Delay3());
  4. 如果“没赢”的Task后续发生了错误,那么异常将不会被观察到,除非你后续对它们进行await(或者查询其Exception属性)
  5. WhenAny很适合为不支持超时或取消的操作添加这些功能:
Task<string> task=SomeAsyncFunc();
//相当于设置了五秒超时
Task winner=await(Task.WhenAny(task,Task.Delay(5000)));
if(winner!=task)
{
    throw new TimeoutException();
}
string result=await task;//Unwarp result/re-throw
  1. 注意:本例中返回的结果是Task类型

WhenAll

  1. 当传给它的所有的Task都完成后,Task.WhenAll会返回一个Task
    await Task.WhenAll(Delay1(),Delay2(),Delay3());
    本例就会在3秒后结束
  2. 通过轮流对3个Task进行await,也可以得到类似的结果:
    Task task1=Delay1(),task2=Delay2(),taks3=Delay3();
    await task1;await task2;await task3;
    不同点是(除了3个await的低效):如果task1出错,我们就无需等待task2和task3了,因为它们的错误也不会被观察到

WhenAll异常

  1. 与之相对的,Task.WhenAll直到所有Task完成,它才会完成,即使有错误发生。如果有多个错误,它们的异常会包裹在Task的AggregateException里,这才是AggregateException的真正用途,包裹多个异常
  2. await组合的Task,只会抛出第一个异常,想要看到所有的异常,你需要这样做:
Task task1=Task.Run(()=>{throw null;});
Task task2=Task.Run(()=>{throw null;});
Task all=Task.WhenAll(task1,task2);
try
{
    await all;
}
catch
{
    Console.WriteLine(all.Exception.InnerExceptions.Count);//2
}
  1. 对一组Task调用WhenAll会返回Task<TResult[]>,也就是所有Task的组合结果
  2. 如果进行await,那么就会得到TResult[]:
Task<int> task1=Task.Run(()=>1);
Task<int> task2=Task.Run(()=>2);
int[] results=await Task.WhenAll(task1,task2);//{1,2}

实例

async Task<int> GetTotalSize(string[] uris)
{
    IEnumerable<Task<byte[]>> downloadTasks=uris.Select(uri=>
        new WebClient().DownloadDataTaskAsync(uri));

    byte[][] contents=await Task.WhenAll(downloadTasks);
    return contents.Sum(c=>c.Length);
}

优化

async Task<int> GetTotalSize(string[] uris)
{
    IEnumerable<Task<int>> downloadTask=uris.Select(async uri=>
        (await new WebClient().DownloadDataTaskAsync(uri)).Length);
    
    int[] contentLengths=await Task.WhenAll(downloadTasks);
    return contentLengths.Sum();
}

自定义Task组合器

  1. 可以编写自定义的Task组合器。最简单的组合器接收一个Task,看下例:
async static Task<TResult> WithTimeout<TResult>(this Task<TResult> task,TimeSpan timeout)
{
    Task winner=await Task.WhenAny(task,Task.Delay(timeout)).ConfigureAwait(false);
    if(winner!=task)
    {
        throw new TimeoutException();
    }
    return await task.ConfigureAwait(false);//Unwarp result/re-throw
}
  1. 这就是为等待的Task添加了超时的功能
  2. 因为这很可能是一个库方法,无需与外界共享状态,所以在await时我们使用了ConfigureAwait(false)来避免弹回到UI的同步上下文
  3. 通过在Task完成时取消Task.Delay我们可以改进上例的效率(避免了计时器的小开销):
async static Task<TResult> WithTimeout<TResult>(this Task<TResult> task,TimeSpan timeout)
{
    var cancelSource=new CancellationTokenSource();
    var delay=Task.Delay(timeout,cancelSource.Token);
    Task winner=await Task.WhenAny(task,delay).ConfigureAwait(false);
    if(winner==task)
    {
        cancelSource.Cancel();
    }
    else
    {
        throw new TimeoutException();
    }
    return await task.ConfigureAwait(false);//Unwarp result/re-throw
}

自定义Task组合器 通过CancellationToken放弃Task

static Task<TResult> WithCancellation<TResult>(this Task<TResult> task, CancellationToken cancelToken)
{
    var tcs = new TaskCompletionSource<TResult>();
    var reg = cancelToken.Register(() => tcs.TrySetCanceled());
    task.ContinueWith(ant =>
    {
        reg.Dispose();
        if (ant.IsCanceled)
        {
            tcs.TrySetCanceled();
        }
        else if (ant.IsFaulted)
        {
            tcs.TrySetException(ant.Exception.InnerException);
        }
        else
        {
            tcs.TrySetResult(ant.Result);
        }
    });
    return tcs.Task;
}

自定义Task组合器

  1. 这个组合器功能类似WhenAll,如果一个Task出错,那么其余的Task也立即出错:
async Task<TResult[]> WhenAllOrError<TResult>(params Task<TResult>[] tasks)
{
    var killJoy = new TaskCompletionSource<TResult[]>();
    foreach (var task in tasks)
    {
        task.ContinueWith(ant =>
        {
            if (ant.IsCanceled)
            {
                killJoy.TrySetCanceled();
            }
            else if (ant.IsFaulted)
            {
                killJoy.TrySetException(ant.Exception.InnerException);
            }
        });
    }
    return await await Task.WhenAny(killJoy.Task, Task.WhenAll(tasks)).ConfigureAwait(false);
}

  这里面TaskCompletionSource的任务就是当任意一个Task出错时,结束工作。所以我们没调用SetResult方法,只调用了它的TrySetCanceled和TrySetException方法。在这里ContinueWith要比GetAwaiter().OnCompleted更方便,因为我们不访问Task的Result,并且此刻不想弹回到UI线程

取消(cancellation)、进度报告、TAP(Task-Based Asynchronous Pattern)、Task组合器 结束

posted @ 2021-02-10 15:58  .NET好耶  阅读(1463)  评论(0编辑  收藏  举报