Comprehensive Rust 并发实战:多线程链接检查器(Multi-threaded Link Checker)完整实现指南
【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust
本篇技术指南以 Comprehensive Rust 课程中 src/concurrency/sync-exercises/link-checker.md 这一节练习为核心,讲解如何综合运用线程(threads)、mpsc 通道(channels)、Mutex与Arc等同步并发原语,构建一个多线程网页链接检查器。读完本文后,你将掌握:用reqwest发起 HTTP 请求、用scraper解析 HTML 提取链接、用thiserror组织错误处理,以及用「工作线程池 + 命令通道 + 结果通道」这一经典生产者-消费者模式实现并行爬取与去重,并亲手在本地运行完整解决方案。
练习背景与课程定位
Multi-threaded Link Checker 是 Comprehensive Rust 课程中「并发(Concurrency)」大章下 同步并发练习 的两个实战题目之一(另一个是 Dining Philosophers),在课程 SUMMARY.md 中被列为并发章节的收官练习。
它要求学生把此前几节学到的知识串起来使用:
- 线程基础:用
std::thread::spawn创建并行执行的工作线程; - 通道(channels):用
std::sync::mpsc在多个生产者与单个消费者之间传递数据; - 共享状态:用
Mutex与Arc在多线程间安全共享不可克隆的资源。
练习的核心目标非常明确:从一个网页出发,检查页面上所有链接是否有效,并递归地检查同一域名下的其他页面,直到该域名下所有可达页面都被验证完毕。这是一个比课程中绝大多数练习规模更大的项目,课程文档特意注明(link-checker.md):本练习的「成功条件」不是写出完美代码,而是让学生在某个真实问题上卡住,并在同学或讲师的帮助下把它解决掉——这是体验真实工程排错过程的一次机会。
依赖选型与项目初始化
三个关键 crate
练习文档明确指定了三个第三方依赖,各自承担一项核心职责:
| Crate | 职责 |
|---|---|
reqwest | 提供 HTTP 客户端能力。练习使用其blocking(同步阻塞)特性,方便在普通线程中直接调用;实际上它同时也是课程异步章节所演示的高层 HTTP 客户端 |
scraper | 提供 HTML 解析与 CSS 选择器能力,用于从页面 HTML 中提取<a>链接 |
thiserror | 提供派生宏#[derive(Error)],以符合惯例的方式为enum错误类型生成标准Errortrait 实现 |
创建项目并添加依赖
在终端中执行:
cargo new link-checker cd link-checker cargo add --features blocking reqwest cargo add scraper cargo add thiserror若
cargo add失败并报error: no such subcommand(说明本机 Cargo 版本较旧),请直接手工编辑Cargo.toml,按下面的清单添加依赖即可。
cargo add执行完毕后,Cargo.toml应形如(link-checker.md 中的示例):
[package] name = "link-checker" version = "0.1.0" edition = "2024" publish = false [dependencies] reqwest = { version = "0.13.1", features = ["blocking"] } scraper = "0.25.0" thiserror = "2.0.18"需要说明的是:以上版本号是练习文档写作时的示例。仓库内同步练习的实际工程 src/concurrency/sync-exercises/Cargo.toml 中使用的版本更高(reqwest = "0.13.4"、scraper = "0.27.0"、thiserror = "2.0.18"),并额外声明了 dev-dependenciestempfile供测试使用。两个版本的核心 API 用法一致,本文后面的代码在两者下均可编译运行。另外注意edition = "2024"要求足够新的 Rust 工具链(1.85 及以上)才能编译。
第一步:顺序版本——单线程访问并解析网页
在动手写并发代码之前,先实现一个顺序版本,把「抓取页面 → 解析链接」这条主线跑通。仓库中的完整代码见 src/concurrency/sync-exercises/link-checker.rs,练习文档通过 mdbook 的{{#include}}指令把其中的setup与visit_page两段代码嵌入讲解。
错误类型设计(setup 片段)
use reqwest::Url; use reqwest::blocking::Client; use scraper::{Html, Selector}; use thiserror::Error; #[derive(Error, Debug)] enum Error { #[error("request error: {0}")] ReqwestError(#[from] reqwest::Error), #[error("bad http response: {0}")] BadResponse(String), }要点解析:
#[from] reqwest::Error让?运算符能把底层请求错误自动转换为Error::ReqwestError,这是thiserror的典型用法;Error::BadResponse(String)用于表达「HTTP 请求成功返回、但状态码不是 2xx」这类业务层面的错误,#[error("bad http response: {0}")]定义了其 Display 输出格式;reqwest::blocking::Client是阻塞式客户端,reqwest::Url是标准化的 URL 类型,后续的域名判断、相对链接拼接都要靠它。
页面访问与链接提取(visit_page 片段)
#[derive(Debug)] struct CrawlCommand { url: Url, extract_links: bool, } fn visit_page(client: &Client, command: &CrawlCommand) -> Result<Vec<Url>, Error> { println!("Checking {:#}", command.url); let response = client.get(command.url.clone()).send()?; if !response.status().is_success() { return Err(Error::BadResponse(response.status().to_string())); } let mut link_urls = Vec::new(); if !command.extract_links { return Ok(link_urls); } let base_url = response.url().clone(); let body_text = response.text()?; let document = Html::parse_document(&body_text); let selector = Selector::parse("a").unwrap(); let href_values = document .select(&selector) .filter_map(|element| element.value().attr("href")); for href in href_values { match base_url.join(href) { Ok(link_url) => { link_urls.push(link_url); } Err(err) => { println!("On {base_url:#}: ignored unparsable {href:?}: {err}"); } } } Ok(link_urls) }这段代码体现了几个关键设计决策:
CrawlCommand结构体:把「要访问的 URL」与「是否需要提取链接」打包成一个命令对象。extract_links字段看似多余,但它正是后面「只抓取同域页面、对站外链接只做有效性检查、不再递归」这一策略的伏笔;- 状态码检查:
send()?只保证请求完成,不代表页面有效;只有is_success()为真才继续解析,否则返回Error::BadResponse; - 相对链接解析:
response.url()拿到的是最终响应地址(可能经过重定向),以它为基准调用base_url.join(href)把相对路径(如/about)解析为绝对 URL,解析失败(如javascript:伪协议)的链接被打印后忽略; extract_links == false的短路返回:当命令只要求检查而不要求递归时,直接返回空列表,避免无谓的 HTML 解析开销。
入口函数 main
练习文档给出的骨架main如下:
fn main() { let client = Client::new(); let start_url = Url::parse("https://www.google.org").unwrap(); let crawl_command = CrawlCommand{ url: start_url, extract_links: true }; match visit_page(&client, &crawl_command) { Ok(links) => println!("Links: {links:#?}"), Err(err) => println!("Could not extract links: {err:#}"), } }运行:
cargo run练习建议从一个较小的网站入手,例如https://www.google.org/,先验证「下载首页 → 打印全部链接」的顺序路径是否工作。文档特别提醒:这个示例代码块在课程中是标记为compile_fail的——因为该骨架并未定义完整的CrawlCommand之外所需的全部内容,它只是让学生理解整体结构,而不是直接可编译的完整程序。
第二步:并行化——用线程池 + 通道改造
练习的第一个任务:
用线程检查链接以并行执行:把要检查的 URL 通过通道发送出去,让若干个线程并行地检查这些 URL。
课程前置知识回顾
要完成这一步,需要先吃透课程中两个并发原语:
mpsc 通道(senders-receivers.md):std::sync::mpsc是 Multi-Producer, Single-Consumer(多生产者、单消费者)通道,一端是Sender<T>(可clone,因此支持多个生产者),另一端是Receiver<T>(不可克隆,只能有一个消费者)。send()与recv()都返回Result——当对端被 drop 时返回Err,表示通道已关闭。这正是工作线程优雅退出的关键信号。
Arc<Mutex<T>>(mutex.md):Mutex<T>提供互斥与可变访问,lock()返回MutexGuard;当持有锁的线程 panic 时锁会进入 poisoned 状态。Receiver不可克隆,要让多个线程共享同一个接收端,就必须用Arc共享引用计数、用Mutex保证同一时刻只有一个线程在执行recv()——这正是Arc<Mutex<Receiver>>组合的典型场景。
并行架构:双通道的任务分发模型
仓库解决方案(link-checker.rs)采用了「命令通道 + 结果通道」的双通道架构:
type CrawlResult = Result<Vec<Url>, (Url, Error)>; fn spawn_crawler_threads( command_receiver: mpsc::Receiver<CrawlCommand>, result_sender: mpsc::Sender<CrawlResult>, thread_count: u32, ) { // To multiplex the non-cloneable Receiver, wrap it in Arc<Mutex<_>>. let command_receiver = Arc::new(Mutex::new(command_receiver)); for _ in 0..thread_count { let result_sender = result_sender.clone(); let command_receiver = Arc::clone(&command_receiver); thread::spawn(move || { let client = Client::new(); loop { let command_result = { let receiver_guard = command_receiver.lock().unwrap(); receiver_guard.recv() }; let Ok(crawl_command) = command_result else { // The sender got dropped. No more commands coming in. break; }; let crawl_result = match visit_page(&client, &crawl_command) { Ok(link_urls) => Ok(link_urls), Err(error) => Err((crawl_command.url, error)), }; result_sender.send(crawl_result).unwrap(); } }); } }设计要点拆解:
- 每个线程持有独立的
Client:Client::new()在每个工作线程内部创建,避免跨线程共享请求对象,也天然规避了「一个连接池被多线程同时使用」的同步问题; Arc<Mutex<Receiver>>共享消费端:代码注释直白地说明了动机——为了复用不可克隆的Receiver,把它包进Arc<Mutex<_>>。lock()的守卫作用域被显式限定在一个块中,recv()返回后立即释放锁,使下一个线程能立刻取到下一个命令;- 通道关闭即线程退出:
let Ok(crawl_command) = command_result else { break; }——当所有Sender都被 drop 后,recv()返回Err,工作线程据此退出循环。这是 mpsc「关闭即终止」语义的实战运用; - 错误随 URL 一起回传:
CrawlResult的Err分支携带(Url, Error),这样控制端在汇总坏链接时能知道「哪个 URL 出了什么错」; - 每个线程独立返回结果:
result_sender.clone()使每个工作线程都成为结果通道的生产者,符合 mpsc「多生产者」语义。
控制端:调度与去重
check_links负责把两个通道接起来,control_crawl则扮演「唯一消费者 + 任务调度器」:
fn check_links(start_url: Url) -> Vec<Url> { let (result_sender, result_receiver) = mpsc::channel::<CrawlResult>(); let (command_sender, command_receiver) = mpsc::channel::<CrawlCommand>(); spawn_crawler_threads(command_receiver, result_sender, 16); control_crawl(start_url, command_sender, result_receiver) }控制端维护两份关键状态:访问去重集合(visited_pages)与域名白名单(domain),封装在CrawlState中:
struct CrawlState { domain: String, visited_pages: std::collections::HashSet<String>, } impl CrawlState { fn new(start_url: &Url) -> CrawlState { let mut visited_pages = std::collections::HashSet::new(); visited_pages.insert(start_url.as_str().to_string()); CrawlState { domain: start_url.domain().unwrap().to_string(), visited_pages } } /// Determine whether links within the given page should be extracted. fn should_extract_links(&self, url: &Url) -> bool { url.domain().is_some_and(|d| d == self.domain) } /// Mark the given page as visited, returning false if it had already /// been visited. fn mark_visited(&mut self, url: &Url) -> bool { self.visited_pages.insert(url.as_str().to_string()) } }mark_visited复用HashSet::insert的返回值:插入成功(此前未访问过)返回true,已存在返回false,一行代码同时完成「记录」与「判重」;should_extract_links用Url::domain()判断链接是否属于起始域名:同域链接需要提取其页面内的链接以继续递归,站外链接则只检查可达性。
第三步:递归爬取——同域限定与终止条件
练习的第二个任务:
扩展程序,递归地从
www.google.org域名下的所有页面提取链接。把上限设为 100 页左右,以免被站点屏蔽。
control_crawl是递归爬取的「大脑」:
fn control_crawl( start_url: Url, command_sender: mpsc::Sender<CrawlCommand>, result_receiver: mpsc::Receiver<CrawlResult>, ) -> Vec<Url> { let mut crawl_state = CrawlState::new(&start_url); let start_command = CrawlCommand { url: start_url, extract_links: true }; command_sender.send(start_command).unwrap(); let mut pending_urls = 1; let mut bad_urls = Vec::new(); while pending_urls > 0 { let crawl_result = result_receiver.recv().unwrap(); pending_urls -= 1; match crawl_result { Ok(link_urls) => { for url in link_urls { if crawl_state.mark_visited(&url) { let extract_links = crawl_state.should_extract_links(&url); let crawl_command = CrawlCommand { url, extract_links }; command_sender.send(crawl_command).unwrap(); pending_urls += 1; } } } Err((url, error)) => { bad_urls.push(url); println!("Got crawling error: {:#}", error); } } } bad_urls }这段循环是整个程序的终止条件所在,值得逐行理解:
pending_urls是活任务的计数器:初始为 1(起始页),每收到一个结果-1,每派发一个新命令+1;- 收到结果 → 扩展任务:对结果中的每个链接,若
mark_visited返回true(首次见到),就根据域名决定是否提取其内链,生成新CrawlCommand发回命令通道,并把pending_urls加一; while pending_urls > 0是天然的工作量感知终止条件:当计数器归零,意味着所有派发出去的命令都已返回、没有新的链接需要检查,此时循环自然结束——无需人为指定遍历深度或页面总数上限;- 坏链接被收集:
Err((url, error))分支把出错的 URL 存入bad_urls并在最后返回,由main打印。
最终入口:
fn main() { let start_url = reqwest::Url::parse("https://www.google.org").unwrap(); let bad_urls = check_links(start_url); println!("Bad URLs: {:#?}", bad_urls); }关于「100 页上限」的实现说明
需要澄清一点:仓库中的解决方案(link-checker.rs)通过同域过滤 + 访问去重间接控制了抓取规模——爬取范围被限制在起始域名内,且每个 URL 只访问一次。它并没有显式实现文档任务中提到的「100 页上限」;如果你想严格遵守任务要求,可以在此基础上增加一个计数器:当visited_pages.len()达到 100 时停止派发新的extract_links: true命令(或直接停止派发新命令),这一改动只需在control_crawl的派发分支加一个判断即可。课程文档给出这个建议的本意是「避免对目标站点造成过大压力而被封禁」,实践中请始终遵守目标网站的使用条款。
完整解决方案与仓库佐证
练习的完整可运行解决方案保存在 src/concurrency/sync-exercises/link-checker.rs(对应 solutions.md 中 Link Checker 一节的{{#include link-checker.rs:solution}}嵌入,mdbook 会将该文件的ANCHOR: solution标记区间直接渲染进讲义)。上文各代码片段合在一起即为完整程序,其模块划分总结如下:
| 模块 | 函数/结构体 | 职责 |
|---|---|---|
| 错误处理 | enum Error | 统一reqwest错误与业务错误 |
| 命令模型 | struct CrawlCommand | 封装「URL + 是否提取链接」 |
| 页面处理 | visit_page | 下载页面、校验状态码、解析并提取链接 |
| 爬取状态 | struct CrawlState | 维护域名白名单与访问去重集合 |
| 工作线程池 | spawn_crawler_threads | 启动 N 个线程并行消费命令、回报结果 |
| 任务调度 | control_crawl | 唯一消费者,负责派发、去重、收集坏链接 |
| 程序入口 | main/check_links | 建立双通道、启动线程池、输出结果 |
运行与验证
在同步练习工程目录下(该工程在 Cargo.toml 中声明了名为link-checker的 bin target):
cargo run --bin link-checker程序会先打印各个被检查页面的Checking <url>日志,遇到无效链接时打印Got crawling error: ...,最后统一输出Bad URLs: [...]列表。运行期间请留意网络可达性与目标站点的响应策略——这也是练习文档反复强调「用小网站起步」的原因。
工程化配套:Bazel 构建支持
作为 Google 内部 Rust 课程,本仓库的练习同时提供了 Bazel 构建配置(BUILD.bazel):
load("@crates//:defs.bzl", "all_crate_deps") load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test") rust_binary( name = "link-checker", srcs = ["link-checker.rs"], deps = all_crate_deps(normal = True), ) rust_test( name = "link-checker_test", size = "small", crate = ":link-checker", )可见练习不仅支持cargo run,还支持bazel run //src/concurrency/sync-exercises:link-checker的构建方式;rust_test目标的存在说明该练习在仓库 CI 中还会以测试形式被编译校验(练习文档中的代码块标记为compile_fail,因为讲义里展示的是教学骨架,而link-checker.rs才是完整可编译版本)。如果你使用 Bazel 构建课程示例,可通过bazel test //src/concurrency/sync-exercises:link-checker_test验证工程配置正确。
学习要点与常见坑位
结合课程文档与完整解决方案,以下是这个练习最值得沉淀的几点:
- 通道的关闭就是信号:不要手动通知工作线程「结束」,drop 所有
Sender,让recv()返回Err作为终止信号——这是 mpsc 设计哲学的直接体现(senders-receivers.md 中send/recv返回Result的语义在此派上用场); - 不可克隆资源的多线程复用模式:
Receiver不可克隆,解法是Arc<Mutex<Receiver>>;把lock()作用域收缩到最小,避免守卫长时间占用锁拖慢其他线程; - 用
HashSet::insert的返回值做去重:既记录又判重,语义清晰且无竞态(去重只发生在唯一的控制线程内,无需加锁); - 域名维度的递归边界:
Url::domain()+extract_links布尔位把「递归爬取」与「广度检查」区分开,让程序只对同域页面做 HTML 解析,站外链接仅验证可达性,既高效又礼貌; - 错误要携带上下文:
CrawlResult的错误分支打包了(Url, Error),否则控制端只能知道「出错了」,却不知道是哪个链接出错。
综合来看,这个练习把 Comprehensive Rust 同步并发章节的所有核心概念——线程、通道、互斥锁、共享所有权——组织成了一个真实可运行的网络爬虫程序,是理解「多生产者-单消费者任务分发」与「递归爬取终止条件」这两个工程模式的极佳范本。动手把代码跑起来、故意改坏几个地方再调试修复,正是课程文档所期待的学习方式。
【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考