目录

题目描述

1242. 多线程网页爬虫

题意分析

输入输出和单线程版本完全一致:给一个起始 URL 和一个只能按需调用的 getUrls(url) 接口,返回所有从起始页可达且与起始页同主机名的 URL,顺序任意。唯一的不同是题目明确要求用多线程实现。

为什么值得并发:getUrls 模拟的是网络请求,是高延迟的 I/O 操作,而两次请求之间没有依赖——只要知道了某个页面的地址,抓它就不需要等别的页面抓完。这是典型的 I/O 密集型可并行场景,加速比不受 CPU 核数限制。

并发引入了两个单线程版本不存在的问题。第一是共享去重表的竞态:两个线程同时发现同一个新 URL,如果「查是否存在」和「写入」不是原子的,这个页面会被抓两次。第二是终止判定:单线程版本靠「队列空」判断结束,但并发下「队列空」不代表结束——可能所有线程都还在等 getUrls 返回,马上就会产生新任务。

主机名规则与单线程版一致:http:// 之后到下一个 / 之前,题目保证协议前缀固定且无端口号。

边界:起始页无外链、全部外链为外域、图中存在环和多路径汇聚、getUrls 返回大量重复链接。

解法:并发 BFS

核心思路

先明确算法骨架没有变:仍然是从起点出发的可达性遍历,扩展时按主机名过滤,靠一张全局去重表保证每个页面只抓一次。改动全部集中在「谁来执行扩展」和「怎么知道全都做完了」。

朴素的并发写法是「主线程维护队列,起一批 worker 从队列取任务」。它的麻烦在于终止:worker 取不到任务时不能直接退出,因为别的 worker 可能正要往队列里塞新任务;用有界通道还会引入死锁——所有 worker 都阻塞在「往满通道里写」时,就没人再来消费了。

换个角度:这个遍历天然是递归形状的——处理一个页面,就派生出若干个「处理它的某个新邻居」的子任务,子任务再派生孙任务。与其自己维护队列,不如直接把「派生子任务并等它们结束」交给运行时。Java 用 ForkJoinPool + RecursiveActioninvokeAll 会等所有子任务完成才返回;Go 用 sync.WaitGroup 配合每发现一个新页面就 go 一个协程,wg.Wait() 在计数归零时返回。两者都把终止判定变成了「派生计数归零」,不需要显式的空队列检测。

这里的不变量是:visited 中的每个 URL 都有且只有一个任务所有者,并且该任务已经运行、已经登记待启动,或已经完成。必须原子完成的是「判断 URL 未访问并取得所有权」;真正启动线程可以稍后发生,但在这段间隙内,当前父任务必须仍被终止机制计数,不能让主线程误判为全部完成。

Java 中,ConcurrentHashMap.newKeySet()add 原子地授予所有权,只有成功者把子任务加入自己的私有任务列表;父任务随后用 invokeAll 启动并等待整批子任务。Go 没有直接使用并发集合,因此用一把 sync.Mutex 保护「查重、写入、wg.Add(1)」三个动作;解锁后再启动协程。此时父协程尚未执行延迟的 Done,所以计数不可能在启动间隙归零。两种写法都把慢速 getUrls 留在同步区域之外。

wg.Add(1) 的位置也有讲究:必须在启动协程之前、在父任务还没结束时调用。如果放到子协程内部,父协程可能先跑完 wg.Done() 让计数提前归零,Wait 直接返回,抓取还没做完就返回了残缺结果。

