金沙官网线上译文: async/await SynchronizationContext

SynchronizationContext in an ASP.NET application

假设我们想在 ASP.NET 应用程序中重用这个代码,我们将代码 Console.WriteLine 转换为 HttpConext.Response.Write 即可,我们可以看到页面上的输出:

public class HomeController : Controller
{
    public ActionResult Index()
    {
        HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Starting");
        var t1 = ExecuteAsync(() => Library.BlockingOperation()));
        var t2 = ExecuteAsync(() => Library.BlockingOperation()));
        var t3 = ExecuteAsync(() => Library.BlockingOperation()));

        Task.WaitAll(t1, t2, t3);
        HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Finished");

        return View();
    }

    private async Task ExecuteAsync(Action action)
    {
        await Task.Yield();

        action();
        HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId}");
    }
}

我们会发现,在浏览器中启动此页面后不会加载。 看来我们是引入了一个死锁。那么这里到底发生了什么呢?

死锁的原因是控制台应用程序调度异步操作与 ASP.NET 不同。 虽然控制台应用程序只是调度线程池上的任务,而 ASP.NET 确保同一 HTTP 请求的所有异步任务都按顺序执行。 由于 Task.Yield 将剩余的工作排队,并立即将控制权返回给调用者,因此我们在运行 Task.WaitAll 的时候有三个等待操作。 Task.WaitAll 是一个阻塞操作,类似的阻塞操作还有如 Task.Wait 或 Task.Result,因此阻止当前线程。

ASP.NET 是在线程池上调度它的任务,阻塞线程并不是导致死锁的原因。 但是由于是顺序执行,这导致不允许等待操作开始执行。 如果他们无法启动,他们将永远不能完成,被阻止的线程不能继续。

此调度机制由 SynchronizationContext 类控制。 每当我们等待任务时,在等待的操作完成后,在 await 语句(即继续)之后运行的所有内容将在当前 SynchronizationContext 上被调度。 上下文决定了如何、何时和在何处执行任务。 您可以使用静态 SynchronizationContext.Current 属性访问当前上下文,并且该属性的值在 await 语句之前和之后始终相同。

在控制台应用程序中,SynchronizationContext.Current 始终为空,这意味着连接可以由线程池中的任何空闲线程拾取,这是在第一个示例中能并行执行操作的原因。 但是在我们的 ASP.NET 控制器中有一个 AspNetSynchronizationContext,它确保前面提到的顺序处理。

要点一:

不要使用阻塞任务同步方法,如 Task.Result,Task.Wait,Task.WaitAll 或 Task.WaitAny。 控制台应用程序的 Main 方法目前是该规则唯一的例外(因为当它们获得完全异步时的行为会有所改变)。

 

“名称” 说明 异常
避免 Async Void 最好使用 async Task 方法而不是 async void 方法 事件处理程序
始终使用 Async 不要混合阻塞式代码和异步代码 控制台 main 方法
配置上下文 尽可能使用 ConfigureAwait(false) 需要上下文的方法

SynchronizationContext in a console application

让我们来看看控制台应用程序中的一些代码:

public class ConsoleApplication
{
    public static void Main()
    {
        Console.WriteLine($"{DateTime.Now.ToString("T")} - Starting");
        var t1 = ExecuteAsync(() => Library.BlockingOperation());
        var t2 = ExecuteAsync(() => Library.BlockingOperation()));
        var t3 = ExecuteAsync(() => Library.BlockingOperation()));

        Task.WaitAll(t1, t2, t3);
        Console.WriteLine($"{DateTime.Now.ToString("T")} - Finished");
        Console.ReadKey();
    }

    private static async Task ExecuteAsync(Action action)
    {
        // Execute the continuation asynchronously
        await Task.Yield();  // The current thread returns immediately to the caller
                             // of this method and the rest of the code in this method
                             // will be executed asynchronously

        action();

        Console.WriteLine($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId}");
    }
}

其中 Library.BlockingOperation() 是一个第三方库,我们用它来阻塞正在使用的线程。 它可以是任何阻塞操作,但是为了测试的目的,您可以使用 Thread.Sleep(2) 来代替实现。

