【问题标题】:HttpClient query occasionally hangsHttpClient 查询偶尔挂起
【发布时间】:2019-05-24 07:41:12
【问题描述】:

我像这样初始化HttpClient

public static CookieContainer cookieContainer = new CookieContainer();
public static HttpClient httpClient = new HttpClient(new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, CookieContainer = cookieContainer }) { Timeout = TimeSpan.FromSeconds(120) };

所以如果在 120 秒内没有收到响应,所有查询都应该抛出 TaskCanceledException。 但有些查询(例如 1 万个 100 000-1 000 000 个)会无限挂起。

我写了以下代码:

public static async Task<HttpResponse> DownloadAsync2(HttpRequestMessage httpRequestMessage)
{
    HttpResponse response = new HttpResponse { Success = false, StatusCode = (int)HttpStatusCode.RequestTimeout, Response = "Timeout????????" };
    Task task;
    if (await Task.WhenAny(
        task = Task.Run(async () =>
        {
            try
            {
                HttpResponseMessage r = await Global.httpClient.SendAsync(httpRequestMessage).ConfigureAwait(false);
                response = new HttpResponse { Success = true, StatusCode = (int)r.StatusCode, Response = await r.Content.ReadAsStringAsync().ConfigureAwait(false) };
            }
            catch (TaskCanceledException)
            {
                response = new HttpResponse { Success = false, StatusCode = (int)HttpStatusCode.RequestTimeout, Response = "Timeout" };
            }
            catch (Exception ex)
            {
                response = new HttpResponse { Success = false, StatusCode = -1, Response = ex.Message + ": " + ex.InnerException };
            }
        }),
        Task.Run(async () =>
        {
            await Task.Delay(TimeSpan.FromSeconds(150)).ConfigureAwait(false);
        })
    ).ConfigureAwait(false) != task)
    {
        Log("150 seconds passed");
    }
    return response;
}

实际上偶尔会执行Log("150 seconds passed");

我这样称呼它:

HttpResponse r = await DownloadAsync2(new HttpRequestMessage
{
    RequestUri = new Uri("https://address.com"),
    Method = HttpMethod.Get
}).ConfigureAwait(false);

为什么 TaskCanceledException 有时在 120 秒后不抛出?

【问题讨论】:

  • 这是我见过的在一定时间后取消请求的最复杂的代码。
  • await Task.WhenAny 只是防止我的任务卡住的解决方法。根本不应该使用它。但首先我需要弄清楚为什么 try 块中的代码有时会无限挂起。
  • 如果它卡住了,那么您的代码中的其他地方就会出现不同的问题。您是否在代码中的某个地方使用了task.Result
  • 我不会在代码中的任何地方使用task.Result。我添加了如何使用DownloadAsync2 方法的代码。
  • 调用该代码的代码是什么?如果在代码中的任何位置调用.Result,它可能会导致它死锁并永远挂起。

标签: c# async-await timeout httpclient


【解决方案1】:

我不知道你调用DownloadAsync2 的频率有多高,但你的代码对于线程池的爆裂和饥饿来说很臭。

默认情况下,ThreadPool 中的初始线程数限制为 CPU 逻辑内核数(对于当今的正常系​​统通常为 12 个),如果 ThreadPool 中的线程不可用,则生成每个新线程需要 500 毫秒。

例如:

for (int i = 0; i < 1000; i++)
{
    HttpResponse r = await DownloadAsync2(new HttpRequestMessage
    {
        RequestUri = new Uri("https://address.com"),
        Method = HttpMethod.Get
    }).ConfigureAwait(false);
}

这段代码很有可能会被冻结,特别是如果您的代码中有一些lock 或任何cpu intensive 任务。因为您每次调用 DownloadAsync2 都会调用新线程,所以 ThreadPool 的所有线程都已消耗,并且仍然需要更多线程。

我知道你可能会说“我的所有任务都已等待,它们已发布以用于其他工作”。但它们也用于启动新的DownloadAsync2 线程,您将达到在完成await Global.httpClient.SendAsync 后没有线程可用于重新分配和完成任务的地步。

