网页的大规模下载 C#

8

我的应用程序需要将大量的网页下载到内存中进行进一步的解析和处理。那么最快的方式是什么?我的当前方法(如下所示)似乎太慢了,有时还会导致超时。

for (int i = 1; i<=pages; i++)
{
    string page_specific_link = baseurl + "&page=" + i.ToString();

    try
    {    
        WebClient client = new WebClient();
        var pagesource = client.DownloadString(page_specific_link);
        client.Dispose();
        sourcelist.Add(pagesource);
    }
    catch (Exception)
    {
    }
}

4
你需要一条T1连接。 - H H
2
由于许多答案都建议并行获取,我想警告您不要发送太多并发请求;如果网站不友好,您可能会被禁止。此外,每个额外线程帮助的限制将有一个极限,并且超过一定点会导致退化。 - Miserable Variable
@Hemal Pandya:这是一个有效的问题,但不是那么值得担心;WebClient类最终将使用HttpWebRequest/HttpWebResponse类,这些类使用ServicePointManager类。默认情况下,ServicePointManager将限制特定域的大多数下载为每次两个(根据HTTP 1.1规范的建议)。 - casperOne
@casperOne 我不知道 ServicePointManager,我只是将其与在命令行上发出一堆 wget ... & 进行了比较。我不知道 HTTP 1.1 推荐,但在这个时代似乎太少了。在我看来,OP 可能会想要覆盖它。 - Miserable Variable
7个回答

6
你处理这个问题的方式将在很大程度上取决于你想要下载多少页以及你引用了多少网站。
我将使用一个很好的整数,比如1000。如果你想从一个单一的网站下载那么多页面,时间会比从分布在几十或几百个网站上的1000个页面中下载要长得多。原因是如果你向一个网站发送大量的并发请求,你可能最终会被阻止。
因此,你必须实施一种“礼貌策略”,在单个网站的多个请求之间发布延迟。这个延迟的长度取决于许多因素。如果该站点的robots.txt文件有一个“crawl-delay”条目,你应该尊重它。如果他们不希望你每分钟访问超过一页,那么你爬行的速度就应该是这个快。如果没有“crawl-delay”,你应该根据一个站点响应的时间来确定延迟。例如,如果你可以在500毫秒内从该站点下载一页,那么你将设置你的延迟为X。如果需要一个完整的秒钟,设置你的延迟为2X。你可以将你的延迟限制在60秒(除非“crawl-delay”更长),我建议你设置一个最小延迟为5到10秒。
我不建议在这里使用Parallel.ForEach。我的测试表明它做得不好。有时它会超负荷连接,并且通常它不允许足够的并发连接。我建议创建一个WebClient实例队列,然后编写以下内容:
// Create queue of WebClient instances
BlockingCollection<WebClient> ClientQueue = new BlockingCollection<WebClient>();
// Initialize queue with some number of WebClient instances

// now process urls
foreach (var url in urls_to_download)
{
    var worker = ClientQueue.Take();
    worker.DownloadStringAsync(url, ...);
}

当您初始化进入队列的WebClient实例时,请将它们的OnDownloadStringCompleted事件处理程序设置为指向已完成的事件处理程序。该处理程序应将字符串保存到文件中(或者您可以使用DownloadFileAsync),然后客户端会将自己添加回ClientQueue。
在我的测试中,我能够使用此方法支持10到15个并发连接。超过这个数量,我会遇到DNS解析问题(DownloadStringAsync不会异步执行DNS解析)。您可以获得更多的连接,但是这需要很多工作。
这是我过去所采取的方法,对于快速下载数千个页面效果非常好。当然,这绝对不是我高性能网络爬虫采用的方法。
我还应该注意,这两个代码块之间的资源使用有一个巨大的差异:
WebClient MyWebClient = new WebClient();
foreach (var url in urls_to_download)
{
    MyWebClient.DownloadString(url);
}

---------------

foreach (var url in urls_to_download)
{
    WebClient MyWebClient = new WebClient();
    MyWebClient.DownloadString(url);
}

第一种方法分配一个WebClient实例,用于所有请求。第二种方法为每个请求分配一个WebClient。两者之间的差异是巨大的。WebClient使用了大量系统资源,在相对较短的时间内分配数千个WebClient将影响性能。相信我...我曾经遇到过这种情况。最好只分配10或20个WebClient(尽可能多地满足并发处理的需求),而不是为每个请求分配一个WebClient

我在某处读到,手动解析站点的 DNS 并将其用于 DownloadStringAsync 可以提高性能。Jim,你试过吗? - paradox
@paradox:是的,你需要提前解析DNS,这样它很可能会在你机器的DNS解析器缓存中。我在我的爬虫程序中做了类似的事情,通过这种方式我可以获得每秒高达100个连接。但对于一个简单的下载应用程序来说,这有点麻烦。请注意,对于单个请求,先执行DNS解析然后再发出请求并不会比直接发出请求更快。只有在下载其他页面时可以同时进行DNS解析才能使事情变得更快。 - Jim Mischel
这种方式使用并行foreach怎么样?https://stackoverflow.com/questions/46284818/parallel-request-to-scrape-multiple-pages-of-a-website - sofsntp
@sofsntp:它可以工作,尽管Sleep循环不太令人满意。他基本上以与我相同的方式限制线程数量。他只是使用更多的代码来实现。 - Jim Mischel
@sofsntp:如果您遇到问题,请发布一个包含小型应用程序的问题,以说明错误。我无法在未看到代码的情况下为您提供帮助。 - Jim Mischel
我建议使用Fillmore的限流解决方案:https://joelfillmore.wordpress.com/2011/04/01/throttling-web-api-calls/#comment-204 - PinoyDev

4

为什么不直接使用网络爬虫框架呢?它可以帮你处理所有的事情,例如多线程、HTTP请求、链接解析、调度、礼貌等。

Abot(https://code.google.com/p/abot/)可以为您处理所有这些内容,并且是用C#编写的。


2
我现在已经使用Abot几个月了,发现它非常可扩展且编写得非常好。它也很好管理,因此代码库经常会有更新。您可以选择调整爬虫作为客户端的外观,以尊重机器人,并注入自己的处理程序,以扩展其他内置类的功能。 - jamesbar2

2

除了@David的完全有效的回答外,我想补充一下他方法的略微简洁的“版本”。

var pages = new List<string> { "http://bing.com", "http://stackoverflow.com" };
var sources = new BlockingCollection<string>();

Parallel.ForEach(pages, x =>
{
    using(var client = new WebClient())
    {
        var pagesource = client.DownloadString(x);
        sources.Add(pagesource);
    }
});

另一种方法是使用async:

static IEnumerable<string> GetSources(List<string> pages)
{
    var sources = new BlockingCollection<string>();
    var latch = new CountdownEvent(pages.Count);

    foreach (var p in pages)
    {
        using (var wc = new WebClient())
        {
            wc.DownloadStringCompleted += (x, e) =>
            {
                sources.Add(e.Result);
                latch.Signal();
            };

            wc.DownloadStringAsync(new Uri(p));
        }
    }

    latch.Wait();

    return sources;
}

1

你应该使用并行编程来实现这个目的。

有很多方法可以达到你想要的效果;最简单的方法可能是像这样:

var pageList = new List<string>();

for (int i = 1; i <= pages; i++)
{
  pageList.Add(baseurl + "&page=" + i.ToString());
}


// pageList  is a list of urls
Parallel.ForEach<string>(pageList, (page) =>
{
  try
    {
      WebClient client = new WebClient();
      var pagesource = client.DownloadString(page);
      client.Dispose();
      lock (sourcelist)
      sourcelist.Add(pagesource);
    }

    catch (Exception) {}
});

1
这也是错误的,因为它在没有同步访问的情况下写入sourcelist。这样做很有可能会导致列表损坏。 - casperOne
即使使用了 AsParallelforeach 也不会并行运行。您必须使用 Parallel.ForEach - Daniel
如果您正在使用最新的Parallel代码,您可能也想使用Concurrent Collections:http://msdn.microsoft.com/en-us/library/system.collections.concurrent.aspx,而不是lock()。 - Ian Mercer

0

我正在使用活动线程计数和任意限制:

private static volatile int activeThreads = 0;

public static void RecordData()
{
  var nbThreads = 10;
  var source = db.ListOfUrls; // Thousands urls
  var iterations = source.Length / groupSize; 
  for (int i = 0; i < iterations; i++)
  {
    var subList = source.Skip(groupSize* i).Take(groupSize);
    Parallel.ForEach(subList, (item) => RecordUri(item)); 
    //I want to wait here until process further data to avoid overload
    while (activeThreads > 30) Thread.Sleep(100);
  }
}

private static async Task RecordUri(Uri uri)
{
   using (WebClient wc = new WebClient())
   {
      Interlocked.Increment(ref activeThreads);
      wc.DownloadStringCompleted += (sender, e) => Interlocked.Decrement(ref iterationsCount);
      var jsonData = "";
      RootObject root;
      jsonData = await wc.DownloadStringTaskAsync(uri);
      var root = JsonConvert.DeserializeObject<RootObject>(jsonData);
      RecordData(root)
    }
}

0

我曾经遇到过类似的情况,这是我解决问题的方法

using System;
    using System.Threading;
    using System.Collections.Generic;
    using System.Net;
    using System.IO;

namespace WebClientApp
{
class MainClassApp
{
    private static int requests = 0;
    private static object requests_lock = new object();

    public static void Main() {

        List<string> urls = new List<string> { "http://www.google.com", "http://www.slashdot.org"};
        foreach(var url in urls) {
            ThreadPool.QueueUserWorkItem(GetUrl, url);
        }

        int cur_req = 0;

        while(cur_req<urls.Count) {

            lock(requests_lock) {
                cur_req = requests; 
            }

            Thread.Sleep(1000);
        }

        Console.WriteLine("Done");
    }

private static void GetUrl(Object the_url) {

        string url = (string)the_url;
        WebClient client = new WebClient();
        Stream data = client.OpenRead (url);

        StreamReader reader = new StreamReader(data);
        string html = reader.ReadToEnd ();

        /// Do something with html
        Console.WriteLine(html);

        lock(requests_lock) {
            //Maybe you could add here the HTML to SourceList
            requests++; 
        }
    }
}

你应该考虑使用并行编程,因为慢速是由于你的软件正在等待I/O操作,而为什么不在一个线程等待I/O时启动另一个线程呢。


0

虽然其他答案都是完全有效的,但它们(在撰写本文时)都忽略了非常重要的一点:通过网络调用IO bound,让线程等待这样的操作会对系统资源造成压力,并影响系统资源。

你真正想做的是利用WebClient上的异步方法(如一些人指出的那样),以及任务并行库处理基于事件的异步模式的能力。

首先,您将获取要下载的URL:

IEnumerable<Uri> urls = pages.Select(i => new Uri(baseurl + 
    "&page=" + i.ToString(CultureInfo.InvariantCulture)));

然后,你会为每个URL创建一个新的WebClient实例,使用{{link1:TaskCompletionSource<T>类}}来异步处理调用(这不会占用一个线程):
IEnumerable<Task<Tuple<Uri, string>> tasks = urls.Select(url => {
    // Create the task completion source.
    var tcs = new TaskCompletionSource<Tuple<Uri, string>>();

    // The web client.
    var wc = new WebClient();

    // Attach to the DownloadStringCompleted event.
    client.DownloadStringCompleted += (s, e) => {
        // Dispose of the client when done.
        using (wc)
        {
            // If there is an error, set it.
            if (e.Error != null) 
            {
                tcs.SetException(e.Error);
            }
            // Otherwise, set cancelled if cancelled.
            else if (e.Cancelled) 
            {
                tcs.SetCanceled();
            }
            else 
            {
                // Set the result.
                tcs.SetResult(new Tuple<string, string>(url, e.Result));
            }
        }
    };

    // Start the process asynchronously, don't burn a thread.
    wc.DownloadStringAsync(url);

    // Return the task.
    return tcs.Task;
});

现在你有一个IEnumerable<T>,你可以将其转换为数组,并使用Task.WaitAll等待所有结果:
// Materialize the tasks.
Task<Tuple<Uri, string>> materializedTasks = tasks.ToArray();

// Wait for all to complete.
Task.WaitAll(materializedTasks);

然后,您只需在Task<T>实例上使用{{link1:Result属性}}即可获取url和内容的对:

// Cycle through each of the results.
foreach (Tuple<Uri, string> pair in materializedTasks.Select(t => t.Result))
{
    // pair.Item1 will contain the Uri.
    // pair.Item2 will contain the content.
}

请注意,上述代码的缺点是没有错误处理。
如果您想获得更高的吞吐量,而不是等待整个列表完成,您可以在下载完成单个页面的内容后处理它;Task<T>应该像管道一样使用,当您完成工作单元时,让它继续到下一个工作单元,而不是等待所有项目完成(如果它们可以以异步方式完成)。

传递一个(被拒绝的)建议编辑:DownloadStringAsync不接受“string”的重载 - 只接受“Uri”。 - user7116
@sixlettervariables:感谢您的建议;我修改了代码,全程使用 Uri - casperOne
这看起来像是伪代码。你在几个地方缺少了 >。例如:IEnumerable<Task<Tuple<Uri, string>> tasks 这段代码无法编译,而且某些类型是错误的。 - Shiva
@Shiva 随意编辑以进行更正。另外,仔细检查后,我发现这是唯一一个角括号未正确管理的地方。 - casperOne

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