Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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. :)

like image 516
darksinge Avatar asked Jun 07 '20 23:06

darksinge


People also ask

What does the move keyword do in Rust?

Keyword move move converts any variables captured by reference or mutable reference to variables captured by value. move is often used when threads are involved. move is also valid before an async block.

What is a closure rust?

Rust's closures are anonymous functions you can save in a variable or pass as arguments to other functions. You can create the closure in one place and then call the closure elsewhere to evaluate it in a different context. Unlike functions, closures can capture values from the scope in which they're defined.


Video Answer


2 Answers

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.

like image 127
EvilTak Avatar answered Sep 30 '22 15:09

EvilTak


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.)

like image 41
Lambda Fairy Avatar answered Sep 30 '22 15:09

Lambda Fairy