异步编程基础

2020/03/02 30

async

暂停一段时间

需要让程序(以异步方式)等待一段时间。这在进行单元测试或者实现重试延迟时非常有用。本解决方案也能用于实现简单的超时。

指数退避

指数退避是一种重试策略,重试的延迟时间会逐次增加。在访问 Web 服务时,最好的方式就是采用指数退避,它可以防止服务器被太多的重试阻塞。

在实际产品的开发中,建议你采用更周密的方案,例如微软企业库中的瞬间错误处理模块(Transient Error Handling Block),或者 Polly。下面的代码只是一个使用 Task.Delay 的简单例子。

static async Task<string> DownloadStringWithRetries(string uri)
{
    using var client = new HttpClient();
    // 第 1 次重试前等 1 秒,第 2 次等 2 秒,第 3 次等 4 秒。
    var nextDelay = TimeSpan.FromSeconds(1);
    for (int i = 0; i < 3; i++)
    {
        try
        {
            return await client.GetStringAsync(uri);
        }
        catch { }

        await Task.Delay(nextDelay);
        nextDelay *= 2;
    }
    // 最后重试一次,以便让调用者知道出错信息。
    return await client.GetStringAsync(uri);
}

超时功能

用 Task.Delay 实现一个简单的超时功能。本例中代码的目的是:如果服务在 3 秒内没有响应,就返回 null。

static async Task<string> DownloadStringWithTimeout(string uri)
{
    using var client = new HttpClient();
    var downloadTask = client.GetStringAsync(uri);
    var timeoutTask = Task.Delay(3000);

    var completedTask = await Task.WhenAny(downloadTask, timeoutTask);
    if (completedTask == timeoutTask)
    {
        return null;
    }
    return await downloadTask;
}

Task.Delay 适合用于对异步代码进行单元测试或者实现重试逻辑。要实现超时功能的话最好使用 CancellationToken。

返回完成的任务

如何实现一个具有异步签名的同步方法。如果从异步接口或基类继承代码,但希望用同步的方法来实现它,就会出现这种情况。对异步代码做单元测试,以及用简单的生成方法存根(stub)或者模拟对象(mock)来产生异步接口,这两种情况下都可使用这种技术。

interface IMyAsyncInterface
{
    Task<int> GetValueAsync();
}

class MySynchronousImplementation : IMyAsyncInterface
{
    public Task<int> GetValueAsync()
    {
        return Task.FromResult(3);
    }
}

Task.FromResult 只能提供结果正确的同步 Task 对象。如果要让返回的 Task 对象有一个其他类型的结果(例如以 NotImplementedException 结束的 Task 对象),就得自行创建使用 TaskCompletionSource 的辅助方法:

static Task<T> NotImplementedAsync<T>()
{
    var tcs = new TaskCompletionSource<T>();
    tcs.SetException(new NotImplementedException());
    return tcs.Task;
}

从概念上讲,Task.FromResult 只不过是 TaskCompletionSource 的一个简化版本,它与上面的代码非常类似。

如果用 Task.FromResult 反复调用同一参数,则可考虑用一个实际的 task 变量。例如,可以一次性建立一个结果为 0 的 Task 对象,在以后的调用中就不需要创建额外的实例了,这样可减少垃圾回收的次数:

private static readonly Task<int> zeroTask = Task.FromResult(0);
static Task<int> GetValueAsync()
{
    return zeroTask;
}

报告进度

异步操作执行的过程中,需要展示操作的进度。

static async Task MyMethodAsync(IProgress<double> progress = null)
{
    double percentComplete = 0;
    while (!done)
    {
        ...
        if (progress != null)
            progress.Report(percentComplete);
    }
}

调用上述方法:

static async Task CallMyMethodAsync()
{
    var progress = new Progress<double>();
    progress.ProgressChanged += (sender, args) =>
    {
        ...
    };
    await MyMethodAsync(progress);
}

需要注意的是,IProgress.Report 方法可以是异步的。这意味着真正报告进度之前,MyMethodAsync 方法会继续运行。基于这个原因,最好把 T 定义为一个不可变类型,或者至少是值类型。如果 T 是一个可变的引用类型,就必须在每次调用 IProgress.Report 时,创建一个单独的副本。

等待一组任务完成

执行几个任务,等待它们全部完成。

Task task1 = Task.Delay(1000);
Task task2 = Task.Delay(2000);
Task task3 = Task.Delay(1000);

await Task.WhenAll(task1, task2, task3);

如果所有任务的结果类型相同,并且全部成功地完成,则 Task.WhenAll 返回存有每个任务执行结果的数组:

Task<int> task1 = Task.FromResult(3);
Task<int> task2 = Task.FromResult(5);
Task<int> task3 = Task.FromResult(7);

int[] result = await Task.WhenAll(task1, task2, task3);
// "results" 含有 { 3, 5, 7 }

Task.WhenAll 方法有以 IEnumerable 类型作为参数的重载,但建议大家不要使用。只要异步代码与 LINQ 结合,显式的“具体化”序列(即对序列求值,创建集合)就会使代码更清晰:

static async Task<string> DownloadAllAsync(IEnumerable<string> urls)
{
    using var client = new HttpClient();

    // 定义每一个 url 的使用方法。
    var downloads = urls.Select(url => client.GetStringAsync(url));
    // 注意,到这里,序列还没有求值,所以所有任务都还没真正启动。

    // 下面,所有的 URL 下载同步开始。
    Task<string>[] downloadTasks = downloads.ToArray();
    // 到这里,所有的任务已经开始执行了。

    // 用异步方式等待所有下载完成。
    string[] htmlPages = await Task.WhenAll(downloadTasks);

    return string.Concat(htmlPages);
}

