C#异步编程学习笔记5 之 异步模式
异步模式
取消(cancellation)
取消,指的是在等待一个异步操作完成的时候,对异步操作进行取消。
-
使用取消标志来实现对并发进行取消。具体实现可以封装一个类:
calss CancellationToken { public bool IsCancellsationRequested { get; private set; } public void Cancel() { IsCancellationRequested = true; } public void ThrowIfCancellationRequested() { if(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
先不管线程安全(应该在读写 IsCancellsationRequested 时进行 lock),这个模式非常的有效,CLR 也提供了一个 CancellationToken 类,它的功能和前面的例子类似。但是,它缺少一个 Cancel 方法,Cancel 方法在另外一个类上暴露:CancellationTokenSource。
这种分离的设计是处于安全考虑:只能对 CancellationToken 访问的方法可以检查取消(操作),但是不能实例化取消(操作,即不能执行取消)。
想要获得取消标志(cancellation token),先实例化 CancellationTokenSource:
var cancelSource = new CancellationTokenSource();
这会暴露一个 Toke 属性,它会返回一个 CancellationToken ,所以可以这样调用:
var cancelSource = new CancellationTokenSource();
Task foo = Foo(cancelSource.Token); //此处的 Token 属性只能对取消动作进行检查,但是不能执行取消动作
...
...(some time later)
cancelSource.Cancel(); //取消动作必须在 CancellationTokenSource 这个类上。
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 在遇到请求时会立即停止(而不是 1 秒钟后才停止)
这里,我们无需调用 ThrowIfCancellationRequested,因为 Delay 会替我们做。
- 取消标记在调用栈中很好地向下传播(就像是因为遇到异常,取消请求会在调用栈中向上级联一样)。
同步方法
同步方法也支持取消(例如 Task 的 Wait 方法)。这种情况下,取消指令需要异步发出(例如,来自另一个 Task)
var cancelSource = new CancellationTokenSource();
Task.Dealy(5000).ContinueWith(ant => cancelSource.Cancel());
...
其它
-
事实上,可以在构造 CancellationTokenSource 的时候指定一个时间间隔,以便一段时间后启动取消。它对于实现超时非常有用,无论是同步还是异步:
var cancelSource = new CancellationTokenSource(5000); try{ await Foo (cancelSource.Token); } catch(OperationCanceledException ex){ 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 < 1000; i++)
{
if(i % 10 == 0)
onProgressPercentChanged(i / 10);
// Do Soomething compute-bound....
}
});
}
Action<int> progress = i => Console.WriteLine(i + " %");
await Foo(progress);
尽管这段代码可以在 Console App(控制台应用)中很好的应用,但在富客户端应用中却不理想。因为它是从 worker 线程报告的进度,可能会导致消费者的线程安全问题。
IProgress<T> 和 Progress<T>
CLR 提供了一对类型来解决此问题:
- IProgress<T> 接口
- Progress<T> 类(实现了上面的接口)
它们的目的就是包装一个委托,以便 UI 程序可以安全的通过同步上下文来报告进度。
接口定义如下:
public interface IProgress<in T>
{
void Report(T value);
}
使用 IProgress<T>:
Task Foo (IProgress<int> onProgressPercentChanged)
{
return Task.Run(() => {
for(int i = 0; i < 1000; i++)
{
if(i % 10 == 0)
onProgressPercentChanged.Report(i / 10);
// Do Soomething compute-bound....
}
});
}
Action<int> progress = i => Console.WriteLine(i + " %");
await Foo(progress);
Progress<T> 的一个构造函数可以接受 Action<T> 类型的委托:
var progress = new Progerss<int>(i => Console.WriteLine(i + " %"));
await Foo(progress);
Progress<T> 还有一个 ProgressChanged 事件,您可以订阅它,而不是 [或附加的] 将 Action 委托传递给构造函数。
在实例化 Progerss<int> 时,类捕获同步上下文(如果存在)。当 Foo 调用 Report 时,委托就是通过该上下文调用的。
异步方法可以通过将 int 替换为公开一系列属性的自定义类型来实现更精细的进度报告。
基于异步 Task 的模式(TA P)
TAP,英文全称 Task-Based Asyncchronous Pattern。
.NET Core 暴露了数百个返回 Task 且可以 await 的异步方法(主要和 I/O 相关)。大多数方法都遵循一个模式,叫做 基于 Task 的异步模式(TAP)。这是我们迄今为止所描述的合理化形式。TAP 方法执行以下操作:
- 返回一个“热”(运行中的)Task 或 Task<TResult>
- 方法名以 Async 结尾(除了像 Task 组合器等情况)
- 会被重载,以便接受 CancellationToken 或(和) IProgress<T>,如果支持相关操作的话。
- 快速返回调用者(只有很小的初始化同步阶段)
- 如果是 I/O 绑定,那么无需绑定线程。
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 2; }
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());
Console.WriteLine("Done");
Console.WriteLine(WinningTask.Result);
因为 Task.WhenAny 本事就返回一个 Task,我们对它进行 await,就会返回最先完成的 Task。
上述的例子完全时非阻塞的,包括最后一行(当访问 Result 属性时,winningTask 已完成),但最好还是对 winningTask 进行 await,因为异常无需 AggregateException 包装就会重新抛出;
Console.WriteLine(await winningTask);
实际上,我们可以在一步中执行两个 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;
注意,本例中返回的结果是 Task 类型。
WhenAll
当传给它的所有的 Task 都完成后,Task.WhenAll 会返回一个 Task。如下面的例子中,程序会在 3 秒后结束。
await Task.WhenAll(Delay1(), Delay2(), Delay3());
通过轮流对 3 个 Task 进行 await,也可以得到类似的结果:
Task task1 = Delay1(), task2 = Delay2(), task3 = Delay3();
await task1; await task2; await task3;
不同点是:
- 3 个 await 的更低效
- 3 个 await 时,如果 task1 出错,我们就无需等待 task2 和 task3 了,它们的错误也不会被观察到。
WhenAll 异常
-
Task.WhenAll 直到所有 Task 完成,它才会完成,即使有错误发生。如果有多个错误,它们的异常会包裹在 Task 的 AggregateException 里(PS:这才是 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); }
-
对一组 Task<TResult> 调用 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);
实例
有如下一个例子使用 Task.WhenAll:
async Task<int> GetTotalSize(string[] uri)
{
IEnumerable<Task<byte[]>> downloadTasks = uri.Select(uri =>
new WebClient().DownloadDataTaskAsync(uri));
byte[][] contents = await Task.WhenAll(downloadTasks);
return contents.Sum(c => c.Length);
}
上述代码可做如下优化:
async Task<int> GetTotalSize(string[] uri)
{
IEnumerable<Task<int>> downloadTasks = uri.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);
}
这就是为等待的 Task 添加了超时的功能。
因为这很可能使一个库方法,无需与外界共享状态,所以在 await 时我们使用了 ConfigureAwait(false) 来避免弹回到 UI 的同步上下文。
通过在 Task 完成时取消 Task.Dealy 我们可以改进上例的效率(避免了计时器的小开销):
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);
}
通过 CancellationToken 放弃 Task
async 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;
}
下面这个组合器功能类似 WhenAll,如果一个 Task 出错,那么其余的 Task 也立即出错:
async Task<TResult[]> WhenAllOrError<TResult>
{
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 线程。