Very specific question about Lifetimes, Closures, threads, 'static, move, and maybe something I don't yet know about

I've been playing around with the standard Rust library's parallelism structures to better understand how to write 100% safe code in the threaded model. I've created several structures of my own for experimentation with, and now wanted to simulate something similar to Haskell's spark.

I currently have a tested and working implementation of a tokio one-shot/pi-calculus style single-use cross-thread channel, which I've named a Ping, whose usage looks like this:

let mut p = Ping::i32::new();
let mut q = p.clone();
let h = thread::spawn(move || {
    let x = q.recv().unwrap();
    println!("{}", x); // Some(22)
});
p.send(22);
h.join();
println!("Some(22) will always print before this");

(if you want the gory details of the above structure, including the function below a with //TODO: about this issue, look here)
Which I thought would be the perfect basis for the following implementation of a Haskell-style spark

pub fn spark<T: 'static, U: 'static>(arg: T, action: Box<dyn FnOnce(T) -> U + Send>) -> Ping<U>
where
    T: Send,
    U: Send + Clone,
{
    let p = Ping::<U>::new();
    let mut q = p.clone();
    let f = move || {
        let x = action(arg);
        q.send(x);
    };

    std::thread::spawn(f);

    p
}

The idea being the thread calling spark would provide a T and a function from T->U, and spark would give back a Ping<U>, while also firing off another thread to fill the clone of the Ping<U> with the result of the function applied to T. The calling thread doesn't have to await, but can continue time consuming work, and then only call .recv() on the Ping when it needs the value. The hope is the value is computed in parallel before it is needed. If that isn't the case, then when the Ping is recv()ed the calling thread blocks, similar to a promise.

This works, but I don't like the 'static lifetime params on T and U, because if I understand lifetimes correctly (and I very well may not), that 'static coerces all Ts and Us into never-freed memory. If I omit 'static, the compiler states that T and U may not live long enough, which is confusing to me, because the move in f seems to clearly show what lifetime belongs where. Is there a way I can annotate f to show the compiler that arg and action are consumed by f (then moved to another thread) and p is the only result that it needs to track?

Also, more generally, why is this happening? I tried wrapping arg and action in Arc::Mutex and Arc respectively, cloned them into fresh variables, then moved them into the closure. I tried with and without defining f separately. The move keyword and I seem to agree on everything else I'm doing with threads, what does it need that I'm missing here? Is it because there's a U that exists in both scopes (though a different U)? If so why is T even involved, there are no Ts left in the outer scope after the move?

Any clarity would be greatly appreciated.

For the record, the above works as expected with:

    fn test_spark(){
        let test_f = |i: i32| i * i;
        let mut my_spark = spark(4, Box::new(test_f));
        let result = my_spark.recv().unwrap();
        match result {
            Some(x) => {
                assert_eq!(16, x)
            }
            _ => panic!("No result")
        }
        
    }

This is wrong. When a type is 'a for some lifetime 'a, that means that it is valid for the value to exist anywhere inside the 'a lifetime, but not necessarily outside. It doesn't have to exists forever, it is just allowed to. So in the case of a type being 'static, that means that it is valid for the value to exist anywhere in the program.

To give an example of what you disallow by saying the type should be 'static, for example you can't choose that T = &u32 pointing to some integer on the stack, because such a reference cannot exist once that stack location is destroyed. Even if you put the reference inside an Arc, the reference is still not allowed to exist once its target is destroyed.

The only time that 'static truly means "lives forevery, leaked memory" is when you are dealing with a reference annotated with 'static such as &'static u32. Then the target of the reference must live forever. However, the reference itself doesn't have to exist forever, it's just allowed to do so, and yet, the reference is 'static.

1 Like

Though the concept is more general, the Rust compiler only ever reasons about lifetimes in the context of stack frames. Values that get sent between threads have to be 'static because the compiler is incapable of determining how long they’ll live in relation to the original thread’s stack. The exception to this is something like crossbeam’s scoped threads, where the library ensures that the spawned threads are joined before the function returns.

This applies to both T and U for slightly different reasons:

  • T is sent to the worker thread at the beginning of the calculation, at which point it can do anything with it, such as storing it in a global structure, which forces 'static
  • U originates from the worker thread and gets stored in the response channel until the main thread collects it. There’s nothing keeping the worker thread alive after it stores the result, and so the value must be able to outlive any remote stack frame. Also, the lifetime of U is being specified by the caller, and it has no way to ensure any lifetime other than ’static will be active when the worker thread is ready to return.
3 Likes

No, this is subtler than that.

First, 'static and other lifetime annotations apply only to references and types containing references. Lifetime annotations do nothing for all other types.

It appears that non-reference types have a 'static lifetime, but that's an illusion. They ignore all lifetime requirements, which works equally well as satisfying all lifetime requirements.

So T: 'static means:

  1. Any type is fine, if it's not a reference and it doesn't contain any references.
  2. In case it does contain a reference, that reference has to be valid until the end of the program.

Second, lifetime annotations don't do anything. They don't affect code generation. They don't make rust compiler manage memory in any way. There's even mrustc alternative compiler that doesn't support borrow checking and ignores all lifetimes completely, and it's compatible with Rust.

In non-generic context lifetime annotations are only an assertion. An explanation to the compiler what the code already does.
In generic context they're a filter for what kinds of data and behaviors are allowed in that code, so that the compiler will only allow code that meets these requirements.

4 Likes

Thank you, @kornel, @alice, and everyone else ("new users can only mention 2 users in a post") that really clears it up. I didn't understand why lifetimes were so closely tied to references until these responses. I think I understand a lot better now.

No, 'static means that the value doesn't contain references to objects that have a shorter lifetime than the 'static lifetime, i.e. usually (but not necessarily) a 'static value owns its data. Such values are still correctly freed when they go out of scope, you don't need to worry about this.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.