ASP.NET Core中异步与同步代码性能的测量

5

我正在尝试使用SQL Server Express和EF Core 3.1.3在ASP.NET Core 3.1中测量异步和同步的性能,并有两个函数完全相同,只是一个是异步的,一个是同步的:

[HttpGet("search/description/{searchString}")]
public async Task<ActionResult<IEnumerable<Products>>> SearchForProductsDescription(String searchString) {
    return await _context.Products.Where(p => p.Description == searchString).ToListAsync();
}

还有同步版本:

return _context.Products.Where(p => p.Description == searchString).ToList();

我正在使用jmeter作为基准测试工具,同步函数比异步函数更快(如预期),但是当我增加jmeter中的线程数以便平均响应时间小于500ms时,同步代码仍然更快。我尝试在数据库中使用1000行和20000行,但仍然更快。我试图找到异步函数比同步函数更快的场景,但我遇到了麻烦,是否有什么我理解错了?


你确定数据库不是瓶颈吗?处理请求时,数据库逐个响应和同时响应可能会有不同的反应。这可能是由于锁定引起的。 - Gabriel Luci
数据库使用默认的隔离级别 READ COMMITTED,除此之外我没有在数据库中做任何更改,所以我认为没有锁定吗? - Nygards
请查看异步不是万能药。如果您的数据库只有一个SQL Express后端,您需要限制Web服务器线程池大小,以便看到async的优势。 - Stephen Cleary
@StephenCleary 我查看了您的链接,并尝试将设置SetMaxThreads的三行代码添加到我的MVC ASP.NET Core服务器的Main()函数中,并使用500个用户运行了我的jmeter基准测试,但与我不使用它时没有任何区别。我认为它会限制服务器中的总线程池,但似乎并没有起作用,有什么建议吗? - Nygards
@Nygards:我不知道。那段代码是我很多年前写的,当时它是可以工作的。 - Stephen Cleary
2个回答

5
我试图找到一个异步函数比同步函数更快的情况,但我遇到了困难,是否有什么地方我理解错误或误解了?
是的。异步代码并不应该更快。它应该需要较少的线程。如果您有10,000个并发请求,您可能会看到在同步过程中资源(内存、句柄、线程、CPU)耗尽的情况。但是在这种情况下,您的数据库会很快崩溃。
在EF异步数据访问中,其目的不是为了改善数据访问性能本身,而是为了符合偏爱异步IO的应用框架的要求,例如具有单个UI线程的桌面应用程序或使用ASP.NET Core的Web应用程序。
在EF中的异步还可以偶尔用来并行发送多个查询或运行一些其他代码,同时等待长时间运行的数据库操作。
以下是一个人工合成的演示,它展示了当线程池小于并发请求的数量时,异步可以比同步更快。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace AsyncDemo
{
    class Program
    {
        static async Task DoStuffAsync()
        {
            await Task.Delay(20);
        }

        static void DoStuffSync()
        {
            Thread.Sleep(20);
        }
        static void Main(string[] args)
        {
            var sw = new Stopwatch();
            List<Task> tasks;
            var iterations = 1000 * 5;

            while (true)
            {
                sw.Restart();
                tasks = new List<Task>();
                for (int i = 0; i < iterations; i++)
                {
                    tasks.Add(Task.Run(() => DoStuffSync()));
                }
                Task.WaitAll(tasks.ToArray());
                Console.WriteLine($"{iterations} sync calls {sw.ElapsedMilliseconds}ms {Process.GetCurrentProcess().Threads.Count} threads");

                tasks = new List<Task>();
                sw.Restart();
                for (int i = 0; i < iterations; i++)
                {
                    tasks.Add(DoStuffAsync());
                }
                Task.WaitAll(tasks.ToArray());
                Console.WriteLine($"{iterations} async calls {sw.ElapsedMilliseconds}ms {Process.GetCurrentProcess().Threads.Count} threads");

            }

        }
    }
}

输出

5000 sync calls 5546ms 35 threads
5000 async calls 29ms 35 threads
5000 sync calls 3951ms 51 threads
5000 async calls 38ms 51 threads
5000 sync calls 3481ms 52 threads
5000 async calls 29ms 52 threads
5000 sync calls 3320ms 53 threads
5000 async calls 34ms 53 threads
5000 sync calls 3259ms 53 threads
5000 async calls 32ms 53 threads
5000 sync calls 3253ms 53 threads
5000 async calls 33ms 53 threads
5000 sync calls 3321ms 53 threads
5000 async calls 31ms 53 threads
5000 sync calls 3275ms 53 threads
5000 async calls 33ms 53 threads
5000 sync calls 3259ms 51 threads
5000 async calls 28ms 51 threads

3
我希望微软能更好地解释这个问题。当我第一次了解async await时,我在荒野中徘徊了一段时间,直到我意识到async不是关于提高性能,而是关于“释放底层线程”,以便像WinForms和WPF中的消息泵等东西仍然可以正常工作。 - Robert Harvey
虽然“符合应用程序框架的要求”这个短语在技术上是正确的,但它并没有真正阐明问题。 - Robert Harvey
我正在开发一个演示Web应用程序,使用ASP.NET Core提供的MVC模板。所有功能默认都是异步的,因此我只需修改它们以适应我的查询即可。因此,上述功能基本上是一个端点,因为同步代码正在等待数据库返回,如果它是异步的,线程将被释放并可以处理另一个请求,直到数据库返回,从而在重负载下增加性能,这不是它的工作方式吗? - Nygards
是的,这就是它的工作原理。但是如果您有足够的线程来处理所有请求并等待数据库调用,那么您不会看到任何性能差异。而且这通常是情况。 - David Browne - Microsoft
1
如果我同时使用2000个线程(用户)运行jmeter,异步响应时间的平均值为730毫秒,同步响应时间的平均值为570毫秒。由于这相当慢且负载较重,难道不应该意味着异步版本应该优于同步版本吗?我只是很难理解在我的情况下何时异步会优于同步,以及为什么模板使用异步如果它更慢? - Nygards