如果有一个任务抛出异常,则 Task.WhenAll 会出错,并把这个异常放在返回的 Task 中。如果多个任务抛出异常,则这些异常都会放在返回的 Task 中。但是,如果这个 Task 在被 await 调用,就只会抛出其中的一个异常。如果要得到每个异常,可以检查 Task.WhenAll 返回的 Task 的 Exception 属性。

等待任意一个任务完成

执行若干个任务,只需要对其中任意一个的完成进行响应。这主要用于:对一个操作进行多种独立的尝试,只要一个尝试完成,任务就算完成。例如,同时向多个 Web 服务询问股票价格,但是只关心第一个响应的。

static async Task<int> FristRespondingUrlAsync(string urlA,string urlB)
{
    using var client = new HttpClient();

    // 并发地开始两个下载任务。
    Task<byte[]> downloadTaskA = client.GetByteArrayAsync(urlA);
    Task<byte[]> downloadTaskB = client.GetByteArrayAsync(urlB);

    // 等待任意一个任务完成。
    Task<byte[]> completedTask = await Task.WhenAny(downloadTaskA, downloadTaskB);

    // 返回从 URL 得到的数据的长度。
    byte[] data = await completedTask;
    return data.Length;
}

Task.WhenAny 返回的 task 对象永远不会以“故障”或“已取消”状态作为结束。该方法的运行结果总是一个 Task 首先完成。如果这个任务完成时有异常,这个异常也不会传递给 Task.WhenAny 返回的 Task 对象。因此,通常需要在 Task 对象完成后继续使用 await。

第一个任务完成后,考虑是否要取消剩下的任务。如果其他任务没有被取消,也没有被继续 await,那它们就处于被遗弃的状态。被遗弃的任务会继续运行直到完成,它们的结果会被忽略,抛出的任何异常也会被忽略。

任务完成时的处理

正在 await 一批任务,希望在每个任务完成时对它做一些处理。另外,希望在任务一完成就立即进行处理,而不需要等待其他任务。

static async Task<int> DelayAndReturnAsync(int val)
{
    await Task.Delay(TimeSpan.FromSeconds(val));
    return val;
}

// 当前,此方法输出“2”, “3”, “1”。
// 我们希望它输出“1”, “2”, “3”。
static async Task ProcessTasksAsync()
{
    // 创建任务队列。
    Task<int> taskA = DelayAndReturnAsync(2);
    Task<int> taskB = DelayAndReturnAsync(3);
    Task<int> taskC = DelayAndReturnAsync(1);
    var tasks = new[] { taskA, taskB, taskC };

    // 按顺序 await 每个任务。
    foreach (var task in tasks)
    {
        var result = await task;
        Console.WriteLine(result);
    }
}

解决方法:

// 现在,这个方法输出“1”, “2”, “3”。
static async Task ProcessTasksAsync()
{
    // 创建任务队列。
    Task<int> taskA = DelayAndReturnAsync(2);
    Task<int> taskB = DelayAndReturnAsync(3);
    Task<int> taskC = DelayAndReturnAsync(1);
    var tasks = new[] { taskA, taskB, taskC };

    var processingTasks = tasks.Select(async t =>
    {
        var result = await t;
        Console.WriteLine(result);
    }).ToArray();

    // 等待全部处理过程的完成。
    await Task.WhenAll(processingTasks);
}

如果上面重构代码的办法不可取,我们还有可选方案。Stephen Toub 和 Jon Skeet 都开发了一个扩展方法,可以让任务按顺序完成,并返回一个任务数组。Setphen Toub 的解决方案见博客文档“Parallel Programming with .NET”(http://t.cn/RhR2V6n),Jon Skeet 的解决方案见他的博客(http://t.cn/RhR2xu9)。

// 现在,这个方法输出“1”, “2”, “3”。
static async Task ProcessTasksAsync()
{
    // 创建任务队列。
    Task<int> taskA = DelayAndReturnAsync(2);
    Task<int> taskB = DelayAndReturnAsync(3);
    Task<int> taskC = DelayAndReturnAsync(1);
    var tasks = new[] { taskA, taskB, taskC };

    // 等待每一个任务完成。
    foreach (var task in tasks.OrderByCompletion())
    {
        var result = await task;
        Console.WriteLine(result);
    }
}

避免上下文延续

在默认情况下,一个 async 方法在被 await 调用后恢复运行时,会在原来的上下文中运行。如果是 UI 上下文,并且有大量的 async 方法在 UI 上下文中恢复,就会引起性能上的问题。

为了避免在上下文中恢复运行,可让 await 调用 ConfigureAwait 方法的返回值,参数 continueOnCapturedContext 设为 false:

async Task ResumeOnContextAsync()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
    // 这个方法在同一个上下文中恢复运行。
}
async Task ResumeWithoutContextAsync()
{
    await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
    // 这个方法在恢复运行时,会丢弃上下文。
}

真正的问题是,在 UI 线程中有多少延续任务,才算是太多?这没有固定的答案,不过微软公司的 Lucian Wischik 公布了一个 WinRT 团队的指导标准(http://t.cn/RhR2KGi):每秒 100 个左右尚可,但每秒 1000 个左右就太多了。

评论