August 11, 2022·6 min read·engineeringrustthreadsownershipio

Rust 1.63 connects thread completion to borrowed data

Scoped threads can borrow local data because their scope waits for completion. Rust 1.63 also gives file descriptors explicit owned and borrowed representations.

A thread that borrows a local variable must finish before the variable disappears. Waiting for a thread in ordinary application code can satisfy that intention, but a safe library interface needs to enforce the relationship even when a join handle is dropped or a closure panics. Rust 1.63's scoped threads put that guarantee in the scope operation itself.

The 11 August announcement introduces scoped threads and I/O safety types. Both changes make a resource's lifetime more explicit. One connects borrowed data to thread completion, while the other distinguishes borrowing a file descriptor from taking responsibility for closing it.

I find this aspect of Rust particularly persuasive for systems work. The useful abstraction expresses why an operation is safe without requiring every caller to reproduce the proof in comments. A local borrowing relationship can stay local when the library also controls the point at which concurrent work ends.

The scope owns the completion obligation

The released scoped-thread implementation and documentation specify that threads not joined manually are joined before scope returns. That is the condition that permits them to borrow non-static data from outside the scope. The guarantee belongs to the scope, rather than to the continued existence of each returned handle.

The distinction matters because a caller can drop a join handle. An ordinary handle can be a useful way to wait, but a safety argument cannot depend on the caller voluntarily retaining and joining it if forgetting the handle is allowed. Scoped threads keep the broader completion accounting in shared scope state.

The implementation increments its count when work starts and decrements it when work finishes. The scope waits while the count is nonzero. This lets the operation retain its completion obligation independently of whether application code uses the individual handle.

That does not create permission to share arbitrary mutable data. The closures still have to satisfy the relevant Send bounds, and the borrow checker still controls which references can coexist. The scope solves the lifetime problem created by a thread potentially outliving the borrowed environment. It does not remove the rules governing concurrent access to that environment.

A borrowed value can remain on the stack

This example gives a scoped worker an immutable borrow of local array data and a mutable borrow of a separate result. The scope waits before the assertion uses that result.

let values = [2_u64, 3, 5];
let mut total = 0;

std::thread::scope(|scope| {
    scope.spawn(|| {
        total = values.iter().sum();
    });
});

assert_eq!(total, 10);

The example does not need shared ownership merely to keep the input alive beyond the call. Its input already outlives the scope, and the worker cannot remain active after that scope returns. The result is available to the enclosing function again once the worker's borrow has ended.

This is useful where the intended concurrency already has a fixed completion boundary. A function can divide work among threads and retain ownership of the inputs in its caller. If the work must outlive the function, scoped borrowing is the wrong lifetime relationship and another ownership arrangement is required.

My preference is to let that requirement determine the design. Shared ownership can be appropriate when several long-lived owners actually exist. Introducing it only to work around a thread lifetime can obscure the simpler fact that all work must complete before this operation returns.

Panics do not bypass the wait

The implementation catches a panic from the scope's closure so it can wait for running threads before resuming unwinding. That ordering protects the same borrowing guarantee on the failure path. The closure's normal return is not the only way execution can leave the body.

The documentation also distinguishes panics in automatically joined threads. If one of those workers panics, the scope panics after joining the threads. Applications that need to inspect an individual panic can join that handle explicitly and examine the returned result.

This is a concrete failure contract. It does not cancel every sibling thread when another thread panics. The scope's obligation is to wait for completion. A worker that waits indefinitely can therefore keep the scope from returning, just as its unfinished work would imply.

Thread creation has its own failure path. Scope::spawn panics if the operating system cannot create the thread. Builder::spawn_scoped returns an I/O result for that operation and also permits configuring a name or stack size. A caller needing recoverable creation failure can select that interface.

These distinctions keep the meaning of completion separate from the policy for handling failure. The library establishes when borrowed data can be released. The application still decides how to react to a failed operation or a worker result.

Two lifetimes describe the boundary

The source documentation distinguishes the scope lifetime from the lifetime of the borrowed environment. Borrowed data must outlive the scope. The scope starts before its closure runs and ends only after the spawned threads have completed, before the outer function returns.

This also limits what nested threads can borrow. Data created inside a worker does not automatically live as long as the whole surrounding scope. A nested worker cannot borrow such a local simply because both workers were spawned through the same scope object.

The implementation comments include an example of that invalid nesting and explain why the scope lifetime must remain invariant. Shrinking that lifetime incorrectly could permit a thread to use data after the worker that owned it had finished. The type-level detail exists to enforce an ordinary lifetime fact about the program.

That is a useful way to read the generic declaration. Its complexity protects a relatively simple user-facing guarantee. The borrowed object must exist until every thread using it has stopped, including nested work that application code may introduce.

File descriptors acquire ownership information

The released I/O safety types address another lifetime relationship. OwnedFd closes its descriptor on drop. BorrowedFd ties a descriptor reference to the lifetime of an owner. Both have a representation suitable for the platform descriptor interface while carrying different Rust obligations.

An integer alone does not express those obligations. A function taking a raw descriptor can consume ownership or merely inspect the resource, and a caller must learn that rule elsewhere. An owned or borrowed wrapper can put the distinction in the function's type.

The borrowed type is copyable because copying a borrow does not create another independent obligation to close the descriptor. Creating a new owned descriptor is a different operation. The documented try_clone_to_owned method duplicates the descriptor and can fail, returning an I/O result.

That distinction is important when comparing Rust ownership with operating-system resource identity. Duplicated descriptors can share the underlying file description while having separate descriptor lifetimes. A copied borrowed wrapper continues referring to the existing owner. It is not equivalent to asking the operating system for another owned descriptor.

Raw conversions retain explicit obligations

BorrowedFd::borrow_raw is unsafe because the caller must guarantee that the descriptor remains open for the returned borrow's lifetime. OwnedFd's raw construction likewise requires a resource suitable for taking ownership, with cleanup performed by close. The wrappers do not infer those facts from an integer.

Converting an owned descriptor into a raw one deliberately prevents its normal drop cleanup. That transfers responsibility out of the wrapper. A binding to another API must then match what the receiving operation promises to do with that descriptor.

Borrow checking finishes an earlier transition

The release also removes the old lexical borrow checker across all editions, completing the non-lexical lifetimes migration. The announcement describes this as a diagnostic and implementation transition that does not change the generated program's behavior. It should not be confused with a new runtime mechanism for extending the life of borrowed objects.

That distinction connects the language and library work in this release. The checker establishes whether references satisfy the program's lifetime rules. The scoped-thread library supplies an operation whose completion guarantee permits a useful borrowing relationship. Neither one keeps an object alive merely because a worker still wants to access it.

I regard these conversions as a useful expression of Rust's systems boundary. Safe ownership operations can carry the ordinary lifetime relationship, while raw entry and exit points require an explicit account of the resource. Scoped threads follow a related pattern by putting completion into the operation that permits borrowing, so the surrounding code can use local data without inventing another owner for it.