通过异步方式快速高效地下载多个文件

4

我有很多需要下载的文件,因此我尝试使用下面的新异步功能。

var streamTasks = urls.Select(async url => (await WebRequest.CreateHttp(url).GetResponseAsync()).GetResponseStream()).ToList();

var streams = await Task.WhenAll(streamTasks);
foreach (var stream in streams)
{
    using (var fileStream = new FileStream("blabla", FileMode.Create))
    {
        await stream.CopyToAsync(fileStream);
    }
}

我担心这段代码会导致内存使用量过大,因为如果有1000个包含2MB文件的文件,那么这段代码将会把1000 * 2MB流加载到内存中。

也许我漏掉了什么或者完全正确。如果没有遗漏,那么最好的方法是等待每个请求并消耗流?


1
@PaulZahra 由于文件内容是流式传输的,而不是急切地加载到内存中,这可能不是一个问题,这取决于“GetResponsesStream”的实现方式。获取响应流并不一定意味着加载整个响应,尽管它可能会这样做。 - Servy
@Servy,如果流在使用前尚未加载,那么这段代码将非常高效,是吗? - Freshblood
1
顺便说一句,虽然你使用了异步方法,但它可能会阻塞主线程一段时间。这是因为在异步下载本身之前,它会检查DNS名称,并且此检查是由一个阻塞函数内部完成的。如果你使用IP而不是域名,异步下载将完全异步化。 - Paul Zahra
@Freshblood 我认为他的意思是在读取时将流写入,不要缓冲太多... 实际上,缓冲区会占用大量内存。 - Paul Zahra
@PaulZahra 你认为我在stackoverflow上的回答中关于块实现的想法怎么样?https://dev59.com/94Daa4cB1Zd3GeqP_RLm#23893276 - Freshblood
显示剩余2条评论
3个回答

5

这两种选择都可能存在问题。一次只下载一个文件无法扩展且耗时,而一次下载所有文件可能会造成过载(此外,在处理它们之前不需要等待所有文件都下载完)。

我更喜欢在此类操作中始终设置可配置大小。一种简单的方法是使用AsyncLock(它利用SemaphoreSlim)。一种更为强大的方法是使用TPL DataflowMaxDegreeOfParallelism

var block = new ActionBlock<string>(url =>
    {
        var stream = (await WebRequest.CreateHttp(url).GetResponseAsync()).GetResponseStream();
        using (var fileStream = new FileStream("blabla", FileMode.Create))
        {
            await stream.CopyToAsync(fileStream);
        }
    },
    new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 100 });

没有ActionBlock类。我无法导入它。也许Parallel.For是正确的选择? - Freshblood
@Freshblood "TPL Dataflow库不随.NET Framework一起分发。要安装它,请在Visual Studio中打开您的项目,从项目菜单中选择管理NuGet包,并在线搜索Microsoft.Tpl.Dataflow包。" - i3arnon
@Freshblood,你可以不用那个库来实现类似的功能,但是你真的应该使用它。 - i3arnon
看起来是一个很简单的代码块。请看我的答案。 - Freshblood

3

无论您是否使用async,您的代码都会将流加载到内存中。 使用async处理I/O部分,通过返回给调用方,直到ResponseStream返回。

您需要做出的选择与async无关,而是涉及程序实现,即如何读取大型流输入。

如果我是您,我会考虑如何将工作负载分成块。 您可以并行读取ResponseStream并将每个流保存到不同的源(可能是文件)中,并从内存中释放它。


我提供了一种你的分块思路的实现。你能否给出反馈? - Freshblood

2

这是我自己从Yuval Itzchakov那里得到的答案分块想法,并提供了实现。请对此实现提供反馈。

foreach (var chunk in urls.Batch(5))
{
    var streamTasks = chunk
        .Select(async url => await WebRequest.CreateHttp(url).GetResponseAsync())
        .Select(async response => (await response).GetResponseStream());

    var streams = await Task.WhenAll(streamTasks);

    foreach (var stream in streams)
    {
        using (var fileStream = new FileStream("blabla", FileMode.Create))
        {
            await stream.CopyToAsync(fileStream);
        }
    }
}

Batch是一个扩展方法,其实现如下。

public static IEnumerable<IEnumerable<T>> Batch<T>(this IEnumerable<T> source, int chunksize)
{
    while (source.Any())
    {
        yield return source.Take(chunksize);
        source = source.Skip(chunksize);
    }
}

1
当你使用批处理时,即使某些文件下载时间更长,你也需要等待所有文件下载完成后再开始处理。这种方式缺乏可扩展性。 - i3arnon
使用HttpWebResponse而不是HttpClient有什么原因吗? - Yuval Itzchakov
HttpClient比WebRequest更简单吗?它相对于WebRequest有哪些优点? - Freshblood
它更好,因为它完全支持使用async/awaitTAP - Yuval Itzchakov
2
如果其中5个在其余文件之前结束,您将在Task.WhenAll处等待,而不是开始下载其他文件。当我说“全部”时,我指的是该批次中的所有文件。 - i3arnon
显示剩余2条评论

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