Five Years of Trying to Add Recursion to lychee

Matthias Endler

在 lychee 中嘗試加入遞迴功能的五年

遞迴一直是 lychee 存在最久、尚未解決的開放議題。至今已擱置超過五年仍未解決。

如果你還沒聽過 lychee,它是一個用 Rust 寫成、快速且非同步的連結檢查工具(BTW)。只要將它指向你的網站、文件、README 或 Markdown 檔案即可。

我在 2020 年因為待在家裡太無聊而開始這個專案。至今約有 4 萬個 GitHub 儲存庫依賴它。Google、AWS、Microsoft、Cloudflare 等許多企業都用它來檢查文件中的連結。

我也曾針對它發表過 演講Podcast 節目,有興趣可以進一步了解。

lychee 飛快奔跑中⋯
lychee 飛快奔跑中⋯

lychee 透過 NGI Zero 計畫獲得了 NLnet 的資助,該計畫支持開放、可信賴的基礎建設。

這筆資助讓我們得以投入大量專注的時間在專案上,而不必只能在深夜寫程式。1 如今資助即將結束,感覺正是撰寫這篇文章的好時機。

而我能說的最誠實的一句話就是:目前呼聲最高的功能——遞迴——至今仍未上線。:,( 但這是有充分理由的!當然,簡單來說就是「很難」,不過讓我們更深入探討原因。

起點

2020 年 12 月 14 日,一位名為 @styfle 的使用者開啟了 議題 #78

最初的遞迴議題
最初的遞迴議題

非常合理!當時 lychee 已經是一個快速、具並行能力的連結檢查工具,功能也相當豐富。想必只要加個小小的 --recursive 旗標來追蹤網域內的連結,應該一天就能誠實地完成吧,對吧?

但五年過去、經歷四次認真的實作嘗試與數個被放棄的 pull request 之後,遞迴功能仍未合併。該議題已被標記為 v1.0 里程碑,我們仍希望在此之前推出。但不知不覺間,它已成了 lychee 的白鯨。

我最初的架構讓這件事變得困難

要理解為何加入遞迴如此困難,你需要先了解 lychee 如何處理流程。以下是在 2020 年底時的流程:

lychee 最初的架構
lychee 最初的架構

基本上就是一條大型管線,從輸入網址,經過連結擷取,到連結檢查,再到輸出格式化。

當 @styfle 開啟這個議題時,我 幾乎立刻就點出了核心問題

沒有回到擷取器的回連。

這個缺失的回饋迴路(從已檢查的回應回到輸入佇列)正是問題的核心。lychee 的管線被設計為一次性、單向的流程:輸入從一端進入,結果從另一端出來,當輸入串流結束時程式就停止。遞迴則需要一個循環:回應必須能夠產生新的輸入。而在非同步、以 channel 為基礎的管線中,循環正是惡龍出沒之處。🐲

我在第一天就知道這點。只是我嚴重低估了我們會用多少種方式把這個循環搞砸。

第一次嘗試:簡單的計數器(2021 年 2 月至 12 月)

我的 第一次嘗試刻意做得很小。我不想重構任何架構;我只是想讓遞迴動起來!所以我直接在 main.rs 中加入處理邏輯。構想是:

  1. 收到回應後,如果它來自原始輸入網域之一,就從中擷取連結。
  2. 將這些新連結推回請求 channel。
  3. 持續追蹤預期請求總數與已完成請求數。
  4. completed == total 時停止。

我新增了一個 recurse() 函式,它會在成功的回應上呼叫 collector::collect_links(),產生一個任務將新請求送入 channel,並回傳產生了多少新請求。一個單純的 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 會增加。到目前為止還好。但擷取、發送與接收都在不同任務中並行發生,因此計數可能會不同步。

我當時對此也不太滿意:

老實說,我對目前的實作不太滿意,因為我是計算佇列中的連結數量,然後在所有連結檢查完後關閉 channel。我覺得這可能會導致一些微妙的錯誤。一定有更好的方法。

是的,過去的 Matthias(馬提亞斯),這個計數器很脆弱,因為:

  • 新連結是非同步被發現的,所以 total_requests 可能在迴圈已經決定要結束之後才被增加。
  • 如果計數差了一個,就會永遠卡住(計數過高)或過早結束(計數過低)。
  • 更糟的是,每個邊界情況都讓計數邏輯變得更複雜。快取的回應、失敗的回應、空白頁面……

@pawroman 在此給了我非常詳盡的審查,包含對 HashSet 快取記憶體用量的仔細分析(處理數百萬個連結也沒問題)、建議使用有號深度值來表示無限遞迴,以及提醒要寫整合測試。這是很好的回饋。只是它無法修正真正錯誤的地方,也就是終止處理的整體方法。

致命一擊

2021 年 9 月,我們決定進行一次較大的重寫:採用基於串流的架構(PR #330)來提升並行能力。它將 Collector::collect_links 從回傳 Vec 改為回傳 Stream,移除了 ClientPool 抽象,並重塑了任務之間的溝通方式。這是一項很大的改進,意味著收集器是惰性的,我們不再需要配置大型的 Vec 來存放請求。但這也意味著遞迴分支徹底壞掉,地毯直接被抽走。

由於我們在 #330 開始實作基於串流的方法,這可能會取代這個分支,因此會先暫緩這個工作。對於等待遞迴支援的各位很抱歉,但我寧願把它做好,而不是倉促合併一個有缺陷的解法。

PR #165 於 2021 年 12 月關閉。串流重構上線,為我們帶來了 35–50% 的速度提升。不錯吧!算是取捨吧。

重點收穫

  • 在非同步管線中計算待處理的工作是很脆弱的。分散式計數差一就意味著死結或提早結束。
  • 大型重構與功能分支處不好。串流重寫讓遞迴分支在還沒準備好之前就過時了。
  • 遞迴會觸及幾乎每一層。這不是可以外掛的東西。

順帶一提關於語言的問題,因為我常被問到:這裡的計數問題不是 Rust 的錯。用 goroutine 和 channel 的 Go 版本,或 Python asyncio 版本,也會遇到同樣的差一錯誤。「回應已處理」與「新請求被發現」之間的競爭條件,是任何並行遞迴爬蟲固有的問題。Rust 的 Stream trait 及其與所有權的搭配,讓串流架構感覺很自然,而那正是讓這份工作失效的原因。所以這或許是 Rust 特有的點。

第二次嘗試:透過 Channel 回饋(2022 年 1 月至 7 月)

既然串流架構已經就位,我又再試了一次。這次,我不再手動計算請求,而是 將發現的 URL 透過一個連接到收集器的 channel 回饋回去

收集器會從輸入 channel 讀取資料,並將收到的內容轉成請求串流。遞迴就只是將新發現的 URL 送入那個 channel。(看,一個回饋迴路!)當 channel 關閉時,串流就會自然結束。

我也嘗試統一輸入型別,讓同一個方法可以接受 VecStream

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

它又卡住了。但這次是完全不同的原因。

這個回饋迴路造成了循環依賴:

  1. 收集器從輸入 channel 讀取並產生請求串流。
  2. 檢查器讀取請求並產生回應。
  3. 遞迴處理器讀取回應並將新輸入送回收集器的 channel。

你看出問題了嗎?

要讓收集器的串流結束,輸入 channel 必須關閉。要讓 channel 關閉,所有發送端都必須被丟棄。但遞迴處理器持有一個發送端;它需要它來推送發現的 URL。而遞迴處理器只有在沒有更多回應時才會停止,這只有在沒有更多請求時才會發生,而這又只有在收集器的串流結束時才會發生。另一個導致死結的循環依賴。

我當時就這麼說:

我目前幾乎沒時間看這個問題,但它會卡住是因為輸入 channel 沒有被丟棄,導致連線懸空。我以為一旦 futures::StreamExt::for_each_concurrent 結束,channel 就會自動關閉(並被丟棄)。

@untitaker 確認了這點,並且即使在最簡單的情況下也能重現死結:

你是想在沒有東西要處理時丟棄 sender 對吧?但 for_each_concurrent 不會因為你還沒這麼做就永遠卡住嗎?(而且你也做不到,因為你需要 sender 來做更多的複製)

即使在空目錄下執行 time lychee --offline -b . '**/*.htm*' -T1,我也能重現死結。

這是用 channel 處理循環資料流的核心問題:channel 以發送端丟棄作為終止訊號,但在循環中你永遠無法丟棄所有發送端,因為每個階段都需要持有一個來維持循環。

我把這個問題帶到 Tokio Discord,得到的建議是:「別再為這個用 channel 了。改用 tokio::spawn 搭配號誌(semaphore)吧。」

效能問題也是

即使忽略死結,還有第二個問題。新的 from_chan 方法在基準測試中比現有的 from 方法慢了約 30%。額外的 channel 間接成本是有代價的,而且即使在非遞迴的情況下——也就是幾乎所有人使用的情況——也要付出這個代價。

重點收穫

  • Channel 是循環管線的錯誤工具。其「最後一個發送端丟棄時關閉」的語意與回饋迴路根本衝突。
  • for_each_concurrent 看起來很完美,其實不然。它能並行處理串流,卻無法讓你把項目回饋回去。
  • 常用路徑不能變慢。如果讓所有不使用遞迴的人都付出代價,那遞迴支援就毫無價值。

以 channel 為基礎的循環死結是任何以 channel 為基礎的系統固有的問題。Go 的 channel 也有同樣的問題。關閉 channel 意味著要知道沒有人會再發送,而循環讓這件事變得不可能。Erlang/OTP 則透過行程監控而非 channel 語意來避開這個問題。至於那 30% 的效能衰退,則帶有 Rust 的色彩。Rust 的零成本抽象文化意味著人們(包括我)期望不為未使用的功能付費。在執行環境較重的語言中,未使用路徑上 30% 的衰退或許可以接受。在 Rust 中,「不為你沒用到的東西付費」幾乎是一種道德立場,這使得那樣的衰退對我來說是無法接受的。

第三次嘗試:號誌(2022 年 2 月)

我嘗試了什麼

我完全放棄在遞迴迴路中使用 channel,改用:

  • Arc<Semaphore> 來限制並行度(取代 channel 原有的背壓)
  • 每個工作單元使用 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 時,沒有內建的方法可以知道所有產生的任務——包含遞迴產生的那些——何時完成。你會需要一個獨立的協調機制,也就是說:你會重新發明第一次嘗試中的計數器,只是現在分散在無數個產生的任務中。我們又繞回了當初想逃離的原點。

許可(permit)的部分也有微妙之處。將 for_each_concurrent 換成原生的 tokio::spawn,會失去 channel 免費提供的有界並行能力。號誌可以把它加回來,但你必須小心管理許可。如果一個任務取得許可、產生子任務並轉移許可,父任務就無法再做更多工作。如果複製許可,你可能會超出並行上限。要讓許可的生命週期完全正確是很棘手的。

重點收穫

  • 號誌解決的是並行,而非終止。你仍需要某種機制來告訴你「所有工作都完成了」。
  • Arc<RwLock<State>> 在非同步 Rust 中是程式碼異味。當你開始把所有東西都包進鎖時,表示你正在與所有權模型對抗,而非順勢而為。這會讓每次跨執行緒存取都要取得鎖,進而損失大量效能。
  • 真正的問題從來不是「如何遞迴?」而是「如何知道遞迴何時結束」。

這是所有失敗中最具 Rust 特色的。號誌方法在 Go 中是很道地的。透過 sync.WaitGroup 加上號誌 channel、並以 sync.Mutex 在 goroutine 之間共享狀態,就是你在 Go 中會採用的做法,因為它有綠色執行緒和負責管理 goroutine 生命週期的執行環境。

但在 Rust 中,tokio::spawn 上的 Send + 'static 限制、借用檢查器對共享可變狀態的排斥,以及 Arc<RwLock<T>> 的成本都成了阻礙。Rust 讓「把所有東西都包進 Arc 和 Mutex」這個逃生口變得足夠痛苦,以至於成了死路。

2022–2024 😴

兩年多來,遞迴議題持續收到想敲碗此功能的留言。有人建議變通方法(透過 xargs 串接 sitemap URL 是熱門做法之一)。最初提出議題的人自己打造了 另一個工具並離開了,我完全能理解。

有人懸賞了 100 歐元的獎金。也有人指出 muffet 已經支援遞迴檢查。在這幾年中 lychee 並未停滯;我們在效能、快取、速率限制和其他功能上投入了大量工作。但遞迴仍是房間裡的大象。

第四次嘗試:Gwenn 接棒挑戰(2025 年 1 月至 3 月)

2024 年底,一位社群貢獻者 @gwennlbh 接下了挑戰。她的計畫回到以 channel 為基礎的模型,但加了個變化:不再嘗試透過關閉 channel 來終止,而是使用 Arc<AtomicUsize> 計數器。就像第一次嘗試,但改用原子操作並在任務間共享!

而且看起來非常優雅:

  1. 保留現有的兩個 mpsc channel(請求與回應)。
  2. 收到回應後,從主體中擷取連結並作為新請求送出。
  3. 使用 Arc<AtomicUsize> 來追蹤剩餘工作——送出新請求時遞增(包含遞迴產生的),處理完回應時遞減,並在歸零時跳出接收迴圈。
  4. 依靠現有快取來避免循環(不再檢查已看過的 URL)。

這是迄今最能實際運作的一次嘗試。它在真實網站上真的能動

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

看著它逐步成形,我非常興奮,並試圖提供有用的設計指引:

  • 預設遞迴深度為 5
  • 嚴格的網域比對(不檢查子網域)
  • 速率限制延後到另一個 PR 處理
  • 接受對 lychee-lib 公開 API 的破壞性變更

在哪裡卡關

然後它同時從好幾個方向撞上了同一道高牆。

1. Channel 背壓死結

當遞迴發現大量連結時,回應處理器會嘗試將新請求送入請求 channel。但如果那個 channel 已滿(受 max_concurrency 限制),發送就會阻塞。被阻塞的回應處理器意味著沒有回應被處理,這表示沒有請求空位被釋出。典型的背壓死結。

@gwennlbh 透過在獨立的 tokio::spawn 中產生「發送新請求」的工作來繞過這個問題,將回應處理與請求發送解耦。這樣可行,但也意味著不再限制這些背景任務會堆積多少(進而使用無上限的記憶體)。

2. 重複請求

由於請求是並行處理的,同一個 URL 可能被多個頁面同時發現,並在其中任何一個被快取前就被送入 channel。快取檢查發生得太晚:在請求已經在傳輸中之後才檢查。沒有針對單一 URL 的同步機制來阻止並行的重複:

由於請求到回應的任務本質上是並行的,在我看來,要防止同一個請求被送入 channel 兩次似乎很難。我試著在各處加上保護 […],卻仍然會得到重複的請求。

作為權宜之計,在 Stats::insert 中加入了去重檢查,但那只阻止了重複回報,而非重複檢查。真正的修正要到很久以後才出現,透過 HostPool 中每個 URI 的 active_requests mutex 來實現,但當時那套機制還不存在。

3. 又是那個計數器

Arc<AtomicUsize> 計數器本質上與第一次嘗試是同一個點子——也帶來了同樣的脆弱性。使用 Ordering::Relaxed(最弱的記憶體順序)時,跨執行緒的遞增與遞減可能會被重新排序,因此計數器可能在工作實際完成前短暫地讀到零。在 Wikipedia 上使用 --max-depth=0 時,它會在最後一個 URL 上卡住。

4. 牽一髮而動全身的變更

subsequent_uris(已發現連結的清單)加入 Response 型別,意味著幾乎每個建立或使用 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)。

那為什麼這件事實際上這麼難?

五年四次嘗試。如果我們退一步看,我認為困難可以歸為幾個類別:

判斷何時完成

每次實作都面臨同樣的問題:你如何知道何時完成了?

在非遞迴管線中,答案很簡單。當輸入串流耗盡且傳輸中的請求已完成時,你就完成了。關閉 channel 發送端,排空接收端,就大功告成了。

在遞迴管線中,輸入串流永遠不會真正耗盡,因為每個回應都可能產生新的輸入。你需要另一種方式來偵測靜止狀態:沒有任何工作正在進行、也不會再產生新工作的狀態。

結果,這個問題在分散式系統中有個名字:✨ 分散式終止偵測(distributed termination detection)。✨

經典的解法(Dijkstra–Scholten權杖傳遞)就是無法很好地對應到 Tokio 以 channel 為基礎的世界。

循環

lychee 的架構本質上是一個 DAG。輸入單向流經各個階段。遞迴引入了一個循環。而以 channel 為基礎的系統中的循環會導致死結,因為 channel 使用「所有發送端都已丟棄」作為完成訊號,而在循環中這個條件本身永遠不會滿足。

背壓

有界的 channel 為你提供了自然的背壓:如果檢查器速度慢,發送端會阻塞直到有空間。這很棒,直到你想要遞迴。現在回應處理器需要發送到請求 channel。如果那個 channel 已滿,回應處理器就會阻塞;如果它阻塞,就沒有回應被消耗;如果沒有回應被消耗,就沒有請求空位被釋出。

去重競爭

我們並行地檢查連結,這意味著多個頁面可能持有同一個連結。若沒有同步,多個任務會發現同一個 URL,並在其中任何一個能標記為「已看過」前就提交它。在第 1 到第 4 次嘗試中,快取幫不上忙,因為快取項目是在檢查後才寫入,而非提交前。

抽象洩漏

遞迴感知想要「無所不在」。回應需要攜帶已發現的連結,請求需要深度,收集器需要理解遞迴輸入,統計與格式化器需要處理重複。

這有多少是 Rust 的錯?

我認為這是閱讀我部落格的人真正想知道答案的問題,所以讓我直接說。我的誠實估計是……大約 30%? 終止問題、循環問題與背壓問題都只是問題領域的一部分。任何用 Go、Python、Java 或 Erlang 寫的並行遞迴爬蟲都必須解決這些問題。在某個時間點,ScrapyColly 以及其他成熟的爬蟲框架都必須處理分散式終止偵測與背壓管理。

Rust 增加的是實作層面的摩擦:

  • 所有權與 Send 限制使得在產生的任務之間共享狀態變得更困難。在 Go 中,你在 goroutine 閉包中捕獲變數就能繼續。在 Rust 中,非同步世界中的所有東西都想被包進 Arc 並要求 Send + 'static
  • 原子操作上明確的記憶體順序迫使你思考並行正確性,也讓「算了,就用 relaxed」成為一個誘人但危險的選擇。
  • Tokio 中的 channel 終止語意比某些其他生態系更嚴格。Go 的 context.Context 為你提供了正交的取消機制,而 Tokio 的 channel 原生並沒有。(在 Tokio 中,你會為此使用 CancellationToken。)

但在另一方面,Rust 也防止了許多問題:

  • 編譯器捕捉了所有不安全地共享可變狀態的嘗試。在 Go 中,那些會成為你在正式環境才發現的微妙執行時錯誤,或許得靠 race detector 才找得到。
  • 善用型別系統,我們可以讓正確的做法同時也是最符合人體工學的做法。

換句話說,Rust 讓錯誤的做法大聲且痛苦地失敗,例如透過編譯器錯誤(但也在測試中造成死結),並讓正確的做法更穩固、更符合人體工學。

新希望

儘管有這麼多失敗的嘗試,這個問題的基礎在 2025–2026 年間已悄然發生變化。許多工作——大多甚至與遞迴無關——讓真正的實作終於看起來觸手可及。

依主機速率限制(2025 年 12 月)

沒有速率限制的遞迴是危險的。Gwenn 在遞迴檢查 Wikipedia 時不小心 DDoS 了自家的 WiFi 路由器,就親身體驗了這點。😬 在 PR #1929 中合併的依主機速率限制,讓遞迴爬取能夠尊重伺服器限制。我之前曾認為這是「超出範圍」而忽略,但實務上它非常重要。

背後的議題(#1605)是我在 2025 年 1 月 6 日——也就是 PR #1603(第四次嘗試)開啟的同一週——所建立的。這個時間點並非巧合。當我們認真嘗試遞迴時,缺乏依主機速率限制立刻暴露為明顯的缺口。它導致對同一主機的並行請求拋出 429 錯誤、在高並行下因競爭條件讓快取失效(議題 #1593),以及全域並行設定對於分散在多個主機上的工作負載來說過於粗糙。

修正引入了 HostPool,這是一個依主機的請求佇列,具有可設定的速率限制、延遲與並行請求上限。每個主機都有自己的 bucket 與各自的設定,可透過 lychee.toml 設定:

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

HostPool 後來成為核心抽象。正是同一個 HostPoolPR #2100 中被重複使用,以統一輸入擷取與連結檢查,這意味著它現在是所有 HTTP 請求流經的單一入口。

它對遞迴很重要,因為 HostPool 提供了依主機的速率限制、去重(透過每個 Host 的依 URI active_requests mutex 與 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 設定)。這導致了實際的錯誤(Wikipedia 對輸入 URL 回傳 403,因為沒有設定 user-agent)。

在此之後,輸入擷取與連結檢查都走同一個 pool。對遞迴而言這很重要,因為遞迴發現的頁面需要被擷取與解析,且應該使用與其他請求相同的客戶端設定。

Sitemap 支援(2026 年 2 月)

Sitemap 支援是許多遞迴使用情境的部分解法。透過解析 sitemap.xml,lychee 可以在完全不需要遞迴爬取的情況下發現網站上的所有頁面。它並非真正遞迴的替代品(它幫不了沒有 sitemap 的網站,也找不到動態連結的頁面),但它解決了許多使用情境的燃眉之急。

真正的遞迴可能是什麼樣子

有了這些基礎,剩下要做的部分之少令人驚訝:

  • 爬取何時完成的問題已由 WaitGroup 解決。
  • 透過產生後續工作而非在已滿的 channel 上阻塞來避免死結。
  • 依主機的 pool 已經對請求進行節流,所以我們不會轟炸伺服器。
  • 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;
    }
}

