综合练习
这一课你将能做到
- 把前 11 课的知识拼装成一个可运行的迷你判题服务(模型、trait、并发、错误处理)
- 掌握「先顺序跑通、再并发改造」的工程节奏,看懂每一次改造引发的编译器反馈
- 知道从本地玩具到生产判题系统之间还差哪几块(沙箱、持久化、限流)
为什么要学
单知识点听懂了,拼起来还是会手生——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:改造练习——故意制造三个编译错误
依次做以下修改并读报错,验证你真的理解了所有权:
- 把
judge_all里的for sub in subs改成for sub in &subs,再在线程里用sub——报 E0373(借用可能活得比数据久),解法是回到 move 版本。 - 在
tx.send((sub.id, verdict))之后再加一行tx.send((sub.id, verdict))——报 E0382(verdict 已被 move 进元组),解法是 clone。 - 删掉
drop(tx)——程序挂住不退出(还有发送端存活),理解通道生命周期协议。
这一步的价值远超继续加功能:编译器报错信息 + 通道行为,就是所有权与并发的动态教科书。
步骤 6:升级路线——从玩具到真服务
三个进阶方向,按投入产出排序:
- 接 axum 变成 HTTP 服务(第 11 课):
judge_all的通道收尾改成 tokio task +Arc<Mutex<HashMap>>缓存结果,暴露POST /submissions与GET /submissions/:id。 - 补真实沙箱:spawn 进程跑编译(
std::process::Command),加超时与资源限制;生产级用容器/cgroup 隔离,判题进程崩溃不影响宿主。 - 持久化与幂等:结果落库(sqlx),提交带客户端幂等键,重复提交返回首次结果——判题是重操作,幂等是命门。
讲清楚要点
- 架构即所有权设计:提交 move 给线程、结果经通道收回,全程无共享可变状态——这个系统天然没有锁,也就没有死锁。
- trait 在边界上抽象:Checker 把「怎么判」和「怎么编排」解耦,测试时可以换 FakeChecker,生产换沙箱版。
- 通道协议三要素:克隆发送端、用完 drop、接收端 for/iter 消费到关闭——背下这个模板,90% 的 mpsc 场景通用。
- 顺序先行:先把
judge_all写成 for 循环顺序版跑通业务,再并发化。并发改造只动编排不动业务,出错面最小。 - 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: &str | error[E0373] 生命周期 | 提前 to_string 后 move 进闭包 |
for sub in &subs 后在线程内用 sub | 借用逃逸出线程 | move 消费 subs,或先 clone 每个元素 |
| 忘记 drop 主发送端 | 程序挂死不退出 | 发送端用完即 drop,接收端才收尾 |
直接 results[&id] 打印 Display 但没实现 Display | error[E0277] | 补 impl fmt::Display for Verdict |
| 一开始就上 axum + 数据库 | 变量太多,错不出根因 | 先本地单文件跑通,再逐层外扩 |
卡点急救
- 程序卡住不输出:九成是通道没收尾——数一遍 tx 的克隆数量,确保全部 drop(含主线程那份);
rx.iter()换成rx.try_iter()可临时暴露问题。 - error[E0382] verdict used after move:send 拿走了所有权。需要保留就先 clone,或把 send 设计成只传必要字段。
- 输出顺序每次不同:并发回收的结果无序是正常现象,展示前按 id 排序;需要确定性顺序的场景在回收侧统一排序,而不是限制并发。
面试常问
-
设计一个线上判题服务,隔离方案怎么选? 三层:进程级(spawn + 资源限制,适合可信代码)、容器级(cgroup/namespace 限制 CPU/内存/文件系统,主流方案)、虚拟机/微虚拟机级(强隔离,用于对抗性提交)。核心原则:判题进程永不以宿主权限运行,超时由外部看门狗强制回收。
-
并发判题的资源控制怎么做? 有界线程池或信号量限制并行判题数(约等于核数),任务队列削峰,单任务双重超时(编译超时 + 运行超时),整批任务加整体预算防止雪崩;对提交接口做幂等键与限流,重复提交直接返回进行中状态。
-
这套 Rust 判题系统相对 Java 实现的性能优势在哪里? 判题热路径无 GC 停顿(尾延迟稳定)、单进程内存占用小一个量级(同机可跑更多并发沙箱)、进程编排与通道回收零锁开销;Java 版优势在生态集成速度,两者工程取舍取决于吞吐与延迟要求。
自测三问
judge_all里如果把for sub in subs改成for sub in &subs.clone(),行为有什么变化?多付出了什么成本?- 通道方案与 Arc<Mutex> 方案各自的适用边界是什么?
- 从这个玩具到生产判题服务,最关键的三个缺口是什么?
小结一句话
十二节课的知识在一个 100 行的判题服务里完成闭环:所有权定架构、trait 定边界、通道定并发——剩下的是把它推向生产的一公里。
延伸
- The Rust Book:最终项目——多线程 Web 服务器
- 下一课预告:Rust 进阶——异步运行时原理与性能调优(tokio 深入、火焰图、零拷贝优化)。