使用System.Net.Http.HttpClient进行并行HTTP请求

15

我正在尝试使用Taskasync/await的正确方法来并行化HTTP请求。我正在使用已经具有检索数据异步方法的HttpClient类。如果我只是在foreach循环中调用它并等待响应,每次只会发送一个请求(这是有道理的,因为在await期间,控制返回到我们的事件循环,而不是返回到foreach循环的下一次迭代)。

我的HttpClient包装器如下所示

public sealed class RestClient
{
    private readonly HttpClient client;

    public RestClient(string baseUrl)
    {
        var baseUri = new Uri(baseUrl);

        client = new HttpClient
        {
            BaseAddress = baseUri
        };
    }

    public async Task<Stream> GetResponseStreamAsync(string uri)
    {
        var resp = await GetResponseAsync(uri);
        return await resp.Content.ReadAsStreamAsync();
    }

    public async Task<HttpResponseMessage> GetResponseAsync(string uri)
    {
        var resp = await client.GetAsync(uri);
        if (!resp.IsSuccessStatusCode)
        {
            // ...
        }

        return resp;
    }

    public async Task<T> GetResponseObjectAsync<T>(string uri)
    {
        using (var responseStream = await GetResponseStreamAsync(uri))
        using (var sr = new StreamReader(responseStream))
        using (var jr = new JsonTextReader(sr))
        {
            var serializer = new JsonSerializer {NullValueHandling = NullValueHandling.Ignore};
            return serializer.Deserialize<T>(jr);
        }
    }

    public async Task<string> GetResponseString(string uri)
    {
        using (var resp = await GetResponseStreamAsync(uri))
        using (var sr = new StreamReader(resp))
        {
            return sr.ReadToEnd();
        }
    }
}

而我们的事件循环调用的代码是

public async void DoWork(Action<bool> onComplete)
{
    try
    {
        var restClient = new RestClient("https://example.com");

        var ids = await restClient.GetResponseObjectAsync<IdListResponse>("/ids").Ids;

        Log.Info("Downloading {0:D} items", ids.Count);
        using (var fs = new FileStream(@"C:\test.json", FileMode.Create, FileAccess.Write, FileShare.Read))
        using (var sw = new StreamWriter(fs))
        {
            sw.Write("[");

            var first = true;
            var numCompleted = 0;
            foreach (var id in ids)
            {
                Log.Info("Downloading item {0:D}, completed {1:D}", id, numCompleted);
                numCompleted += 1;
                try
                {
                    var str = await restClient.GetResponseString($"/info/{id}");
                    if (!first)
                    {
                        sw.Write(",");
                    }

                    sw.Write(str);

                    first = false;
                }
                catch (HttpException e)
                {
                    if (e.StatusCode == HttpStatusCode.Forbidden)
                    {
                        Log.Warn(e.ResponseMessage);
                    }
                    else
                    {
                        throw;
                    }
                }
            }

            sw.Write("]");
        }

        onComplete(true);
    }
    catch (Exception e)
    {
        Log.Error(e);
        onComplete(false);
    }
}

我尝试过几种不同的方法,包括Parallel.ForEachLinq.AsParallel以及将循环的整个内容包装在一个Task中。


可能是Nesting await in Parallel.ForEach的重复问题。 - Michael Freidgeim
1个回答

25

基本思路是跟踪所有异步任务,并一次性等待它们。最简单的方法是将 foreach 的主体提取到单独的异步方法中,然后执行以下操作:

var tasks = ids.Select(i => DoWorkAsync(i));
await Task.WhenAll(tasks);

这种方式会单独发出各个任务(仍然按顺序,但不等待I/O完成),并且您可以同时等待它们全部完成。

请注意,您还需要进行一些配置 - HTTP默认被限制为仅允许同一服务器的两个并发连接。


请查看此处的被接受的答案:https://dev59.com/JmIk5IYBdhLWcg3wn_f1 - Jim L
1
默认情况下是的。HTTP限流是HTTP规范的一部分,因此在技术上禁用(或放宽)它就是违反规范。话虽如此,我们生活在不同的时代——与HTTP首次设计时相比,多个并发请求并不那么糟糕。无论如何,如果您预计会受到(显着的)速率限制,您可能还想自己实现限流——否则,您只是在浪费大量内存并行执行任务,而您可以将它们流式传输——当然,前提是您不需要同时获得所有响应。 - Luaan
5
这个方法可行,但有两个注意点:1.连接限制是@Luaan提到的(通过ServicePointManager.DefaultConnectionLimit=8解决) 2.HttpClient超时似乎从请求排队开始计时,而不是从发送给服务器开始。虽然不是最优解,但我通过设置较长的超时时间来解决这个问题。使用这个解决方案后,我现在可以在约5分钟内完成大约50分钟的下载过程。 - Austin Wagner

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