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 线程。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值