异步和等待:它们是不好的吗?

23

我们最近基于SOA开发了一个网站,但是在负载下出现了严重的负载和性能问题。我在这里发布了一个与此问题相关的问题:

ASP.NET网站在负载下变得无响应

该网站由一个API(WEB API)站点和一个Web站点组成,它们分别托管在4节点群集上,并调用API。两者都使用ASP.NET MVC 5进行开发,所有操作/方法都基于async-await方法。

在运行网站时,我们使用了一些监控工具(如NewRelic),调查了几个转储文件并对工作进程进行了分析后,发现在非常轻的负载(例如16个并发用户)下,我们最终拥有了约900个线程,利用了100%的CPU,并填满了IIS线程队列!

即使我们通过引入大量缓存和性能改进来将网站部署到生产环境中,我们团队中的许多开发人员仍然认为我们必须删除所有异步方法,并将API和Web站点转换为普通的Web API和Action方法,它们只是简单地返回一个Action结果。

我个人不满意这种方法,因为我的直觉是我们没有正确使用异步方法,否则这意味着微软引入了一个基本上是相当破坏性和无法使用的功能!

您是否了解任何可以澄清异步方法应该/可以在哪里以及如何使用它们以避免此类情况的参考资料?例如,根据我在MSDN上阅读的内容,我认为API层应该是异步的,但Web站点可以是普通的非异步ASP.NET MVC站点。

更新:

以下是处理与API的所有通信的异步方法。

public static async Task<T> GetApiResponse<T>(object parameters, string action, CancellationToken ctk)
{
        using (var httpClient = new HttpClient())
        {
            httpClient.BaseAddress = new Uri(BaseApiAddress);

            var formatter = new JsonMediaTypeFormatter();

            return
                await
                    httpClient.PostAsJsonAsync(action, parameters, ctk)
                        .ContinueWith(x => x.Result.Content.ReadAsAsync<T>(new[] { formatter }).Result, ctk);
        }
    }

这种方法有什么可笑的地方吗?请注意,当我们将所有方法转换为非异步方法时,性能得到了大幅提升。

以下是一个示例用法(我删去了与验证、日志记录等相关的其他代码。此代码是MVC动作方法的主体)。

在我们的服务包装器中:

public async static Task<IList<DownloadType>> GetSupportedContentTypes()
{
  string userAgent = Request.UserAgent;
  var parameters = new { Util.AppKey, Util.StoreId, QueryParameters = new { UserAgent = userAgent } };
  var taskResponse = await  Util.GetApiResponse<ApiResponse<SearchResponse<ProductItem>>>(
                    parameters,
                    "api/Content/ContentTypeSummary",
                    default(CancellationToken));
                    return task.Data.Groups.Select(x => x.DownloadType()).ToList();
 }

在行动中:

public async Task<ActionResult> DownloadTypes()
    {
        IList<DownloadType> supportedTypes = await ContentService.GetSupportedContentTypes();

2
应用程序如何使用async/await的任何示例?当与像数据库调用和文件操作这样的IO绑定操作一起使用时,它通常应该增加可扩展性,而不是减少可扩展性。如果它正在生成不必要的线程,以便每个方法都可以标记为async,那么这可能是一个警告信号,并且很可能是问题的原因。 - Anthony Chu
1
也许你的代码使用 Task.Run 包装同步 API,而不是使用本来就是异步的 API?https://dev59.com/tXzaa4cB1Zd3GeqPOlft。通常,在服务器上使用 Task.Run 是一个坏主意。 - noseratio - open to work
@AnthonyChu,基本上 WEB 和 API(消费者和服务)中的每个方法和操作方法都是异步的!今天我创建了一个完全同步的版本,并将它们放在负载测试下。出乎意料的是,如果我直接访问 API,则异步版本比同步版本更快。但是,当我使用 Apache 基准测试工具访问网站时,API 的异步版本要慢三倍!你认为仅将与 DB 或 ElasticSearch 通信的方法转换为异步会更好吗?正如 Noseratio 所说,使用自然异步的 API? - Aref Karimi
@Aref - 你是否同时调用了多个Web API?如果没有,那么将其异步化并没有什么好处。当您需要进行多个同时请求并等待它们全部完成时(而不是按顺序完成,可以并行完成),异步非常有用。您能否展示一下您的控制器操作方法是什么样子的? - Erik Funkenbusch
看,这些像async和await这样的关键字并不意味着你不需要跟踪你的应用程序正在做什么,以及线程使用情况。你需要理解并发发生了什么,应用程序在等待什么,然后找出是否因此存在某种争用。我们只能做到这么多,因为这很可能是一个复杂的交互,只有你自己才能发现。 - Erik Funkenbusch
显示剩余5条评论
3个回答

37

这种方法有什么可笑的地方吗?需要注意的是,当我们将所有方法转换为非异步方法时,性能得到了显著提高。

我至少可以看出这里有两件事情出了问题:

public static async Task<T> GetApiResponse<T>(object parameters, string action, CancellationToken ctk)
{
        using (var httpClient = new HttpClient())
        {
            httpClient.BaseAddress = new Uri(BaseApiAddress);

            var formatter = new JsonMediaTypeFormatter();

            return
                await
                    httpClient.PostAsJsonAsync(action, parameters, ctk)
                        .ContinueWith(x => x.Result.Content
                            .ReadAsAsync<T>(new[] { formatter }).Result, ctk);
        }
    }

首先,您传递给 ContinueWith 的 lambda 是阻塞的:

x => x.Result.Content.ReadAsAsync<T>(new[] { formatter }).Result

这相当于:

x => { 
    var task = x.Result.Content.ReadAsAsync<T>(new[] { formatter });
    task.Wait();
    return task.Result;
};

因此,你正在阻塞一个线程池,在该线程池上执行了Lambda表达式。这会有效地破坏自然异步ReadAsAsync API的优势,并降低Web应用程序的可扩展性。在代码中要注意类似这样的其他地方。

其次,ASP.NET请求由具有特殊同步上下文的服务器线程处理,即AspNetSynchronizationContext。当您使用await进行续约时,续约回调将被发布到相同的同步上下文中,编译器生成的代码会处理此问题。然而,当您使用ContinueWith时,这不会自动发生。

因此,您需要明确提供正确的任务调度程序、删除阻塞的.Result(这将返回一个任务)并解包嵌套的任务:

return
    await
        httpClient.PostAsJsonAsync(action, parameters, ctk).ContinueWith(
            x => x.Result.Content.ReadAsAsync<T>(new[] { formatter }), 
            ctk,
            TaskContinuationOptions.None, 
            TaskScheduler.FromCurrentSynchronizationContext()).Unwrap();

话虽如此,但在这里你确实不需要使用ContinueWith这样的额外复杂性:

var x = await httpClient.PostAsJsonAsync(action, parameters, ctk);
return await x.Content.ReadAsAsync<T>(new[] { formatter });

以下由Stephen Toub撰写的文章非常相关:

“异步性能:理解异步和await的成本”

如果我必须在同步上下文中调用异步方法,无法使用await,最好的方法是什么?

你几乎永远不需要混合使用awaitContinueWith,应该坚持使用await。基本上,如果你使用async,它必须是全程异步"all the way"

对于服务器端ASP.NET MVC/Web API执行环境,这意味着控制器方法应该是async并返回一个TaskTask<>,请查阅此页面。ASP.NET会跟踪给定HTTP请求的挂起任务。直到所有任务都完成,请求才会完成。

如果您确实需要在ASP.NET中从同步方法调用异步方法,可以使用AsyncManager,例如此链接来注册一个挂起的任务。对于经典ASP.NET,可以使用PageAsyncTask

在最坏的情况下,您会调用task.Wait()并阻塞,因为否则您的任务可能会超出特定HTTP请求的边界。

对于客户端UI应用程序,可以从同步方法调用异步方法的一些不同场景是可能的。例如,你可以使用ContinueWith(action, TaskScheduler.FromCurrentSynchronizationContext()),并从action触发完成事件(如这里)。


1
不错的发现。我对ContinueWith有所怀疑,但无法具体指出问题在哪里。我觉得很清楚,有些东西阻止了线程返回到线程池中。我喜欢你摆脱ContinueWith的解决方案。简单多了。 - Erik Funkenbusch
@ErikTheViking,也许有一些类似这样的片段。或者像在紧密循环中调用这个函数。我不确定一个单独的阻塞调用是否可能会对16个用户造成问题。 - noseratio - open to work
1
@Noseratio,你真是个天才!我做了那个改变后,我的负载测试结果大幅度提高了!有一个问题:如果我必须在同步上下文中调用异步方法,而使用await不可能,最好的方法是什么? - Aref Karimi
@Aref,很高兴能帮到你。我已经更新了答案,以回答你关于从同步方法调用异步方法的问题。 - noseratio - open to work

3

async和await不应该创建大量的线程,特别是仅有16个用户。实际上,它应该帮助您更好地利用线程。在MVC中,async和await的目的是在忙于处理IO绑定任务时放弃线程池线程。这使我想到,您可能在某些地方做了一些愚蠢的事情,比如产生线程然后无限期地等待。

然而,900个线程并不算太多,如果它们使用100%的CPU,那么它们就没有等待,正在处理某些东西。您应该去查找这个“某些东西”的问题所在。您说您已经使用了像NewRelic这样的工具,那么它们指向了哪些方法作为CPU使用率的来源呢?

如果我是您,我会首先证明仅仅使用async和await不是您问题的原因。只需创建一个模仿行为的简单站点,然后对其运行相同的测试。

第二,拷贝您的应用程序,并开始剥离一些内容,然后对其进行测试。尝试找出问题所在。


我该如何找到那个愚蠢的东西?我们已经对整个网站和API进行了分析,并修复了所有性能瓶颈。 - Aref Karimi
@Aref:你应该查找任何使用Task.RunTask.Factory.StartNewTask.Start的情况;这些在ASP.NET中不应该被使用。同样,任何使用Parallel或Parallel LINQ的情况也是如此。 - Stephen Cleary
@Aref - 一次Web请求会做多少个Restful服务的调用呢? - Erik Funkenbusch
@ErikTheViking,可能需要3-5个调用。我们已经尽可能地缓存了数据,但仍然有大约90%的CPU使用率,此外NewRelic显示大约50%的请求在队列中。 - Aref Karimi
这让我感觉你可能正在某个地方阻塞或忙等待,这会序列化你的调用或创建死锁。你真的需要同时映射出正在发生的事情,以及你在等待完成的事情等等。 - Erik Funkenbusch
显示剩余3条评论

3

有很多内容需要讨论。

首先,async/await可以在应用程序几乎没有业务逻辑时自然地帮助您。我的意思是,async/await的目的是不让许多线程处于等待状态,主要是一些IO操作,例如数据库查询(和提取)。如果您的应用程序使用CPU进行巨大的业务逻辑,并占用100%,则async/await无法帮助您。

900个线程的问题在于它们效率低下 - 如果它们并发运行。关键是最好拥有与服务器核心/处理器相同数量的“业务”线程。原因是线程上下文切换,锁争用等。有许多像LMAX distruptor模式或Redis这样的系统可以在一个线程(或每个核心一个线程)中处理数据。这只是更好,因为您不必处理锁定。

如何实现所述方法?查看disruptor,对传入请求进行排队,并逐个处理它们,而不是并行处理它们。

相反的方法,当几乎没有业务逻辑,许多线程只是等待IO,是将async/await投入工作的好地方。

它主要是如何工作的:有一个从网络读取字节的线程 - 大多数情况下只有一个。一旦有请求到达,该线程读取数据。还有一个有限的工作线程池用于处理请求。异步的要点是,一旦一个处理线程正在等待某些事情,主要是IO、数据库,线程将返回轮询,并且可以用于另一个请求。一旦IO响应就绪,池中的某个线程将用于完成处理。这是您可以使用少量线程为秒处理数千个请求的方法。

我建议您画出网站的工作原理图,每个线程的操作以及并发如何工作。请注意,有必要决定吞吐量或潜伏期对您来说重要。


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