3
从评论中看来,你已经知道 async 可以使 Web 服务器通过不显式等待响应而保持更高的响应度,而不是使查询更快。在同步和异步调用并排比较的例子中,由于需要设置以交接和恢复操作,异步调用可能稍微慢一些。因此,没有性能测试可以证明任何改进。异步是为了使服务器对负载更加响应。
考虑一个 Web 服务器有 100 个工作线程处理请求。每当用户连接到您的网站、刷新页面等时,他们都会被分配给一个工作线程。在任何时候,可以处理 100 个请求。如果第 101 个请求进入时所有其他线程都在忙于处理请求,则可能发生以下两种情况之一:分配一个额外的线程(与其他 100 个线程竞争资源和时间,并且需要时间来分配额外的线程),或者该请求等待线程空闲。因为您可能有一些请求操作可能需要一秒左右,大多数请求可能只需要几毫秒运行,因此仅在有比 100 个并发请求多得多的情况下才会注意到这个问题,它们坐着等待工人。
假设有一个特定的报告,需要在数百万行和大量表之间进行一些相当庞大的 SQL 调用。例如,年末报告或季度报告。这些报告通常不会经常运行,但当他们运行时,可能会有很多人同时运行。(例如财政年度的第一周)每个查询需要 10 秒以上才能运行,数据库本身只能处理那么多请求,因此第一个查询可能需要 10 秒钟,但会阻止其他查询,因此平均可能推迟 30 或甚至 60 秒。现在当有 100 个请求进入时,每个线程都被占用了 30+ 秒。随着线程池耗尽,您的站点变得无响应。异步查询可以帮助适应此情况。请求仍将需要 ~30s,但 Web 服务器释放了监听工作线程,以便它可以响应其他请求,其中许多请求不会触发报告,但在线程池已满时会等待。
因此,要真正观察到这样的效果,您需要一个非常昂贵的操作,确定可用工作线程的数量,并使用超过该数量的请求数进行负载测试。例如,将 Web 服务器限制为 5 个线程,启动 5 个昂贵的请求,然后再启动一个额外的“廉价”请求,并测量该廉价请求的响应性。对于同步代码,该廉价请求将被等待线程之一占用。对于异步代码,该最后一个请求将得到更快的响应。迄今为止,您的尝试看起来有点错误,因为您要求所有请求执行相同的调用并期望性能提升。这是不可能的,它是关于较小、较简单的请求被拖住等待更长时间的请求完成。如果您的查询过于简单(快速),则没有什么可观察的。您需要一个需要几秒钟才能运行的查询,然后将其与通常需要几毫秒的请求混合在一起。
异步并不是网站性能的万能药。它可能更难调试,并且 inherently(自身)会稍微慢一些。通常你会看到在示例中无处不用异步来保持一致性,或者是简单的示例中,异步并没有提供任何好处。我通常建议默认使用同步调用,并将异步调用用于显着昂贵的操作。例如,按ID加载单个记录我会尽量保持同步,但是搜索,特别是涉及文本匹配或涉及非索引列或复杂关联的搜索,我会使其为异步。通常会有需要不同实现的示例,甚至我也不会依赖异步。例如上面的带有 EOFY 类型报告的示例,可能需要几秒钟才能运行:这样的情况下,我会考虑实现一个队列以处理明确的后台工作人员(或池)处理的报告请求,并使用指向报告副本的 DBContext 帮助确保不会运行太多的请求并行,并且不阻止主应用程序数据库的读写访问。如果存在大多数请求可能同时触发该操作的可能性,则仅在昂贵的查询上简单地添加 async 是不够的。您可以释放 Web 服务器线程池,但仍会导致该瓶颈在链的下游使网站瘫痪。

假设我有10个线程和100个并发请求在jmeter中运行。如果我测量相同的数据库调用,但一个是异步的,一个是同步的,并且数据库调用需要正好100毫秒,那么异步的应该更快,因为同步版本的10个线程将等待100毫秒的数据库,而异步版本可以处理其他请求并开始这些请求的数据库调用。或者是因为我的数据库是瓶颈,或者是因为我在同一台计算机上运行Web服务器和数据库? - Nygards
在这种情况下,我会认为数据库可能是潜在的瓶颈。对于一个花费100毫秒的调用,我不建议使用异步,因为异步可能会增加5-20毫秒的调用时间,这只是一个粗略的估计,具体取决于硬件条件。从一台机器启动的测试场景仅能近似模拟来自数百个来源的数百个请求。IIS可能会花费数百毫秒来指导请求,因此任何预期的差异都可以轻松掩盖。要观察到这样的现象,您需要在更便宜、快速的请求中添加昂贵的异步操作。此外,昂贵的同步请求将被锁定。 - Steve Py
“更加响应”确实意味着更快。是的,在理论情况下,如果您有最多100个线程,所有线程都很忙,并且您收到另一个请求,它将无法得到服务,直到另一个请求完成。但是,如果它“本质上较慢”,则需要更多资源才能完成相同(功能)工作,因此在现实中永远不会有帮助。 - Dojo

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接