困難的部分(知道何時停止、不會死結、不會淹沒伺服器)已經被那些本來就與遞迴無關的工作所解決。遞迴成為良好架構的副產品,而非硬套在一個從未為此設計的管線上的特例。

那麼,我們失敗了嗎⋯⋯?

很長一段時間,我告訴自己我們失敗了。四次嘗試、五年,似乎一事無成。

但把這一切寫出來改變了我的看法。每次嘗試都遇到了 channel 終止語意、背壓死結、所有權人體工學與分散式終止偵測的某種組合。這些都不是 lychee 的問題。它們是困難的並行系統問題。我們只是缺乏詞彙來描述它們,而在不經意間,那些原語已被建立起來。有時,你為一個功能所寫的最重要的程式碼,是那些從未提及該功能的程式碼。

所以不,我不認為我們失敗了。我們跌跌撞撞地朝著正確的方向前進。

感謝 NLnet 資助 lychee 的工作,也感謝多年來所有為遞迴工作做出貢獻的人,無論是程式碼、設計回饋或精神支持。這是一條漫長的路,但我們比以往任何時候都更接近終點。

  1. 老實說,我還是會在深夜寫程式。但我本來就是這樣的人。

原文由 Matthias Endler 發布

本文章由 muse-spark-1.2-contributor 進行翻譯