news 2026/9/11 23:13:43

Comprehensive Rust 并发实战:多线程链接检查器(Multi-threaded Link Checker)完整实现指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Comprehensive Rust 并发实战:多线程链接检查器(Multi-threaded Link Checker)完整实现指南

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)、MutexArc等同步并发原语,构建一个多线程网页链接检查器。读完本文后,你将掌握:用reqwest发起 HTTP 请求、用scraper解析 HTML 提取链接、用thiserror组织错误处理,以及用「工作线程池 + 命令通道 + 结果通道」这一经典生产者-消费者模式实现并行爬取与去重,并亲手在本地运行完整解决方案。

练习背景与课程定位

Multi-threaded Link Checker 是 Comprehensive Rust 课程中「并发(Concurrency)」大章下 同步并发练习 的两个实战题目之一(另一个是 Dining Philosophers),在课程 SUMMARY.md 中被列为并发章节的收官练习。

它要求学生把此前几节学到的知识串起来使用:

  • 线程基础:用std::thread::spawn创建并行执行的工作线程;
  • 通道(channels):用std::sync::mpsc在多个生产者与单个消费者之间传递数据;
  • 共享状态:用MutexArc在多线程间安全共享不可克隆的资源。

练习的核心目标非常明确:从一个网页出发,检查页面上所有链接是否有效,并递归地检查同一域名下的其他页面,直到该域名下所有可达页面都被验证完毕。这是一个比课程中绝大多数练习规模更大的项目,课程文档特意注明(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}}指令把其中的setupvisit_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) }

这段代码体现了几个关键设计决策:

  1. CrawlCommand结构体:把「要访问的 URL」与「是否需要提取链接」打包成一个命令对象。extract_links字段看似多余,但它正是后面「只抓取同域页面、对站外链接只做有效性检查、不再递归」这一策略的伏笔;
  2. 状态码检查send()?只保证请求完成,不代表页面有效;只有is_success()为真才继续解析,否则返回Error::BadResponse
  3. 相对链接解析response.url()拿到的是最终响应地址(可能经过重定向),以它为基准调用base_url.join(href)把相对路径(如/about)解析为绝对 URL,解析失败(如javascript:伪协议)的链接被打印后忽略;
  4. 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(); } }); } }

设计要点拆解:

  1. 每个线程持有独立的ClientClient::new()在每个工作线程内部创建,避免跨线程共享请求对象,也天然规避了「一个连接池被多线程同时使用」的同步问题;
  2. Arc<Mutex<Receiver>>共享消费端:代码注释直白地说明了动机——为了复用不可克隆的Receiver,把它包进Arc<Mutex<_>>lock()的守卫作用域被显式限定在一个块中,recv()返回后立即释放锁,使下一个线程能立刻取到下一个命令;
  3. 通道关闭即线程退出let Ok(crawl_command) = command_result else { break; }——当所有Sender都被 drop 后,recv()返回Err,工作线程据此退出循环。这是 mpsc「关闭即终止」语义的实战运用;
  4. 错误随 URL 一起回传CrawlResultErr分支携带(Url, Error),这样控制端在汇总坏链接时能知道「哪个 URL 出了什么错」;
  5. 每个线程独立返回结果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_linksUrl::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 }

这段循环是整个程序的终止条件所在,值得逐行理解:

  1. pending_urls是活任务的计数器:初始为 1(起始页),每收到一个结果-1,每派发一个新命令+1
  2. 收到结果 → 扩展任务:对结果中的每个链接,若mark_visited返回true(首次见到),就根据域名决定是否提取其内链,生成新CrawlCommand发回命令通道,并把pending_urls加一;
  3. while pending_urls > 0是天然的工作量感知终止条件:当计数器归零,意味着所有派发出去的命令都已返回、没有新的链接需要检查,此时循环自然结束——无需人为指定遍历深度或页面总数上限;
  4. 坏链接被收集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验证工程配置正确。

学习要点与常见坑位

结合课程文档与完整解决方案,以下是这个练习最值得沉淀的几点:

  1. 通道的关闭就是信号:不要手动通知工作线程「结束」,drop 所有Sender,让recv()返回Err作为终止信号——这是 mpsc 设计哲学的直接体现(senders-receivers.md 中send/recv返回Result的语义在此派上用场);
  2. 不可克隆资源的多线程复用模式Receiver不可克隆,解法是Arc<Mutex<Receiver>>;把lock()作用域收缩到最小,避免守卫长时间占用锁拖慢其他线程;
  3. HashSet::insert的返回值做去重:既记录又判重,语义清晰且无竞态(去重只发生在唯一的控制线程内,无需加锁);
  4. 域名维度的递归边界Url::domain()+extract_links布尔位把「递归爬取」与「广度检查」区分开,让程序只对同域页面做 HTML 解析,站外链接仅验证可达性,既高效又礼貌;
  5. 错误要携带上下文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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 23:13:17

WeKnora 离线部署:Docker + Ollama 跑通文档问答全链路

WeKnora 离线部署&#xff1a;Docker Ollama 跑通文档问答全链路 【免费下载链接】WeKnora Open-source LLM knowledge platform: turn raw documents into a queryable RAG, an autonomous reasoning agent, and a self-maintaining Wiki. 项目地址: https://gitcode.com/G…

作者头像 李华
网站建设 2026/9/11 23:12:40

X光安检YOLO数据集:三格式统一与工业级训练适配

简介&#xff1a;本资源是面向计算机视觉初学者与YOLO目标检测实践者的X光安检场景专用数据集&#xff0c;解决真实工业场景下缺乏高质量、多格式标注数据的训练瓶颈问题。数据集包含5000张真实X光安检图像&#xff0c;配套VOC&#xff08;XML&#xff09;、COCO&#xff08;JS…

作者头像 李华
网站建设 2026/9/11 23:12:39

基于Spring Boot的个人云盘管理系统:从数据模型到秒传与断点续传

简介&#xff1a;基于 Spring Boot 的个人云盘管理系统毕业设计项目&#xff0c;面向需要完成课程设计或毕业论文的计算机专业学生&#xff0c;覆盖用户注册登录与角色权限、多格式文件上传下载、树状文件夹管理、全文搜索与标签、分享链接与多人协同编辑、评论反馈、版本历史与…

作者头像 李华
网站建设 2026/9/11 23:10:57

如何在本地配置 PostHog Tasks 后台代理和 GitHub App 集成?

如何在本地配置 PostHog Tasks 后台代理和 GitHub App 集成&#xff1f; 【免费下载链接】posthog :hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiment…

作者头像 李华