其他链接检查工具如何实现递归
原文由 Matthias Endler 于 发布,订阅该博客
在我发布了 Five Years of Trying to Add Recursion to lychee 之后,收到了一条非常中肯的提问:
如果递归这么难,其他链接检查工具是怎么做的?明明已经有很多工具在爬网站了!
这让我一头扎进了其他链接检查工具的源码里。核心结论是:它们并没有找到什么我们遗漏的巧妙技巧。它们从第一次提交起就是作为爬虫来构建的,而我最初是把 lychee 做成了一个流式工具。
我去读了我们在 lychee 的 README 中列出的那些支持递归的检查工具的源码:muffet(Go)、LinkChecker(Python)、linkinator(TypeScript)和 broken-link-checker(JavaScript)。本文将逐一拆解它们究竟是如何处理递归的、为此付出了什么代价,以及这对 lychee 意味着什么。
如果你还没看过第一篇文章,简单总结一下:lychee 最初被设计为一次性的单向流水线(inputs → extract → check → output)。而递归需要一个环(响应会产生新的输入),在基于异步 channel 的流水线里,环就是暗藏巨龙的地方。🐲 历经五年、四次尝试后,真正把递归做对所需的拼图才刚刚到位。
DAG 与环
我看过的所有支持递归的检查工具,都是由同样的三部分构成的:
- 一个可变的工作队列(我们称之为“frontier / 待爬队列”),而不是固定的输入流。发现的新 URL 会回到它来源的同一个队列中。
- 一个在入队时(请求完成之前)就更新的已访问集合,这样两个页面同时发现同一个链接时,不会重复提交。
- 一个用来回答“都做完了吗?”的原语:
WaitGroup、可 join 的队列计数器、onIdle()Promise,或队列排空事件。
从图上看,lychee 和其他工具截然不同:
graph TD
subgraph crawler["Everyone else: a cycle"]
direction TB
CQ[Frontier queue] --> CW[Worker pool]
CW --> CP[Fetch and parse page]
CP -->|new links| CQ
CP --> CR[Results]
end
subgraph lychee["lychee: a DAG"]
direction TB
LA[Inputs] --> LB[Extractor]
LB --> LC[Checker]
LC --> LD[Results]
end爬虫天生就有一条回边。我们的流水线没有,我之前每一次失败的尝试,都是想把这条回边硬塞进一个根本不是为它设计的图里。
我们再仔细看看这个图的设计:
graph TD
Seed[Seed URLs] --> Enq["Enqueue step: is URL in visited set?"]
Enq -->|yes| Skip[Drop]
Enq -->|no| Mark["Mark visited, then push"]
Mark --> Q[Frontier queue]
Q --> Pool["Worker pool, bounded concurrency"]
Pool --> FP[Fetch page and extract links]
FP -->|discovered links| Enq
FP --> Rec[Results]
Q -.->|empty AND no worker busy| Stop[Terminate]注意,已访问检查发生在入队这一步,与标记操作原子性地一起完成,且在 worker 真正发起网络请求之前。正是这个顺序,彻底解决了困扰 lychee 第 1 到第 4 次尝试的去重竞态问题——在那些尝试中,缓存是在检查之后才写入的。
每个工具都是在这个模型上的变体。
muffet(Go):WaitGroup 与集合
muffet 在理念上最接近 lychee:一个快速、单二进制、并发的网站检查工具。去重与调度的决策集中在一个方法里(page_checker.go):
func (c *pageChecker) addPage(p page) {
if !c.donePages.Add(p.URL().String()) {
c.daemonManager.Add(func() { c.checkPage(p) })
}
}donePages 是一个 concurrentStringSet(被互斥锁保护的 map[string]struct{})。Add 返回该 URL 是否已存在,因此只有第一次出现的页面才会被调度。去重在入队时发生,由集合的互斥锁来同步。这基本上就是上面那张图的逐行代码实现。
检查一个页面时会并发地抓取其所有链接,并把符合条件的链接重新喂回 addPage,也就是那条回边:
go func(u string) {
defer w.Done()
status, p, err := c.fetcher.Fetch(u)
// ...
if !c.onePageOnly && p != nil && c.linkValidator.Validate(p.URL()) {
c.addPage(p) // recursion: discovered page re-enters the frontier
}
}(u)muffet 如何知道何时结束
muffet 对终止问题的答案是一个围绕 sync.WaitGroup 构建的小型 daemonManager(daemon_manager.go):
func (m daemonManager) Add(f func()) {
m.waitGroup.Add(1)
m.daemons <- func() {
f()
m.waitGroup.Done()
}
}
func (m daemonManager) Run() {
go func() {
for f := range m.daemons {
go f()
}
}()
m.waitGroup.Wait() // <- termination
}每调度一个页面,计数就加一;每完成一个页面,计数就减一;当计数归零时 Wait() 返回。整个爬取过程在调用 Run() 之前先通过一次 addPage 来启动,因此在任何人等待之前,计数就已经是正数了。
这正是尝试 1 和尝试 4 中我尝试过(并失败了)的同一个计数器。区别在于其不变式:waitGroup.Add(1) 永远只在已经运行、且持有正计数的 daemon 内部被调用(或在启动引导时)。不存在计数短暂归零、而实际仍有待处理任务的窗口。Go 的 WaitGroup 如此自然地维护了这一不变式,以至于让人根本感觉不到这是在做分布式终止检测,但它本质上正是如此。它在精神上等同于Kait 在 2026 年为 lychee 贡献的那个 WaitGroup 原语。
取舍在哪里
- 并发不受 daemon manager 限制。
Run()会为每个任务执行go f(),无限制地创建 goroutine。真正的限流发生在下游的semaphore(基于带缓冲 channel 的计数信号量)和按主机的限流器池中。muffet 把“待爬队列”和“限速器”分开了,而这正是 lychee 过去试图用同一个有界 channel 同时承担两种职责时所缺失的分离。 - 廉价的 goroutine 承担了大量工作。在 Go 中为每个链接启动一个 goroutine 是“完全没问题”的。而在 Rust 中等价的做法(为每个链接
tokio::spawn,且每个都需要Send + 'static的状态)正是把我推向Arc<RwLock<…>>以及我曾写过的那些所有权痛苦的原因。 - 在可扩展性上,muffet 是一个专注的 CLI,而不是库。它没有插件接口;你只能得到命令行参数提供的功能。lychee 则有意将
lychee-lib作为可复用的 crate 发布,这抬高了要求,因为每一个架构决策都必须符合公共 API 的标准。 - 在可伸缩性上,无限制的 goroutine 加上内存中的已访问集合,可以轻松应对大型网站,但由于没有落盘的待爬队列,真正超大规模的爬取仍受限于内存。这点和 lychee 一样。
小结:muffet
- muffet 的终止机制就是
sync.WaitGroup,仅此而已。这是 lychee 花了五年才收敛到的设计,而 muffet 从第一天起就从 Go 标准库中免费获得了它。 - 待爬队列和并发限流器是两回事。由互斥锁保护的集合是待爬队列;信号量加主机限流器负责限制并发。把二者混为一谈,正是导致 lychee 死锁的原因。
- Goroutine 隐藏了 Rust 中必须显式付出的成本。在 Go 中轻而易举的按任务模型,正是 Rust 中
Send/所有权摩擦显现的地方。
LinkChecker(Python):可 join 的无界队列
LinkChecker 自 2000 年就已存在。它是一个同步的线程池爬虫。
它的待爬队列是一个手写的 UrlQueue(cache/urlqueue.py),是 Python queue.Queue 的克隆,带有 task_done()/join()。看看它最早的设计注释:
def __init__(self, max_allowed_urls=None):
# Note: don't put a maximum size on the queue since it would
# lead to deadlocks when all worker threads called put().
self.queue = collections.deque()
# ...
self.unfinished_tasks = 0它明确指出了曾困扰我的那个死锁。
这条注释所说的,正是我们在尝试 4 的背压死锁中遇到、并且是被有意规避的。lychee 曾试图把发现的 URL 推入一个有界 channel;当 channel 填满时,响应处理器被阻塞,响应无法被消费,空位也无法释放。死锁。💥
LinkChecker 的解法带着一种粗粝的直接:待爬队列是无界的。背压在别处强制执行(固定的线程数和按主机的限流),绝不会通过阻塞一个同时也是消费者的生产者来实现。
用计数器实现终止——而且做对了
join() 会一直阻塞,直到 unfinished_tasks 归零(urlqueue.py):
def task_done(self, url_data):
with self.all_tasks_done:
self.finished_tasks += 1
self.unfinished_tasks -= 1
self.in_progress -= 1
if self.unfinished_tasks <= 0:
self.all_tasks_done.notify_all()
def join(self, timeout=None):
with self.all_tasks_done:
while self.unfinished_tasks:
self.all_tasks_done.wait()又是一个计数器。但 _put 中的递增和 task_done 中的递减都在队列的 Condition 锁内部完成,且 worker 只有在完全处理完一项(包括将其子项入队)之后才会调用 task_done。因此子项会在父项被标记完成之前就被计入,不会出现提前归零的情况。这本质上是用互斥锁和条件变量实现的 WaitGroup 语义。
去重,在请求之前
LinkChecker 在入队时就把 URL 写入结果缓存(urlqueue.py):
def _put(self, url_data):
key = url_data.cache_url
cache = url_data.aggregate.result_cache
if cache.has_result(key):
return # already queued/checked -> skip
# ...
self.queue.append(url_data)
self.unfinished_tasks += 1
# add a None placeholder so this URL is never queued twice
cache.add_result(key, None)那个 add_result(key, None) 哨兵正是 lychee 几次尝试中所缺失的“修复”。当任何 worker 线程去检查该 URL 时,缓存已经表明“已被占用”,因此来自另一页面的并发发现就成了空操作。
按主机的礼貌控制与终止保护
Aggregate(director/aggregator.py)会按主机进行限流:
@synchronized(_hosts_lock)
def wait_for_host(self, host):
t = time.time()
if host in self.times and self.times[host] > t:
time.sleep(self.times[host] - t)
# spread requests using maxrequestspersecond
wait_time = random.uniform(wait_time_min, wait_time_max)
self.times[host] = time.time() + wait_time而 abort() 会调用 urlqueue.join(timeout=…),因此卡住的爬取不会永远挂起。
取舍在哪里
- 使用阻塞线程而非异步。每一个(默认 10–100 个)
Checker线程都通过requests进行阻塞式 I/O。简单且久经考验,但并发上限就是线程数,且每个线程都占用完整的栈。lychee 的 Tokio 模型能在少数几个系统线程上支撑数千个并发在途请求;LinkChecker 做不到,也没打算这么做。 - 无界的待爬队列用无界的内存换来了对死锁的避免。显式的“不设最大长度”决策意味着在超大网站上内存会持续增长。为此它设置了
max_allowed_urls上限和定期的cleanup()来缓解。 - 可扩展性非常出色。LinkChecker 拥有真正的插件系统(
linkcheck/plugins/:锚点检查、SSL、病毒扫描等)以及多种输出记录器。它是这几款工具中最具可扩展性的,也为此付出了代价——代码库庞大、成熟,但也略显陈旧。 - 在可伸缩性上,它受限于 GIL 和线程数量,因此原始吞吐量是这几款中最低的,但正确性和功能覆盖度很高。
小结:LinkChecker
- 无界待爬队列是一个经过深思熟虑的防死锁选择,仅用一行注释就说明清楚。它描述的正是我们在 lychee 尝试 4 中遇到的问题。
- 在
put()时去重(在缓存中放入None占位符)是它们的同步机制。缓存必须在请求之前就声明对该 URL 的所有权,而不是之后。 - 线程用吞吐量换来了简单性。阻塞线程池是最容易写对的模型……也是最慢的一种。
linkinator(TypeScript):单线程的 queue.onIdle()
linkinator 是一个 Node.js 检查工具,它受益于 Go 和 Rust 都不具备的一点:单线程事件循环。对已访问集合的“检查并插入”是免费原子的,因为永远不会有两个回调同时运行。
待爬队列是一个限制并发的 Queue(类似 p-queue 的结构)。终止逻辑在 check() 中只需一行(src/index.ts):
const queue = new Queue({ concurrency: options.concurrency || 100 });
// ... seed the queue ...
// resolve when nothing is queued or running:
await queue.onIdle();onIdle() 是该库的终止检测:当队列为空且没有正在执行的任务时,它才会 resolve。这与 muffet 的 WaitGroup 和 LinkChecker 的 join() 是同一个思路,只是以 Promise 的形式表达,并由单线程运行时提供保障,因此不需要用互斥锁来保护已访问集合。
回边与无竞态的去重
在爬取时,crawl() 会 GET 页面、提取链接,并为每个新 URL 重新进入队列(src/index.ts):
const inCache = options.cache.has(result.url.href);
if (!inCache) {
// Mark visited...
options.cache.add(result.url.href);
// Create the promise for this check
const checkPromise = (async () => {
await this.crawl({ url: result.url, /* ... */ });
})();
// Store the promise.
// Another page discovering the same URL can wait on this promise
// instead of enqueuing a duplicate check.
options.pendingChecks.set(result.url.href, checkPromise);
// Enqueue...
options.queue.add(() => checkPromise);
}由于 JavaScript 是单线程的,整个过程会不间断地执行完毕。在 Rust 或 Go 中,这是一段必须用互斥锁保护的临界区(还得保证顺序正确);而在 Node 中,它仅仅是三条语句。这正是递归在 Node 中比在 Rust 中更容易的头号原因。这纯粹是语言特性的差异。
linkinator 还维护了一个以 `${url}|${parent}` 为键的 relationshipCache,以及一个 pendingChecks 映射,这样它就可以等待正在进行的检查,同时仍能针对引用该链接的每个父页面报告重复的失效链接。这些复用操作本身也会被推入同一个队列,因此 onIdle() 同样会正确地等待它们完成。
HEAD 与 GET
linkinator 对叶子链接使用 HEAD,但需要爬取时则使用 GET,因为递归需要响应体来发现更多链接:
response = await makeRequest(
options.crawl ? 'GET' : 'HEAD',
options.url.href, /* ... */
);这恰恰是lychee 剩下尚未解决的问题:只有用 body 抓取过的页面才能继续递归。linkinator 在爬取时干脆总是 GET;而 lychee 则计划复用刚刚检查时已缓存在本地的响应体。
取舍在哪里
- 单线程既是福气也是天花板。没有数据竞争,去重逻辑简单到不可能出错,但 HTML 解析是会阻塞唯一事件循环的 CPU 工作。对于数千个页面,你会被限制在单核上。而 lychee 的多线程运行时可以并行地解析和检查。
- 它存在内存中结果膨胀的问题。源码中明确注释了“在高度互链的站点上会出现巨大的结果膨胀”:
results数组、cache和relationshipCache都会随着爬取而增长。对文档站点来说没问题,对超大站点则会很重。 - 限速是被动的,而非主动的。它有一个
delayCache,会在收到429和Retry-After时对特定主机进行退避,但没有像 lychee 的HostPool那样通用的按主机并发上限。linkinator 可能会一直猛击某个主机,直到对方抱怨;而 lychee 现在会在对方抱怨之前就主动控制节奏。 - 在可扩展性上,它是一个
EventEmitter(on('link')、on('pagestart')等),因此易于嵌入和脚本化,这一点很不错。它和 lychee 一样,首先是一个库。
小结:linkinator
queue.onIdle()就是终止机制。简单,且由 JS 运行时提供。- 单线程事件循环让请求去重几乎是免费的。这是该场景下递归更容易的最主要结构性原因。
- 响应式的 429 退避不等同于主动的按主机限速。lychee 的
HostPool目标更高,但也需要更复杂的机制。
broken-link-checker(JavaScript):事件驱动的双队列
broken-link-checker(BLC)把事件驱动模型推向了极致。它基于 limited-request-queue 构建,这是一个带有 maxSockets(并发)和 rateLimit 的队列,并且它嵌套了两个这样的队列:一个站点级队列来驱动页面级的 HtmlUrlChecker。
待爬队列和去重逻辑位于 SiteChecker(lib/public/SiteChecker.js)中。已访问页面通过 URLCache 跟踪,并在入队时写入:
#enqueuePage(url, customData, auth) {
// Mark before crawl to avoid links to self within page.
this.#sitePagesChecked.set(url, PAGE_WAS_CHECKED);
this.#htmlUrlChecker.enqueue(url, customData, auth);
}递归由一个过滤器控制,它决定发现的链接是否会成为需要爬取的页面:
#maybeEnqueuePage(link, customData, auth) {
const tagGroup = this.#options.tags.recursive[
this.#options.filterLevel
][link.get(HTML_TAG_NAME)] ?? {};
const attrSupported = link.get(HTML_ATTR_NAME) in tagGroup;
if (!attrSupported ||
link.get(IS_BROKEN) ||
!link.get(IS_INTERNAL) ||
this.#sitePagesChecked.has(rebasedURL) || // dedup check
!this.#isAllowed(link)) { // robots.txt
// do nothing
} else if (this.#options.includePage(rebasedURL)) {
this.#enqueuePage(rebasedURL, customData, auth);
}
}通过事件级联实现终止
BLC 既没有计数器,也没有 onIdle()。它依赖队列的排空事件。当页面级队列被排空时,它会触发 END_EVENT,进而让 SiteChecker 发出 SITE_EVENT 并调用站点队列的 done 回调;当站点队列排空时,则会触发 REQUEST_QUEUE_END_EVENT。这就是对外公开的 END_EVENT:
.on(END_EVENT, () => {
this.emit(SITE_EVENT, this.#currentPageError, this.#currentSiteURL, this.#currentCustomData);
this.#currentDone(); // tell the site queue this site is finished
});这就是它们的终止检测,表达为“请求队列报告已空”。
而按照经典的 Node.js 风格,done 回调才是真正通知站点队列为下一个站点腾出空位的信号。因此,一个站点的终止才允许下一个站点开始,而整个爬取的终止才允许进程退出。这是一条从页面队列到站点队列再到进程的事件级联。
取舍在哪里
- 它是最懂“网络礼仪”的一个。会遵守 robots.txt(
getRobotsTxt、isAllowed)、尊重rel=nofollow,并且将rateLimit和maxSockets作为一等特性。这是一个默认就很礼貌的爬虫。 - 事件级联很强大,但也很繁琐。终止逻辑分散在半打事件处理器和两个嵌套队列中。它能工作,但控制流比
await queue.onIdle()难懂得多。这正是我之前描述过的“抽象泄漏”问题在 JS 中的翻版——对递归的感知被分散在了众多处理器各处。 - 它同样是单线程的,有着和 linkinator 一样的天花板,再加上每个站点一份内存中的
URLCache。 - 就成熟度与活跃度而言,它被非常广泛地使用(支撑了大量工具),但开发已放缓。不过其架构依然稳健,值得研究。
小结:broken-link-checker
- 终止是一连串队列排空事件的级联,而非计数器。思路相同,语法不同。
- 礼貌性是内置的。robots.txt、
rateLimit和maxSockets使它成为默认情况下对服务器最友好的递归检查工具。 - 事件驱动的控制流是代价。将递归逻辑分散到众多处理器中,正是让这一功能难以理解的那类复杂度的体现。
关于 markdown-link-check 与“工业级”爬虫的补充说明
我们的 README 将 markdown-link-check 标记为支持递归,但其中有些细微差别:它是在 Markdown 文件之间递归,而不是通过爬取线上网站。不存在 HTTP 待爬队列,也没有上述意义上的终止问题。提一下是为了让对比更诚实,但不值得专门拆解。
如果想看这一模式在工业级规模下的样子,可以看看 Scrapy(Python/Twisted)或 Colly(Go)。两者都采用了相同的做法:一个带可插拔、可选落盘队列的调度器(待爬队列)、一个去重过滤器(通常是 Bloom filter 而非 HashSet)、一个有界的下载器池,以及显式的“引擎空闲 → 关闭爬虫”终止机制。它们解决的正是 lychee 曾苦苦挣扎的问题(分布式终止检测、背压、去重),只不过背后有着多年专注于爬虫的工程积累。要点并不是“lychee 应该成为 Scrapy”:而是爬取本身已是一套非常成熟的架构,而 lychee 目前只是站在另一种架构之上。
横向对比
| 工具 | 语言 / 运行时 | 并发模型 | 待爬队列 | “完成”信号 | 去重点 | 按主机限流 |
|---|---|---|---|---|---|---|
| muffet | Go,goroutine | goroutine 池 + 信号量 + 按主机限流器 | 互斥保护的集合 + daemon 通道 | sync.WaitGroup | 入队时检查已访问集合 | 按主机限流池 |
| LinkChecker | Python,线程 | 固定大小的阻塞线程池 | 无界 UrlQueue | 可 join 队列计数器(join()) | put() 时检查结果缓存 | wait_for_host(按请求/秒) |
| linkinator | Node,事件循环 | 单线程 + p-queue(concurrency) | p-queue | queue.onIdle() | 入队时检查 Set(无竞态) | 响应式 429 delayCache |
| broken-link-checker | Node,事件循环 | limited-request-queue(maxSockets) | 嵌套请求队列 | 队列排空事件 | 入队时检查 URLCache | maxSockets + rateLimit |
| lychee (2026) | Rust,Tokio | 任务 + HostPool | channel + WaitGroup | WaitGroup | HostPool 的 active_requests | HostPool 按主机池 |
2026 年的 lychee 终于在每一列上都对上了。WaitGroup 对应 muffet 的 sync.WaitGroup 和 LinkChecker 的 join()。HostPool 对应 BLC 的 rateLimit/maxSockets 和 LinkChecker 的 wait_for_host。按 URI 的 active_requests 互斥锁则对应所有工具在入队时做的去重。
那我们为什么不能直接照搬它们?
有三个原因,按“在多大程度上算是 lychee 自身的问题”递增排序。
它们生来就是爬虫;lychee 生来是流。
上述每个工具的核心数据结构中都有回边。lychee 的核心则是一个为 99% 场景优化的 DAG(一组文件/URL,检查一次,速度要快)。在流水线上事后硬加一个环,远比一开始就带有环要困难得多。这本质上是架构层面的问题。
待爬队列和限速器必须是不同的对象。
muffet(集合 + 信号量)、LinkChecker(无界队列 + 线程数)、linkinator(p-queue + delayCache)、BLC(请求队列 + maxSockets)都把“下一步做什么”和“以多快速度做”分开了。lychee 早期的尝试试图让一个有界 channel 同时承担两种职责,而让环经过有界 channel 就会死锁。修复方案(lychee 的 HostPool 加上基于无界工作源的 WaitGroup)正是我们现在所追求的同一种分离。
单线程运行时免费获得去重。
两个 Node 工具仅用一个普通的 Set 就完成了去重,且完全不需要加锁,因为事件循环将访问串行化了。Go 和 Python 需要付出一个互斥锁。Rust 不仅要付出互斥锁,还要与借用检查器争论在 tokio::spawn 之间谁拥有共享状态。这就是我上次估算的约 30% 的“Rust 税”:不是算法本身,而是要在 Send + 'static 约束下表达共享可变 frontier 状态所带来的摩擦。
以上这些都不是对 lychee 设计的否定。对于常见的非递归场景,单向流是正确的选择:这正是 lychee 速度快的原因,也是尝试 2 中 30% 的 channel 回退成为阻碍的理由。其他工具无论是否递归,每次运行都要为那条回边付出代价。lychee 拒绝这么做,而这一原则正是递归花了五年才实现的原因,也是它落地后不会拖慢大多数人实际使用路径的原因。我相信我们可以鱼与熊掌兼得:一种既支持递归、又不牺牲一次性流水线速度的爬虫架构。但这比简单地“照抄它们的做法”要难得多,因为大多数链接检查工具从一开始就没把毫不妥协的性能作为首要目标。
核心要点
- 没有什么秘方。每个支持递归的检查工具都是工作列表 + 已访问集合 + 静默检测器。所谓的“技巧”,就是从第一次提交起就长得像个爬虫。
- 终止永远是同一个思想换了件外衣:
sync.WaitGroup(muffet)、可 join 队列计数器(LinkChecker)、queue.onIdle()(linkinator)、队列排空事件(BLC)、WaitGroup(lychee 2026)。它们全都是分布式终止检测。 - 去重应该在入队时、在请求之前完成。在检查之后才将 URL 标记为已访问(这正是 lychee 前四次尝试所做的)是 bug。其他所有工具都在 URL 进入待爬队列的那一刻就声明了所有权。
- 把待爬队列和限速器分开。一个既是队列又是背压机制的有界 channel,一旦加入环就会立刻死锁。
- 天下没有免费的午餐。Node 的单线程让去重变得轻而易举,代价是性能;Go 的 goroutine 和
WaitGroup让终止变得轻而易举,代价是需要一个运行时;Rust 两者都不免费给你,但它给了你一个拒绝让竞态通过编译的编译器——如果你清楚自己在做什么,就能让网卡跑到冒烟。
因此,当有人问“其他链接检查工具是如何做递归的?”,真正的答案是:它们从一开始就把递归做进了架构之中,并依赖运行时(提供了 WaitGroup、可 join 队列、空闲 Promise 等便利)来解决终止问题,而无需显式地去解决“分布式终止检测”。
感谢 muffet、LinkChecker、linkinator 和 broken-link-checker 的维护者们:阅读你们的源码是学习爬虫架构最清晰的途径,我们都在同一条路上,只是做了不同的权衡。
随机一篇博客
评论
登录后参与讨论