解题步骤

  • 先算出起始页的主机名并存为常量。这个值被所有线程只读共享,没有写入就没有竞态,不需要任何同步。
  • 把起始 URL 预先放进 visited,再为它派生第一个任务。先入集合再派生,是为了防止某个页面链回起点时起点被第二次派生。
  • 每个任务的职责固定为三步:对自己的 URL 调一次 getUrls,逐个检查邻居,对新的同域邻居派生子任务。慢调用 getUrls 必须在任何锁之外执行,这是并发版本能真正加速的前提。
  • 邻居检查的顺序是「先比主机名,再抢占去重表」。先过滤后标记,visited 最终就直接是答案;反过来会把外域 URL 写进结果。
  • 抢占用原子操作完成。Java 直接依赖 ConcurrentHashMap.newKeySet()add 返回值;Go 在同一个临界区完成「查 map、写 map、wg.Add(1)」,然后释放锁并启动协程。这样一个 URL 一旦进入 visited,终止计数中已经有对应任务;慢调用仍然不持锁。
  • 等待全部完成。Java 里 invokeAll(tasks) 让当前任务阻塞直到所有直接子任务结束,递归展开后根任务的 invoke 返回即代表整棵任务树完成;Go 里主协程 wg.Wait(),计数在每个 walkdefer wg.Done() 中递减,归零即全部完成。
  • 处理异常退出。Java 用 finally 关闭专用线程池,任务异常由 pool.invoke 向调用方传播,不会把尚未完成的集合当作正常答案返回。Go 题目接口没有错误返回值;defer wg.Done() 保证任何正常返回路径都会注销任务。若生产接口增加错误返回,应记录或传播错误,同时仍让 Done 执行,不能静默返回部分结果。
  • 全部结束后把 visited 转成切片/列表返回。此时已经没有并发写入,可以安全地遍历。
  • getHost 从下标 7(http:// 之后)开始找第一个 /:找到就截取到它之前,找不到说明 URL 没有路径部分,返回从 7 到末尾的整段。

startUrl = "http://a.com/x" 走一遍,图为:getUrls("http://a.com/x") 返回 ["http://a.com/y", "http://b.com/z", "http://a.com/x"]getUrls("http://a.com/y") 返回 ["http://a.com/x", "http://a.com/w"]getUrls("http://a.com/w") 返回空。

getHost("http://a.com/x"):从下标 7 的字符 'a' 开始扫,第一个 / 在下标 12,得到 "a.com"visited = {http://a.com/x},为起点派生任务 T1,等待计数为 1。

T1 调用 getUrls 拿到三个链接。http://a.com/y 主机名是 a.com,同域;抢占 visited 成功,计数加到 2,派生 T2。http://b.com/z 主机名 b.com,与 a.com 不等,跳过,不进 visitedhttp://a.com/x 同域,但抢占失败(起点已在集合中),忽略——这一步挡住了自环。T1 的邻居处理完毕,其计数递减。

T2 调用 getUrls 拿到两个链接。http://a.com/x 抢占失败,忽略。http://a.com/w 是新的同域页面,抢占成功,派生 T3。T2 结束。

T3 的外链为空,直接结束。此时所有派生任务都已完成、计数归零,Wait 返回。结果是 visited 的三个元素:http://a.com/xhttp://a.com/yhttp://a.com/w。整个过程中 getUrls 恰好被调用三次,每个可达同域页面一次。

关键在于 T2 和 T3 可能与其他任务并发执行,但由于「抢占成功者才派生」这条不变量,无论线程调度顺序如何,每个 URL 都只会被派生一次,结果集合完全确定(只是元素顺序不确定,而题目允许任意顺序)。

代码实现

// 抢占式去重:只有把 URL 抢进并发集合的线程才派生子任务,invokeAll 负责终止判定。
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;

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

        ForkJoinPool pool = new ForkJoinPool();
        try {
            pool.invoke(new CrawlTask(startUrl, host, htmlParser, visited));
        } finally {
            pool.shutdown();
        }

        return new ArrayList<>(visited);
    }

    private static class CrawlTask extends 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);
    }
}
// 互斥锁保护「查 + 写 + 登记任务」,慢调用 GetUrls 留在锁外;WaitGroup 归零即全部完成。
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)
	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:]
}

复杂度分析

  • 时间复杂度:若 URL 平均长度为 L,总工作量是 $O((V + E) \cdot L)$,其中 V 为可达同域页面数、E 为这些页面返回的外链总数;每个页面抓取一次,每条链接做主机名提取和哈希操作。并发降低的是墙钟时间,不改变总工作量;它受线程/连接上限、图的关键路径和 getUrls 延迟共同影响,不能仅用图深度给出无条件保证。
  • 空间复杂度:$O(V \cdot L)$,L 为 URL 平均长度。visited 存下全部可达同域 URL;同时存活的任务数不超过 V,每个任务只持有一个 URL 引用和几个共享对象的引用。

