CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Rust 语言入门/课时/综合练习
RawBlame
课时目录12 节

Rust 语言入门

  1. 01Rust 是什么
  2. 02基础语法
  3. 03所有权与借用
  4. 04生命周期
  5. 05结构体与枚举
  6. 06错误处理
  7. 07Trait 与泛型
  8. 08集合类型
  9. 09并发与 async
  10. 10宏与元编程
  11. 11Web 与生态
  12. 12综合练习

综合练习

CLI + JSON API 小项目与 cargo test。

40 分钟

综合练习

这一课你将能做到

  1. 把前 11 课的知识拼装成一个可运行的迷你判题服务(模型、trait、并发、错误处理)
  2. 掌握「先顺序跑通、再并发改造」的工程节奏,看懂每一次改造引发的编译器反馈
  3. 知道从本地玩具到生产判题系统之间还差哪几块(沙箱、持久化、限流)

为什么要学

单知识点听懂了,拼起来还是会手生——move 和生命周期在真实调用链里怎么配合、trait 对象和通道怎么协作,只有完整写过一遍才知道。这个迷你判题服务刻意覆盖本课程全部主线:struct/enum 建模、Result 错误处理、trait 抽象、线程并发、通道回收,最后给出接入 axum 的升级路线。

它也是面试的完美素材:判题服务是「高性能后端」的浓缩案例,隔离、并发、幂等三个经典系统设计题都能从它出发。

先建立图像

mini-judge 数据流:

提交列表 Vec<Submission>
        │  逐个分发(所有权转移)
        ▼
N 个工作线程 ──沙箱模拟──→ Checker trait 判定 → Verdict
        │                                        │
        └────── mpsc 通道发送 (id, verdict) ──────┘
                                                 │
                        主线程 rx 回收 → HashMap<u64, Verdict> → 统计输出

知识点落位对照:

项目部件来源课程
Submission / Verdict 建模第 5 课 struct 与 enum
Display 输出第 7 课实现标准 trait
Checker trait第 7 课 trait 抽象
并发编排 + 通道第 9 课 thread 与 mpsc
main 返回 Result第 6 课错误处理

跟着做

步骤 1:建项目,写数据模型

cargo new mini-judge
cd mini-judge

src/main.rs 开头:

use std::collections::HashMap;
use std::fmt;
use std::sync::mpsc;
use std::thread;

#[derive(Debug, Clone)]
struct Submission {
    id: u64,
    problem: String,
    code: String,
}

#[derive(Debug, Clone, PartialEq)]
enum Verdict {
    Accepted { time_ms: u32 },
    WrongAnswer,
    CompileError(String),
}

impl fmt::Display for Verdict {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Verdict::Accepted { time_ms } => write!(f, "AC({time_ms} ms)"),
            Verdict::WrongAnswer => write!(f, "WA"),
            Verdict::CompileError(e) => write!(f, "CE:{e}"),
        }
    }
}

Verdict 用 enum 而不是 bool:AC/WA/CE 是状态机,未来加 TLE/RE 只需加变体,match 的穷尽性会逼着所有处理点同步更新。

步骤 2:Checker trait 与实现

trait Checker {
    fn check(&self, output: &str, expect: &str) -> Verdict;
    fn name(&self) -> &str { "checker" }     // 默认实现
}

struct ExactChecker;

impl Checker for ExactChecker {
    fn check(&self, output: &str, expect: &str) -> Verdict {
        if output.trim() == expect.trim() {
            Verdict::Accepted { time_ms: 3 }    // 真实系统由沙箱计时
        } else {
            Verdict::WrongAnswer
        }
    }
    fn name(&self) -> &str { "exact" }
}

判题策略被 trait 收口:以后加「忽略行尾空白」「浮点误差容忍」的比对器,都只是新增一个 impl Checker for ...,编排层零改动——这就是面向 trait 编程的复利。

步骤 3:并发判题编排(核心)

