ASP.NET Core 性能优化最佳实践
本文提供了 ASP.NET Core 的性能最佳实践指南。
积 极利用缓存
这里有一篇文档在多个部分中讨论了如何积极利用缓存。 有关详细信息,请参阅︰ https://docs.microsoft.com/en-us/aspnet/core/performance/caching/response?view=aspnetcore-3.1.
了解代码中的热点路径
在本文档中, 代码热点路径 定义为频繁调用的代码路径以及执行时间的大部分时间。 代码热点路径通常限制应用程序的扩展和性能,并在本文档的多个部分中进行讨论。
避免阻塞式调用
ASP.NET Core 应用程序应设计为同时处理许多请求。 异步 API 可以使用一个小池线程通过非阻塞式调用来处理数以千计的并发请求。 线程可以处理另一个请求,而不是等待长时间运行的同步任务完成。
ASP.NET Core 应用程序中的常见性能问题通常是由于那些本可以异步调用但却采用阻塞时调用而导致的。 同步阻塞会调用导致 线 程池饥饿 和响应时间降级。
不要:
- 通过调用 Task.Wait 或 Task.Result 来阻止异步执行。
- 在公共代码路径中加锁。 ASP.NET Core 应用程序应设计为并行运行代码,如此才能使得性能最佳。
- 调用 Task.Run 并立即 await 。 ASP.NET Core 本身已经是在线程池线程上运行应用程序代码了,因此这样调用 Task.Run 只会导致额外的不必要的线程池调度。 而且即使被调度的代码会阻止线程, Task.Run 也并不能避免这种情况,这样做没有意义。
要:
- 确保 代码热点路径 全部异步化。
- 如在进行调用数据读写、I/O 处理和长时间操作的 API 时,存在可用的异步 API。那么务必选择异步 API 。 但是,不要 使用 Task.Run 来包装同步 API 使其异步化。
- 确保 controller/Razor Page actions 异步化。 整个调用堆栈是异步的,就可以利用 async/await 模式的性能优势。
使用性能分析程序 ( 例如 PerfView) 可用于查找频繁添加到 线程池 的线程。
Microsoft-Windows-DotNETRuntime/ThreadPoolWorkerThread/Start事件表示新线程被添加到线程池。
使用 IEumerable<T> 或 IAsyncEnumerable<T> 作为返回值
在 Action 中返回IEumerable<T>将会被序列化器中进行同步迭代 。 结果是可能导致阻塞或者线程池饥饿。 想要要避免同步迭代集合,可以在返回迭代集合之前使用 ToListAsync使其异步化。
从 ASP.NET Core 3.0 开始, IAsyncEnumerable<T> 可以用作为 IEumerable<T> 的替代方法,以异步方式进行迭代。 有关更多信息,请参阅 Controller Action 的返回值类型。
尽可能少的使用大对象
.NET Core 垃圾收集器 在 ASP.NET Core 应用程序 中起到自动管理内存的分配和释放的作用。 自动垃圾回收通常意味着开发者不需要担心如何或何时释放内存。 但是,清除未引用的对象将会占用 CPU 时间,因此开发者应最小化 代码热点路径 中的分配的对象。 垃圾回收在大对象上代价特大 (> 85 K 字节 ) 。 大对象存储在 large object heap 上,需要 full (generation 2) garbage collection 来清理。 与 generation 0 和 generation 1 不同,generation 2 需要临时暂挂应用程序。 故而频繁分配和取消分配大型对象会导致性能耗损。
建议 :
- 要 考虑缓存频繁使用的大对象。 缓存大对象可防止昂贵的分配开销。
- 要使用 ArrayPool<T> 作为池化缓冲区以保存大型数组。
- 不要 在代码热点路径 上分配许多短生命周期的大对象。
可以通过查看 PerfView 中的垃圾回收 (GC) 统计信息来诊断并检查内存问题,其中包括:
- 垃圾回收挂起时间。
- 垃圾回收中耗用的处理器时间百分比。
- 有多少垃圾回收发生在 generation 0, 1, 和 2.
有关更多信息,请参阅 垃圾回收和性能。
优化数据操作和 I/O
与数据存储器和其他远程服务的交互通常是 ASP.NET Core 应用程序最慢的部分。 高效读取和写入数据对于良好的性能至关重要。
建议 :
- 要 以异步方式调用所有数据访问 API 。
- 不要 读取不需要的数据。 编写查询时,仅返回当前 HTTP 请求所必需的数据。
- 要 考虑缓存从数据库或远程服务检索的频繁访问的数据 ( 如果稍微过时的数据是可接受的话 ) 。 根据具体的场景,可以使用 MemoryCache 或 DistributedCache。 有关更多信息,请参阅 https://docs.microsoft.com/en-us/aspnet/core/performance/caching/response?view=aspnetcore-3.1.
- 要 尽量减少网络往返。 能够单次调用完成就不应该多次调用来读取所需数据。
- 要 在 Entity Framework Core 访问数据以用作只读情况时, 使用 no-tracking方式查询。 EF Core 可以更高效地返回 no-tracking 查询的结果。
- 要 使用过滤器和聚集 LINQ 查询 (例如,
.Where,.Select或.Sum语句) ,以便数据库执行过滤提高性能 。 - 要 考虑 EF Core 可能在客户端解析一些查询运算符,这可能导致查询执行效率低下。 有关更多信息,请参阅 客户端计算相关的性能问题。
- 不要 在集合上使用映射查询,这会导致执行 "N + 1" SQL 查询。 有关更多信息,请参阅 优化子查询。
请参阅 EF 高性能专题 以了解可能提高应用性能的方法:
在代码提交之前,我们建议评估上述高性能方法的影响。 编译查询的额外复杂性可能无法一定确保性能提高。
可以通过使用 Application Insights 或使用分析工具查看访问数据所花费的时间来检测查询问题。 大多数数据库还提供有关频繁执行的查询的统计信息,这也可以作为重要参考。
通过 HttpClientFactory 建立 HTTP 连接池
虽然 HttpClient 实现了 IDisposable 接口,但它其实被设计为可以重复使用单个实例。 关闭 HttpClient 实例会使套接字在短时间内以 TIME_WAIT 状态打开。 如果经常创建和释放 HttpClient 对象,那么应用程序可能会耗尽可用套接字。 在 ASP.NET Core 2.1 中,引入了HttpClientFactory 作为解决这个问题的办法。 它以池化 HTTP 连接的方式从而优化性能和可靠性。
建议 :
- 不要 直接创建和释放
HttpClient实例。 - 要 使用 HttpClientFactory 来获取
HttpClient实例。 有关更多信息,请参阅 使用 HttpClientFactory 以实现弹性 HTTP 请求。
确保公共代码路径快若鹰隼
如果你想要所有的代码都保持高速, 高频调用的代码路径就是优化的最关键路径。 优化措施包括:
- 考虑优化应用程序请求处理管道中的 Middleware ,尤其是在管道中排在更前面运行的 Middleware 。 这些组件对性能有很大影响。
- 考虑优化那些每个请求都要执行或每个请求多次执行的代码。 例如,自定义日志,身份认证与授权或 transient 服务的创建等等。
建议 :
- 不要 使用自定义 middleware 运行长时任务 。
- 要 使用性能分析工具( 如 Visual Studio Diagnostic Tools 或 PerfView) 来定位 代码热点路径。
在 HTTP 请求之外运行长时任务
对 ASP.NET Core 应用程序的大多数请求可以由调用服务的 controller 或页面模型处理,并返回 HTTP 响应。 对于涉及长时间运行的任务的某些请求,最好使整个请求-响应进程异步。
建议 :
- 不要把等待长时间运行的任务完成,作为普通 HTTP 请求处理的一部分。
- 要 考虑使用 后台服务 或 Azure Function 处理长时间运行的任务。 在应用外执行任务特别有利于 CPU 密集型任务的性能。
- 要 使用实时通信,如 SignalR,以异步方式与客户端通信。
缩小客户端资源
复杂的 ASP.NET Core 应用程序经常包含很有前端文件例如 JavaScript, CSS 或图片文件。 可以通过以下方法优化初始请求的性能:
- 打包,将多个文件合并为一个文件。
- 压缩,通过除去空格和注释来缩小文件大小。
建议 :
压缩 Http 响应
减少响应的大小通常会显着提高应用程序的响应性。 而减小内容大小的一种方法是压缩应用程序的响应。 有关更多信息,请参阅 响应压缩。
使用最新的 ASP.NET Core 发行版
ASP.NET Core 的每个新发行版都包含性能改进。 .NET Core 和 ASP.NET Core 中的优化意味着较新的版本通常优于较旧版本。 例如, .NET Core 2.1 添加了对预编译的正则表达式的支持,并从使用 Span<T> 改进性能。 ASP.NET Core 2.2 添加了对 HTTP/2 的支持。 ASP.NET Core 3.0 增加了许多改进 ,以减少内存使用量并提高吞吐量。 如果性能是优先考虑的事情,那么请升级到 ASP.NET Core 的当前版本。
最小化异常
异常应该竟可能少。 相对于正常代码流程来说 ,抛出和捕获异常是缓慢的。 因此,不应使用异常来控制正常程序流。
建议 :
- 不要 使用抛出或捕获异常作为正常程序流的手段,特别是在 代码热点路径 中。
- 要 在应用程序中包含用于检测和处理导致异常的逻辑。
- 要 对意外的执行情况抛出或捕获异常。
应用程序诊断工具( 如 Application Insights ) 可以帮助识别应用程序中可能影响性能的常见异常。
性能和可靠性
下文将提供常见性能提示和已知可靠性问题的解决方案。
避免在 HttpRequest/HttpResponse body 上同步读取或写入
ASP.NET Core 中的所有 I/O 都是异步的。 服务器实现了 Stream 接口,它同时具有同步和异步的方法重载。 应该首选异步方式以避免阻塞线程池线程。 阻塞线程会导致线程池饥饿。
不要使用如下操作: https://docs.microsoft.com/en-us/dotnet/api/System.IO.StreamReader.ReadToEnd。 它会阻止当前线程等待结果。 这是 sync over async 的示例。
public class BadStreamReaderController : Controller
{
[HttpGet("/contoso")]
public ActionResult<ContosoData> Get()
{
var json = new StreamReader(Request.Body).ReadToEnd();
return JsonSerializer.Deserialize<ContosoData>(json);
}
}
在上述代码中, Get 采用同步的方式将整个 HTTP 请求主体读取到内存中。 如果客户端上载数据很慢,那么应用程序就会出现看似异步实际同步的操作。 应用程序看似异步实际同步,因为 Kestrel 不 支持同步读取。
应该采用如下操作: https://docs.microsoft.com/en-us/dotnet/api/System.IO.StreamReader.ReadToEndAsync ,在读取时不阻塞线程。
public class GoodStreamReaderController : Controller
{
[HttpGet("/contoso")]
public async Task<ActionResult<ContosoData>> Get()
{
var json = await new StreamReader(Request.Body).ReadToEndAsync();
return JsonSerializer.Deserialize<ContosoData>(json);
}
}
上述代码异步将整个 HTTP request body 读取到内存中。
[!WARNING] 如果请求很大,那么将整个 HTTP request body 读取到内存中可能会导致内存不足 (OOM) 。 OOM 可导致应用奔溃。 有关更多信息,请参阅 避免将大型请求主体或响应主体读取到内存中。
应该采用如下操作: 使用不缓冲的方式完成 request body 操作:
public class GoodStreamReaderController : Controller
{
[HttpGet("/contoso")]
public async Task<ActionResult<ContosoData>> Get()
{
return await JsonSerializer.DeserializeAsync<ContosoData>(Request.Body);
}
}
上述代码采用异步方式将 request body 序列化为 C# 对象。
优先选用 Request.Form 的 ReadFormAsync
应该使用 HttpContext.Request.ReadFormAsync 而不是 HttpContext.Request.Form。 HttpContext.Request.Form 只能在以下场景用安全 使用。
- 该表单已被
ReadFormAsync调用,并且 - 数据已经被从
HttpContext.Request.Form读取并缓存
不要使用如下操作: 例如以下方式使用 HttpContext.Request.Form。 HttpContext.Request.Form 使用了sync over async ,这将导致线程饥饿.
public class BadReadController : Controller
{
[HttpPost("/form-body")]
public IActionResult Post()
{
var form = HttpContext.Request.Form;
Process(form["id"], form["name"]);
return Accepted();
}
应该使用如下操作: 使用HttpContext.Request.ReadFormAsync 异步读取表单正文。
public class GoodReadController : Controller
{
[HttpPost("/form-body")]
public async Task<IActionResult> Post()
{
var form = await HttpContext.Request.ReadFormAsync();
Process(form["id"], form["name"]);
return Accepted();
}