Hello. Assume a code like below. We have an mutable variable and pass the variable to another thread (tokio or not doesn't matter) which changes it. This code won't work. After the thread completion a will not change to 2.
The question is why Rust allows this code to compile? There are no errors, no warning, just nothing even in runtime.
We have to use Arc+Mutex or Arc+Atomic to solve the problem, but Rust doesn't show the problem.
let mut a = 1;
for _ in 0..10 {
tokio::spawn(async move {
a += 1;
if a > 3 {
// this will never reached, because a is 2 (always)
}
})
.await
.unwrap();
}
Yes, I have the two copies. But shouldn't an error be there? Just because it is an obvious error.
I mean, every time I made a mistake Rust beats my fingers by the borrow checker and so on and makes me think of "Oh, I can not change this variable from another thread", but in the topic's example the compiler is quiet.
But from type system perspective this needs to be allowed, because many other things in Rust are Copy (including shared references!) and can be moved into async blocks and closures.
Rust doesn't allow moving uninitialized variables, so the pattern of initializing a variable outside of a closure and using it inside is normal.
Consider this, which is technically equivalent of your example:
fn main() {
let mut a = 0;
let mut callback = move || { a += 1; println!("called {a} time(s)"); };
callback();
callback();
callback();
}
You can't have let mut a = 0 inside the closure. You can't not have let mut, you can't not initialize a.
It could be possible to use &mut u32 instead of a move, or Arc<AtomicU32> in the closure to prevent a being Copy, but these would be less flexible and/or with more overhead.
Better say, this situation made me think of ... you know, Rust always has only two choices:
If it compiles - it works.
If it doesn't compile - you have to fix an error AND there IS an error somewhere.
This situation appeared in real code, today. It was more complex code, though, but this made this "error/lint" even more difficult to find. The worst thing is that Rust didn't say a word against, so I really didn't know it won't work.
In my personal opinion, there’s good reason for linting against this kind of behavior, because, as far as I’m aware, it’s the only case where adding move to a closure will (significantly) alter program behavior, whereas other than that, move vs. no-move is usually only affecting the question of “does the code compile or not” and perhaps “what’s the exact memory layout of this closure”.
@IAkumaI, in your original post you said there were no warnings, but if I expand your snippet to a compilable program,
#[tokio::main]
async fn main() {
let mut a = 1;
tokio::spawn(async move {
a += 1;
})
.await
.unwrap();
assert_eq!(a, 2);
}
I get two warnings:
warning: value assigned to `a` is never read
warning: unused variable: `a`
Is that what you saw, too, or did you have a situation where there were no warnings of any kind? (This may be useful information for designing a lint.)
Threads don't exist from Rust's perspective. tokio::spawn, thread::spawn, etc. are just functions taking a callback with 'static + Send + Sync, but Rust has no idea if this code will even run at all.
Moving a struct that is Copy always duplicates it, leaving the original binding usable. This is fundamental, so I fail to see why it would be surprising.