所以方法必须等到一个线程可用或生成完成(即使在超时之后)。罕见但可行。

【讨论】:

  • 我有 100 个任务,每个任务都像每秒一次一样查询 DownloadAsync2。此代码 24/7 运行,有时可能每周挂起一次。所以它就像 100*60*60*24*7=60 480 000 个查询中的 1 个挂起。我还发现挂起的是SendAsync 方法。因此,我添加了一个设置为 X 秒的取消令牌,但即使使用取消令牌,它有时也永远不会完成,也永远不会抛出 TaskCanceledException。我认为这是一些较低层的问题。 @Misha 你有同样的行为吗?
  • @LukAss741 所以我建议将配置中的 MinimumThreads 计数设置为 100 并检查其效果(或者甚至更高到 200 仅用于测试)
  • 设置 100 个 workerThreads 或 completionPortThreads 或两者兼而有之?
  • @LukAss741 这样做:ThreadPool.SetMinThreads(100, 100)
  • @LukAss741,是的,对我来说完全一样。绝对奇怪。挂起的是 SendAsync
【解决方案2】:

在 Windows 和 .NET 上,对同一端点的并发传出 HTTP 请求数限制为 2(根据 HTTP 1.1 规范)。如果您向同一端点创建大量并发请求,它们将排队。这是您所经历的一种可能的解释。

另一种可能的解释是:您没有明确设置 HttpClient 的 Timeout 属性,因此它默认为 100 秒。如果你不断地发出新的请求,而之前的请求没有完成,系统资源就会被耗尽。

我建议将 Timeout 属性设置为较低的值 - 与您拨打电话的频率(1 秒?)成正比,并可选择增加与 ServicePointManager.DefaultConnectionLimit 的并发传出连接数

【讨论】:

  • 已更新 DefaultConnectionLimit,它与超时无关。在这个阶段似乎线程池可能会用完线程,但是限制为 500 我不确定为什么不将未使用的线程放回池中。这可能是完成端口线程的限制,但很难说,因为我需要等待数周(如果不是数月)才能看到
  • 您需要 100 秒的超时时间吗?这在高并发应用程序中绝对是个问题。
  • 我已经使用 DefaultConnectionLimit = 100 至少一年了,但它并没有解决偶尔挂起的问题。我也从不同时查询相同的网址。然而,我同时查询同一主机的几个不同端点。我怀疑这是 SendAsync 方法的一些问题,因为当它挂起时,即使取消令牌也无法取消它。
【解决方案3】:

我发现它是 httpClient.SendAsync 方法偶尔会挂起。因此,我添加了一个设置为 X 秒的取消令牌。但即使使用取消令牌,它有时也可能会卡住并且永远不会抛出 TaskCanceledException

因此,我着手解决使 SendAsync 任务永远停留在后台并继续其他工作的解决方法。

这是我的解决方法:

public static async Task<Response> DownloadAsync3(HttpRequestMessage httpRequestMessage, string caller)
{
    Response response;
    try
    {
        using CancellationTokenSource timeoutCTS = new CancellationTokenSource(httpTimeoutSec * 1000);
        using HttpResponseMessage r = await Global.httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseContentRead, timeoutCTS.Token).WithCancellation(timeoutCTS.Token).ConfigureAwait(false);
        response = new Response { Success = true, StatusCode = (int)r.StatusCode, Message = await r.Content.ReadAsStringAsync().ConfigureAwait(false) };
    }
    catch (TaskCanceledException)
    {
        response = new Response { Success = false, StatusCode = (int)HttpStatusCode.RequestTimeout, Message = "Timeout" };
    }
    catch (Exception ex)
    {
        response = new Response { Success = false, StatusCode = -1, Message = ex.Message + ": " + ex.InnerException };
    }
    httpRequestMessage.Dispose();
    return response;
}

public static Task<T> WithCancellation<T>(this Task<T> task, CancellationToken cancellationToken)
{
    return task.IsCompleted
        ? task
        : task.ContinueWith(
            completedTask => completedTask.GetAwaiter().GetResult(),
            cancellationToken,
            TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);
}

