C# 异步编程基础(十) 取消(cancellation)、进度报告、TAP(Task-Based Asynchronous Pattern)、Task组合器
此入门教程是记录下方参考资料视频的过程
开发工具:Visual Studio 2019
目录
C# 异步编程基础(六)Continuation 继续/延续 、TaskCompletionSource、实现 Task.Delay
C# 异步编程基础(十) 取消(cancellation)、进度报告、TAP(Task-Based Asynchronous Pattern)、Task组合器
取消 cancellation
- 使用取消标志来实现对部分进行取消,可以封装一个类:
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
- 先不管线程安全(应该在读写IsCancellationRequested时进行lock),这个模式非常的有效,CLR也提供了CancellationToken类,它的功能和前面的例子类似
- 但是它缺少了一个Cancel方法,Cancel方法在另外一个类上进行暴露:CancellationTokenSource
- 这种分离的设计是出于安全考虑:只能对CancellationToken访问的方法可以检查取消,但是不能实例化取消
获取CancellationToken
- 想获得取消标志(cancellation token),先实例化CancellationTokenSource:
var cancelSource=new CancellationTokenSource();
- 这会暴露一个Token属性,它会返回一个CancellationToken,所以我们可以这样调用:
var cancelSource=new CancellationTokenSource();
Task foo=foo(cancelSource.Token);
...
...(some time later)
cancelSource.Cancel();
//cancelSource.Token只能对取消进行检测,不能执行
Delay
- CLR里大部分的异步方法都支持CancellationToken,包括Delay方法
async Task Foo(CancellationToken cancellationToken)
{
for(int i=0;i<10;i++)
{
Console.WriteLine(i);
await Task.Delay(1000,cancellationToken);
}
}
- 这时,task在遇到请求时会立即停止(而不是一秒钟后才停止)
- 这里我们无需调用ThrowIfCancellationRequested,因为Delay会替我们做
取消标记在调用栈中很好地向下传播(就像是异常,取消请求在栈中向上级联一样)
同步方法
- 同步方法也支持取消(例如Task的Wait方法)。这种情况下,取消指令需要异步发出(例如,来自另一个Task)
var cancwlSource=new CancellationTokenSourec();
Task.Delay(5000).ContinueWith(ant=>cancwlSource.Cancel());
...
其它
- 事实上,您可以在构造CancellationTokenSource时指定一个时间间隔,以便在一段时间后启动取消。它对于实现超时非常有用,无论是同步还是异步:
var cancelSource=new CancellationSource(5000);
try
{
await Foo(cancelSource.Token);
}
catch(OperationCanceledException e)
{
Console.WriteLine("Cancelled");
}
- CancellationToken这个struct提供了一个Register方法,它可以让你注册一个回调委托,这个委托会在取消时触发。它会返回一个对象,这个对象在取消注册时可以被Dispose掉
- 编译器的异步函数生成的Task在遇到未处理的OperationCanceledException异常时会自动进入取消状态(IsCanceled返回true,IsFaulted返回false)
- 使用Task.Run创建的Task也是如此。这里是指向构造函数传递(相同的)CancellationToken
- 在异步场景中,故障Task和取消的Task之间的区别并不重要,因为它们在await时都会抛出一个OperationCanceledException。但这在高级并行编程场景(特别是条件continuation)中很重要
进度报告
- 有时,你希望异步操作在运行的过程中能实时反馈进度。一个简单的解决办法就是向异步方法传入一个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
- CLR提供了一对类型来解决此问题:
IProgress接口
Progress类(实现了上面的接口) - 它们的目的就是包装一个委托,以便UI程序可以安全的通过同步上下文来报告进度
- 接口定义如下:
public interface IProgress<in T>
{
void Report(T value);
}
- 使用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 ...
}
}
});
}
- Progress
的一个构造函数可以接受Action 类型的委托:
var progress=new Progress<int> (i=>Console.WriteLine(i+" %"));
await Foo(progress);
- Progress
还有一个ProgressChanged事件,您可以订阅它,而不是(或附加的)将Action委托传递给构造函数 - 在实例化Progress
时,类捕获同步上下文(如果存在)
当Foo调用Report时,委托是通过该上下文调用的 - 异步方法可以通过将int替换为公开一系列属性的自定义类型来实现更精细的进度报告
TAP(Task-Based Asynchronous Pattern)基于异步Task的模式
- .NET Core暴露了数百个返回Task且可以await的异步方法(主要和I/O相关)。大多数方法都遵循一个模式,叫做基于Task的异步模式(TAP)。这是我们迄今为止所描述的合理形式化。TAP方法执行以下操作:
返回一个“热”(运行中的)Task或Task
方法名以Async结尾(处理像Task组合器等情况)
会被重载,以便接受CancellationToken或(和)IProgress,如果支持相关操作的话
快速返回调用者(只有很小的初始化同步阶段)
如果是I/O绑定,那么无需绑定线程 - C#来实现TAP还是比较简单的
Task组合器
- 异步函数有一个让其保持一致的协议(可以一致的返回Task),这能让其保持良好的结果:可以使用以及编写Task组合器,也就是可以组合Task,但是并不关心Task具体做什么的函数
- CLR提供了两个Task组合器:
Task.WhenAny
Task.WhenAll - 本节课假设定义了以下方法:
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
- 当一组Task中任何一个Task完成时,Task.WhenAny会返回完成的Task
Task<int> winningTask=await Task.WhenAny(Delay1(),Delay2(),Delay3());
Concole.WriteLine("Done");
Console.WriteLine(winningTask.Result);//1
- 因为Task.WhenAny本身就返回一个Task,我们对它进行await,就会返回最先完成的Task
- 上例完全是非阻塞的,包括最后一行(当访问Result属性时,winningTask已经完成),但最好还是对winningTask进行await,因为异常无需AggregateException包装就会重新抛出:
Console.WriteLine(await winningTask);//1
- 实际上,我们可以在一步中执行两个await:
int answer=await await Task.WhenAny(delay1(),Delay2(),Delay3());
- 如果“没赢”的Task后续发生了错误,那么异常将不会被观察到,除非你后续对它们进行await(或者查询其Exception属性)
- 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
- 注意:本例中返回的结果是Task类型
WhenAll
- 当传给它的所有的Task都完成后,Task.WhenAll会返回一个Task
await Task.WhenAll(Delay1(),Delay2(),Delay3());
本例就会在3秒后结束 - 通过轮流对3个Task进行await,也可以得到类似的结果:
Task task1=Delay1(),task2=Delay2(),taks3=Delay3();
await task1;await task2;await task3;
不同点是(除了3个await的低效):如果task1出错,我们就无需等待task2和task3了,因为它们的错误也不会被观察到
WhenAll异常
- 与之相对的,Task.WhenAll直到所有Task完成,它才会完成,即使有错误发生。如果有多个错误,它们的异常会包裹在Task的AggregateException里,这才是AggregateException的真正用途,包裹多个异常
- 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
}
- 对一组Task
调用WhenAll会返回Task<TResult[]>,也就是所有Task的组合结果 - 如果进行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组合器
- 可以编写自定义的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
}
- 这就是为等待的Task添加了超时的功能
- 因为这很可能是一个库方法,无需与外界共享状态,所以在await时我们使用了ConfigureAwait(false)来避免弹回到UI的同步上下文
- 通过在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组合器
- 这个组合器功能类似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线程