运行程序,输出结果为:

16:39:15 - Starting

16:39:17 - Completed task ``on thread 11

16:39:17 - Completed task ``on thread 10

16:39:17 - Completed task ``on thread 9

16:39:17 - Finished

在示例中,我们创建三个任务阻塞线程一段时间。 Task.Yield 强制一个方法是异步的,通过调度这个语句之后的所有内容(称为_continuation_)来执行,但立即将控制权返回给调用者(Task.Yield 是告知调度者"我已处理完成,可以将执行权让给其他的线程",至于最终调用哪个线程,由调度者决定,可能下一个调度的线程还是自己本身)。 从输出中可以看出,由于 Task.Yield 所有的操作最终并行执行,总执行时间只有两秒。

 

始终使用 Async

异步代码让我想起了一个故事,有个人提出世界是悬浮在太空中的,但是一个老妇人立即提出质疑,她声称世界位于一个巨大乌龟的背上。 当这个人问乌龟站在哪里时,老夫人回答:“很聪明,年轻人,下面是一连串的乌龟!”在将同步代码转换为异步代码时,您会发现,如果异步代码调用其他异步代码并且被其他异步代码所调用,则效果最好 — 一路向下(或者也可以说“向上”)。 其他人已注意到异步编程的传播行为,并将其称为“传染”或将其与僵尸病毒进行比较。 无论是乌龟还是僵尸,无可置疑的是,异步代码趋向于推动周围的代码也成为异步代码。 此行为是所有类型的异步编程中所固有的,而不仅仅是新 async/await 关键字。

“始终异步”表示,在未慎重考虑后果的情况下,不应混合使用同步和异步代码。 具体而言,通过调用 Task.Wait 或 Task.Result 在异步代码上进行阻塞通常很糟糕。 对于在异步编程方面“浅尝辄止”的程序员,这是个特别常见的问题,他们仅仅转换一小部分应用程序,并采用同步 API 包装它,以便代码更改与应用程序的其余部分隔离。 不幸的是,他们会遇到与死锁有关的问题。 在 MSDN 论坛、Stack Overflow 和电子邮件中回答了许多与异步相关的问题之后,我可以说,迄今为止,这是异步初学者在了解基础知识之后最常提问的问题: “为何我的部分异步代码死锁?”

图 3 演示一个简单示例,其中一个方法发生阻塞,等待 async 方法的结果。 此代码仅在控制台应用程序中工作良好,但是在从 GUI 或 ASP.NET 上下文调用时会死锁。 此行为可能会令人困惑,尤其是通过调试程序单步执行时,这意味着没完没了的等待。 在调用 Task.Wait 时,导致死锁的实际原因在调用堆栈中上移。

图 3 在异步代码上阻塞时的常见死锁问题

 

public static class DeadlockDemo
{
  private static async Task DelayAsync()
  {
    await Task.Delay(1000);
  }
  // This method causes a deadlock when called in a GUI or ASP.NET context.
public static void Test()
  {
    // Start the delay.
var delayTask = DelayAsync();
    // Wait for the delay to complete.
delayTask.Wait();
  }
}

这种死锁的根本原因是 await 处理上下文的方式。 默认情况下,当等待未完成的 Task 时,会捕获当前“上下文”,在 Task 完成时使用该上下文恢复方法的执行。 此“上下文”是当前 SynchronizationContext(除非它是 null,这种情况下则为当前 TaskScheduler)。 GUI 和 ASP.NET 应用程序具有 SynchronizationContext,它每次仅允许一个代码区块运行。 当 await 完成时,它会尝试在捕获的上下文中执行 async 方法的剩余部分。 但是该上下文已含有一个线程,该线程在(同步)等待 async 方法完成。 它们相互等待对方,从而导致死锁。