【讨论】:

  • 我的软件从 50 个不同的主机名中查询 api,我怀疑其中只有 2-3 个可能偶尔会永远挂起。无论如何,我不能肯定地说,因为它不能按需复制,而且很少发生。
  • 为什么 api 从不响应会出现问题?你有一个 api 调用列表然后你做一个Task.WhenAll
  • @JohanP 如果 api 从不响应,那么await httpClient.SendAsync 应该在一段时间后以超时结束。问题是有时它会永远卡住。
  • 一个从不响应的 api 只是一个问题,如果有代码实际上在等待你的应用程序响应的响应。如果您遇到超级罕见的挂起,今天您的代码会发生什么情况?
  • 总是有代码在等待api响应。如果它仍然停留在等待状态,那么剩余的代码将永远不会被执行。
【解决方案4】:

使用Flurl,您可以为每个客户端、每个请求或全局配置超时。


// call once at application startup
FlurlHttp.Configure(settings => settings.Timeout = TimeSpan.FromSeconds(120));

string url = "https://address.com";

// high level scenario
var response = await url.GetAsync();

// low level scenario
await url.SendAsync(
    HttpMethod.Get, // Example
    httpContent, // optional
    cancellationToken,  // optional
    HttpCompletionOption.ResponseHeaderRead);  // optional

// Timeout at request level
await url
    .WithTimeout(TimeSpan.FromSeconds(120))
    .GetAsync();

Fluent HTTP documentation

Flurl configuration documentation

【讨论】:

    【解决方案5】:

    答案是:

    ThreadPool.SetMinThreads(MAX_THREAD_COUNT, MAX_THREAD_COUNT);
    

    其中 MAX_THREAD_COUNT 是某个数字(我使用 200)。您必须至少设置第二个参数(completionPortThreads),并且很可能是第一个(workerThreads)。我已经设置了第一个,但没有设置第二个,现在它正在工作,我保持两个设置。

    唉,这不是答案。见下面的cmets

    【讨论】:

    • ThreadPool.SetMinThreads(128, 128);对我不起作用。即使使用这种设置,我仍然会遇到挂起。不过我会尝试200、200。你说你目前使用的是 200、200 吗?
    • @LukAss741 - aarrgghh!你是对的 - 仍然得到它们。这是另一个想法:您是在主 GUI 线程和后台线程中访问 HttpClient 吗?我有多个应用程序都使用相同的代码行,似乎唯一一个挂起的是我也从主线程进行查询的那个(所有其他应用程序只从后台线程进行查询)
    • @LukAss741 - 我认为这可能是未释放的“泄漏”工作线程,可能是远程请求“失败”
    • 顺便说一句,挂起的应用程序有一个连续访问 API 的线程,以及每 15 秒访问一次该 API 的主线程(相同的 HttpClient)。所以很少“争用”,一次最多有两个请求。其他跨多个线程敲击 API 的应用程序似乎没有挂起
    • 我只在后台任务中查询http。我相信这个问题需要深入研究 HttpClient 的代码,这超出了我的编程知识。
    【解决方案6】:

    好吧,我正式放弃了!我将代码替换为:

        try
        {
            return Task.Run(() => httpClient.SendAsync(requestMessage)).Result;
        }
        catch (AggregateException e)
        {
            if (e.InnerException != null)
                throw e.InnerException;
            throw;
        }
    

    【讨论】:

    • 我不明白这段代码有什么用处。您为什么不使用我在回答中提供的解决方法?
    • @LukAss741 - 基本上在一行中工作(不包括用于获取原始异常的异常处理程序)。我想要的也是如此 - 在异步方法周围放置一个防弹同步包装器。我认为没有理由无益地添加额外代码
    • 最初的问题是关于阻塞后台上下文线程。此代码不再需要任何上下文切换,因为该块位于原始调用线程上 - 通过删除问题解决了问题;-)
    猜你喜欢
    • 1970-01-01
    • 2023-03-23
    • 1970-01-01
    • 2014-07-15
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多