fn judge_all(subs: Vec<Submission>, expect: &str) -> HashMap<u64, Verdict> {
    let (tx, rx) = mpsc::channel();

    for sub in subs {
        let tx = tx.clone();              // 每个线程一份发送端
        let expect = expect.to_string();  // 期望输出复制进线程(move 语义)
        thread::spawn(move || {
            // 真实系统此处调用沙箱编译运行;这里用规则模拟
            let output = if sub.code.contains("WRONG") { "no" } else { "42" };
            let checker = ExactChecker;
            let verdict = checker.check(output, &expect);
            tx.send((sub.id, verdict)).unwrap();
        });
    }
    drop(tx);                             // 放弃主发送端,通道关闭后 recv 流结束

    rx.iter().collect()                   // (u64, Verdict) 流直接收成 HashMap
}

三个所有权细节:subs 被 for 循环整体消费(每个提交 move 给一个线程,无共享);expect 提前 to_string 再 move(闭包借不了主线程栈);drop(tx) 是通道协议的关键——所有发送端消失,rx.iter() 才会自然终止。

步骤 4:入口与统计

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let subs = vec![
        Submission { id: 1, problem: "A+B".into(), code: "print(42)".into() },
        Submission { id: 2, problem: "A+B".into(), code: "print(WRONG)".into() },
        Submission { id: 3, problem: "A+B".into(), code: "fn main(){} print(42)".into() },
    ];

    let results = judge_all(subs, "42");

    let mut ids: Vec<u64> = results.keys().copied().collect();
    ids.sort_unstable();
    for id in ids {
        println!("提交 {id}:{}", results[&id]);   // Display 在这里生效
    }

    let ac = results.values()
        .filter(|v| matches!(v, Verdict::Accepted { .. }))
        .count();
    println!("统计:{}/{} 通过", ac, results.len());
    Ok(())
}

cargo run 输出:

提交 1:AC(3 ms)
提交 2:WA
提交 3:AC(3 ms)
统计:2/3 通过

步骤 5:改造练习——故意制造三个编译错误

依次做以下修改并读报错,验证你真的理解了所有权:

  1. 把 judge_all 里的 for sub in subs 改成 for sub in &subs,再在线程里用 sub——报 E0373(借用可能活得比数据久),解法是回到 move 版本。
  2. 在 tx.send((sub.id, verdict)) 之后再加一行 tx.send((sub.id, verdict))——报 E0382(verdict 已被 move 进元组),解法是 clone。
  3. 删掉 drop(tx)——程序挂住不退出(还有发送端存活),理解通道生命周期协议。

这一步的价值远超继续加功能:编译器报错信息 + 通道行为,就是所有权与并发的动态教科书。

步骤 6:升级路线——从玩具到真服务

三个进阶方向,按投入产出排序:

  1. 接 axum 变成 HTTP 服务(第 11 课):judge_all 的通道收尾改成 tokio task + Arc<Mutex<HashMap>> 缓存结果,暴露 POST /submissions 与 GET /submissions/:id。
  2. 补真实沙箱:spawn 进程跑编译(std::process::Command),加超时与资源限制;生产级用容器/cgroup 隔离,判题进程崩溃不影响宿主。
  3. 持久化与幂等:结果落库(sqlx),提交带客户端幂等键,重复提交返回首次结果——判题是重操作,幂等是命门。

讲清楚要点

  1. 架构即所有权设计:提交 move 给线程、结果经通道收回,全程无共享可变状态——这个系统天然没有锁,也就没有死锁。
  2. trait 在边界上抽象:Checker 把「怎么判」和「怎么编排」解耦,测试时可以换 FakeChecker,生产换沙箱版。
  3. 通道协议三要素:克隆发送端、用完 drop、接收端 for/iter 消费到关闭——背下这个模板,90% 的 mpsc 场景通用。
  4. 顺序先行:先把 judge_all 写成 for 循环顺序版跑通业务,再并发化。并发改造只动编排不动业务,出错面最小。
  5. enum 状态机贯穿始终:Verdict 从本地函数返回值一路变成 HTTP 响应体,serde derive 接管序列化,模型不用改。