请注意,控制台应用程序不会形成这种死锁。 它们具有线程池 SynchronizationContext 而不是每次执行一个区块的 SynchronizationContext,因此当 await 完成时,它会在线程池线程上安排 async 方法的剩余部分。 该方法能够完成,并完成其返回任务,因此不存在死锁。 当程序员编写测试控制台程序,观察到部分异步代码按预期方式工作,然后将相同代码移动到 GUI 或 ASP.NET 应用程序中会发生死锁,此行为差异可能会令人困惑。

此问题的最佳解决方案是允许异步代码通过基本代码自然扩展。 如果采用此解决方案,则会看到异步代码扩展到其入口点(通常是事件处理程序或控制器操作)。 控制台应用程序不能完全采用此解决方案,因为 Main 方法不能是 async。 如果 Main 方法是 async,则可能会在完成之前返回,从而导致程序结束。 图 4 演示了指导原则的这一例外情况: 控制台应用程序的 Main 方法是代码可以在异步方法上阻塞为数不多的几种情况之一。

图 4 Main 方法可以调用 Task.Wait 或 Task.Result

 

class Program
{
  static void Main()
  {
    MainAsync().Wait();
  }
  static async Task MainAsync()
  {
    try
    {
      // Asynchronous implementation.
await Task.Delay(1000);
    }
    catch (Exception ex)
    {
      // Handle exceptions.
}
  }
}

允许异步代码通过基本代码扩展是最佳解决方案,但是这意味着需进行许多初始工作,该应用程序才能体现出异步代码的实际好处。 可通过几种方法逐渐将大量基本代码转换为异步代码,但是这超出了本文的范围。 在某些情况下,使用 Task.Wait 或 Task.Result 可能有助于进行部分转换,但是需要了解死锁问题以及错误处理问题。 我现在说明错误处理问题,并在本文后面演示如何避免死锁问题。

每个 Task 都会存储一个异常列表。 等待 Task 时,会重新引发第一个异常,因此可以捕获特定异常类型(如 InvalidOperationException)。 但是,在 Task 上使用 Task.Wait 或 Task.Result 同步阻塞时,所有异常都会用 AggregateException 包装后引发。 请再次参阅图 4。 MainAsync 中的 try/catch 会捕获特定异常类型,但是如果将 try/catch 置于 Main 中,则它会始终捕获 AggregateException。 当没有 AggregateException 时,错误处理要容易处理得多,因此我将“全局”try/catch 置于 MainAsync 中。

金沙官网线上,至此,我演示了两个与异步代码上阻塞有关的问题: 可能的死锁和更复杂的错误处理。 对于在 async 方法中使用阻塞代码,也有一个问题。 请考虑此简单示例:

 

public static class NotFullyAsynchronousDemo
{
  // This method synchronously blocks a thread.
public static async Task TestNotFullyAsync()
  {
    await Task.Yield();
    Thread.Sleep(5000);
  }
}

此方法不是完全异步的。 它会立即放弃,返回未完成的任务,但是当它恢复执行时,会同步阻塞线程正在运行的任何内容。 如果此方法是从 GUI 上下文调用,则它会阻塞 GUI 线程;如果是从 ASP.NET 请求上下文调用,则会阻塞当前 ASP.NET 请求线程。 如果异步代码不同步阻塞,则其工作效果最佳。 图 5 是将同步操作替换为异步替换的速查表。

图 5 执行操作的“异步方式”

执行以下操作… 替换以下方式… 使用以下方式
检索后台任务的结果 Task.Wait 或 Task.Result await
等待任何任务完成 Task.WaitAny await Task.WhenAny
检索多个任务的结果 Task.WaitAll await Task.WhenAll
等待一段时间 Thread.Sleep await Task.Delay

总结这第二个指导原则便是,应避免混合使用异步代码和阻塞代码。 混合异步代码和阻塞代码可能会导致死锁、更复杂的错误处理及上下文线程的意外阻塞。 此指导原则的例外情况是控制台应用程序的 Main 方法,或是(如果是高级用户)管理部分异步的基本代码。

忘掉上下文

