Course Builder个人学习工作台 · 本地模式
Rust:从所有权到可测试的命令行工具

17|线程:移动数据并汇总计算结果

目标:把独立的文本交给工作线程,并通过 join 收回结果,避免共享可变计数器。

假设需要处理几份互不相关的文本,最直接的分工是每个工作者拥有一份输入,返回一份报告,主线程最后相加。这个结构沿用了已学的“拥有输入 → 计算 → 返回拥有结果”,只是执行位置换到了另一个线程。

thread::spawn 启动线程并返回 JoinHandle。闭包前的 move 让被捕获的值按值进入闭包;对于 String,意味着移动所有权。join() 等待线程结束,返回 Result;成功包含返回值,失败表示线程发生 panic。普通业务错误与线程 panic 不应混淆,本例计算函数没有业务错误分支。

跨线程移动涉及 Send 能力,共享引用涉及 Sync 能力;编译器会检查相关约束。当前使用的 String 和只含整数的报告能按这里的方式在线程间传递。

每个线程只拥有自己的输入

从第 15 课及之后的项目状态继续,创建 examples/threads.rs:

use std::thread;
use textlab::analyze;

fn main() {
    let inputs = vec![String::from("rust cargo"), String::from("你好 Rust")];
    let mut handles = Vec::new();
    for input in inputs {
        handles.push(thread::spawn(move || analyze(&input)));
    }

    let mut total_words = 0;
    for handle in handles {
        match handle.join() {
            Ok(report) => total_words += report.words,
            Err(_) => {
                eprintln!("worker panicked");
                std::process::exit(1);
            }
        }
    }
    println!("total_words={total_words}");
}

运行前先预测:两个线程是否都会修改 total_words?

cargo run --quiet --example threads

预期输出:

total_words=4

第一个循环消耗输入 Vec,逐个移动 String,然后保存线程句柄;第二个循环逐个消费句柄,主线程得到报告后才修改总数。move || 是不接收参数、捕获拥有值的闭包。两个工作线程可以同时运行;按句柄顺序 join 不代表它们按这个顺序启动或完成。

失败分支选择立即结束这个教学程序。process::exit 不执行当前线程栈上的析构清理,也不会等待其他线程;需要清理或完整汇总错误的程序应先收集所有 join 结果再返回状态。这里不用它处理用户输入,主 CLI 仍通过返回 ExitCode 正常退出。

输入增加,汇总不变

  1. 在 inputs 中加入 String::from("one two three")。
  2. 运行,预期变成 total_words=7。
  3. 恢复输入。线程数量随文本数量增长适合这个极小实验,不适合直接处理成千上万个文件;实际批处理需要限制并发度,并通过测量判断是否获益。

独立练习:在保留两个原始输入的情况下,同时汇总字节数,输出 total_words=4 total_bytes=21。只在主线程中更新计数。

参考修改与验收

在原代码 let mut total_words = 0; 后增加 let mut total_bytes = 0;,把成功分支完整替换为:

Ok(report) => {
    total_words += report.words;
    total_bytes += report.bytes;
}

这是现有 match 的局部分支,保留失败分支。最后的打印改为 println!("total_words={total_words} total_bytes={total_bytes}");,并在打印前加入 assert_eq!(total_words, 4);、assert_eq!(total_bytes, 21);。运行同一命令,应得到题目中的输出。

两份输入分别为 10 字节和 11 字节。报告在线程完成后按值交给主线程,不需要为总数加锁。

如果移除 move 后出现闭包可能活得比借用数据更久的诊断,检查线程是否借用了循环中的局部变量;spawn 要求捕获的数据满足相应存活约束。如果 join() 报句柄已经被移动,检查是否试图对同一线程 join 两次。如果只看见部分输出就结束,检查主线程是否等待了需要的工作线程。

检查点:新增 examples/threads.rs。这展示可独立扩展的批处理方式;最终单文件 CLI 继续同步执行,避免为一个小文件增加线程开销。

参考资料