跳转到内容

Rust 错误 E0373:closure may outlive the current function(闭包可能比函数存活更久)

E0373 的意思是:一个闭包借用了某个局部变量,但拥有这个变量的函数结束之后,这个闭包可能还在运行(或者还存在)。你通常会在第一次调用 thread::spawn,或者第一次从函数里返回闭包时遇到它。好消息是:编译器已经告诉你怎么修了,而且几乎总是一个词:move。本页先给出修法,再解释原因。这个系列的其他错误,请看 Rust 编译错误详解。

error[E0373]: closure may outlive the current function, but it borrows `scores`, which is owned by the current function
--> src/main.rs:5:32
|
5 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `scores`
6 | println!("Scores: {scores:?}"); // ❌ 只借用了 scores
| ------ `scores` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:5:18
|
5 | let handle = thread::spawn(|| {
| __________________^
6 | | println!("Scores: {scores:?}"); // ❌ 只借用了 scores
7 | | });
| |______^
help: to force the closure to take ownership of `scores` (and any other referenced variables), use the `move` keyword
|
5 | let handle = thread::spawn(move || {
| ++++

第一行的意思是“闭包可能比当前函数活得更久,但它借用了归当前函数所有的 scores”。help 建议:要强制闭包获得 scores(以及其他被引用的变量)的所有权,就使用 move 关键字。

产生这个错误的程序是这样的:

use std::thread;
fn main() {
let scores = vec![90, 85, 77];
let handle = thread::spawn(|| {
println!("Scores: {scores:?}"); // ❌ 只借用了 scores
});
handle.join().unwrap();
}

在 async 代码里,你会看到同一个错误代码,措辞稍有不同:“async block may outlive the current function, but it borrows user_id”(async 代码块可能比当前函数活得更久,但它借用了 user_id)。这是同一个错误,修法也一样(async move)。

从上往下读这些标签:

  1. ^^ may outlive borrowed value(可能比被借用的值活得更久)指向闭包 ||。Rust 无法保证在 main 继续往下执行之前,这个闭包已经结束。
  2. `scores` is borrowed here(scores 在这里被借用)指向闭包使用 scores 的地方。因为 println! 只需要读取它,闭包捕获的是 scores 的引用,而不是这个 vector 本身。
  3. function requires argument type to outlive 'static(函数要求参数类型比 'static 活得更久)解释了为什么这是个问题:thread::spawn 只接受能在程序需要时一直活着的闭包。'static 的意思是“没有借用任何可能消失的东西”。

想象你把笔记本借给朋友,说“用完还给我”,可你马上就要搬去另一个城市。如果朋友做得慢,笔记本就无处可还了。Rust 拒绝这种约定,要求你干脆把笔记本送出去。

闭包会用开销最小的可行方式捕获变量。如果它只是读取一个变量,就用 & 借用它。对于马上就运行的闭包,比如传给 .map() 或 .filter() 的那些,这样做非常好。更多内容见闭包这一页。

新线程就不一样了。它可以运行任意长的时间,而且除非你调用 join,否则 main 不会等它。如果线程持有 scores 的引用,而 main 先结束了,scores 就会被丢弃,线程读到的就是已释放的内存。这就是“悬垂引用”,C 和 C++ 多线程代码里最常见的 bug 之一。Rust 在编译时就阻止了它。

返回闭包时也会发生同样的事。函数返回时,它的局部变量(以及参数)都会被丢弃。如果闭包还借用着它们,就会指向不存在的东西。

修复 1:加上 move(通常的答案)

Section titled “修复 1:加上 move(通常的答案)”

几乎每次都可以用这个方法,这也正是 rustc 的建议。move 让闭包获得它所用变量的所有权,这些变量会跟着闭包一起走:

use std::thread;
fn main() {
let scores = vec![90, 85, 77];
let handle = thread::spawn(move || {
let total: i32 = scores.iter().sum();
println!("分数:{scores:?},总分 {total}");
});
handle.join().unwrap();
}

输出:

分数:[90, 85, 77],总分 252
▶ 动手试一试

对于返回闭包的函数,修法也一样。没有 move 时,|x| x + n 借用的是参数 n,而 make_adder 一返回,n 就没了。加上 move 后,每个闭包都保留一份自己的 n:

fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
fn main() {
let add_five = make_adder(5);
let add_ten = make_adder(10);
println!("{}", add_five(1));
println!("{}", add_ten(1));
}

输出:

6
11
▶ 动手试一试

move 不会改变你得到的是哪一种闭包 trait。一个只读取数据的 move 闭包仍然是 Fn,可以调用很多次。

修复 2:还要用这个值时,先克隆一份

Section titled “修复 2:还要用这个值时,先克隆一份”

加上 move 之后,变量就归闭包所有了。如果 main 之后还要用它,你会得到 E0382(“borrow of moved value”,借用已移动的值),rustc 会建议你在移动之前先克隆。给闭包做一份副本,原来的留着:

use std::thread;
fn main() {
let guests = vec![String::from("Ana"), String::from("Ben")];
let guests_for_thread = guests.clone();
let handle = thread::spawn(move || {
println!("线程看到了 {} 位客人", guests_for_thread.len());
});
handle.join().unwrap();
println!("main 里还有:{guests:?}");
}

输出:

线程看到了 2 位客人
main 里还有:["Ana", "Ben"]
▶ 动手试一试

克隆一个小列表开销很小,完全没问题。如果数据很大,或者有很多线程都需要它,就用 Arc(修复 3),让它们共享同一份数据。

修复 3:用 Arc 在多个线程之间共享

Section titled “修复 3:用 Arc 在多个线程之间共享”

当多个线程需要读取同一份数据时,就用这个方法。Arc(“atomically reference counted”,原子引用计数)让多个所有者共享同一个值。Arc::clone 只复制一个指针并把计数加一,数据本身不会被复制:

use std::sync::Arc;
use std::thread;
fn main() {
let prices = Arc::new(vec![4.5, 2.0, 3.5]);
let mut handles = Vec::new();
for discount in [0.0, 0.1, 0.5] {
let prices = Arc::clone(&prices); // 每个线程一个句柄
handles.push(thread::spawn(move || {
let total: f64 = prices.iter().sum();
total * (1.0 - discount)
}));
}
for handle in handles {
println!("总价:{:.2}", handle.join().unwrap());
}
println!("价格:{prices:?}");
}

输出:

总价:10.00
总价:9.00
总价:5.00
价格:[4.5, 2.0, 3.5]
▶ 动手试一试

单独的 Arc 只提供只读共享。如果线程还需要修改数据,就把它包成 Arc<Mutex<T>>。参见并发和共享状态。

修复 4:用 std::thread::scope 安全地借用

Section titled “修复 4:用 std::thread::scope 安全地借用”

当线程只是在一小段工作里用到数据,而你宁愿借用也不想移动或克隆时,就用这个方法。在 thread::scope 里启动的线程,保证会在 scope 返回之前结束,所以借用局部变量是安全的,也不需要 move:

use std::thread;
fn main() {
let scores = vec![90, 85, 77, 64];
let (first, second) = scores.split_at(2);
let (a, b) = thread::scope(|s| {
let h1 = s.spawn(|| first.iter().sum::<i32>());
let h2 = s.spawn(|| second.iter().sum::<i32>());
(h1.join().unwrap(), h2.join().unwrap())
}); // 到这里两个线程都已经结束
println!("{a} + {b} = {}", a + b);
println!("仍然归我们所有:{scores:?}");
}

输出:

175 + 141 = 316
仍然归我们所有:[90, 85, 77, 64]
▶ 动手试一试

对于“把工作拆开,再使用结果”这类需求,作用域线程是最干净的答案。共享状态这一课就是从它讲起的。

在循环里创建线程。 即使是一个普通的数字,只要闭包只读取它,也会被借用:

for worker in 1..=3 {
handles.push(thread::spawn(|| {
println!("{worker} 号工人开工了"); // ❌ 借用了 `worker`
}));
}

修复:thread::spawn(move || ...)。worker 是 i32,所以 move 只是把它复制进每个线程。

返回装箱的闭包。 在返回 Box<dyn Fn() -> String> 的函数里写 Box::new(|| greeting.clone()),会借用局部变量 greeting,rustc 还会加上 note: closure is returned here(闭包在这里被返回)。修复:Box::new(move || greeting.clone())。更多内容见 Fn、FnMut 与 FnOnce。

异步任务(Tokio 等)。 tokio::spawn 和 thread::spawn 一样:任务可能在当前函数返回很久之后还在运行,所以它需要 'static 数据。写 tokio::spawn(async { ... }) 并在里面使用局部变量,会得到 “async block may outlive the current function”。修复:tokio::spawn(async move { ... }),并把任务外面还要用的东西克隆一份或者 Arc::clone。参见 Async/await。

小测验

1. 一个线程闭包没用 move 就打印了 name。为什么会得到 E0373?

2. 你加上了 move,但 main 在创建线程之后还需要打印这个 vector。最简单的修法是什么?

3. 哪个工具能让线程不用 move 就借用局部变量?

得分: 0 / 3