為 lychee 加入遞迴功能的五年嘗試
原文由 Matthias Endler 于 發布,訂閱此部落格
遞迴是 lychee 存在最久、尚未解決的 issue。這個問題已經在那裡擺了五年多,至今仍未解決。
如果你還沒聽過 lychee,它是一個用 Rust 寫的快速、非同步連結檢查工具(BTW)。你只要把它指向你的網站、文件、README 或 Markdown 檔案就行了。
我在 2020 年因為待在家裡太無聊而開始了這個專案。到現在,大約有 4 萬個 GitHub 儲存庫依賴它。Google、AWS、微軟、Cloudflare 等許多公司都用它來檢查文件中的連結。
我也曾針對這個專案做過演講和 Podcast,如果你想更深入了解可以去看看。
lychee 透過 NGI Zero 計畫獲得了 NLnet 的資助,該計畫透過其 NGI Zero 計畫支持開放、可信賴的基礎建設。
這筆資助讓我們得以投入大量專注的時間在專案上,而不必只能在深夜寫程式。1 現在資助即將結束,感覺正是寫下這篇文章的好時機。
而我能說的最誠實的一句話就是:大家最期待的功能——遞迴——到現在還是沒上線。:,( 但這是有充分理由的!當然,簡單來說就是「很難」,不過我們來深入聊聊到底難在哪裡。
一切的起點
2020 年 12 月 14 日,一位名為 @styfle 的使用者開啟了 issue #78:

非常合理的要求!當時 lychee 已經是一個快速、支援並行的連結檢查工具,功能也相當豐富。加個小小的 --recursive 旗標來追蹤同網域內的連結,應該一天就能搞定吧,對吧?
但五年過去,經歷了四次認真的實作嘗試、好幾個被放棄的 pull request,遞迴功能至今仍未合併。這個 issue 被標記在 v1.0 的里程碑中,我們仍然希望能在 1.0 之前推出。但不知不覺間,它已經成了 lychee 的白鯨。
當初的架構讓一切變得困難
要理解為什麼遞迴這麼難加,得先了解 lychee 是怎麼處理事情的。以下是 2020 年底時的流程:
基本上就是一條巨大的管線:從輸入 URL,經過連結擷取、連結檢查,最後到輸出格式化。
當 @styfle 提出這個 issue 時,我 幾乎立刻就看出了核心問題:
沒有回到擷取器的回路。
這個缺失的回饋迴路(從已檢查的回應回到輸入佇列)就是整個問題的癥結。lychee 的管線本來被設計成一次性的單向流程:輸入從一端進去,結果從另一端出來,當輸入串流結束時程式就停止。而遞迴需要一個循環:回應必須能夠產生新的輸入。而在基於非同步 channel 的管線中,循環就是惡龍出沒之處。🐲
我在第一天就知道這點。只是我嚴重低估了我們會用多少種方式把這個循環搞砸。
第一次嘗試:簡單的計數器(2021 年 2 月至 12 月)
我的第一次嘗試刻意做得很小。我不想重構任何架構,只想讓遞迴能動!所以我直接在 main.rs 裡加入處理邏輯。想法是這樣的:
- 收到回應後,如果它來自原始輸入網域之一,就從中擷取連結。
- 把這些新連結推回請求 channel。
- 維持一個計數,追蹤預期請求總數與已完成請求數。
- 當
completed == total時就停止。
我新增了一個 recurse() 函式,它會對成功的回應呼叫 collector::collect_links(),產生一個非同步任務把新請求送進 channel,並回傳產生了多少個新請求。一個單純的 HashSet<String> 則作為「已看過」快取,避免重複檢查同一個 URL。
除此之外:
- 在
Request與Response結構中加入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。我覺得這可能會導致一些難以察覺的 bug。一定有更好的方法。
是的,過去的 Matthias,這個計數器很脆弱,因為:
- 新連結是非同步被發現的,所以
total_requests可能在迴圈已經決定要結束之後才被增加。 - 只要計數差一個,你就會永遠卡住(計數過高)或是太早退出(計數過低)。
- 更慘的是,每個邊界情況都讓計數邏輯變得更複雜。快取的回應、失敗的回應、空白頁面……
@pawroman 在這裡給了我非常詳盡的審查,包含對 HashSet 快取記憶體用量的仔細分析(處理到數百萬個連結都沒問題)、建議用有號數值來表示無限遞迴,以及提醒要加上整合測試。這些回饋都很好。只是它無法修正真正出錯的地方,也就是整個終止判斷的方法本身。
致命一擊
2021 年 9 月,我們決定進行一次較大的重寫:改為基於串流的架構(PR #330)以提升並行能力。它把 Collector::collect_links 從回傳 Vec 改為回傳 Stream,移除了 ClientPool 抽象,並重塑了任務之間的溝通方式。這是個很大的進步,意味著收集器變成惰性的,我們不再需要配置龐大的請求 Vec。但這也意味著遞迴的分支整個壞掉,腳下的地毯被抽走了。
我們會再次暫緩這個分支,因為我們已經在 #330 中開始實作基於串流的做法,這可能很快就會取代這個分支。對於所有等待遞迴支援的大家很抱歉,但我寧願把它做對,也不想倉促合併一個有 bug 的解法。
PR #165 在 2021 年 12 月被關閉。串流重構順利合併,為我們帶來了 35–50% 的速度提升。不錯吧!算是取捨吧。
重點收穫
- 在非同步管線中計算待處理的工作是很脆弱的。分散式計數中差一個,就意味著死結或提早退出。
- 大規模重構與功能分支處不來。串流重寫讓遞迴分支在還沒準備好之前就過時了。
- 遞迴會觸及幾乎每一層。這不是能隨便外掛上去的東西。
順帶一提,關於程式語言的問題,因為我常被問到:這裡的計數問題不是 Rust 的錯。用 goroutine 和 channel 的 Go 版本,或是用 Python asyncio 的版本,也會遇到同樣差一個的 bug。「回應已處理」與「發現新請求」之間的競爭條件,是任何並行的遞迴爬蟲天生就會有的。Rust 的 Stream trait 及其與所有權的互動方式,讓串流架構感覺很自然,而正是這點讓先前的成果失效了。所以這或許算是跟 Rust 有關的一點。
第二次嘗試:透過 Channel 回送(2022 年 1 月至 7 月)
既然串流架構已經就位,我又再試了一次。這次,我不再手動計算請求,而是把發現的 URL 透過一個連接到收集器的 channel 回送回去。
收集器會從輸入 channel 讀取資料,並將收到的東西轉成請求串流。遞迴就只需要把新發現的 URL 送進那個 channel 就好。(看,一個回饋迴路!)當 channel 關閉時,串流就會自然結束。
我也嘗試統一輸入型別,讓同一個方法可以接受 Vec 或 Stream:
pub enum InputType {
Stream(Pin<Box<dyn Stream<Item = Input>>>),
Seq(Vec<Input>),
}它又卡住了。但這次是完全不同的原因。
這個回饋迴路造成了循環依賴:
- 收集器從輸入 channel 讀取並產生請求串流。
- 檢查器讀取請求並產生回應。
- 遞迴處理器讀取回應並把新的輸入送回收集器的 channel。
你看出問題了嗎?
要讓收集器的串流結束,輸入 channel 必須關閉。要讓 channel 關閉,所有的 sender 都必須被丟棄。但遞迴處理器持有一個 sender;它需要它來把發現的 URL 推回去。而遞迴處理器只有在沒有更多回應時才會停止,而這只有在沒有更多請求時才會發生,而這又只有在收集器的串流結束時才會發生。又是一個導致死結的循環依賴。
我當時就這麼說了:
到目前為止我只有很少時間看這個問題,但它會卡住是因為輸入 channel 沒有被丟棄,導致連線懸在那裡。我以為一旦
futures::StreamExt::for_each_concurrent結束,channel 就會自動關閉(並被丟棄)。
@untitaker 證實了這點,甚至在極簡單的情況下也能重現死結:
你是想在沒有東西要處理時就把
sender丟棄對吧?但for_each_concurrent不會因為你還沒這麼做就永遠卡住嗎?(而且你也做不到,因為你還需要 sender 來複製)
我甚至在空目錄下執行
time lychee --offline -b . '**/*.htm*' -T1都能重現死結。
這就是用 channel 來做循環資料流的核心問題:channel 用 sender 被丟棄來作為終止訊號,但在一個循環中你永遠無法丟棄所有的 sender,因為每個階段都需要持有一個來維持循環。
我把這個問題帶到 Tokio 的 Discord 上,得到的建議是:「別再為這個用 channel 了。改用 semaphore 搭配 tokio::spawn。」
效能問題也是
即使不談死結,還有第二個問題。新的 from_chan 方法在基準測試中比現有的 from 方法慢了大約 30%。額外的 channel 間接層是有代價的,而且即使在非遞迴的情況下也要付出這個代價——而那幾乎是所有人使用的情境。
重點收穫
- Channel 是處理循環管線的錯誤工具。它們「最後一個 sender 丟棄時關閉」的語意,與回饋迴路在本質上就互相衝突。
for_each_concurrent看起來很完美,其實不然。它能並行地處理串流,卻沒辦法讓你把項目回送進去。- 通用路徑不能變慢。如果為了支援遞迴而讓所有不使用它的人都付出代價,那就沒有意義了。
channel 循環造成的死結是任何基於 channel 的系統天生就會有的問題。Go 的 channel 也有同樣的問題。關閉一個 channel 意味著你得確定不會再有人發送,而循環讓這件事變得不可能。Erlang/OTP 則是用行程監控而非 channel 語意來避開這個問題。至於那 30% 的效能衰退,則有 Rust 的因素。Rust 的零成本抽象文化意味著大家(包括我)都預期不使用的功能不該帶來任何成本。在那些仰賴執行環境的語言中,未使用路徑上 30% 的衰退或許還能接受。但在 Rust 中,「不為沒用到的東西付費」幾乎是一種信念,這讓那樣的衰退對我來說完全無法接受。
第三次嘗試:號誌(Semaphore,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 裡會這樣做的方式,因為 Go 有 green thread 和會幫你管理 goroutine 生命週期的執行環境。
但在 Rust 中,tokio::spawn 上的 Send + 'static 限制、借用檢查器對共享可變狀態的排斥,以及 Arc<RwLock<T>> 的成本,都成了阻礙。Rust 讓「把所有東西都包進 Arc 和 Mutex」這個逃生口變得夠痛苦,最後成了死路。
2022–2024 😴
兩年多來,遞迴這個 issue 不斷收到想要這個功能的人的留言。大家提出了各種變通方法(透過 xargs 傳遞 sitemap URL 是其中一個熱門做法)。最初提出這個 issue 的人自己打造了另一個工具然後就離開了,我完全能理解。
有人懸賞了 100 歐元。也有人指出已經有支援遞迴檢查的 muffet。在這幾年裡,lychee 也沒有停下腳步;我們在效能、快取、速率限制和其他功能上投入了很多。但遞迴始終是房間裡的大象。
第四次嘗試:Gwenn 接棒(2025 年 1 月至 3 月)
2024 年底,一位社群貢獻者 @gwennlbh 撿起了這個挑戰。她的計畫回到基於 channel 的模型,但多了一個轉折:不再嘗試透過關閉 channel 來判斷結束,而是使用 Arc<AtomicUsize> 計數器。就像第一次嘗試一樣,只不過這次是原子性的、能在任務間共享!
而且看起來非常優雅:
- 保留現有的兩個 mpsc channel(請求與回應)。
- 收到回應後,從內文中擷取連結並作為新請求送出。
- 用
Arc<AtomicUsize>追蹤剩餘工作——送出新請求時遞增(包含遞迴產生的),處理完回應時遞減,當計數歸零時就跳出接收迴圈。 - 依賴現有的快取來避免循環(不再檢查已經看過的 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. 各處都需要改動
在 Response 型別中加入 subsequent_uris(已發現連結的清單)意味著幾乎每個建立或使用 Response 的檔案都要動到。每個 Response::new() 的呼叫都得加上兩個新參數(在非遞迴的情況下是 vec![] 和 0)。
5. 收集器被繞過了
為了從回應主體中擷取連結,程式碼在檢查器內直接建立了一個全新的 Collector,繞過了原本會尊重 --exclude、--include 和片段檢查等使用者旗標的已設定收集器。
這條路的終點
在 2025 年 1 月一陣爆發性的進展後,腳步慢了下來。合併衝突越堆越多。分支底下的 CI 檢查規則也改了。@gwennlbh 換到 Windows 後,OpenSSL 相依套件怎麼都編譯不過。2025 年 3 月,她很誠實地寫道:
雖然我一直有點不想承認,但很明顯我已經失去繼續做下去的動力了 […] 對不起 T_T
我不希望她道歉。作為一名志工,在一個困難的功能、複雜的非同步程式碼庫中,她比任何人都走得更遠。相反地,我很感謝她投入時間把事情往前推進。
重點收穫
- 原子計數器只是穿著風衣的手動計數器。它有著同樣的失效模式。
- 當你得在每個
Response::new()呼叫中都加上vec![]和0時,那就是抽象洩漏了。 - 外部貢獻者會面臨額外的阻力。建置環境的差異、與不斷變動的目標之間的衝突,以及龐大非同步程式碼庫本身的認知負擔,讓這個功能對貢獻者來說特別殘酷。
這些問題有多少是 Rust 特有的?我會說大概一半。背壓本身就是問題領域的一部分。任何語言的並行爬蟲都會遇到。那個 Ordering::Relaxed 的陷阱則有點 Rust 特有,因為 Rust 會逼你去選擇一種記憶體順序(Go 的 sync/atomic 也是,但大多數 Go 開發者會直接用 sync.WaitGroup)。
那麼,這到底為什麼這麼難?
五年內四次嘗試。如果退一步來看,我覺得困難可以歸為幾個類別:
知道何時才算完成
每個實作都面臨同一個問題:你怎麼知道自己已經完成了?
在非遞迴的管線中,答案很簡單。當輸入串流耗盡且正在進行的請求都已完成時,你就完成了。關掉 channel 的 sender,把 receiver 清空,就大功告成了。
在遞迴管線中,輸入串流永遠不會真正耗盡,因為每個回應都可能產生新的輸入。你需要另一種方式來偵測靜止狀態:也就是沒有任何工作正在進行、也不會再產生新工作的狀態。
原來,這個問題在分散式系統中有個名字:✨ 分散式終止偵測。✨
那些經典解法(Dijkstra–Scholten、權杖傳遞)就是沒辦法很好地對應到 Tokio 基於 channel 的世界。
循環
lychee 的架構本質上是一個 DAG。輸入單向地流經各個階段。遞迴引入了一個循環。而在基於 channel 的系統中,循環會造成死結,因為 channel 用「所有 sender 都丟棄」作為完成訊號,而在循環中這個條件永遠不會自行達成。
背壓
有界的 channel 會給你天然的背壓:如果檢查器很慢,發送方就會阻塞直到有空間。這很美好,直到你想要遞迴。現在回應處理器需要發送到請求 channel。如果那個 channel 滿了,回應處理器就會阻塞;如果它阻塞了,就沒有回應會被消耗;如果沒有回應被消耗,就沒有請求空位會被釋放。
去重競爭
我們是並行地檢查連結,這意味著多個頁面可能包含同一個連結。如果沒有同步,好幾個任務會在其中任何一個能標記為「已看過」之前,就發現同一個 URL 並提交它。在第 1 到第 4 次嘗試中,快取救不了我們,因為快取項目是在檢查之後才寫入,而不是在提交之前。
抽象洩漏
對遞迴的感知想要存在於「各處」。回應需要攜帶已發現的連結,請求需要有深度,收集器需要理解遞迴輸入,統計與格式化器也需要處理重複。
這有多少是 Rust 的錯?
我想這是讀我部落格的人最想知道答案的問題,所以讓我直接說。我誠實的估計是……大概 30% 吧?終止問題、循環問題和背壓問題,都只是這個問題領域本身的一部分。任何用 Go、Python、Java 或 Erlang 寫的並行遞迴爬蟲都得解決這些。在某個時間點,Scrapy、Colly 以及其他成熟的爬蟲框架都曾不得不處理分散式終止偵測和背壓管理。
Rust 增加的是實作層面的摩擦:
- 所有權和
Send限制讓在產生的任務之間共享狀態變得更難。在 Go 中,你在 goroutine 的閉包中捕捉變數就結束了。在 Rust 中,非同步世界裡的每樣東西都想被包進Arc並且是Send + 'static。 - 原子操作上明確的記憶體順序迫使你去思考並行的正確性,也讓「唉,就用 relaxed 吧」變成一個誘人卻危險的選擇。
- Tokio 的 channel 終止語意比某些其他生態系更嚴格。Go 的
context.Context給你一個正交的取消機制,而 Tokio 的 channel 原生就沒有。(在 Tokio 中,你得用 CancellationToken 來做到這點。)
但另一方面,Rust 也防止了很多問題:
- 編譯器攔下了每一次不安全地共享可變狀態的嘗試。在 Go 中,那些會是得在正式環境、或許用 race detector 才會發現的細微執行期 bug。
- 善用型別系統,我們可以讓正確的做法同時也是符合人體工學的做法。
換句話說,Rust 讓錯誤的做法大聲且痛苦地失敗——例如透過編譯器錯誤(還有測試中的死結)——並讓正確的做法更穩固、更好用。
新的希望
儘管有這麼多失敗的嘗試,這個問題的基礎已在 2025–2026 年間悄悄地改變。一堆工作——其中大多數甚至跟遞迴無關——讓真正的實作終於看起來觸手可及。
針對單一主機的速率限制(2025 年 12 月)
沒有速率限制的遞迴是很危險的。Gwenn 在遞迴檢查 Wikipedia 時不小心 DDoS 了自家的 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 後來成為一個核心抽象。正是同一個 HostPool 在 PR #2100 中被重用來統一輸入擷取與連結檢查,這意味著它現在是所有 HTTP 請求流經的唯一入口。
它對遞迴很重要,因為 HostPool 為我們提供了針對單一主機的速率限制、去重(透過每個 Host 各自的 per-URI active_requests mutex 和 HostCache),以及適當粒度的快取,這讓遞迴爬取能夠成為一個有禮貌的網路公民(尊重速率限制標頭、在收到 429 時退避)。
WaitGroup(2026 年 2 月)
最近最重要的事情,就是由 Kait 貢獻並在 PR #2046 中合併的 WaitGroup 原語。它是解決終止問題的一大步。
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解決。 - 透過產生後續工作而非在已滿的 channel 上阻塞,來避免死結。
- 針對單一主機的池已經會調節請求速度,所以我們不會打爆伺服器。
- 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 的工作,也感謝這些年來所有為遞迴這個功能付出過的人,無論是程式碼、設計回饋還是精神上的支持。這是一條漫長的路,但我們比以往任何時候都更接近終點。
老實說,我到現在還是會在深夜寫程式。但我天生就是這樣。 ↩
隨機一篇部落格
留言
登入後參與討論