正如我们在前面的例子中看到的,上下文捕获可以非常方便。 但是在许多情况下,我们不需要为 "continuation" 恢复的上下文。 上下文捕获是有代价的,如果我们不需要它,最好避免这个附加的逻辑。 假设我们要切换到日志框架,而不是直接写入加载的网页。 我们重写我们的帮助:

class AsyncHelper
{
    public static async Task ExecuteAsync(Action action)
    {
        await Task.Run(action);
        Log.Info($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId}");
    }
}

现在在 await 语句之后,AspNetSynchronizationContext 中没有我们需要的东西,因此在这里不恢复它是安全的。 在等待任务之后,可以使用 ConfigureAwait(false) 禁用上下文捕获。 这将告诉等待的任务调度其当前 SynchronizationContext 的延续。 因为我们使用 Task.Run,上下文是 null,因此连接被调度在线程池上(没有顺序执行约束)。

使用 ConfigureAwait(false) 时要记住的两个细节:

  • 当使用 ConfigureAwait(false) 时,不能保证 "continuation" 将在不同的上下文中运行。 它只是告诉基础设施不恢复上下文,而不是主动切换到其他的东西(使用 Task.Run 如果你想摆脱上下文)。
  • 禁用上下文捕获仅限于使用 ConfigureAwait(false) 的 await 语句。 在下一个 await(在同一方法中,在调用方法或被调用的方法)语句中,如果没有另外说明,上下文将被再次捕获和恢复。 所以你需要添加 ConfigureAwait(false) 到所有 await 语句,以防你不依赖上下文。

 

本文中的最佳做法更大程度上是“指导原则”,而不是实际规则。 其中每个指导原则都有一些例外情况。 我将解释每个指导原则背后的原因,以便可以清楚地了解何时适用以及何时不适用。 图 1 中总结了这些指导原则;我将在以下各节中逐一讨论。

TL; DR;

由于异步代码的 SynchronizationContext,异步代码在不同环境中的表现可能不同。 但是,当遵循最佳做法时,我们可以将遇到问题的几率减少到最低限度。 因此,请确保您熟悉 async/await 最佳实践并坚持使用它们。

 

原文: Context Matters

避免 Async Void

Async 方法有三种可能的返回类型: Task、Task<T> 和 void,但是 async 方法的固有返回类型只有 Task 和 Task<T>。 当从同步转换为异步代码时,任何返回类型 T 的方法都会成为返回 Task<T> 的 async 方法,任何返回 void 的方法都会成为返回 Task 的 async 方法。 下面的代码段演示了一个返回 void 的同步方法及其等效的异步方法:

 

void MyMethod()
{
  // Do synchronous work.
Thread.Sleep(1000);
}
async Task MyMethodAsync()
{
  // Do asynchronous work.
await Task.Delay(1000);
}

返回 void 的 async 方法具有特定用途: 用于支持异步事件处理程序。 事件处理程序可以返回某些实际类型,但无法以相关语言正常工作;调用返回类型的事件处理程序非常困难,事件处理程序实际返回某些内容这一概念也没有太大意义。 事件处理程序本质上返回 void,因此 async 方法返回 void,以便可以使用异步事件处理程序。 但是,async void 方法的一些语义与 async Task 或 async Task<T> 方法的语义略有不同。

Async void 方法具有不同的错误处理语义。 当 async Task 或 async Task<T> 方法引发异常时,会捕获该异常并将其置于 Task 对象上。 对于 async void 方法,没有 Task 对象,因此 async void 方法引发的任何异常都会直接在 SynchronizationContext(在 async void 方法启动时处于活动状态)上引发。 图 2 演示本质上无法捕获从 async void 方法引发的异常。

图 2 无法使用 Catch 捕获来自 Async Void 方法的异常

 

private async void ThrowExceptionAsync()
{
  throw new InvalidOperationException();
}
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
  try
  {
    ThrowExceptionAsync();
  }
  catch (Exception)
  {
    // The exception is never caught here!
throw;
  }
}

可以通过对 GUI/ASP.NET 应用程序使用 AppDomain.UnhandledException 或类似的全部捕获事件观察到这些异常,但是使用这些事件进行常规异常处理会导致无法维护。

