Five Years of Trying to Add Recursion to lychee

Matthias Endler

给 lychee 添加递归的五年尝试

原文由 Matthias Endler 发布,订阅该博客

递归一直是 lychee 悬而未决时间最长的开放问题。五年多过去了,它依然没有解决。

如果你之前没听说过,lychee 是一个用 Rust(BTW)编写的快速异步链接检查器。你只需把它指向你的网站、文档、README 或 Markdown 文件即可。

我是在 2020 年因为在家无聊才开始做这个项目的。到现在,大约有 4 万个 GitHub 仓库依赖它。Google、AWS、Microsoft、Cloudflare 等众多公司都在用它来检查文档中的链接。

我还做过相关的演讲播客,感兴趣的话可以去了解一下。

lychee 飞起来啦……
lychee 飞起来啦……

lychee 获得了 NLnet 通过其 NGI Zero 计划提供的资助,该计划致力于支持开放、可信的基础设施。

这笔资助让我们能够投入大量专注的时间来做项目,而不是只能在深夜写代码。1 如今资助即将结束,现在似乎正是写下这篇文章的好时机。

而我能说的最真诚的一句话是:用户呼声最高的功能——递归,至今仍未发布。:,( 但这事出有因!当然,一句话概括就是“太难了”,不过我们还是深入聊聊吧。

起点

2020 年 12 月 14 日,一位名叫 @styfle 的用户提交了 issue #78

最初的递归需求
最初的递归需求

这个需求非常合理!当时,lychee 已经是一个功能丰富、快速并发的链接检查器。给它加一个小小的 --recursive 参数,让它在同一个域名内跟随链接继续检查,难道不是一两天就能搞定的事吗?

然而五年过去,经历了四次认真的实现尝试和数个被废弃的 pull request 之后,递归功能依然没有合并。这个 issue 已经被列入 v1.0 的里程碑,我们仍希望能在 1.0 之前发布它。但不知不觉中,它已经成了 lychee 的白鲸。

最初的架构让事情变难了

要理解为什么添加递归如此困难,就得先了解 lychee 的处理流程。下面是 2020 年底时的流程图:

lychee 最初的架构
lychee 最初的架构

本质上就是一条大流水线:从输入 URL 开始,经过链接提取,到链接检查,再到结果输出。

当 @styfle 提出这个 issue 时,我几乎立刻就看出了核心问题

没有回到提取器的回路。

这个缺失的反馈回路(从已检查的响应回到输入队列)一句话就概括了全部难题。lychee 的流水线被设计为一次性的单向流动:输入从一端进入,结果从另一端出来,当输入流耗尽时程序就结束。而递归需要一个环路:响应必须能够产生新的输入。在基于异步和通道的流水线中,环路正是暗藏巨龙的地方。🐲

这一点我在第一天就明白了。只是我严重低估了我们会在这个环路上踩多少种坑。

尝试一:简单的计数器(2021 年 2 月至 12 月)

我的第一次尝试刻意做得很小。我不想重构整个架构,只想让递归跑起来!于是我直接在 main.rs 里加了处理逻辑。思路是:

  1. 收到响应后,如果它来自最初输入的域名,就从中提取链接。
  2. 把这些新链接推回请求通道。
  3. 维护一个已预期请求总数与已完成请求数的计数。
  4. completed == total 时停止。

我加了一个 recurse() 函数,它会在成功的响应上调用 collector::collect_links(),派生一个任务把新请求发送到通道中,并返回新增的请求数量。一个普通的 HashSet<String> 则作为“已见”缓存,避免重复检查同一个 URL。

除此之外:

  • RequestResponse 结构体上加了 recursion_level 字段
  • 新增了 --recursive / -r 标志
  • 新增了 --depth 选项,用于限制最大递归深度
  • 通过域名过滤来确保只在输入的域名范围内递归

听起来很直接,对吧?

错了

程序根本停不下来。

终止逻辑是一个 while curr < total_requests 循环:

let mut curr = 0;
while curr < total_requests {
    curr += 1;
    let response = recv_resp.recv().await.context("Receive channel closed")?;
    // ... process response, potentially incrementing total_requests
}

当响应到达并产生新请求时,total_requests 会增加。到目前为止还好。但提取、发送和接收都是并发地在不同任务中进行的,所以计数很容易不同步。

当时我自己就对这个实现不太满意:

说实话,我对现在的实现已经不太满意了,因为我是通过统计队列中的链接数量,然后在所有链接检查完后关闭通道。这可能会导致一些隐蔽的 bug。我觉得一定有更好的办法。

是的,过去的 Matthias,计数器之所以脆弱,是因为:

  • 新链接是异步发现的,所以 total_requests 可能会在循环已经决定退出之后才增加。
  • 只要计数差一个,就要么永远挂起(计得太多),要么过早退出(计得太少)。
  • 更糟的是,每多一个边界情况,计数逻辑就会变得更复杂。已缓存的响应、失败的响应、空页面……

@pawroman 在这里给出了一次非常细致的 review,包括对 HashSet 缓存内存占用的仔细分析(数百万链接以内都没问题)、用有符号深度值来表示无限递归的建议,以及补充集成测试的提醒。反馈很好,只是无法解决真正的问题——整个终止方案本身就是错的。

致命一击

2021 年 9 月,我们决定做一次更大的重构:基于流的架构(PR #330),以提升并发性能。它把 Collector::collect_links 从返回 Vec 改为返回 Stream,移除了 ClientPool 抽象,并重塑了任务之间的通信方式。这是个巨大的改进,意味着收集器变成了惰性的,我们不再需要分配庞大的请求 Vec。但这也意味着递归分支彻底挂了,地基被直接抽走。

这个分支会先暂停一下,因为我们在 #330 中开始实现基于流的方案,它很快可能会取代这个分支。很抱歉让等待递归支持的各位久等了,但我更想把它做对,而不是仓促合并一个有 bug 的方案。

PR #165 在 2021 年 12 月被关闭。基于流的重构最终落地,带来了 35–50% 的性能提升。不错!有得必有失吧。

经验教训

  • 在异步流水线中统计未完成的工作非常脆弱。 分布式计数差一个,就会死锁或提前退出。
  • 大重构和功能分支合不来。 基于流的重写让递归分支在还没准备好之前就过时了。
  • 递归几乎会触及每一层。 这不是能简单外挂的功能。

顺带诚实地聊一下语言的问题,因为经常有人问:这里的计数问题不是 Rust 的错。用 Go 的 goroutine 和 channel,或者 Python 的 asyncio 写同样会遇到相同的差一错误。“响应已处理”和“新请求已发现”之间的竞态是任何并发递归爬虫固有的。Rust 的 Stream trait 及其与所有权的结合让流式架构显得很自然,而正是这个架构让之前的工作作废了。所以这或许是唯一一个和 Rust 相关的点。

尝试二:通过通道回送(2022 年 1 月至 7 月)

流式架构就位后,我又尝试了一次。这次不再手动计数请求,而是把新发现的 URL 通过一条连接到收集器的通道回送回去

收集器会从输入通道读取数据,并将其转换为请求流。递归就意味着把新发现的 URL 发送到那个通道里。(看,一个反馈回路!)当通道关闭时,流自然也就结束了。

我还尝试统一输入类型,让同一个方法既能接受 Vec 也能接受 Stream

pub enum InputType {
    Stream(Pin<Box<dyn Stream<Item = Input>>>),
    Seq(Vec<Input>),
}

又卡死了。 但这次原因完全不同。

反馈回路造成了循环依赖:

  1. 收集器从输入通道读取,产生请求流。
  2. 检查器读取请求,产生响应。
  3. 递归处理器读取响应,再把新输入发送回收集器的通道。

看出问题了吗?

要让收集器的流结束,输入通道必须关闭。要关闭通道,所有发送端都必须被丢弃。但递归处理器持有了一个发送端——它需要这个发送端来回推新发现的 URL。而递归处理器只有在没有更多响应时才会停止,而这又只有在没有更多请求时才会发生,而这又只有在收集器的流结束时才会发生。又是一个导致死锁的循环依赖。

我当时就这么说的:

我还没来得及仔细看这个问题,但它会挂起是因为输入通道没有被丢弃,导致连接一直悬着。我本以为一旦 futures::StreamExt::for_each_concurrent 结束,通道会自动关闭(并丢弃)。

@untitaker 复现了这个问题,确认即使在最简单的情况下也会死锁:

你是想在没有东西要处理时丢弃 sender 对吧?但如果还没丢弃,for_each_concurrent 不就会一直挂起吗?(而且你也没法丢,因为你还需要 sender 来做克隆)

即使执行 time lychee --offline -b . '**/*.htm*' -T1 在一个空目录上,我也能复现死锁。

这就是在循环数据流中使用通道的核心困境:通道以“丢弃最后一个发送端”作为结束信号,但在循环中你永远无法丢弃所有发送端,因为每一阶段都需要持有一个来维持循环。

我把这个问题发到了 Tokio 的 Discord 上,得到的建议是:“别再为这个用通道了。改用带 tokio::spawn 的信号量吧。”

还有性能问题

即便不考虑死锁,还有第二个问题。新的 from_chan 方法比现有的 from 方法慢了约 30%。额外的通道间接开销是有代价的,而且这个代价即使在非递归的情况下也要付出——而这恰恰是绝大多数人使用的方式。

经验教训

  • 通道不适合做循环流水线。 它们“最后一个发送端丢弃即关闭”的语义与反馈回路根本冲突。
  • for_each_concurrent 看似完美,实则不然。 它能并发地处理流,却无法让你把数据回送进去。
  • 通用路径不能变慢。 如果递归支持让所有不使用它的人都要付出代价,那就毫无价值。

通道循环导致的死锁是任何基于通道的系统固有的。Go 的 channel 也有同样的问题。关闭通道意味着要确信不会再有人发送,而循环让这一点变得不可能。Erlang/OTP 则通过进程监控而非通道语义来绕开这个问题。至于那 30% 的性能回退,则多少有点 Rust 的因素。Rust 的零成本抽象文化让人们(包括我)期望“不用的功能就不该付出代价”。在运行时较重的语言中,未使用路径上 30% 的回退或许可以接受。在 Rust 中,“你不为没用到的东西付费”几乎是一种信条,这让那次回退变得不可接受。

尝试三:信号量(2022 年 2 月)

我尝试了什么

我彻底抛弃了用于递归循环的通道,转而使用:

  • Arc<Semaphore> 来限制并发(取代通道天然的背压)
  • tokio::spawn 来处理每个工作单元(取代 for_each_concurrent
  • OwnedSemaphorePermit 交给每个任务,这样在派生递归子任务时可以“转移”许可

原型其实挺简洁的:

const MAX_CONCURRENCY: usize = 10;

fn recurse(permit: OwnedSemaphorePermit, i: usize) -> JoinHandle<()> {
    tokio::spawn(async move {
        handle_input(permit, i).await;
    })
}

async fn handle_input(permit: OwnedSemaphorePermit, i: usize) {
    println!("got = {i}");
    if i % 9 == 0 {
        recurse(permit, 10).await.unwrap();
    }
}

但我想你已经能猜到问题了:它还是卡死了。

当我试图把这个模型搬到真实代码库时,所有权的要求立刻变得非常难看。链接检查器需要客户端配置、缓存、进度条、统计信息以及其他一堆东西。要在派生的任务间共享这些,几乎所有东西都得包进 Arc<RwLock<State>>。我在这个分支上试过这种模型,但由于所有权和 Send 的限制,代码变得相当丑陋。

光靠信号量不够

信号量解决了并发限制的问题,却完全没有解决终止问题。用 tokio::spawn 时,没有内置的办法知道所有派生的任务——包括那些递归派生的——何时才算全部完成。你还需要一套额外的协调机制,换句话说:你得重新发明尝试一中的计数器,只不过现在它分散在数量不限的派生任务中。我们绕了一圈,又回到了当初想要逃离的东西。

许可的管理还有一个微妙之处。把 for_each_concurrent 换成裸的 tokio::spawn,就失去了通道免费提供的有界并发。信号量把这个能力加了回来,但你必须小心地管理许可。如果一个任务获取了许可,派生了子任务并转移了许可,父任务就无法再做其他工作。如果克隆许可,又可能突破并发上限。要把许可的生命周期处理得恰到好处,非常棘手。

经验教训

  • 信号量解决的是并发,而不是终止。 你仍然需要某种机制来告诉你“所有工作都已完成”。
  • Arc<RwLock<State>> 在异步 Rust 中是一种代码异味。 当你开始把所有东西都包进锁里时,说明你在与所有权模型对抗,而不是顺应它。这会让很多性能白白流失,因为每次访问都要在所有线程间获取锁。
  • 真正的问题从来不是“怎么递归?” 而是“怎么知道递归何时结束。”

这是几次失败中最具 Rust 特色的一次。信号量方案在 Go 中是很符合习惯的。在 Go 中,用 sync.WaitGroup 加上一个信号量通道,通过 sync.Mutex 在 goroutine 间共享状态,就是这么做的,因为 Go 有绿色线程和负责管理 goroutine 生命周期的运行时。

但在 Rust 中,tokio::spawn 上的 Send + 'static 约束、借用检查器对共享可变状态的排斥,以及 Arc<RwLock<T>> 的开销,都成了障碍。Rust 让“把所有东西包进 Arc 和 Mutex”这种捷径变得足够痛苦,以至于成了一条死路。

2022–2024 😴

两年多的时间里,递归问题下不断有人留言催更。大家提出了各种变通方案(通过 xargs 串联 sitemap URL 是比较流行的一种)。最初提交这个 issue 的人自己造了一个工具然后就离开了,我完全理解。

有人悬赏了 100 欧元。也有人提到了已经支持递归检查的 muffet。这些年 lychee 也没有停滞不前,在性能、缓存、限流等方面做了大量工作。但递归始终是房间里的大象。

尝试四:Gwenn 放手一搏(2025 年 1 月至 3 月)

2024 年底,社区贡献者 @gwennlbh 接过了接力棒。她的方案回到了基于通道的模型,但加了一个变化:不再试图通过关闭通道来终止,而是使用 Arc<AtomicUsize> 计数器。就像尝试一一样,只不过是原子化的、跨任务共享的!

而且它看起来如此优雅:

  1. 保留现有的两个 mpsc 通道(请求和响应)。
  2. 收到响应后,从响应体中提取链接并作为新请求发送。
  3. Arc<AtomicUsize> 跟踪剩余工作——发送新请求(包括递归产生的)时递增,处理完一个响应时递减,当计数归零时跳出接收循环。
  4. 依靠现有缓存来避免循环(不再检查已见过的 URL)。

这是迄今为止最接近可用的一次尝试。它在真实网站上真的跑通了

lychee -R https://endler.dev \
       --recursed-domains endler.dev

看着它逐步成型,我真的很兴奋,也试着给出一些设计上的指导:

  • 默认递归深度为 5
  • 严格的域名匹配(不检查子域名)
  • 限流延后到另一个 PR 再做
  • 接受对 lychee-lib 公开 API 的破坏性变更

卡在哪里

然后它又从好几个方向同时撞上了同一堵墙。

1. 通道背压死锁

当递归发现大量链接时,响应处理器会尝试把新请求发送到请求通道。但如果该通道已满(受 max_concurrency 限制),发送就会阻塞。被阻塞的响应处理器意味着没有响应能被处理,也就意味着没有请求槽位会被释放。典型的背压死锁。

@gwennlbh 通过把“发送新请求”的工作放到单独的 tokio::spawn 中来规避,让响应处理与请求发送解耦。这能跑通,但也意味着这些后台任务的数量不再受限(进而会无限制地消耗内存)。

2. 重复请求

由于请求是并行处理的,同一个 URL 可能被多个页面同时发现,并在其中任何一个被缓存之前就被送入通道。缓存检查发生得太晚——已经在请求发出之后。没有任何针对单一 URL 的同步机制来阻止并发重复:

由于请求到响应的任务是并行执行的,在我看来,想完全避免把同一个请求发送两次到通道里是很困难的。我试着在各处加了守卫 […] 但似乎还是会出现重复。

作为权宜之计,在 Stats::insert 中加入了去重检查,但那只能阻止重复的报告,不能阻止重复的检查。真正的修复要等到很久以后,随着 HostPool 中每个 URI 对应的 active_requests 互斥锁的引入才到来,但那套机制当时还不存在。

3. 又是计数器

Arc<AtomicUsize> 计数器本质上还是尝试一的老思路——也带来了同样的脆弱性。使用 Ordering::Relaxed(最弱的内存排序)时,跨线程的递增和递减可能会被重排,因此计数器可能在工作实际完成之前就短暂地读到零。在 Wikipedia 上使用 --max-depth=0 时,它会在最后一个 URL 上卡死。

4. 改动遍地

Response 类型中加入 subsequent_uris(发现的链接列表)意味着几乎每个构造或消费 Response 的地方都要改。每个 Response::new() 调用都得加上两个新参数(在非递归情况下是 vec![]0)。

5. 收集器被绕过

为了从响应体中提取链接,代码在检查器内部临时构建了一个全新的 Collector,绕过了会尊重用户 --exclude--include 和片段检查等标志的已配置收集器。

此路不通

在 2025 年 1 月的一波冲刺之后,进展慢了下来。合并冲突越积越多。CI 的 lint 规则在分支下方发生了变化。@gwennlbh 换到了 Windows,却无法让 OpenSSL 依赖编译通过。2025 年 3 月,她坦诚地写道:

虽然之前有点不愿承认,但很明显,我已经失去继续做下去的动力了 […] 对不起 T_T

我不想让她道歉。她作为志愿者,在一个复杂的异步代码库中,挑战了一个艰难的功能,已经比任何人都走得更远。相反,我很感激她投入时间推动了事情的进展。

经验教训

  • 原子计数器不过是穿了件外套的手动计数器。 它有同样的失效模式。
  • 当你不得不在每个 Response::new() 调用中都加上 vec![]0 时,说明抽象已经泄露了。
  • 外部贡献者面临额外的阻力。 构建环境的差异、与不断变化的主分支的冲突,以及大型异步代码库本身的认知负担,使得这个功能对贡献者来说尤其艰难。

这些问题中有多少是 Rust 特有的?大概有一半吧。背压本身就是问题域的一部分。任何语言的并发爬虫都会遇到它。Ordering::Relaxed 这个坑在某种程度上是 Rust 特有的,因为 Rust 要求你选择一个内存排序(Go 的 sync/atomic 也是如此,但大多数 Go 开发者会直接用 sync.WaitGroup)。

那为什么这件事真的这么难?

五年四次尝试。如果退后一步看,我觉得困难可以归为几类:

知道何时结束

每次实现都面临同一个问题:怎么知道自己已经完成了?

在非递归的流水线中,答案很简单。当输入流耗尽且正在进行的请求都已完成时,就结束了。关闭通道的发送端,排空接收端,就大功告成了。

在递归流水线中,输入流永远不会真正耗尽,因为每个响应都可能产生新的输入。你需要另一种方式来检测静止:即没有任何工作正在进行、也不会再产生新工作的状态。

事实上,这个问题在分布式系统中有个名字:✨ 分布式终止检测。✨

经典的解决方案(Dijkstra–Scholten令牌传递)只是不太好映射到 Tokio 基于通道的世界里。

循环

lychee 的架构本质上是一个 DAG。输入单向流经各个阶段。递归引入了一个环。而基于通道的系统中的环会导致死锁,因为通道以“所有发送端都已丢弃”作为完成信号,而在环中这个条件永远不会自行满足。

背压

有界通道为你提供了天然的背压:如果检查器很慢,发送端就会阻塞直到有空位。这很好,直到你想要递归。现在响应处理器需要向请求通道发送数据。如果该通道已满,响应处理器就会阻塞;如果它阻塞了,就没有响应被消费;如果没有响应被消费,就没有请求槽位被释放。

去重竞态

我们是并发地检查链接的,这意味着多个页面可能包含同一个链接。如果没有同步,几个任务会同时发现同一个 URL,并在其中任何一个将其标记为“已见”之前就提交。直到尝试四,缓存都救不了我们,因为缓存条目是在检查之后才写入的,而不是在提交之前。

抽象泄露

递归的感知想要“无处不在”。响应需要携带发现的链接,请求需要深度,收集器需要理解递归输入,统计和格式化器需要处理重复项。

这有多少是 Rust 的错?

我想这是读我博客的人真正想问的问题,所以我直接回答。我诚实的估计是……大约 30%? 终止问题、循环问题和背压问题都只是问题域本身的一部分。任何用 Go、Python、Java 或 Erlang 写的并发递归爬虫都必须解决它们。到了一定阶段,ScrapyColly 以及其他成熟的爬虫框架都不得不去做分布式终止检测和背压管理。

Rust 额外带来的是实现层面的摩擦:

  • 所有权和 Send 约束让跨派生任务共享状态变得更难。在 Go 中,你在 goroutine 闭包中捕获变量就完事了。在 Rust 中,异步世界里的所有东西都想被包进 Arc 并要求 Send + 'static
  • 原子操作上显式的内存排序迫使你去思考并发正确性,同时也让“算了,就用 relaxed 吧”这种诱人但危险的选择变得可能。
  • Tokio 中通道的终止语义比某些其他生态更严格。Go 的 context.Context 给你提供了一种正交的取消机制,而 Tokio 的通道本身并不原生具备。(在 Tokio 中,你需要为此使用CancellationToken。)

但另一方面,Rust 也避免了很多问题:

  • 编译器捕获了每一次不安全地共享可变状态的尝试。在 Go 中,那些会成为微妙的运行时 bug,也许要到生产环境或借助 race detector 才能发现。
  • 善用类型系统,我们可以让正确的事同时也是符合人体工学的事。

换句话说,Rust 让错误的做法响亮而痛苦地失败——比如以编译错误(以及测试中的死锁)的形式——而让正确的做法更可靠、更符合人体工学。

新的希望

尽管多次尝试都失败了,但这个问题在 2025–2026 年间已经悄然发生了变化。一堆工作——其中大部分甚至不是为了递归——让真正的实现终于看起来触手可及。

按主机的限流(2025 年 12 月)

没有限流的递归是危险的。Gwenn 在递归检查 Wikipedia 时就不小心把自家的 WiFi 路由器给打挂了。😬 按主机的限流在 PR #1929 中被合并,让递归爬取能够遵守服务器的限制。我之前曾把这事当作“超出范围”而忽略,但实践证明它极其重要。

背后的 issue(#1605)是我在 2025 年 1 月 6 日提出的——恰好与 PR #1603(尝试四)开启是同一周。这个时间绝非巧合。一旦我们认真尝试递归,缺乏按主机限流的问题就立刻暴露为一个明显的短板。它导致对同一主机的并发请求抛出 429 错误、在高并发下由于竞态导致缓存失效(issue #1593),以及全局并发设置对于分散在多个主机上的工作负载来说过于粗糙。

修复引入了 HostPool,这是一个按主机划分的请求队列,带有可配置的限流、延迟和并发请求上限。每个主机都有自己的桶和独立的设置,可以通过 lychee.toml 配置:

[hosts."github.com"]
max_concurrent_requests = 10
request_delay = "100ms"

HostPool 后来成了一个核心抽象。正是同一个 HostPoolPR #2100 中被复用,将输入获取与链接检查统一起来,这意味着现在所有 HTTP 请求都流经这一个单一入口。

它对递归很重要,因为 HostPool 提供了按主机的限流、去重(通过每个 Host 的按 URI 的 active_requests 互斥锁和 HostCache),以及合适粒度的缓存,让递归爬取能够成为一个守规矩的网络公民(尊重限流头、在收到 429 时退避)。

WaitGroup(2026 年 2 月)

最近最重要的进展是 WaitGroup 原语,由 Kait 贡献并在 PR #2046 中合并。它是解决终止问题的一步关键。

WaitGroup 是一种等待动态任务集合的机制,这些任务本身还可以派生更多任务。它由两部分组成:

  • WaitGroup,唯一的等待者,当所有工作完成时触发。
  • WaitGuard,可克隆的守卫,由每个任务持有。当最后一个守卫被丢弃时,等待者完成。

关键在于 WaitGuard 可以被克隆。任务可以派生子任务(递归!),同时保持 WaitGroup 只有在所有守卫——包括递归子任务持有的那些——都被丢弃后才完成的约束。

这干净地解决了终止问题:

let (waiter, guard) = WaitGroup::new();

// Each request carries a guard clone
send_req.send((guard.clone(), request)).await;

// In the response handler, if recursing:
// the guard is cloned for each new request
for new_request in discovered_links {
    send_req.send((guard.clone(), new_request)).await;
}

// The original guard is dropped when the response is fully processed.
// When ALL guards are dropped (no more work), waiter.wait() returns.

它已经被接入到 lychee 的主检查循环中。collect_responses 函数使用 take_until(waiter.wait()) 在工作完成时停止接收。当前代码中甚至有一条注释,恰好预见了这一点:

// unused for now, but will be used for recursion eventually. by holding
// an extra `send_req` endpoint, we prevent the natural termination when
// each channel finishes and closes. instead, we rely on the WaitGroup to
// break the cyclic channels.
let _ = send_req;

这正是我们之前几次尝试所缺少的那一块拼图。

统一的请求处理(PR #2100,2026 年 3 月已合并)

PR #2100 将输入 URL 的获取与链接检查器的 HostPool 统一起来。在此之前,CLI 输入的 URL 走的是一个独立的 reqwest::Client,它不与检查器共享配置(user-agent、限流、TLS 设置)。这导致了真实的 bug(Wikipedia 对输入 URL 返回 403,因为没有设置 user-agent)。

在此之后,输入获取和链接检查都走同一个池。对于递归而言,这很重要,因为递归发现的页面需要被获取和解析,并且应该使用与其他请求相同的客户端配置。

Sitemap 支持(2026 年 2 月)

Sitemap 支持在一定程度上解决了许多递归的使用场景。通过解析 sitemap.xml,lychee 可以无需递归爬取就能发现站点上的所有页面。它并不能完全取代真正的递归(它帮不了没有 sitemap 的站点,也找不到动态链接的页面),但它确实解除了很多用例的阻塞。

真正的递归可能是什么样子

有了这些基础,还剩下什么呢?引人注目的是,已经完成的部分是如此之多:

  • 爬取何时结束的问题已由 WaitGroup 解决。
  • 通过派生后续工作而不是在通道已满时阻塞,避免了死锁。
  • 按主机的池已经对请求进行了限速,因此不会压垮服务器。
  • lychee 已经会跳过已见过的 URL,这在每个页面都链接到相同导航和页脚时很重要。
  • 取回页面是唯一开放的问题。 lychee 在检查完页面后会丢弃页面内容,但递归需要 HTML 来发现更多链接。它刚刚检查完,还在缓存里,所以我们可以免费再次拿到。(假设请求方法是 GET,而不是不返回正文的 HEAD。)

一旦这些就位,真正的递归就只需要寥寥数行。当一个已检查的页面位于允许的域名内且未超过深度限制时,从缓存中取出其内容,提取链接,并作为全新请求通过同一流水线重新发送:

if recursive && is_same_domain(&response, &recursion_domains) && depth < max_depth {
    let content = resolver.url_contents(response.url()).await?;  // cache hit
    let links = extractor.extract(&content);
    for req in request::create(links, ...) {
        send_req.send((guard.clone(), Ok(req))).await;
    }
}

最难的部分(何时停止、如何不死锁、如何不压垮服务器)已经通过那些最初并非为递归而做的工作解决了。递归成了良好架构的副产品,而不是硬塞进本不为此设计的流水线中的特例。

那么,我们失败了吗……?

很长一段时间里,我都觉得我们失败了。四次尝试,五年时间,似乎什么都没发布。

但把这一切写出来改变了我的看法。每次尝试都碰上了通道终止语义、背压死锁、所有权人体工学和分布式终止检测的某种组合。这些都不是 lychee 独有的问题。它们是困难的并发系统问题。我们只是当时缺乏讨论它们的词汇,而就在我没注意的时候,那些原语已经被构建出来了。有时,为一个功能编写的最重要的代码,恰恰是那些从未提及该功能的代码。

所以不,我不认为我们失败了。我们是在跌跌撞撞中朝着正确的方向前进。

感谢 NLnet 对 lychee 工作的资助,也感谢多年来为递归工作做出贡献的每一个人,无论是代码、设计反馈还是精神支持。这是一段漫长的旅程,但我们比以往任何时候都更接近终点。

  1. 好吧,公平地说,我依然经常熬夜写代码。但那只是我的习惯使然。

本文章由 muse-spark-1.2-contributor 进行翻译

评论