Why is the "move" keyword necessary when it comes to threads; why would I ever not want that behavior?
Asked Answered
G

3

15

For example (taken from the Rust docs):

let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
    println!("Here's a vector: {:?}", v);
});

This is not a question about what move does, but about why it is necessary to specify.

In cases where you want the closure to take ownership of an outside value, would there ever be a reason not to use the move keyword? If move is always required in these cases, is there any reason why the presence of move couldn't just be implied/omitted? For example:

let v = vec![1, 2, 3];
let handle = thread::spawn(/* move is implied here */ || {
    // Compiler recognizes that `v` exists outside of this closure's
    // scope and does black magic to make sure the closure takes
    // ownership of `v`.
    println!("Here's a vector: {:?}", v);
});

The above example gives the following compile error:

closure may outlive the current function, but it borrows `v`, which is owned by the current function

When the error magically goes away simply by adding move, I can't help but wonder to myself: why would I ever not want that behavior?


I'm not suggesting anything is wrong with the required syntax. I'm just trying to gain a deeper understanding of move from people who understand Rust better than I do. :)

Gilolo answered 7/6, 2020 at 23:31 Comment(2)
"In cases where you want the closure to take ownership of an outside value, would there ever be a reason not to use the move keyword?" - No. That's why move exists: to tell the compiler "I want this closure to take ownership of the things it captures."Murine
Note that you may want to reuse v after declaring the closure, and the compiler can't know that. So the move is also here to tell the compiler: "I don't intend to use v after this."Methylene
M
18

It's all about lifetime annotations, and a design decision Rust made long ago.

See, the reason why your thread::spawn example fails to compile is because it expects a 'static closure. Since the new thread can run longer than the code that spawned it, we have to make sure that any captured data stays alive after the caller returns. The solution, as you pointed out, is to pass ownership of the data with move.

But the 'static constraint is a lifetime annotation, and a fundamental principle of Rust is that lifetime annotations never affect run-time behavior. In other words, lifetime annotations are only there to convince the compiler that the code is correct; they can't change what the code does.

If Rust inferred the move keyword based on whether the callee expects 'static, then changing the lifetimes in thread::spawn may change when the captured data is dropped. This means that a lifetime annotation is affecting runtime behavior, which is against this fundamental principle. We can't break this rule, so the move keyword stays.


Addendum: Why are lifetime annotations erased?

  • To give us the freedom to change how lifetime inference works, which allows for improvements like non-lexical lifetimes (NLL).

  • So that alternative Rust implementations like mrustc can save effort by ignoring lifetimes.

  • Much of the compiler assumes that lifetimes work this way, so to make it otherwise would take a huge effort with dubious gain. (See this article by Aaron Turon; it's about specialization, not closures, but its points apply just as well.)

Mowry answered 8/6, 2020 at 6:32 Comment(0)
F
12

There are actually a few things in play here. To help answer your question, we must first understand why move exists.

Rust has 3 types of closures:

  1. FnOnce, a closure that consumes its captured variables (and hence can only be called once),
  2. FnMut, a closure that mutably borrows its captured variables, and
  3. Fn, a closure that immutably borrows its captured variables.

When you create a closure, Rust infers which trait to use based on how the closure uses the values from the environment. The manner in which a closure captures its environment depends on its type. A FnOnce captures by value (which may be a move or a copy if the type is Copyable), a FnMut mutably borrows, and a Fn immutably borrows. However, if you use the move keyword when declaring a closure, it will always "capture by value", or take ownership of the environment before capturing it. Thus, the move keyword is irrelevant for FnOnces, but it changes how Fns and FnMuts capture data.

Coming to your example, Rust infers the type of the closure to be a Fn, because println! only requires a reference to the value(s) it is printing (the Rust book page you linked talks about this when explaining the error without move). The closure thus attempts to borrow v, and the standard lifetime rules apply. Since thread::spawn requires that the closure passed to it have a 'static lifetime, the captured environment must also have a 'static lifetime, which v does not outlive, causing the error. You must thus explicitly specify that you want the closure to take ownership of v.

This can be further exemplified by changing the closure to something that the compiler would infer to be a FnOnce -- || v, as a simple example. Since the compiler infers that the closure is a FnOnce, it captures v by value by default, and the line let handle = thread::spawn(|| v); compiles without requiring the move.

Falco answered 8/6, 2020 at 1:42 Comment(3)
I'm not sure if this is correct. The move keyword and Fn traits are orthogonal. And Vec<T> is never Copy.Mowry
@LambdaFairy apologies, I mistakenly assumed that Vec<T: Copy> is Copy. Thanks for pointing that out!Falco
@Falco A good rule of thumb is to remember that, unlike C++'s copy ctors, which may contain arbitrary code, Rust implements copy as a straight bitwise copy of the memory occupied by the value itself (or behaves as if it were implemented that way). Since a Vec consists of three machine words one of which is a pointer to heap-allocated data, making Vrc copy would cause two Vecs to point to the same data, wreaking havoc at Drop time and causing aliasing issues before that.Stoneblind
G
4

The existing answers have great information, which led me to an understanding that is easier for me to think about, and hopefully easier for other Rust newcomers to get.


Consider this simple Rust program:

fn print_vec (v: &Vec<u32>) {
    println!("Here's a vector: {:?}", v);
}

fn main() {
    let mut v: Vec<u32> = vec![1, 2, 3];
    print_vec(&v); // `print_vec()` borrows `v`
    v.push(4);
}

Now, asking why the move keyword can't be implied is like asking why the "&" in print_vec(&v) can't also be implied.

Rust’s central feature is ownership. You can't just tell the compiler, "Hey, here's a bunch of code I wrote, now please discern perfectly everywhere I intend to reference, borrow, copy, move, etc. Kthnxsbye!" Symbols and keywords like & and move are a necessary and integral part of the language.

In hindsight, this seems really obvious, and makes my question seem a little silly!

Gilolo answered 11/6, 2020 at 19:2 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.