Async void 方法具有不同的组合语义。 返回 Task 或 Task<T> 的 async 方法可以使用 await、Task.WhenAny、Task.WhenAll 等方便地组合而成。 返回 void 的 async 方法未提供一种简单方式,用于向调用代码通知它们已完成。 启动几个 async void 方法不难,但是确定它们何时结束却不易。 Async void 方法会在启动和结束时通知 SynchronizationContext,但是对于常规应用程序代码而言,自定义 SynchronizationContext 是一种复杂的解决方案。

Async void 方法难以测试。 由于错误处理和组合方面的差异,因此调用 async void 方法的单元测试不易编写。 MSTest 异步测试支持仅适用于返回 Task 或 Task<T> 的 async 方法。 可以安装 SynchronizationContext 来检测所有 async void 方法都已完成的时间并收集所有异常,不过只需使 async void 方法改为返回 Task,这会简单得多。

显然,async void 方法与 async Task 方法相比具有几个缺点,但是这些方法在一种特定情况下十分有用: 异步事件处理程序。 语义方面的差异对于异步事件处理程序十分有意义。 它们会直接在 SynchronizationContext 上引发异常,这类似于同步事件处理程序的行为方式。 同步事件处理程序通常是私有的,因此无法组合或直接测试。 我喜欢采用的一个方法是尽量减少异步事件处理程序中的代码(例如,让它等待包含实际逻辑的 async Task 方法)。 下面的代码演示了这一方法,该方法通过将 async void 方法用于事件处理程序而不牺牲可测试性:

 

private async void button1_Click(object sender, EventArgs e)
{
  await Button1ClickAsync();
}
public async Task Button1ClickAsync()
{
  // Do asynchronous work.
await Task.Delay(1000);
}

如果调用方不希望 async void 方法是异步的,则这些方法可能会造成严重影响。 当返回类型是 Task 时,调用方知道它在处理将来的操作;当返回类型是 void 时,调用方可能假设方法在返回时完成。 此问题可能会以许多意外方式出现。 在接口(或基类)上提供返回 void 的方法的 async 实现(或重写)通常是错误的。 某些事件也假设其处理程序在返回时完成。 一个不易察觉的陷阱是将 async lambda 传递到采用 Action 参数的方法;在这种情况下,async lambda 返回 void 并继承 async void 方法的所有问题。 一般而言,仅当 async lambda 转换为返回 Task 的委托类型(例如,Func<Task>)时,才应使用 async lambda。

总结这第一个指导原则便是,应首选 async Task 而不是 async void。 Async Task 方法更便于实现错误处理、可组合性和可测试性。 此指导原则的例外情况是异步事件处理程序,这类处理程序必须返回 void。 此例外情况包括逻辑上是事件处理程序的方法,即使它们字面上不是事件处理程序(例如 ICommand.Execute implementations)。

不仅仅如此

SynchronizationContext 可以做的不仅仅是调度任务。 AspNetSynchronizationContext 也确保正确的用户设置在当前正在执行的线程(记住,它是在整个线程池中安排工作),它使得  HttpContext.Current 可用。
在我们的代码中这些都是没有必要的,因为我们能够使用 Controller 的 HttpContext 属性。 如果我们想要提取我们超级有用的 ExecuteAsync 到一个帮助类,这变得很明显:

class AsyncHelper
{
    public static async Task ExecuteAsync(Action action)
    {
        await Task.Run(action);
        HttpContext.Current.Response.Write($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId} 
");
    }
}

我们刚刚将 HttpContext.Response 更改为静态可用的 HttpContext.Current.Response 。 这仍然可以工作,这得益于 AspNetSynchronizationContext,但如果你尝试在 Task.Run 中访问 HttpContext.Current ,你会得到一个 NullReferenceException,因为 HttpContext.Current 没有设置。

 

图 1 异步编程指导原则总结

本文由金沙官网线上发布于编程,转载请注明出处:金沙官网线上译文: async/await SynchronizationContext

您可能还会对下面的文章感兴趣: