题目描述

✅ 1242. 多线程网络爬虫

题意分析

从起始 URL 沿页面链接向外抓取,返回所有能到达且主机名与起点相同的 URL,包含起点本身。不同页面可能重复指向同一个地址,页面之间也可能形成环,同一 URL 只能安排一次抓取。

本题还要求并发执行抓取,不能让一个页面的等待阻塞所有其他页面。返回前必须确认全部已派生任务结束,而不是只看暂时没有待取的 URL。代码按 http:// 且主机部分不含端口的输入约定提取主机名,比较的是完整主机名,不是 URL 字符串前缀。

解法:并发可达性遍历

核心思路

[!blue]

将页面看成图节点、链接看成有向边。任务负责抓取自己的 URL,并从返回链接中筛出同主机候选;候选若从未发现,就派生新任务。关键是让多个任务共享同一个已访问集合,而各自的慢速接口调用可以并行执行。

“查到没有访问”和“登记已经访问”必须成为一个原子动作,否则两个线程可能同时看到不存在,并重复抓取同一页面。Java 使用并发集合 add 的返回值取得唯一处理权;Go 在同一把锁内查重、写入集合并登记任务数量。调用 getUrls 不持有这把锁,避免网络等待把全部工作串行化。

Java 把新发现的 URL 建成子任务,由 invokeAll 等待本任务的全部孩子。每个孩子又等待自己的后代,因此根任务完成就代表整棵任务树完成。重复链接只归属于第一个成功登记它的任务,不会因为原网页图有环而产生相互等待的任务环。

Go 用 WaitGroup 记录已经登记但尚未完成的任务。起点先 Add(1) 再启动;任务发现新 URL 时也先 Add(1) 再启动协程,结束时由 defer Done() 注销。父任务在派生期间仍计数在内,因此新任务尚未真正运行时,计数也不会提前降到零。

等待结束后,不再有任务能发现或写入新 URL,此时才遍历访问集合生成答案。Java 的专用线程池在 finally 中关闭;结果顺序取决于并发调度与集合遍历,不需要是发现顺序。

解题步骤

  1. 提取起点主机,将起点先登记到共享访问集合。
  2. 启动起点任务,任务在锁外获取该页面链接并过滤不同主机。
  3. 对候选 URL 原子地完成查重与登记;只有首次登记成功者创建处理任务。
  4. Java 等待各级子任务并最终等待根任务;Go 在启动前计数、结束时减数,由主协程等待计数归零。
  5. 确认全部任务结束后生成 URL 列表返回,并关闭 Java 专用线程池。

代码实现

class Solution {
    public List<String> crawl(String startUrl, HtmlParser htmlParser) {
        String host = getHost(startUrl);
        Set<String> visited = java.util.concurrent.ConcurrentHashMap.newKeySet();

        visited.add(startUrl);

        java.util.concurrent.ForkJoinPool pool = new java.util.concurrent.ForkJoinPool();

        try {
            // 等待起点任务树全部完成后,才读取结果集合。
            pool.invoke(new CrawlTask(startUrl, host, htmlParser, visited));
        } finally {
            pool.shutdown();
        }

        return new ArrayList<>(visited);
    }

    private static class CrawlTask extends java.util.concurrent.RecursiveAction {
        private final String url;
        private final String host;
        private final HtmlParser parser;
        private final Set<String> visited;

        CrawlTask(String url, String host, HtmlParser parser, Set<String> visited) {
            this.url = url;
            this.host = host;
            this.parser = parser;
            this.visited = visited;
        }

        @Override
        protected void compute() {
            List<CrawlTask> tasks = new ArrayList<>();

            for (String next : parser.getUrls(url)) {
                if (!host.equals(getHost(next))) {
                    continue;
                }

                // 原子取得新页面的处理权,只有成功者登记子任务。
                if (visited.add(next)) {
                    tasks.add(new CrawlTask(next, host, parser, visited));
                }
            }

            // 等待所有子任务结束,父任务完成才代表整棵子任务树完成。
            invokeAll(tasks);
        }
    }

    private static String getHost(String url) {
        int start = 7;
        int slash = url.indexOf('/', start);

        if (slash == -1) {
            return url.substring(start);
        }

        return url.substring(start, slash);
    }
}
import "sync"

func crawl(startUrl string, htmlParser HtmlParser) []string {
    host := getHost(startUrl)

    var mu sync.Mutex
    visited := map[string]struct{}{startUrl: {}}

    var wg sync.WaitGroup
    var walk func(u string)
    walk = func(u string) {
        // 当前任务结束时注销,父任务派生期间始终保持计数。
        defer wg.Done()
        for _, next := range htmlParser.GetUrls(u) {
            if getHost(next) != host {
                continue
            }
            mu.Lock()
            if _, ok := visited[next]; ok {
                mu.Unlock()
                continue
            }
            // 查重、标记和登记在同一锁内完成,避免重复任务。
            visited[next] = struct{}{}
            wg.Add(1)
            mu.Unlock()

            // 新任务已登记后再启动,防止等待计数提前归零。
            go walk(next)
        }
    }

    wg.Add(1)
    go walk(startUrl)
    // 计数归零后不再有并发写入,才可遍历结果 map。
    wg.Wait()

    res := make([]string, 0, len(visited))
    for u := range visited {
        res = append(res, u)
    }
    return res
}

func getHost(url string) string {
    for i := 7; i < len(url); i++ {
        if url[i] == '/' {
            return url[7:i]
        }
    }
    return url[7:]
}

复杂度分析

  • 时间复杂度:设实际抓取同主机页面数为 V,这些页面返回的链接总数为 E,URL 长度上界为 L,本地总工作量期望为 $O((V+E)L)$,同时需要执行 V 次抓取调用。并发会改变实际经过的时间,但不能把总工作量直接除以线程数,接口延迟和调度同样影响耗时。
  • 空间复杂度:设同时仍被任务保留的接口返回链接数最多为 B,访问集合、任务状态和这些链接列表合计为 $O((V+B)L)$。Go 每个新页面会启动一个任务,不能将活跃任务空间视为固定常数。

关键点总结

[!green]

  • URL 的唯一登记决定抓取任务的唯一所有者,同时防止重复链接与环造成重复工作。
  • 锁只保护共享状态,慢速抓取在锁外执行。
  • 完成条件覆盖正在运行和已经登记的全部任务,不是某个队列暂时为空。
  • 子任务登记发生在父任务完成之前,等待计数不会遗漏尚未启动的工作。
  • 等待完成再读取结果,Go 普通 map 此时才不存在并发写入。

易错点总结

[!yellow]

  • 使用普通集合无同步并发写入,或者把查重与登记分开,导致数据竞争或重复任务。
  • 先启动协程再增加等待计数,主协程可能先看到零而返回。
  • 抓取接口也放在全局锁内,不同任务必须轮流等待网络,失去并发意义。
  • Java 根任务没有等待后代,或 Go 漏掉 Done(),分别会导致返回不完整或无法结束。
  • 把“暂时没有排队任务”当成完成,忽略仍在抓取中的页面之后可能发现新链接。
  • 仅用字符串前缀判断同主机,可能把主机名开头相似但实际不同的 URL 也纳入。

相似题目

题目 难度 关联与区别
1236. 网络爬虫 中等 单线程图遍历提供功能基础,本题增加并发后还需保护访问集合与任务完成计数。
1188. 设计有限阻塞队列 中等 生产者消费者队列可用于分发待爬URL,但爬虫还需判断队列空且所有工作者都已结束任务。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2024/80513569
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!