译文: async/await SynchronizationContext 上下文问题

async / await 使异步代码更容易写,由于它隐藏了不少细节。 许多这些细节都捕获在 SynchronizationContext 中,这些可能会改变异步代码的行为彻底因为你执行你的代码的环境(例如WPF,Winforms,控制台或ASP.NET)所控制。 若果尝试经过忽略 SynchronizationContext 产生的影响,您可能遇到死锁和竞争条件情况。html

SynchronizationContext 控制任务连续的调度方式和位置,而且有许多不一样的上下文可用。 若是你正在编写一个 WPF 应用程序,构建一个网站或使用 ASP.NET 的API,你应该知道你已经使用了一个特殊的 SynchronizationContext 。浏览器

 

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) 来代替实现。app

运行程序,输出结果为:
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 全部的操做最终并行执行,总执行时间只有两秒。框架

 

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}");
    }
}

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

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

ASP.NET 是在线程池上调度它的任务,阻塞线程并非致使死锁的缘由。 可是因为是顺序执行,这致使不容许等待操做开始执行。 若是他们没法启动,他们将永远不能完成,被阻止的线程不能继续。网站

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

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

要点一:

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

 

解决方案

如今咱们知道不该该使用 Task.WaitAll,让咱们修复咱们的控制器的 Index Action:

public async Task<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()));
 
    await Task.WhenAll(t1, t2, t3);
    HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Finished 
");
 
    return View();
}

咱们将 Task.WaitAll(t1,t2,t3)更改成非阻塞等待 Task.WhenAll(t1,t2,t3),这也要求咱们将方法的返回类型从 ActionResult 更改成 async 任务。

更改后咱们看到页面上输出以下结果:

16:41:03 - Starting
16:41:05 - Completed task on thread 60
16:41:07 - Completed task on thread 50
16:41:09 - Completed task on thread 74
16:41:09 - Finished
 
这看起来更好,但咱们有另外一个问题。 页面如今须要六秒的加载,而不是咱们在控制台应用程序中的两秒。 输出很好地显示 AspNetSynchronizationContext 确实调度其在线程池上的工做,由于咱们能够看到执行任务的不一样线程。 可是因为这种上下文的顺序性质,它们不会并行运行。 虽然咱们解决了死锁,咱们的复制粘贴代码仍然低于在控制台应用程序中使用的效率。

要点二:

永远不要假设异步代码是以并行方式执行的,除非你显式地将其设置为并行执行。 用 Task.Run 或 Task.Factory.StartNew 调度异步代码来使他们并行运行。

 

第二次尝试

咱们使用新的的规则:

private async Task ExecuteAsync(Action action)
{
    await Task.Yield();
 
    action();
    HttpContext.Response.Write($"{DateTime.Now.ToString("T")} - Completed task on thread {Thread.CurrentThread.ManagedThreadId} 
");
}

to:

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

Task.Run 在没有 SynchronizationContext 的状况下在线程池上调度给定的操做。 这意味着在任务内运行的全部内容都将 SynchronizationContext.Current 设置为 null。 结果是全部入队操做均可以由任何线程自由选取,而且它们没必要遵循ASP.NET上下文指定的顺序执行顺序。 这也意味着任务可以并行执行。

注意 HttpContext 不是线程安全的,所以咱们不该该在 Task.Run 中访问它,由于这可能在 html 输出上产生奇怪的结果。 可是因为上下文捕获,Response.Write 被确保发生在 AspNetSynchronizationContext(这是在 await 以前的当前上下文)中,确保对 HttpContext 的序列化访问。

此次的输出结果为:

16:42:27 - Starting
16:42:29 - Completed task on thread 9
16:42:29 - Completed task on thread 12
16:42:29 - Completed task on thread 14
16:42:29 - Finished
 

不只仅如此

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 没有设置。

 

忘掉上下文

正如咱们在前面的例子中看到的,上下文捕获能够很是方便。 可是在许多状况下,咱们不须要为 "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 语句,以防你不依赖上下文。

 

TL; DR;

因为异步代码的 SynchronizationContext,异步代码在不一样环境中的表现可能不一样。 可是,当遵循最佳作法时,咱们能够将遇到问题的概率减小到最低限度。 所以,请确保您熟悉 async/await 最佳实践并坚持使用它们。

 

原文: Context Matters

相关文章
相关标签/搜索