《Concurrency in C# Cookbook》--- 读书随记(2)

CHAPTER 2 Async Basics

《Concurrency in C# Cookbook》
Asynchronous, Parallel, and Multithreaded Programming

Author: Stephen Cleary

如果需要电子书的小伙伴,可以留下邮箱,看到了会发送的

本章向您介绍了使用async和await异步操作的基本知识。在这里,我们只处理自然的异步操作,即 HTTP 请求、数据库命令和 Web 服务调用等操作

2.1 Pausing for a Period of Time

Problem

您需要(异步地)等待一段时间。这是单元测试或实现重试延迟时的常见场景。在编写简单的超时时也会出现这种情况

Solution

Task 类型具有一个静态方法 Delay,该方法返回在指定时间之后完成的task

async Task<T> DelayResult<T>(T result, TimeSpan delay)
{
    await Task.Delay(delay);
    return result;
}

Exponential backoff算法是一种增加重试间隔时间的策略。在使用 Web 服务时使用它,以确保服务器不会被重试淹没

async Task<string> DownloadStringWithRetries(HttpClient client, string uri)
{
    // Retry after 1 second, then after 2 seconds, then 4.
    TimeSpan nextDelay = TimeSpan.FromSeconds(1);

    for (int i = 0; i != 3; ++i)
    {
        try
        {
            return await client.GetStringAsync(uri);
        }
        catch
        {
        }

        await Task.Delay(nextDelay);
        nextDelay = nextDelay + nextDelay;
    }

    // Try one last time, allowing the error to propagate.
    return await client.GetStringAsync(uri);
}

对于生产代码,我建议使用更彻底的解决方案,比如 Polly NuGet 库; 这段代码只是 Task 的一个简单示例。延迟使用

您还可以将 Task.Delay 用作一个简单的超时。CancellationTokenSource 是用于实现超时的常规类型。您可以将取消令牌包装在无限的Task.Delay中,以提供在指定时间后取消的任务。最后,使用 Task.Whenany 的计时器任务来实现“软超时”

async Task<string> DownloadStringWithTimeout(HttpClient client, string uri)
{
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));

    Task<string> downloadTask = client.GetStringAsync(uri);
    Task timeoutTask = Task.Delay(Timeout.InfiniteTimeSpan, cts.Token);

    Task completedTask = await Task.WhenAny(downloadTask, timeoutTask);

    if (completedTask == timeoutTask)
        return null;

    return await downloadTask;
}

虽然可以使用 Task.Delay 作为“软超时”,但这种方法有其局限性。如果操作超时,则不取消该操作; 在前面的示例中,下载任务将继续下载,并在放弃之前下载完整的响应。首选方法是使用取消令牌作为超时,并将其直接传递给操作。也就是说,有时候操作是不可取消的,在这种情况下,Task.Delay 可能被其他代码用来执行类似操作超时的操作

2.2 Returning Completed Tasks

Problem

您需要实现具有异步签名的同步方法。如果您从异步接口或基类继承,但希望同步实现该接口或基类,则可能出现这种情况。当单元测试异步代码时,当您需要一个异步接口的简单stub或mock时,这种技术特别有用

Solution

可以使用 Task.FromResult 创建并返回一个新的 Task< T > ,并且这个Task是带着结果和已经是完成状态的

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

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

如果是没有返回结果的,也就是它是Task的返回值的,那么可以使用 Task.CompletedTask 返回结果。除了这些,还有很多类似的静态方法提供,Task.FromException、Task.FromCanceled

Discussion

如果使用同步代码实现异步接口,请避免任何形式的阻塞。当方法可以异步实现时,异步方法阻塞然后返回已完成的任务并不理想。对于反例,请考虑 NETBCL 中的 Console 文本读取器。Console.In.ReadLineAsync 实际上会阻塞调用线程,直到读取一行,然后它将返回一个完成的任务。这种行为并不直观,并且让很多开发人员感到惊讶。如果异步方法阻塞,则阻止调用线程启动其他任务,这会干扰并发性,甚至可能导致死锁

如果经常使用具有相同值的 Task.FromResult,请考虑缓存实际任务。例如,如果您创建了一个结果为零的 Task < int > ,那么您就可以避免创建必须进行垃圾回收的额外实例

private static readonly Task<int> zeroTask = Task.FromResult(0);

Task<int> GetValueAsync()
{
    return zeroTask;
}

逻辑上,Task.FromResult、 Task.FromException 和 Task.FromCanceled 都是通用 TaskCompletionSource< T > 的助手方法和快捷方式。TaskCompletionSource< T > 是一个低级类型,可用于与其他形式的异步代码进行互操作。一般来说,如果你想返回一个已经完成的任务,你应该使用简写 Task.FromResult 和类似的函数。使用 TaskCompletionSource< T > 返回在将来某个时间完成的任务

2.3 Reporting Progress

Problem

您需要在操作执行时反映进度

Solution

使用提供的 IProgress < T > 和 Progress < T > 类型。您的异步方法应该采用 IProgress < T > 参数; T 是您需要报告的任何类型的进度

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

async Task CallMyMethodAsync()
{
    var progress = new Progress<double>();

    progress.ProgressChanged += (sender, args) =>
    {
        ...
    };

    await MyMethodAsync(progress);
}

2.4 Waiting for a Set of Tasks to Complete

Problem

您有几个任务,需要等待它们全部完成

Solution

框架为此提供了一个 Task.WhenAll 方法。此方法接受多个任务,并返回在所有这些任务完成时完成的任务

Task task1 = Task.Delay(TimeSpan.FromSeconds(1));
Task task2 = Task.Delay(TimeSpan.FromSeconds(2));
Task task3 = Task.Delay(TimeSpan.FromSeconds(1));
await Task.WhenAll(task1, task2, task3);

如果所有的Taks的返回类型都是一样的,那么这个方法会返回包含所有结果的数组

Task<int> task1 = Task.FromResult(3);
Task<int> task2 = Task.FromResult(5);
Task<int> task3 = Task.FromResult(7);
int[] results = await Task.WhenAll(task1, task2, task3);

存在一个包含 IEnumable 任务的 Task.WhenAll 方法重载; 但是,我不建议您使用它。每当我将异步代码与 LINQ 混合使用时,我发现当我显式地“具体化”序列时,代码更加清晰

async Task<string> DownloadAllAsync(HttpClient client, IEnumerable<string> urls)
{
    // Define the action to do for each URL.
    var downloads = urls.Select(url => client.GetStringAsync(url));

    // Note that no tasks have actually started yet
    // because the sequence is not evaluated.
    // Start all URLs downloading simultaneously.
    Task<string>[] downloadTasks = downloads.ToArray();

    // Now the tasks have all started.
    // Asynchronously wait for all downloads to complete.
    string[] htmlPages = await Task.WhenAll(downloadTasks);

    return string.Concat(htmlPages);
}

如果任何任务抛出异常,则 Task.WhenAll 将使其返回的任务与该异常一起发生故障。如果多个任务抛出异常,那么所有这些异常都将放置在 Task.WhenAll 返回的 Task 上。然而,当等待该任务时,只会抛出其中一个异常。如果需要每个特定的异常,可以检查 Task.WhenAll 返回的 Task 上的 Exception 属性

async Task ThrowNotImplementedExceptionAsync()
{
    throw new NotImplementedException();
}

async Task ThrowInvalidOperationExceptionAsync()
{
    throw new InvalidOperationException();
}

async Task ObserveOneExceptionAsync()
{
    var task1 = ThrowNotImplementedExceptionAsync();
    var task2 = ThrowInvalidOperationExceptionAsync();
    
    try
    {
        await Task.WhenAll(task1, task2);
    }
    catch (Exception ex)
    {
        // "ex" is either NotImplementedException or InvalidOperationException.
        ...
    }
}
async Task ObserveAllExceptionsAsync()
{
    var task1 = ThrowNotImplementedExceptionAsync();
    var task2 = ThrowInvalidOperationExceptionAsync();
    Task allTasks = Task.WhenAll(task1, task2);
    try
    {
        await allTasks;
    }
    catch
    {
        AggregateException allExceptions = allTasks.Exception;
        ...
    }
}

大多数情况下,在使用 Task.WhenAll 时,我不会观察到所有的异常,通常只响应抛出的第一个错误就足够了,而不是所有的异常

请注意,在前面的示例中,ThrowNotIntegrmentedExceptionAsync 和 ThrowInvalidOperationExceptionAsync 方法不直接抛出异常; 它们使用的是异步关键字,因此它们的异常被捕获并放置在正常返回的任务上。这是返回可等待类型的方法的正常和预期行为

2.5 Waiting for Any Task to Complete

Problem

您有几个任务,并且只需要响应其中一个即将完成的任务。当您在一个操作中进行多次独立尝试时,最常见的情况是遇到这个问题,例如,您可以同时从多个 Web 服务请求股票报价,但是您只关心第一个响应的服务

Solution

使用 Task.WhenAny 方法。Task.WhenAny 方法接受一系列任务,并返回在任何任务完成时完成的任务。返回任务的结果是已完成的任务

