这是Mike Rousos发表的一篇留言
我最近有机会帮助开发人员使用ASP.NET Core应用程序,该应用程序在功能上正确但在用户负载很重时很慢。我们发现了几个不同的因素,这些因素在调查时会导致应用程序的减速,但大部分问题都是阻塞线程的一些变化,这些线程可能以非阻塞方式运行。对于我来说,这是一个很好的提醒,在web应用程序等多线程场景中使用非阻塞模式是多么重要。
谨防锁定
我们注意到的第一个问题之一(通过使用PerfView进行CPU分析)是花费了很多时间来记录代码路径。这一点通过在调试器中临时调用调用堆栈得到证实,该调试器显示许多线程被阻塞,等待获取锁定。事实证明,应用程序中的一些常见日志代码路径错误地冲洗了Application Insights遥测。Flushing App Insights需要全局锁定,通常不应在应用程序执行过程中手动完成。但是,在这种情况下,Application Insights每个HTTP请求至少被刷新一次,在负载下,这成为一个大瓶颈!
你可以在下面的图片中看到示例。在本示例中,我有一个ASP.NET Core 2.0 Web API,可以使用Entity Framework Core对Azure SQL数据库执行常见的CRUD操作。加载测试运行在我的笔记本电脑上的服务(不是最好的测试环境),请求的平均处理时间约为0.27秒。然而,在锁内添加自定义ILoggerProvider
呼叫之后Console.WriteLine
,平均响应时间升至1.85秒 - 这对于最终用户来说是一个非常明显的差异。使用PerfView和一个调试器,我们可以看到很多时间(PerfView的示例的66%)花费在自定义日志记录方法中,并且在等待轮到他们的锁时,很多工作线程停留在那里(延迟响应) 。
ASP.NET Core的控制台日志记录器在版本1.0和1.1中曾经有过这样的锁定,导致它在高流量的情况下速度很慢,但这些问题已在ASP.NET Core 2.0中解决。尽管如此,记住生产仍然是最佳实践。
对于非常敏感的场景,您可以使用LoggerMessage
甚至进一步优化日志记录。LoggerMessage
允许提前定义日志消息,以便每次记录特定消息时不需要分析消息模板。我们的文档中提供了更多详细信息,但基本模式是日志消息被定义为强类型代表:
// This delegate logs a particular predefined message private static readonly Actionint, Exception> _retrievedWidgets = LoggerMessage.Define<int>( LogLevel.Information, new EventId(1, nameof(RetrievedWidgets)), "Retrieved {Count} widgets"); // A helper extension method to make it easy to call the // LoggerMessage-produced delegate from an ILogger public static void RetrievedWidgets(this ILogger logger, int count) => _retrievedWidgets(logger, count, null);
然后,根据需要调用该代理以进行高性能日志记录:
var widgets = await _dbContext.Widgets.AsNoTracking()。ToListAsync(); _logger.RetrievedWidgets(widgets.Length);
保持异步调用
我们调查在缓慢的ASP.NET Core应用程序中发现的另一个问题类似:调用Task.Wait()
或Task.Result
使用应用程序控制器而不是使用异步调用await
。通过使控制器动作异步并等待这些类型的调用,正在等待被调用的任务完成时,正在执行的线程被释放以服务于其他请求。
我在示例应用程序中通过用同步替代方法替换操作方法中的异步调用来重现此问题。起初,这只会导致一个小的放缓(0.32秒的平均响应,而不是0.27秒),因为我在示例中调用的异步方法都非常快。为了模拟更长的异步任务,我更新了我的示例的异步和同步版本,以便Task.Delay(200)
在每个控制器中都有一个动作(当然,我await
在异步时和.Wait()
同步时使用这些动作)。在异步情况下,平均响应时间从0.27s变为0.46s,如果每个请求都有额外的暂停或200ms,那么它或多或少就是我们所期望的。但在同步情况下,平均时间从0.32秒变为1.47秒!
下面的图表显示了很多这种缓存来自哪里。图表中的绿线代表每秒提供的请求,红线代表用户负载。在第一张图表(运行我的示例的异步版本时采取的)中,您可以看到,随着用户数量的增加,更多的请求将被提供。Task.Wait()
另一方面,在第二张图表(对应于案例)中,在用户负载增加之后,每秒钟的请求数量仍然保持平稳几分钟,然后才增加以跟上。这是因为现有的服务请求线程池无法跟上更多的用户(因为它们都是在Task.Wait()
呼叫时被阻塞),并且吞吐量直到更多的线程被创建时才得到改善。
将调试器附加到这两种情况下,我发现在异步测试中使用了75个托管线程,但在同步测试中使用了232个线程。虽然同步测试并最终加入足够的线程来处理传入的请求,调用Task.Result
并Task.Wait
引起延迟用户负荷变化时。分析器(如AsyncFixer)可以帮助查找可以使用异步替代方法的位置,并且如果需要,还可以使用EventSource事件在运行时查找阻塞调用。
装饰
在我帮助调查的应用程序中存在一些其他性能问题(服务器GC未在ASP.NET Core 1.1模板中启用,例如,已在ASP.NET Core 2.0中更正的内容),但问题的一个常见主题我们发现是在不必要地阻塞线程。无论是来自锁争用还是等待任务完成,为了在ASP.NETCore应用程序中保持良好性能,保持线程畅通非常重要。
如果您想深入了解自己的应用程序以查找性能问题区域,请查看Channel9 PerfView教程,以了解PerfView如何帮助在.NET应用程序中发现CPU和内存相关的性能问题。
(原文:ASP.NET Core Performance Improvements)