你可能会懵的地方

  • 为什么不用 Arc<Mutex<HashMap>> 让线程直接写共享结果? 能用但没必要:通道方案无锁、无死锁面、数据单向流动更好测。共享内存留给「确实需要就地读写同一份数据」的场景(如计数器)。

  • 每个提交开一个线程,提交多了怎么办? 玩具可以,生产不行。真实判题用有界线程池(线程数≈核心数)加任务队列,CPU 密集型任务的吞吐由核数决定,多余的排队而不是开线程。

  • 期望输出 expect 为什么按字符串传? 真实系统里期望输出按题目存库,判题时读出。这里聚焦编排所以硬编码;扩展方向是 Submission 带上 problem_id,判题前查询题目元数据。

  • time_ms 是假的,真的怎么算? 沙箱侧记录:进程启动到退出的墙上时间或 CPU 时间(Instant 计时),随输出一起返回。判题内核的关键是把「执行」和「计量」封装在同一个隔离边界内。

  • 为什么 main 返回 Result 而不是让 panic 崩? CLI 惯例:Err 走非零退出码并打印错误,方便脚本与 CI 判断;panic 留给真正的程序 bug。这与第 6 课的纪律一致。

错法对照

错法问题正解
线程里借用主线程的 expect: &strerror[E0373] 生命周期提前 to_string 后 move 进闭包
for sub in &subs 后在线程内用 sub借用逃逸出线程move 消费 subs,或先 clone 每个元素
忘记 drop 主发送端程序挂死不退出发送端用完即 drop,接收端才收尾
直接 results[&id] 打印 Display 但没实现 Displayerror[E0277]补 impl fmt::Display for Verdict
一开始就上 axum + 数据库变量太多,错不出根因先本地单文件跑通,再逐层外扩

卡点急救

  1. 程序卡住不输出:九成是通道没收尾——数一遍 tx 的克隆数量,确保全部 drop(含主线程那份);rx.iter() 换成 rx.try_iter() 可临时暴露问题。
  2. error[E0382] verdict used after move:send 拿走了所有权。需要保留就先 clone,或把 send 设计成只传必要字段。
  3. 输出顺序每次不同:并发回收的结果无序是正常现象,展示前按 id 排序;需要确定性顺序的场景在回收侧统一排序,而不是限制并发。

面试常问

  • 设计一个线上判题服务,隔离方案怎么选? 三层:进程级(spawn + 资源限制,适合可信代码)、容器级(cgroup/namespace 限制 CPU/内存/文件系统,主流方案)、虚拟机/微虚拟机级(强隔离,用于对抗性提交)。核心原则:判题进程永不以宿主权限运行,超时由外部看门狗强制回收。

  • 并发判题的资源控制怎么做? 有界线程池或信号量限制并行判题数(约等于核数),任务队列削峰,单任务双重超时(编译超时 + 运行超时),整批任务加整体预算防止雪崩;对提交接口做幂等键与限流,重复提交直接返回进行中状态。

  • 这套 Rust 判题系统相对 Java 实现的性能优势在哪里? 判题热路径无 GC 停顿(尾延迟稳定)、单进程内存占用小一个量级(同机可跑更多并发沙箱)、进程编排与通道回收零锁开销;Java 版优势在生态集成速度,两者工程取舍取决于吞吐与延迟要求。

自测三问

  1. judge_all 里如果把 for sub in subs 改成 for sub in &subs.clone(),行为有什么变化?多付出了什么成本?
  2. 通道方案与 Arc<Mutex> 方案各自的适用边界是什么?
  3. 从这个玩具到生产判题服务,最关键的三个缺口是什么?

小结一句话

十二节课的知识在一个 100 行的判题服务里完成闭环:所有权定架构、trait 定边界、通道定并发——剩下的是把它推向生产的一公里。

延伸

  • The Rust Book:最终项目——多线程 Web 服务器
  • 下一课预告:Rust 进阶——异步运行时原理与性能调优(tokio 深入、火焰图、零拷贝优化)。
加载本节练习…
← 上一课Web 与生态