// Returns the length of data at the first URL to respond.
async Task<int> FirstRespondingUrlAsync(HttpClient client, string urlA, string urlB)
{
    // Start both downloads concurrently.
    Task<byte[]> downloadTaskA = client.GetByteArrayAsync(urlA);
    Task<byte[]> downloadTaskB = client.GetByteArrayAsync(urlB);

    // Wait for either of the tasks to complete.
    Task<byte[]> completedTask =
        await Task.WhenAny(downloadTaskA, downloadTaskB);

    // Return the length of the data retrieved from that URL.
    byte[] data = await completedTask;

    return data.Length;
}

Discussion

A 返回的任务从未以错误或取消状态完成。这个“外部”任务总是成功完成,其结果值是要完成的第一个 Task (“内部”任务)。如果内部任务使用异常完成,则该异常不会传播到外部任务(由 A 返回的任务)。您通常应该在内部任务完成后等待它,以确保观察到任何异常

当第一个任务完成时,考虑是否取消剩余的任务。如果其他任务没有被取消,但也从未被等待,那么它们将被放弃。被放弃的任务将运行到完成,其结果将被忽略。这些被放弃的任务的任何异常也将被忽略。如果这些任务没有被取消,它们会继续运行,并且可能会不必要地使用资源,比如 HTTP 连接、 DB 连接或计时器

可以使用 Task.WhenAny 来实现超时(例如,使用 Task.Delay 作为任务之一) ,但不推荐使用 Task.Delay 。通过cancellation来表示超时更自然,而cancellation还有一个额外的好处,即如果超时,它实际上可以取消操作

2.6 Processing Tasks as They Complete

Problem

您有一组任务要等待,并且希望在每个任务完成后对其进行一些处理。但是,您希望在每个任务完成后立即执行处理,而不是等待任何其他任务

async Task<int> DelayAndReturnAsync(int value)
{
    await Task.Delay(TimeSpan.FromSeconds(value));
    return value;
}
// Currently, this method prints "2", "3", and "1".
// The desired behavior is for this method to print "1", "2", and "3".
async Task ProcessTasksAsync()
{
    // Create a sequence of tasks.
    Task<int> taskA = DelayAndReturnAsync(2);
    Task<int> taskB = DelayAndReturnAsync(3);
    Task<int> taskC = DelayAndReturnAsync(1);
    Task<int>[] tasks = new[] { taskA, taskB, taskC };
    // Await each task in order.
    foreach (Task<int> task in tasks)
    {
        var result = await task;
        Trace.WriteLine(result);
    }
}

代码目前按顺序等待每个任务,即使序列中的第三个任务是第一个要完成的任务。您希望代码执行处理(例如,Trace。WriteLine) ,因为每个任务都在不等待其他任务的情况下完成。

Solution

最简单的解决方案是通过引入一个更高级别的异步方法来重构代码,该方法处理等待任务和处理其结果。一旦处理过程被分解出来,代码就大大简化了:

async Task AwaitAndProcessAsync(Task<int> task)
{
    int result = await task;
    Trace.WriteLine(result);
}
// This method now prints "1", "2", and "3".
async Task ProcessTasksAsync()
{
    // Create a sequence of tasks.
    Task<int> taskA = DelayAndReturnAsync(2);
    Task<int> taskB = DelayAndReturnAsync(3);
    Task<int> taskC = DelayAndReturnAsync(1);
    Task<int>[] tasks = new[] { taskA, taskB, taskC };
    IEnumerable<Task> taskQuery =
        from t in tasks select AwaitAndProcessAsync(t);

    Task[] processingTasks = taskQuery.ToArray();

    // Await all processing to complete
    await Task.WhenAll(processingTasks);
}

所显示的重构是解决这个问题的最干净和最可移植的方法。请注意,它与原始代码略有不同。这个解决方案将同时执行任务处理,而原始代码将一次执行一个任务处理

2.7 Avoiding Context for Continuations

Problem

当一个异步方法在等待之后恢复时,默认情况下它将在相同的上下文中继续执行。如果该上下文是一个 UI 上下文,并且大量异步方法在 UI 上下文中恢复,那么这可能会导致性能问题

Solution

async Task ResumeOnContextAsync()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
    // This method resumes within the same context.
}

async Task ResumeWithoutContextAsync()
{
    await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
    // This method discards its context when it resumes.
}

为了避免在上下文中恢复,请等待 ConfigureAwait 的结果,并为其 ContineOnCapturedContext 参数传递 false

Discussion

在 UI 线程上运行太多的延续会导致性能问题。这种类型的性能问题很难诊断,因为不是单一的方法使系统变慢。相反,随着应用程序变得越来越复杂,UI 性能开始受到“数千次剪纸”的影响

