Why does thread::spawn need static lifetime for generic bounds?

The 'static bound on a type doesn't control how long that object lives; it controls the allowable lifetime of references that object holds.

Without such a bound, since T is generic and only bounded by the traits given, val may have references to objects of any arbitrary lifetime. For instance, you might write this function:

fn evil() {
    let x = 10;
    test(&x);
}

Now val is a reference to a variable on the stack. But the thread that you've spawned could keep running after test and then evil have returned. Now that pointer is dangling, pointing to some memory that has been reused for something else.

The reason that it works with String is that String does comply with the 'static bound; String is a concrete type, and doesn't contain any borrowed references. A generic type T, however, could contain references of any possible lifetime, and the thread could outlive any of those lifetimes.

I think one of the key things that a lot of people trip over is thinking that lifetime annotations refer to the lifetime of the object they are applied to. They do not; they refer to the minimum possible lifetime of any borrowed references that the object contains. This, of course, constrains the possible lifetimes of that object; an object cannot outlive its borrowed references, so its maximum possible lifetime must be shorter than the minimum possible lifetimes of the references it contains.

When handing an object off to a thread, it must have only 'static references, because the new thread could outlive the original thread.

Now, there was once a way to do what you want; spawn a thread that captures a value that has references of lifetimes shorter than 'static. It was called std::thread::scoped. It worked by returning a JoinGuard when invoked, which would block the invoking thread on Drop (when the JoinGuard went out of scope, at the end of the block that spawned the thread) until the spawned thread exited, to ensure that all stack variables would still be live for the full duration of the thread. Sadly, this assumption turned out to have a fatal flaw, as it was possible to leak the JoinGuard, causing Drop to never be called. This caused much wailing and gnashing of teeth, but eventually meant that thread::scoped had to be dropped as it was unsound (could lead to undefined behavior).

Luckily, there is a way around this, implemented in the crossbeam crate. You can guarantee that your guard won't leak, by passing it in to a closure as a borrowed reference; the function that passes it in, crossbeam::scope, then owns the scope and can prevent it from leaking, allowing passing borrowed references to be passed in to new threads safely, as the crossbeam::scope method can block and not return until all of the threads spawned from its scope have exited, and there's no way to leak that.

Anyhow, for your purposes, you may or may not need borrowed references to be able to be passed in. If you don't, just add a 'static bound to your type parameter, and you'll be all set, and should be able to pass in objects that own their contents like String, Vec<T>, Box<T> (the latter two only if T itself is also bounded by 'static), integers, structs containing the preceding types, string literals (which are references, but they are 'static references that live as long as the whole program) and so on. If you do need to pass in objects that could contain borrowed references of lifetimes shorter than 'static, you'll need to use something like crossbeam::scope.

52 Likes