关键点总结

  • 并发版本要改的从来不是算法,而是三件事:共享状态的原子性、慢调用与临界区的分离、终止条件的判定方式。把这三点拆开讲,比笼统地说「加锁」更能体现对并发的理解。
  • 「查重 + 标记」必须是一个原子操作。contains 后再 add 在并发下是经典的检查后使用漏洞,两个线程会同时通过检查。常见做法是使用并发集合的原子插入、sync.Map.LoadOrStore,或用互斥锁包住检查与写入。
  • 锁的临界区里绝不能放 I/O。getUrls 一旦被锁住,所有线程串行等待,并发退化成加了锁开销的单线程——这是面试官最常挖的坑。
  • 用「所有已登记任务完成」代替「队列为空」做终止判定。队列空只是瞬时状态,不代表没有在途任务;WaitGroup 用计数表达该条件,invokeAll 则通过父任务等待全部子任务形成结构化终止。
  • wg.Add 必须在启动协程之前、父协程 Done 之前调用。放在子协程内部会导致计数提前归零、Wait 提前返回、结果残缺,而且这类 bug 有很强的随机性,本地跑十次可能都不复现。
  • 有界通道加固定 worker 池在这类「任务会派生任务」的场景下容易死锁:所有 worker 都阻塞在往满通道写入时,就没有消费者了。要么用无界队列 + 显式的在途计数,要么直接用结构化并发,后者在白板上更短也更难写错。
  • 面试视角:当前 Go 写法会按页面派生协程,适合题目规模;生产环境若要限制并发度,应由固定 worker 池或独立调度器消费任务,并继续维护在途任务数。不要让持有并发名额的递归任务同步等待新的名额,否则所有 worker 都可能互相等待而死锁。

易错点总结

  • 错误写法:用普通 HashSet 做去重表 → 多个线程同时写入会破坏内部结构,走查里的 http://a.com/x 被 T1、T2 同时读写时可能丢失元素甚至抛出 ConcurrentModificationException,返回结果不完整。
  • 错误写法:if (!visited.contains(next)) { visited.add(next); ... } 分两步做 → 两个线程可能同时通过 contains 检查,同一个页面被派生两次,getUrls 被重复调用,环状图上可能无限膨胀。
  • 错误写法:Go 里把 htmlParser.GetUrls(u) 写在 mu.Lock()mu.Unlock() 之间 → 所有协程排队等同一把锁,并发度退化到 1,墙钟时间和单线程版本一样甚至更慢。
  • 错误写法:Go 里把 wg.Add(1) 写在子协程内部(go func(){ wg.Add(1); ... }())→ 主协程的 wg.Wait() 可能在子协程还没执行 Add 时就看到计数为 0 并返回,走查中可能只返回起点一个 URL。
  • 错误写法:忘记 defer wg.Done() 或在提前 return 的分支里漏掉 Done → 计数永远不归零,wg.Wait() 永久阻塞,程序超时。
  • 错误写法:用固定 worker 池 + 有界 channel,且 worker 在处理任务时直接往同一个 channel 写新任务 → 走查图放大到超过缓冲区容量时,所有 worker 都阻塞在写入上、无人消费,直接死锁。
  • 错误写法:先 visited.add(next) 再判断主机名 → http://b.com/z 被写进结果集合,返回的列表里混入外域 URL。
  • 错误写法:起始 URL 不预先放进 visited → 走查里 http://a.com/x 链回自己,起点被第二次抢占成功并派生任务,环上任务无限增殖。
  • 错误写法:getHost 从下标 0 开始找 / → 命中协议里的斜杠,所有主机名都变成空串,过滤失效,会把整个网络爬下来。
  • 错误写法:getHost 不处理「7 之后没有 /」的情况 → http://a.com 这种无路径 URL 上取到 -1 下标,Java 抛 StringIndexOutOfBoundsException,Go 切片越界 panic。
  • 错误写法:Java 里派生子任务后不调用 invokeAll(或只 forkjoin)→ 根任务的 invoke 提前返回,visited 还在被后台任务写入时就被拷贝成列表,结果随机缺失。

相似题目

题目 难度 考察点
1236. 网络爬虫 中等 同一遍历的单线程版,可用来对照并发改造改了哪三处
1114. 按序打印 简单 只需线性顺序约束,用信号量或 CountDownLatch 串行化
1226. 哲学家进餐 中等 多把锁的获取顺序问题,重点是避免循环等待造成的死锁
841. 钥匙和房间 中等 同样是可达性遍历,但邻接表已知且单线程,可对比骨架部分