真正的问题是,在 UI 线程上有多少个延续是太多了?这个问题没有确切的答案,但是微软的 Lucian Wischik 公布了通用 Windows 团队使用的指导方针: 每秒100左右是可以的,但是每秒1000左右就太多了

最好从一开始就避免这个问题。对于您编写的每个异步方法,如果它不需要恢复到其原始上下文,则使用 ConfigureAwait。这样做没有坏处

在编写异步代码时注意上下文也是一个好主意。通常,异步方法要么需要上下文(处理 UI 元素或 ASP.NET 请求/响应) ,要么不需要上下文(执行后台操作)。如果您有一个异步方法,其中包含需要上下文的部分和不需要上下文的部分,请考虑将其分割为两个(或更多)异步方法。这种方法有助于将代码更好地组织成层

2.8 Handling Exceptions from async Task Methods

Problem

异常处理是任何设计的关键部分。为成功案例进行设计很容易,但是在处理失败案例之前,设计是不正确的。幸运的是,处理来自异步任务方法的异常非常简单

Solution

可以通过简单的 try/catch 捕获异常,就像对同步代码所做的那样

async Task ThrowExceptionAsync()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
    throw new InvalidOperationException("Test");
}

async Task TestAsync()
{
    try
    {
        await ThrowExceptionAsync();
    }
    catch (InvalidOperationException)
    {
    }
}

异步 Task 方法引发的异常放置在返回的 Task 上。它们只有在等待返回的任务时才会引发

async Task ThrowExceptionAsync()
{
    await Task.Delay(TimeSpan.FromSeconds(1));
    throw new InvalidOperationException("Test");
}

async Task TestAsync()
{
    // The exception is thrown by the method and placed on the task.
    Task task = ThrowExceptionAsync();
    try
    {
        // The exception is re-raised here, where the task is awaited.
        await task;
    }
    catch (InvalidOperationException)
    {
        // The exception is correctly caught here.
    }
}

Discussion

当异步 Task 方法抛出异常时,将捕获该异常并将其放在返回的 Task 上。因为异步 void 方法没有一个 Task 来放置它们的异常,所以它们的行为是不同的

当您等待出错的 Task 时,将重新引发该任务的第一个异常。如果您熟悉重新引发异常的问题,那么您可能想知道堆栈跟踪。请放心: 当重新引发异常时,原始堆栈跟踪将得到正确保存

2.9 Handling Exceptions from async void Methods

Problem

您有一个异步 void 方法,需要处理从该方法传播的异常

Solution

没有好的解决办法。如果可能的话,将方法更改为返回 Task 而不是 void。在某些情况下,这样做是不可能的; 例如,假设您需要对 ICommand 实现进行单元测试(该实现必须返回 void)。在这种情况下,可以为 Execute 方法提供返回 Task 的重载:

sealed class MyAsyncCommand : ICommand
{
    async void ICommand.Execute(object parameter)
    {
        await Execute(parameter);
    }

    public async Task Execute(object parameter)
    {
        ... // Asynchronous command implementation goes here.
    }

    ... // Other members (CanExecute, etc.)
}

最好避免从异步 void 方法中传播异常。如果必须使用异步 void 方法,请考虑将其所有代码包装在 try 块中并直接处理异常

还有另一种处理异步 void 方法异常的解决方案。当异步 void 方法传播异常时,该异常将在异步 void 方法开始执行时处于活动状态的 SynchronizationContext 上引发。如果您的执行环境提供了 SynchronizationContext,那么它通常有办法在全局范围内处理这些顶级异常。例如,WPF 有 Application.DispatcherUnhandledException,Universal Windows 有 Application.UnhandledException,ASP.NET 有 UseExceptionHandler 中间件

2.10 Creating a ValueTask

Problem

您需要实现一个返回 ValueTask< T > 的方法

Solution

ValueTask < T > 在通常可以返回同步结果并且很少有异步行为的场景中用作返回类型。一般来说,对于您自己的应用程序代码,应该使用 Task < T > 作为返回类型,而不是 ValueTask < T > 。只有在分析显示性能有所提高后,才可以考虑在自己的应用程序中使用 ValueTask < T > 作为返回类型。也就是说,在某些情况下,您需要实现一个返回 Value Task < T > 的方法。IAsyncDisposable 就是这样一种情况,它的 DispoAsync 方法返回 ValueTask

posted @   huang1993  阅读(53)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· DeepSeek “源神”启动!「GitHub 热点速览」
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· NetPad:一个.NET开源、跨平台的C#编辑器
点击右上角即可分享
微信分享提示